From c62b3f2dfc68daa5675ef5f2c3934364be1d3249 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 23 Jun 2026 16:58:28 -0700 Subject: [PATCH 001/120] feat: add Understudy error classes to Canopy errors directory --- .../scripts/src/classes/errors/UnderstudyConnectedError.js | 6 ++++++ .../src/classes/errors/UnderstudyNotConnectedError.js | 6 ++++++ .../scripts/src/classes/errors/UnderstudySaveInfoError.js | 6 ++++++ .../src/classes/errors/UnknownRepeatingActionError.js | 6 ++++++ 4 files changed, 24 insertions(+) create mode 100644 Canopy[BP]/scripts/src/classes/errors/UnderstudyConnectedError.js create mode 100644 Canopy[BP]/scripts/src/classes/errors/UnderstudyNotConnectedError.js create mode 100644 Canopy[BP]/scripts/src/classes/errors/UnderstudySaveInfoError.js create mode 100644 Canopy[BP]/scripts/src/classes/errors/UnknownRepeatingActionError.js 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'; + } +} From 27834040beb60abb4ec4510702ae758082a8d8ae Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 23 Jun 2026 17:02:24 -0700 Subject: [PATCH 002/120] feat: add simplayer utils.js with tests --- .../scripts/src/classes/simplayer/utils.js | 56 +++++++++++++++ .../src/classes/simplayer/utils.test.js | 68 +++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 Canopy[BP]/scripts/src/classes/simplayer/utils.js create mode 100644 __tests__/BP/scripts/src/classes/simplayer/utils.test.js 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..e80d6c4e --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/simplayer/utils.js @@ -0,0 +1,56 @@ +import { Block, Entity, GameMode, Player } from "@minecraft/server"; + +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`); +} 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..d9dcb32c --- /dev/null +++ b/__tests__/BP/scripts/src/classes/simplayer/utils.test.js @@ -0,0 +1,68 @@ +import { describe, it, expect, vi } from 'vitest'; +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')); + +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'); + }); +}); + +describe('getLookAtRotation', () => { + it('returns pitch and yaw from base to target', () => { + const base = { x: 0, y: 0, z: 0 }; + const target = { x: 0, y: 1.62001002, 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'); + }); +}); + +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('throws when container is null', () => { + expect(() => swapSlots(null, 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('throws on unknown game mode string', () => { + expect(() => portOldGameModeToNewUpdate('unknown')).toThrow(); + }); + + it('throws when gameMode is not a string', () => { + expect(() => portOldGameModeToNewUpdate(0)).toThrow(); + }); +}); + +describe('getLocationInfoFromSource', () => { + it('throws for invalid source', () => { + expect(() => getLocationInfoFromSource({})).toThrow(); + }); +}); From 57f1292e9766dea354bca6839e03da3b96e87e8c Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 23 Jun 2026 17:03:46 -0700 Subject: [PATCH 003/120] feat: add simplayer RepeatableAction and Actions classes --- .../scripts/src/classes/simplayer/Actions.js | 55 +++++++ .../src/classes/simplayer/RepeatableAction.js | 135 ++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 Canopy[BP]/scripts/src/classes/simplayer/Actions.js create mode 100644 Canopy[BP]/scripts/src/classes/simplayer/RepeatableAction.js 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/RepeatableAction.js b/Canopy[BP]/scripts/src/classes/simplayer/RepeatableAction.js new file mode 100644 index 00000000..54cefad9 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/simplayer/RepeatableAction.js @@ -0,0 +1,135 @@ +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 const TIMING_OPTIONS = Object.freeze({ + ONCE: 'once', + CONTINUOUS: 'continuous', + INTERVAL: 'interval', + AFTER: 'after', + STOP: 'stop' +}); + +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; + } +} From 7fe4333af9f64d5f24c6b9ad9bb74d267f64f52e Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 23 Jun 2026 17:06:38 -0700 Subject: [PATCH 004/120] feat: add simplayer inventory and player info saver classes --- .../src/classes/simplayer/PlayerInfoSaver.js | 94 +++++++++++++++ .../simplayer/UnderstudyInventorySaver.js | 110 ++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 Canopy[BP]/scripts/src/classes/simplayer/PlayerInfoSaver.js create mode 100644 Canopy[BP]/scripts/src/classes/simplayer/UnderstudyInventorySaver.js 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..c6daf7b8 --- /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 { noSimplayerSaving } from "../../rules/simplayer/noSimplayerSaving"; +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 (noSimplayerSaving.getValue()) + 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 (noSimplayerSaving.getValue()) + throw new UnderstudySaveInfoError(`Player ${this.#understudy.name} has no player info saved due to '${noSimplayerSaving.getID()}' rule being enabled`); + 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 (noSimplayerSaving.getValue()) + 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/UnderstudyInventorySaver.js b/Canopy[BP]/scripts/src/classes/simplayer/UnderstudyInventorySaver.js new file mode 100644 index 00000000..96f2b62c --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/simplayer/UnderstudyInventorySaver.js @@ -0,0 +1,110 @@ +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) { + itemStack = itemsWithNBT.find(item => item.typeId === itemWithoutNBT.typeId && item.amount === itemWithoutNBT.amount); + itemsWithNBT.splice(itemsWithNBT.indexOf(itemStack), 1); + } + 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); + equippable.setEquipment(equipmentSlot, itemStack); + } + } +} From 6a8d22045d53c5b65f898908ad2fe93cd875acf2 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 23 Jun 2026 17:08:29 -0700 Subject: [PATCH 005/120] feat: add Understudy class with lazy-on hook Co-Authored-By: Claude Sonnet 4.6 --- .../src/classes/simplayer/Understudy.js | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 Canopy[BP]/scripts/src/classes/simplayer/Understudy.js 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..47f340c5 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js @@ -0,0 +1,289 @@ +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; + this.teleport({ location, rotation, dimension }); + system.run(() => { + 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(`§e${this.name} left the game`); + } + + 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.lookAtBlock(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; + } + + claimProjectiles(radius) { + const simulatedPlayer = this.simulatedPlayer; + const projectileComponents = this.#getProjectileComponentsInRange(simulatedPlayer, radius); + const numChanged = this.#changeProjectileOwner(projectileComponents, simulatedPlayer); + if (numChanged === 0) + return world.sendMessage(`<${simulatedPlayer.name}> §7No claimable projectiles found within ${radius} blocks.`); + world.sendMessage(`<${simulatedPlayer.name}> §7Successfully became the owner of ${numChanged} projectiles.`); + 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(`§cError while swapping items: ${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; From da61bf95780466784eed95300848fa1b582d77c0 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 23 Jun 2026 17:28:39 -0700 Subject: [PATCH 006/120] feat: add Understudies with lazy event/interval management and isUnderstudy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also fix incorrect relative lib import paths in Understudy.js and UnderstudyInventorySaver.js (../../lib → ../../../lib for simplayer subdir), create noSimplayerSaving rule stub, and add @minecraft/server-gametest alias to vitest.config.js. Co-Authored-By: Claude Sonnet 4.6 --- .../src/classes/simplayer/Understudies.js | 127 ++++++++++++++++++ .../src/classes/simplayer/Understudy.js | 2 +- .../simplayer/UnderstudyInventorySaver.js | 2 +- .../src/rules/simplayer/noSimplayerSaving.js | 7 + .../classes/simplayer/Understudies.test.js | 71 ++++++++++ vitest.config.js | 3 +- 6 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 Canopy[BP]/scripts/src/classes/simplayer/Understudies.js create mode 100644 Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving.js create mode 100644 __tests__/BP/scripts/src/classes/simplayer/Understudies.test.js 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..49f68a97 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/simplayer/Understudies.js @@ -0,0 +1,127 @@ +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 = `[${prefix}§r] ${understudy.name}`; + } + + 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); + if (prefix === '') { + for (const understudy of Understudies.understudies) + understudy.simulatedPlayer.nameTag = understudy.name; + } else { + for (const understudy of Understudies.understudies) + understudy.simulatedPlayer.nameTag = `[${prefix}§r] ${understudy.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 `§cSimplayer '${name}' is not online.`; + } + + static getAlreadyOnlineMessage(name) { + return `§cSimplayer '${name}' is already online.`; + } +} + +export default Understudies; diff --git a/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js b/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js index 47f340c5..2b232a7b 100644 --- a/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js +++ b/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js @@ -1,7 +1,7 @@ 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 { Vector } from "../../../lib/Vector"; import { PlayerInfoSaver } from "./PlayerInfoSaver"; import { Actions } from "./Actions"; import { UnderstudyNotConnectedError } from "../errors/UnderstudyNotConnectedError"; diff --git a/Canopy[BP]/scripts/src/classes/simplayer/UnderstudyInventorySaver.js b/Canopy[BP]/scripts/src/classes/simplayer/UnderstudyInventorySaver.js index 96f2b62c..4d01d620 100644 --- a/Canopy[BP]/scripts/src/classes/simplayer/UnderstudyInventorySaver.js +++ b/Canopy[BP]/scripts/src/classes/simplayer/UnderstudyInventorySaver.js @@ -1,5 +1,5 @@ import { EntityComponentTypes, EquipmentSlot, world } from "@minecraft/server"; -import SRCItemDatabase from "../../lib/SRCItemDatabase/ItemDatabase.js"; +import SRCItemDatabase from "../../../lib/SRCItemDatabase/ItemDatabase.js"; export class UnderstudyInventorySaver { constructor(understudy) { diff --git a/Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving.js b/Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving.js new file mode 100644 index 00000000..7d7bea5f --- /dev/null +++ b/Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving.js @@ -0,0 +1,7 @@ +import { Rule } from '../../lib/canopy/Rule.js'; + +export const noSimplayerSaving = new Rule({ + category: 'Rules', + identifier: 'noSimplayerSaving', + description: { text: 'Disables saving/loading of simulated player data.' } +}); 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..1e10bff6 --- /dev/null +++ b/__tests__/BP/scripts/src/classes/simplayer/Understudies.test.js @@ -0,0 +1,71 @@ +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/noSimplayerSaving', () => ({ + noSimplayerSaving: { getValue: vi.fn(() => false), getID: vi.fn(() => 'noSimplayerSaving') } +})); + +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(); + }); +}); 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: { From 8a1b89e14523a4b4e4d727ec5c00c0d976078cb6 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 23 Jun 2026 17:30:49 -0700 Subject: [PATCH 007/120] feat: add simplayer rules using Canopy BooleanRule directly --- .../src/rules/simplayer/noSimplayerSaving.js | 18 ++++-- .../src/rules/simplayer/simplayerRejoining.js | 62 +++++++++++++++++++ 2 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 Canopy[BP]/scripts/src/rules/simplayer/simplayerRejoining.js diff --git a/Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving.js b/Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving.js index 7d7bea5f..79b8691b 100644 --- a/Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving.js +++ b/Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving.js @@ -1,7 +1,13 @@ -import { Rule } from '../../lib/canopy/Rule.js'; +import { BooleanRule } from "../../lib/canopy/Canopy"; -export const noSimplayerSaving = new Rule({ - category: 'Rules', - identifier: 'noSimplayerSaving', - description: { text: 'Disables saving/loading of simulated player data.' } -}); +class NoSimplayerSaving extends BooleanRule { + constructor() { + super({ + identifier: 'noSimplayerSaving', + description: 'Disables saving playerdata for simplayers. Improves performance but causes simplayers to lose their inventory and location when they leave and rejoin.', + defaultValue: false + }); + } +} + +export const noSimplayerSaving = new NoSimplayerSaving(); 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..db685032 --- /dev/null +++ b/Canopy[BP]/scripts/src/rules/simplayer/simplayerRejoining.js @@ -0,0 +1,62 @@ +import { BooleanRule } from "../../lib/canopy/Canopy"; +import { system, world } from "@minecraft/server"; +import Understudies from "../../classes/simplayer/Understudies"; + +class SimplayerRejoining extends BooleanRule { + simplayersToRejoinDP = 'simplayersToRejoin'; + + constructor() { + super({ + identifier: 'simplayerRejoining', + description: 'Makes online simplayers rejoin when the world reloads.', + 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.getValue()) + 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.getValue()) + world.setDynamicProperty(this.simplayersToRejoinDP, JSON.stringify(Understudies.understudies.map(player => player.name))); + else + world.setDynamicProperty(this.simplayersToRejoinDP, JSON.stringify([])); + } +} + +export const simplayerRejoining = new SimplayerRejoining(); From e0be71215c016e48b038024ec87d6e7a2b9e2a9c Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 23 Jun 2026 17:43:00 -0700 Subject: [PATCH 008/120] feat: add all 15 simplayer commands using canopy:player prefix Co-Authored-By: Claude Sonnet 4.6 --- .../src/commands/simplayer/playeraction.js | 53 ++++++++++++++ .../simplayer/playerclaimprojectiles.js | 19 +++++ .../src/commands/simplayer/playerinventory.js | 30 ++++++++ .../src/commands/simplayer/playerjoin.js | 21 ++++++ .../src/commands/simplayer/playerleave.js | 20 +++++ .../src/commands/simplayer/playerlook.js | 73 +++++++++++++++++++ .../src/commands/simplayer/playermove.js | 67 +++++++++++++++++ .../src/commands/simplayer/playerprefix.js | 19 +++++ .../src/commands/simplayer/playerrejoin.js | 27 +++++++ .../src/commands/simplayer/playerselect.js | 23 ++++++ .../src/commands/simplayer/playersneak.js | 21 ++++++ .../src/commands/simplayer/playersprint.js | 21 ++++++ .../src/commands/simplayer/playerstop.js | 18 +++++ .../src/commands/simplayer/playerswapheld.js | 18 +++++ .../src/commands/simplayer/playertp.js | 19 +++++ 15 files changed, 449 insertions(+) create mode 100644 Canopy[BP]/scripts/src/commands/simplayer/playeraction.js create mode 100644 Canopy[BP]/scripts/src/commands/simplayer/playerclaimprojectiles.js create mode 100644 Canopy[BP]/scripts/src/commands/simplayer/playerinventory.js create mode 100644 Canopy[BP]/scripts/src/commands/simplayer/playerjoin.js create mode 100644 Canopy[BP]/scripts/src/commands/simplayer/playerleave.js create mode 100644 Canopy[BP]/scripts/src/commands/simplayer/playerlook.js create mode 100644 Canopy[BP]/scripts/src/commands/simplayer/playermove.js create mode 100644 Canopy[BP]/scripts/src/commands/simplayer/playerprefix.js create mode 100644 Canopy[BP]/scripts/src/commands/simplayer/playerrejoin.js create mode 100644 Canopy[BP]/scripts/src/commands/simplayer/playerselect.js create mode 100644 Canopy[BP]/scripts/src/commands/simplayer/playersneak.js create mode 100644 Canopy[BP]/scripts/src/commands/simplayer/playersprint.js create mode 100644 Canopy[BP]/scripts/src/commands/simplayer/playerstop.js create mode 100644 Canopy[BP]/scripts/src/commands/simplayer/playerswapheld.js create mode 100644 Canopy[BP]/scripts/src/commands/simplayer/playertp.js 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..8ade0fa0 --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js @@ -0,0 +1,53 @@ +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, TIMING_OPTIONS } from "../../classes/simplayer/RepeatableAction"; + +new VanillaCommand({ + name: 'canopy:playeraction', + description: 'commands.playeraction', + enums: [ + { name: 'canopy:simplayerAction', values: Object.values(REPEATABLE_ACTIONS) }, + { 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, playername, action, timingOption = TIMING_OPTIONS.ONCE, ticks) => { + const understudy = Understudies.get(playername); + if (!understudy) + return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; + const actions = understudy.actions; + switch (timingOption) { + case TIMING_OPTIONS.ONCE: + actions.once(action); + break; + case TIMING_OPTIONS.AFTER: + if (ticks === void 0) + return { status: CustomCommandStatus.Failure, message: `§cInvalid '${timingOption}' tick duration: ${ticks}. Expected an integer.` }; + actions.once(action, ticks); + break; + case TIMING_OPTIONS.CONTINUOUS: + actions.repeat(action); + break; + case TIMING_OPTIONS.INTERVAL: + if (ticks === void 0) + return { status: CustomCommandStatus.Failure, message: `§cInvalid '${timingOption}' tick duration: ${ticks}. Expected an integer.` }; + actions.repeat(action, ticks); + break; + case TIMING_OPTIONS.STOP: + actions.remove(action); + break; + default: + return { status: CustomCommandStatus.Failure, message: `§cInvalid ${action} timing: ${timingOption}.` }; + } + return { status: CustomCommandStatus.Success }; + } +}); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerclaimprojectiles.js b/Canopy[BP]/scripts/src/commands/simplayer/playerclaimprojectiles.js new file mode 100644 index 00000000..6d4468a4 --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerclaimprojectiles.js @@ -0,0 +1,19 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +new VanillaCommand({ + name: 'canopy:playerclaimprojectiles', + description: 'commands.playerclaimprojectiles', + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + optionalParameters: [{ name: 'radius', type: CustomCommandParamType.Float }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (_origin, playername, radius = 25) => { + const understudy = Understudies.get(playername); + if (!understudy) + return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; + system.run(() => understudy.claimProjectiles(radius)); + return { status: CustomCommandStatus.Success }; + } +}); 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..d021a163 --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerinventory.js @@ -0,0 +1,30 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +new VanillaCommand({ + name: 'canopy:playerinventory', + description: 'commands.playerinventory', + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (_origin, playername) => { + const understudy = Understudies.get(playername); + if (!understudy) + return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; + const playerInventory = understudy.getInventory(); + if (!playerInventory) + return { status: CustomCommandStatus.Success, message: '§cNo inventory found' }; + if (playerInventory.size === playerInventory.emptySlotsCount) + return { status: CustomCommandStatus.Success, message: `§7${understudy.name}'s inventory is empty.` }; + let message = `${understudy.name}'s inventory:`; + for (let i = 0; i < playerInventory.size; i++) { + const itemStack = playerInventory.getItem(i); + if (itemStack !== void 0) { + const colorCode = i < 10 ? '§a' : ''; + message += `\n§7- ${colorCode}${i}§7: ${itemStack.typeId} x${itemStack.amount}`; + } + } + return { status: CustomCommandStatus.Success, message }; + } +}); 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..af524ffd --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerjoin.js @@ -0,0 +1,21 @@ +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"; + +new VanillaCommand({ + name: 'canopy:playerjoin', + description: 'commands.playerjoin', + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin], + callback: (origin, playername) => { + if (Understudies.isOnline(playername)) + return { status: CustomCommandStatus.Failure, message: Understudies.getAlreadyOnlineMessage(playername) }; + system.run(() => { + const understudy = Understudies.create(playername); + understudy.join(getLocationInfoFromSource(origin.getSource())); + Understudies.addNametagPrefix(understudy); + }); + } +}); 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..736e630b --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerleave.js @@ -0,0 +1,20 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +new VanillaCommand({ + name: 'canopy:playerleave', + description: 'commands.playerleave', + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (_origin, playername) => { + const understudy = Understudies.get(playername); + if (!understudy) + return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; + system.run(() => { + understudy.leave(); + Understudies.remove(understudy); + }); + } +}); 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..97019838 --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js @@ -0,0 +1,73 @@ +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"; + +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', STOP: 'stop' +}); + +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 } +}; + +new VanillaCommand({ + 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: 'location', type: CustomCommandParamType.Location } + ], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, playername, lookOption, location) => { + const understudy = Understudies.get(playername); + if (!understudy) + return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; + 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: + system.run(() => understudy.look(CARDINAL_ROTATIONS[lookOption])); + break; + case LOOK_OPTIONS.BLOCK: { + const source = origin.getSource(); + if (source instanceof Entity === false) + return { status: CustomCommandStatus.Failure, message: '§cBlock targeting may only be used by entities.' }; + const block = source.getBlockFromViewDirection({ maxDistance: 16*64 })?.block; + if (block === void 0) + return { status: CustomCommandStatus.Failure, message: '§cNo block in view.' }; + system.run(() => understudy.look(block)); + break; + } + case LOOK_OPTIONS.ENTITY: { + const source = origin.getSource(); + if (source instanceof Entity === false) + return { status: CustomCommandStatus.Failure, message: '§cEntity targeting may only be used by entities.' }; + const entity = source.getEntitiesFromViewDirection({ maxDistance: 16*64 })[0]?.entity; + if (entity === void 0) + return { status: CustomCommandStatus.Failure, message: '§cNo entity in view.' }; + system.run(() => understudy.look(entity)); + break; + } + case LOOK_OPTIONS.ME: + if (origin instanceof ServerCommandOrigin) + return { status: CustomCommandStatus.Failure, message: '§cSelf-targeting cannot be used by the server.' }; + system.run(() => understudy.look(origin.getSource())); + break; + case LOOK_OPTIONS.AT: + system.run(() => understudy.look(Vector.from(location))); + break; + case LOOK_OPTIONS.STOP: + system.run(() => understudy.stopLooking()); + break; + default: + return { status: CustomCommandStatus.Failure, message: `§cInvalid look option: '${lookOption}'` }; + } + return { status: CustomCommandStatus.Success }; + } +}); 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..21a4a772 --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playermove.js @@ -0,0 +1,67 @@ +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' +}); + +new VanillaCommand({ + 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, playername, moveOption, location) => { + const understudy = Understudies.get(playername); + if (!understudy) + return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; + switch (moveOption) { + case MOVE_OPTIONS.FORWARD: case MOVE_OPTIONS.BACKWARD: + case MOVE_OPTIONS.LEFT: case MOVE_OPTIONS.RIGHT: + system.run(() => understudy.moveRelative(moveOption)); + break; + case MOVE_OPTIONS.BLOCK: { + const source = origin.getSource(); + if (source instanceof Entity === false) + return { status: CustomCommandStatus.Failure, message: '§cMoving to a block may only be used by entities.' }; + const block = source.getBlockFromViewDirection({ maxDistance: 16*64 })?.block; + if (block === void 0) + return { status: CustomCommandStatus.Failure, message: '§cNo block in view.' }; + system.run(() => understudy.moveLocation(block)); + break; + } + case MOVE_OPTIONS.ENTITY: { + const source = origin.getSource(); + if (source instanceof Entity === false) + return { status: CustomCommandStatus.Failure, message: '§cMoving to an entity may only be used by entities.' }; + const entity = source.getEntitiesFromViewDirection({ maxDistance: 16*64 })[0]?.entity; + if (entity === void 0) + return { status: CustomCommandStatus.Failure, message: '§cNo entity in view.' }; + system.run(() => understudy.moveLocation(entity)); + break; + } + case MOVE_OPTIONS.ME: + if (origin instanceof ServerCommandOrigin) + return { status: CustomCommandStatus.Failure, message: '§cMoving to yourself cannot be used by the server.' }; + system.run(() => understudy.moveLocation(origin.getSource())); + break; + case MOVE_OPTIONS.TO: + system.run(() => understudy.moveLocation(Vector.from(location))); + break; + case MOVE_OPTIONS.STOP: + system.run(() => understudy.stopMoving()); + break; + default: + return { status: CustomCommandStatus.Failure, message: `§cInvalid move option: '${moveOption}'` }; + } + return { status: CustomCommandStatus.Success }; + } +}); 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..1adfd9f2 --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerprefix.js @@ -0,0 +1,19 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +new VanillaCommand({ + name: 'canopy:playerprefix', + description: 'commands.playerprefix', + mandatoryParameters: [{ name: 'prefix', type: CustomCommandParamType.String }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (_origin, prefix) => { + if (prefix === '-none') { + system.run(() => Understudies.setNametagPrefix('')); + return { status: CustomCommandStatus.Success, message: '§7Simplayer prefix removed.' }; + } + system.run(() => Understudies.setNametagPrefix(prefix)); + return { status: CustomCommandStatus.Success, message: `§7Simplayer prefix set to "§r${prefix}§r§7".` }; + } +}); 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..1cee48f5 --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerrejoin.js @@ -0,0 +1,27 @@ +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"; + +new VanillaCommand({ + name: 'canopy:playerrejoin', + description: 'commands.playerrejoin', + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, playername) => { + if (Understudies.isOnline(playername)) + return { status: CustomCommandStatus.Failure, message: Understudies.getAlreadyOnlineMessage(playername) }; + system.run(() => { + 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); + }); + return { status: CustomCommandStatus.Success }; + } +}); 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..122315d0 --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerselect.js @@ -0,0 +1,23 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +new VanillaCommand({ + 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, playername, slotNumber) => { + const understudy = Understudies.get(playername); + if (!understudy) + return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; + if (slotNumber < 0 || slotNumber > 8) + return { status: CustomCommandStatus.Failure, message: `§cInvalid slot number: ${slotNumber}. Expected a number from 0 to 8.` }; + system.run(() => understudy.selectSlot(slotNumber)); + return { status: CustomCommandStatus.Success }; + } +}); 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..231828e0 --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playersneak.js @@ -0,0 +1,21 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +new VanillaCommand({ + 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, playername, shouldSneak) => { + const understudy = Understudies.get(playername); + if (!understudy) + return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; + system.run(() => understudy.sneak(shouldSneak)); + return { status: CustomCommandStatus.Success }; + } +}); 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..4cb68ab8 --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playersprint.js @@ -0,0 +1,21 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +new VanillaCommand({ + 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, playername, shouldSprint) => { + const understudy = Understudies.get(playername); + if (!understudy) + return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; + system.run(() => understudy.sprint(shouldSprint)); + return { status: CustomCommandStatus.Success }; + } +}); 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..d06190cc --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerstop.js @@ -0,0 +1,18 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +new VanillaCommand({ + name: 'canopy:playerstop', + description: 'commands.playerstop', + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (_origin, playername) => { + const understudy = Understudies.get(playername); + if (!understudy) + return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; + system.run(() => understudy.stopAll()); + return { status: CustomCommandStatus.Success }; + } +}); 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..10f85ca6 --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerswapheld.js @@ -0,0 +1,18 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, EntityCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +new VanillaCommand({ + name: 'canopy:playerswapheld', + description: 'commands.playerswapheld', + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, EntityCommandOrigin], + callback: (origin, playername) => { + const understudy = Understudies.get(playername); + if (!understudy) + return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; + system.run(() => understudy.swapHeldItemWithPlayer(origin.getSource())); + return { status: CustomCommandStatus.Success }; + } +}); 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..c8aff0a1 --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playertp.js @@ -0,0 +1,19 @@ +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"; + +new VanillaCommand({ + name: 'canopy:playertp', + description: 'commands.playertp', + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin], + callback: (origin, playername) => { + const understudy = Understudies.get(playername); + if (!understudy) + return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; + system.run(() => understudy.teleport(getLocationInfoFromSource(origin.getSource()))); + return { status: CustomCommandStatus.Success }; + } +}); From 86c4bcdf628687efc1794030c84d728c3b786a38 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 23 Jun 2026 17:45:01 -0700 Subject: [PATCH 009/120] feat: wire simplayer commands, rules, and startup hooks into Canopy Co-Authored-By: Claude Sonnet 4.6 --- Canopy[BP]/scripts/main.js | 21 +++++++++++++++++++++ Canopy[BP]/scripts/src/onReload.js | 7 +++++-- Canopy[BP]/scripts/src/onStart.js | 3 ++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/Canopy[BP]/scripts/main.js b/Canopy[BP]/scripts/main.js index 909284c4..4e29e03f 100644 --- a/Canopy[BP]/scripts/main.js +++ b/Canopy[BP]/scripts/main.js @@ -35,6 +35,23 @@ import './src/commands/lifetimequery' import './src/commands/lifetimequeryitem' import './src/commands/velocity' +// 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/playerclaimprojectiles' +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' + // Script Events import './src/commands/scriptevents/counter' import './src/commands/scriptevents/spawn' @@ -81,6 +98,10 @@ import './src/rules/entitySeparation' import './src/rules/enderPearlChunkLoading' import './src/rules/renderEndGatewayExits' +// Simulated Player Rules +import './src/rules/simplayer/noSimplayerSaving' +import './src/rules/simplayer/simplayerRejoining' + // Load Time Processes import './src/onStart' import './src/onReload' 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(); } From c63559800d31bf88d2f9264d3e0a01a251cd0c3e Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 23 Jun 2026 17:57:26 -0700 Subject: [PATCH 010/120] fix: correct BooleanRule import path and use getNativeValue() in simplayer rules Rule import path from rules/simplayer/ must be ../../../lib/canopy/Canopy (not ../../lib/canopy/Canopy which resolves to non-existent src/lib/). Also replace getValue() with getNativeValue() since Rule.getValue() is async and these are native Canopy rules with synchronous dynamic property access. Co-Authored-By: Claude Sonnet 4.6 --- Canopy[BP]/scripts/src/classes/simplayer/PlayerInfoSaver.js | 6 +++--- Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving.js | 2 +- .../scripts/src/rules/simplayer/simplayerRejoining.js | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Canopy[BP]/scripts/src/classes/simplayer/PlayerInfoSaver.js b/Canopy[BP]/scripts/src/classes/simplayer/PlayerInfoSaver.js index c6daf7b8..498df371 100644 --- a/Canopy[BP]/scripts/src/classes/simplayer/PlayerInfoSaver.js +++ b/Canopy[BP]/scripts/src/classes/simplayer/PlayerInfoSaver.js @@ -19,7 +19,7 @@ export class PlayerInfoSaver { } #saveOnInterval() { - if (noSimplayerSaving.getValue()) + if (noSimplayerSaving.getNativeValue()) return; if ((system.currentTick - this.#understudy.createdTick) % this.saveInterval === 0) { this.save(); @@ -34,7 +34,7 @@ export class PlayerInfoSaver { } get() { - if (noSimplayerSaving.getValue()) + if (noSimplayerSaving.getNativeValue()) throw new UnderstudySaveInfoError(`Player ${this.#understudy.name} has no player info saved due to '${noSimplayerSaving.getID()}' rule being enabled`); let playerInfo; try { @@ -48,7 +48,7 @@ export class PlayerInfoSaver { } save() { - if (noSimplayerSaving.getValue()) + if (noSimplayerSaving.getNativeValue()) return; if (!this.#understudy.isConnected()) throw new UnderstudyNotConnectedError(); diff --git a/Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving.js b/Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving.js index 79b8691b..a5a240f7 100644 --- a/Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving.js +++ b/Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving.js @@ -1,4 +1,4 @@ -import { BooleanRule } from "../../lib/canopy/Canopy"; +import { BooleanRule } from "../../../lib/canopy/Canopy"; class NoSimplayerSaving extends BooleanRule { constructor() { diff --git a/Canopy[BP]/scripts/src/rules/simplayer/simplayerRejoining.js b/Canopy[BP]/scripts/src/rules/simplayer/simplayerRejoining.js index db685032..c693cff6 100644 --- a/Canopy[BP]/scripts/src/rules/simplayer/simplayerRejoining.js +++ b/Canopy[BP]/scripts/src/rules/simplayer/simplayerRejoining.js @@ -1,4 +1,4 @@ -import { BooleanRule } from "../../lib/canopy/Canopy"; +import { BooleanRule } from "../../../lib/canopy/Canopy"; import { system, world } from "@minecraft/server"; import Understudies from "../../classes/simplayer/Understudies"; @@ -25,7 +25,7 @@ class SimplayerRejoining extends BooleanRule { } onStartup() { - if (!this.getValue()) + if (!this.getNativeValue()) return; const simplayersToRejoinStr = world.getDynamicProperty(this.simplayersToRejoinDP); let playersToRejoin; @@ -52,7 +52,7 @@ class SimplayerRejoining extends BooleanRule { } onShutdown() { - if (this.getValue()) + if (this.getNativeValue()) world.setDynamicProperty(this.simplayersToRejoinDP, JSON.stringify(Understudies.understudies.map(player => player.name))); else world.setDynamicProperty(this.simplayersToRejoinDP, JSON.stringify([])); From 82b11b53dff185a3eceaabe9f6dc33177d7dbf0e Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 23 Jun 2026 17:58:47 -0700 Subject: [PATCH 011/120] fix: update noSimplayerSaving mock to use getNativeValue in Understudies tests Co-Authored-By: Claude Sonnet 4.6 --- __tests__/BP/scripts/src/classes/simplayer/Understudies.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/BP/scripts/src/classes/simplayer/Understudies.test.js b/__tests__/BP/scripts/src/classes/simplayer/Understudies.test.js index 1e10bff6..b51c851e 100644 --- a/__tests__/BP/scripts/src/classes/simplayer/Understudies.test.js +++ b/__tests__/BP/scripts/src/classes/simplayer/Understudies.test.js @@ -5,7 +5,7 @@ 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/noSimplayerSaving', () => ({ - noSimplayerSaving: { getValue: vi.fn(() => false), getID: vi.fn(() => 'noSimplayerSaving') } + noSimplayerSaving: { getNativeValue: vi.fn(() => false), getID: vi.fn(() => 'noSimplayerSaving') } })); let Understudies; From b64a4af1600777a65eec013da445cfce806266e3 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 23 Jun 2026 23:12:18 -0700 Subject: [PATCH 012/120] test: fix erroring tests --- __tests__/BP/scripts/lib/canopy/rules/AbilityRule.test.js | 3 ++- __tests__/BP/scripts/lib/canopy/rules/Rule.test.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/__tests__/BP/scripts/lib/canopy/rules/AbilityRule.test.js b/__tests__/BP/scripts/lib/canopy/rules/AbilityRule.test.js index bd95d126..9f04e52f 100644 --- a/__tests__/BP/scripts/lib/canopy/rules/AbilityRule.test.js +++ b/__tests__/BP/scripts/lib/canopy/rules/AbilityRule.test.js @@ -67,7 +67,8 @@ describe('AbilityRule', () => { }); it('should use a custom action item if provided', () => { - const customArrowAbility = new AbilityRule(testRuleData, { 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/Rule.test.js b/__tests__/BP/scripts/lib/canopy/rules/Rule.test.js index a446aebd..3cf321e4 100644 --- a/__tests__/BP/scripts/lib/canopy/rules/Rule.test.js +++ b/__tests__/BP/scripts/lib/canopy/rules/Rule.test.js @@ -95,7 +95,7 @@ describe('Rule', () => { describe('getSuggestedOptions', () => { it('should return suggestedOptions when provided', () => { - const rule = new IntegerRule({ category: 'Rules', identifier: 'opts_rule', suggestedOptions: [1, 10, 80], valueRange: { range: { min: 1, max: 80 } } }); + const rule = new IntegerRule({ category: 'Rules', identifier: 'opts_rule', suggestedOptions: [1, 10, 80], valueRange: { range: { min: 0, max: 80 } } }); expect(rule.getSuggestedOptions()).toEqual([1, 10, 80]); }); From 647c2da5a61e7692d23747f6cfcc8ce94a2257c8 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 00:16:29 -0700 Subject: [PATCH 013/120] feat: merge understudy in --- Canopy[BP]/manifest.json | 4 + .../src/commands/simplayer/playeraction.js | 70 +- .../simplayer/playerclaimprojectiles.js | 26 +- .../src/commands/simplayer/playerinventory.js | 39 +- .../src/commands/simplayer/playerjoin.js | 24 +- .../src/commands/simplayer/playerleave.js | 24 +- .../src/commands/simplayer/playerlook.js | 114 +-- .../src/commands/simplayer/playermove.js | 110 +-- .../src/commands/simplayer/playerprefix.js | 24 +- .../src/commands/simplayer/playerrejoin.js | 46 +- .../src/commands/simplayer/playerselect.js | 30 +- .../src/commands/simplayer/playersneak.js | 30 +- .../src/commands/simplayer/playersprint.js | 30 +- .../src/commands/simplayer/playerstop.js | 24 +- .../src/commands/simplayer/playerswapheld.js | 24 +- .../src/commands/simplayer/playertp.js | 24 +- .../src/classes/simplayer/Actions.test.js | 167 +++++ .../classes/simplayer/PlayerInfoSaver.test.js | 180 +++++ .../simplayer/RepeatableAction.test.js | 175 +++++ .../classes/simplayer/Understudies.test.js | 122 ++++ .../src/classes/simplayer/Understudy.test.js | 667 ++++++++++++++++++ .../UnderstudyInventorySaver.test.js | 169 +++++ .../src/classes/simplayer/utils.test.js | 169 ++++- .../commands/simplayer/playeraction.test.js | 97 +++ .../simplayer/playerclaimprojectiles.test.js | 45 ++ .../simplayer/playerinventory.test.js | 73 ++ .../src/commands/simplayer/playerjoin.test.js | 51 ++ .../commands/simplayer/playerleave.test.js | 47 ++ .../src/commands/simplayer/playerlook.test.js | 119 ++++ .../src/commands/simplayer/playermove.test.js | 118 ++++ .../commands/simplayer/playerprefix.test.js | 35 + .../commands/simplayer/playerrejoin.test.js | 53 ++ .../commands/simplayer/playerselect.test.js | 57 ++ .../commands/simplayer/playersneak.test.js | 45 ++ .../commands/simplayer/playersprint.test.js | 45 ++ .../src/commands/simplayer/playerstop.test.js | 38 + .../commands/simplayer/playerswapheld.test.js | 40 ++ .../src/commands/simplayer/playertp.test.js | 45 ++ .../rules/simplayer/noSimplayerSaving.test.js | 32 + .../simplayer/simplayerRejoining.test.js | 125 ++++ 40 files changed, 3127 insertions(+), 230 deletions(-) create mode 100644 __tests__/BP/scripts/src/classes/simplayer/Actions.test.js create mode 100644 __tests__/BP/scripts/src/classes/simplayer/PlayerInfoSaver.test.js create mode 100644 __tests__/BP/scripts/src/classes/simplayer/RepeatableAction.test.js create mode 100644 __tests__/BP/scripts/src/classes/simplayer/Understudy.test.js create mode 100644 __tests__/BP/scripts/src/classes/simplayer/UnderstudyInventorySaver.test.js create mode 100644 __tests__/BP/scripts/src/commands/simplayer/playeraction.test.js create mode 100644 __tests__/BP/scripts/src/commands/simplayer/playerclaimprojectiles.test.js create mode 100644 __tests__/BP/scripts/src/commands/simplayer/playerinventory.test.js create mode 100644 __tests__/BP/scripts/src/commands/simplayer/playerjoin.test.js create mode 100644 __tests__/BP/scripts/src/commands/simplayer/playerleave.test.js create mode 100644 __tests__/BP/scripts/src/commands/simplayer/playerlook.test.js create mode 100644 __tests__/BP/scripts/src/commands/simplayer/playermove.test.js create mode 100644 __tests__/BP/scripts/src/commands/simplayer/playerprefix.test.js create mode 100644 __tests__/BP/scripts/src/commands/simplayer/playerrejoin.test.js create mode 100644 __tests__/BP/scripts/src/commands/simplayer/playerselect.test.js create mode 100644 __tests__/BP/scripts/src/commands/simplayer/playersneak.test.js create mode 100644 __tests__/BP/scripts/src/commands/simplayer/playersprint.test.js create mode 100644 __tests__/BP/scripts/src/commands/simplayer/playerstop.test.js create mode 100644 __tests__/BP/scripts/src/commands/simplayer/playerswapheld.test.js create mode 100644 __tests__/BP/scripts/src/commands/simplayer/playertp.test.js create mode 100644 __tests__/BP/scripts/src/rules/simplayer/noSimplayerSaving.test.js create mode 100644 __tests__/BP/scripts/src/rules/simplayer/simplayerRejoining.test.js diff --git a/Canopy[BP]/manifest.json b/Canopy[BP]/manifest.json index b3b9052c..3424d1b9 100644 --- a/Canopy[BP]/manifest.json +++ b/Canopy[BP]/manifest.json @@ -52,6 +52,10 @@ "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": [ diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js b/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js index 8ade0fa0..136506eb 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js @@ -3,24 +3,30 @@ import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandO import Understudies from "../../classes/simplayer/Understudies"; import { REPEATABLE_ACTIONS, TIMING_OPTIONS } from "../../classes/simplayer/RepeatableAction"; -new VanillaCommand({ - name: 'canopy:playeraction', - description: 'commands.playeraction', - enums: [ - { name: 'canopy:simplayerAction', values: Object.values(REPEATABLE_ACTIONS) }, - { 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, playername, action, timingOption = TIMING_OPTIONS.ONCE, ticks) => { +export class PlayerActionCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playeraction', + description: 'commands.playeraction', + enums: [ + { name: 'canopy:simplayerAction', values: Object.values(REPEATABLE_ACTIONS) }, + { 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) + }); + } + + playeractionCommand(_origin, playername, action, timingOption = TIMING_OPTIONS.ONCE, ticks) { const understudy = Understudies.get(playername); if (!understudy) return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; @@ -30,18 +36,12 @@ new VanillaCommand({ actions.once(action); break; case TIMING_OPTIONS.AFTER: - if (ticks === void 0) - return { status: CustomCommandStatus.Failure, message: `§cInvalid '${timingOption}' tick duration: ${ticks}. Expected an integer.` }; - actions.once(action, ticks); - break; + return this.#singleAfterAction(actions, action, timingOption, ticks); case TIMING_OPTIONS.CONTINUOUS: actions.repeat(action); break; case TIMING_OPTIONS.INTERVAL: - if (ticks === void 0) - return { status: CustomCommandStatus.Failure, message: `§cInvalid '${timingOption}' tick duration: ${ticks}. Expected an integer.` }; - actions.repeat(action, ticks); - break; + return this.#intervalAction(actions, action, timingOption, ticks); case TIMING_OPTIONS.STOP: actions.remove(action); break; @@ -50,4 +50,20 @@ new VanillaCommand({ } return { status: CustomCommandStatus.Success }; } -}); + + #singleAfterAction(actions, action, timingOption, ticks) { + if (ticks === void 0) + return { status: CustomCommandStatus.Failure, message: `§cInvalid '${timingOption}' tick duration: ${ticks}. Expected an integer.` }; + actions.once(action, ticks); + return { status: CustomCommandStatus.Success }; + } + + #intervalAction(actions, action, timingOption, ticks) { + if (ticks === void 0) + return { status: CustomCommandStatus.Failure, message: `§cInvalid '${timingOption}' tick duration: ${ticks}. Expected an integer.` }; + actions.repeat(action, ticks); + return { status: CustomCommandStatus.Success }; + } +} + +export const playeractionCommand = new PlayerActionCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerclaimprojectiles.js b/Canopy[BP]/scripts/src/commands/simplayer/playerclaimprojectiles.js index 6d4468a4..9882c885 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerclaimprojectiles.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerclaimprojectiles.js @@ -2,18 +2,26 @@ import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, sy import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; import Understudies from "../../classes/simplayer/Understudies"; -new VanillaCommand({ - name: 'canopy:playerclaimprojectiles', - description: 'commands.playerclaimprojectiles', - mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], - optionalParameters: [{ name: 'radius', type: CustomCommandParamType.Float }], - permissionLevel: CommandPermissionLevel.Any, - allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], - callback: (_origin, playername, radius = 25) => { +export class PlayerClaimProjectilesCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playerclaimprojectiles', + description: 'commands.playerclaimprojectiles', + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + optionalParameters: [{ name: 'radius', type: CustomCommandParamType.Float }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, ...args) => this.playerclaimprojectilesCommand(origin, ...args) + }); + } + + playerclaimprojectilesCommand(_origin, playername, radius = 25) { const understudy = Understudies.get(playername); if (!understudy) return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; system.run(() => understudy.claimProjectiles(radius)); return { status: CustomCommandStatus.Success }; } -}); +} + +export const playerclaimprojectilesCommand = new PlayerClaimProjectilesCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerinventory.js b/Canopy[BP]/scripts/src/commands/simplayer/playerinventory.js index d021a163..fe0a3e25 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerinventory.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerinventory.js @@ -2,29 +2,46 @@ import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus } f import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; import Understudies from "../../classes/simplayer/Understudies"; -new VanillaCommand({ - name: 'canopy:playerinventory', - description: 'commands.playerinventory', - mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], - permissionLevel: CommandPermissionLevel.Any, - allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], - callback: (_origin, playername) => { +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) return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; const playerInventory = understudy.getInventory(); if (!playerInventory) return { status: CustomCommandStatus.Success, message: '§cNo inventory found' }; + return { status: CustomCommandStatus.Success, message: this.#getInventoryMessage(understudy, playerInventory) }; + } + + #getInventoryMessage(understudy, playerInventory) { if (playerInventory.size === playerInventory.emptySlotsCount) - return { status: CustomCommandStatus.Success, message: `§7${understudy.name}'s inventory is empty.` }; + return `§7${understudy.name}'s inventory is empty.`; + return this.#getFormattedInventoryMessage(understudy, playerInventory); + } + + #getFormattedInventoryMessage(understudy, playerInventory) { let message = `${understudy.name}'s inventory:`; for (let i = 0; i < playerInventory.size; i++) { const itemStack = playerInventory.getItem(i); if (itemStack !== void 0) { const colorCode = i < 10 ? '§a' : ''; - message += `\n§7- ${colorCode}${i}§7: ${itemStack.typeId} x${itemStack.amount}`; + message += ` +§7- ${colorCode}${i}§7: ${itemStack.typeId} x${itemStack.amount}`; } } - return { status: CustomCommandStatus.Success, message }; + return message; } -}); +} + +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 index af524ffd..75fe428e 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerjoin.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerjoin.js @@ -3,13 +3,19 @@ import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandO import Understudies from "../../classes/simplayer/Understudies"; import { getLocationInfoFromSource } from "../../classes/simplayer/utils"; -new VanillaCommand({ - name: 'canopy:playerjoin', - description: 'commands.playerjoin', - mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], - permissionLevel: CommandPermissionLevel.Any, - allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin], - callback: (origin, playername) => { +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)) return { status: CustomCommandStatus.Failure, message: Understudies.getAlreadyOnlineMessage(playername) }; system.run(() => { @@ -18,4 +24,6 @@ new VanillaCommand({ 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 index 736e630b..fc60ef06 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerleave.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerleave.js @@ -2,13 +2,19 @@ import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, sy import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; import Understudies from "../../classes/simplayer/Understudies"; -new VanillaCommand({ - name: 'canopy:playerleave', - description: 'commands.playerleave', - mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], - permissionLevel: CommandPermissionLevel.Any, - allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], - callback: (_origin, playername) => { +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) return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; @@ -17,4 +23,6 @@ new VanillaCommand({ 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 index 97019838..aa25f579 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js @@ -3,71 +3,101 @@ import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandO import Understudies from "../../classes/simplayer/Understudies"; import { Vector } from "../../../lib/Vector"; -const LOOK_OPTIONS = Object.freeze({ +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', STOP: 'stop' }); -const CARDINAL_ROTATIONS = { +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 } }; -new VanillaCommand({ - 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: 'location', type: CustomCommandParamType.Location } - ], - permissionLevel: CommandPermissionLevel.Any, - allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], - callback: (origin, playername, lookOption, location) => { +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: 'location', type: CustomCommandParamType.Location } + ], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, ...args) => this.playerlookCommand(origin, ...args) + }); + } + + playerlookCommand(origin, playername, lookOption, location) { const understudy = Understudies.get(playername); if (!understudy) return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; 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: - system.run(() => understudy.look(CARDINAL_ROTATIONS[lookOption])); - break; - case LOOK_OPTIONS.BLOCK: { - const source = origin.getSource(); - if (source instanceof Entity === false) - return { status: CustomCommandStatus.Failure, message: '§cBlock targeting may only be used by entities.' }; - const block = source.getBlockFromViewDirection({ maxDistance: 16*64 })?.block; - if (block === void 0) - return { status: CustomCommandStatus.Failure, message: '§cNo block in view.' }; - system.run(() => understudy.look(block)); + this.#lookAtCardinal(understudy, lookOption); break; - } - case LOOK_OPTIONS.ENTITY: { - const source = origin.getSource(); - if (source instanceof Entity === false) - return { status: CustomCommandStatus.Failure, message: '§cEntity targeting may only be used by entities.' }; - const entity = source.getEntitiesFromViewDirection({ maxDistance: 16*64 })[0]?.entity; - if (entity === void 0) - return { status: CustomCommandStatus.Failure, message: '§cNo entity in view.' }; - system.run(() => understudy.look(entity)); - break; - } + case LOOK_OPTIONS.BLOCK: + return this.#lookAtBlock(origin, understudy); + case LOOK_OPTIONS.ENTITY: + return this.#lookAtEntity(origin, understudy); case LOOK_OPTIONS.ME: - if (origin instanceof ServerCommandOrigin) - return { status: CustomCommandStatus.Failure, message: '§cSelf-targeting cannot be used by the server.' }; - system.run(() => understudy.look(origin.getSource())); - break; + return this.#lookAtMe(origin, understudy); case LOOK_OPTIONS.AT: - system.run(() => understudy.look(Vector.from(location))); + this.#lookAtLocation(understudy, location); break; case LOOK_OPTIONS.STOP: - system.run(() => understudy.stopLooking()); + this.#stopLooking(understudy); break; default: return { status: CustomCommandStatus.Failure, message: `§cInvalid look option: '${lookOption}'` }; } 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: '§cBlock targeting may only be used by entities.' }; + const block = source.getBlockFromViewDirection({ maxDistance: 16*64 })?.block; + if (block === void 0) + return { status: CustomCommandStatus.Failure, message: '§cNo block in view.' }; + system.run(() => understudy.look(block)); + return { status: CustomCommandStatus.Success }; + } + + #lookAtEntity(origin, understudy) { + const source = origin.getSource(); + if (source instanceof Entity === false) + return { status: CustomCommandStatus.Failure, message: '§cEntity targeting may only be used by entities.' }; + const entity = source.getEntitiesFromViewDirection({ maxDistance: 16*64 })[0]?.entity; + if (entity === void 0) + return { status: CustomCommandStatus.Failure, message: '§cNo entity in view.' }; + system.run(() => understudy.look(entity)); + return { status: CustomCommandStatus.Success }; + } + + #lookAtMe(origin, understudy) { + if (origin instanceof ServerCommandOrigin) + return { status: CustomCommandStatus.Failure, message: '§cSelf-targeting cannot be used by the server.' }; + system.run(() => understudy.look(origin.getSource())); + return { status: CustomCommandStatus.Success }; + } + + #lookAtLocation(understudy, location) { + system.run(() => understudy.look(Vector.from(location))); + } + + #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 index 21a4a772..de4a338e 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playermove.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playermove.js @@ -8,60 +8,90 @@ export const MOVE_OPTIONS = Object.freeze({ BLOCK: 'block', ENTITY: 'entity', ME: 'me', TO: 'to', STOP: 'stop' }); -new VanillaCommand({ - 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, playername, moveOption, location) => { +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) + }); + } + + playermoveCommand(origin, playername, moveOption, location) { const understudy = Understudies.get(playername); if (!understudy) return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; switch (moveOption) { case MOVE_OPTIONS.FORWARD: case MOVE_OPTIONS.BACKWARD: case MOVE_OPTIONS.LEFT: case MOVE_OPTIONS.RIGHT: - system.run(() => understudy.moveRelative(moveOption)); - break; - case MOVE_OPTIONS.BLOCK: { - const source = origin.getSource(); - if (source instanceof Entity === false) - return { status: CustomCommandStatus.Failure, message: '§cMoving to a block may only be used by entities.' }; - const block = source.getBlockFromViewDirection({ maxDistance: 16*64 })?.block; - if (block === void 0) - return { status: CustomCommandStatus.Failure, message: '§cNo block in view.' }; - system.run(() => understudy.moveLocation(block)); + this.#moveRelatively(understudy, moveOption); break; - } - case MOVE_OPTIONS.ENTITY: { - const source = origin.getSource(); - if (source instanceof Entity === false) - return { status: CustomCommandStatus.Failure, message: '§cMoving to an entity may only be used by entities.' }; - const entity = source.getEntitiesFromViewDirection({ maxDistance: 16*64 })[0]?.entity; - if (entity === void 0) - return { status: CustomCommandStatus.Failure, message: '§cNo entity in view.' }; - system.run(() => understudy.moveLocation(entity)); - break; - } + case MOVE_OPTIONS.BLOCK: + return this.#moveToBlock(origin, understudy); + case MOVE_OPTIONS.ENTITY: + return this.#moveToEntity(origin, understudy); case MOVE_OPTIONS.ME: - if (origin instanceof ServerCommandOrigin) - return { status: CustomCommandStatus.Failure, message: '§cMoving to yourself cannot be used by the server.' }; - system.run(() => understudy.moveLocation(origin.getSource())); - break; + return this.#moveToMe(origin, understudy); case MOVE_OPTIONS.TO: - system.run(() => understudy.moveLocation(Vector.from(location))); + this.#moveToLocation(understudy, location); break; case MOVE_OPTIONS.STOP: - system.run(() => understudy.stopMoving()); + this.#stopMoving(understudy); break; default: return { status: CustomCommandStatus.Failure, message: `§cInvalid move option: '${moveOption}'` }; } 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: '§cMoving to a block may only be used by entities.' }; + const block = source.getBlockFromViewDirection({ maxDistance: 16*64 })?.block; + if (block === void 0) + return { status: CustomCommandStatus.Failure, message: '§cNo block in view.' }; + 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: '§cMoving to an entity may only be used by entities.' }; + const entity = source.getEntitiesFromViewDirection({ maxDistance: 16*64 })[0]?.entity; + if (entity === void 0) + return { status: CustomCommandStatus.Failure, message: '§cNo entity in view.' }; + system.run(() => understudy.moveLocation(entity)); + return { status: CustomCommandStatus.Success }; + } + + #moveToMe(origin, understudy) { + if (origin instanceof ServerCommandOrigin) + return { status: CustomCommandStatus.Failure, message: '§cMoving to yourself cannot be used by the server.' }; + 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 index 1adfd9f2..dcaad77c 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerprefix.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerprefix.js @@ -2,13 +2,19 @@ import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, sy import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; import Understudies from "../../classes/simplayer/Understudies"; -new VanillaCommand({ - name: 'canopy:playerprefix', - description: 'commands.playerprefix', - mandatoryParameters: [{ name: 'prefix', type: CustomCommandParamType.String }], - permissionLevel: CommandPermissionLevel.Any, - allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], - callback: (_origin, prefix) => { +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: '§7Simplayer prefix removed.' }; @@ -16,4 +22,6 @@ new VanillaCommand({ system.run(() => Understudies.setNametagPrefix(prefix)); return { status: CustomCommandStatus.Success, message: `§7Simplayer prefix set to "§r${prefix}§r§7".` }; } -}); +} + +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 index 1cee48f5..3f577d4e 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerrejoin.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerrejoin.js @@ -3,25 +3,35 @@ import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandO import Understudies from "../../classes/simplayer/Understudies"; import { getLocationInfoFromSource } from "../../classes/simplayer/utils"; -new VanillaCommand({ - name: 'canopy:playerrejoin', - description: 'commands.playerrejoin', - mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], - permissionLevel: CommandPermissionLevel.Any, - allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], - callback: (origin, playername) => { +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)) return { status: CustomCommandStatus.Failure, message: Understudies.getAlreadyOnlineMessage(playername) }; - system.run(() => { - 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); - }); + 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 index 122315d0..a7d2332e 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerselect.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerselect.js @@ -2,16 +2,22 @@ import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, sy import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; import Understudies from "../../classes/simplayer/Understudies"; -new VanillaCommand({ - 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, playername, slotNumber) => { +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) return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; @@ -20,4 +26,6 @@ new VanillaCommand({ 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 index 231828e0..30da32eb 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playersneak.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playersneak.js @@ -2,20 +2,28 @@ import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, sy import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; import Understudies from "../../classes/simplayer/Understudies"; -new VanillaCommand({ - 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, playername, shouldSneak) => { +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) return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; 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 index 4cb68ab8..34ecb036 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playersprint.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playersprint.js @@ -2,20 +2,28 @@ import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, sy import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; import Understudies from "../../classes/simplayer/Understudies"; -new VanillaCommand({ - 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, playername, shouldSprint) => { +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) return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; 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 index d06190cc..4c73e956 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerstop.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerstop.js @@ -2,17 +2,25 @@ import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, sy import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; import Understudies from "../../classes/simplayer/Understudies"; -new VanillaCommand({ - name: 'canopy:playerstop', - description: 'commands.playerstop', - mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], - permissionLevel: CommandPermissionLevel.Any, - allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], - callback: (_origin, playername) => { +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) return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; 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 index 10f85ca6..6d00aa6a 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerswapheld.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerswapheld.js @@ -2,17 +2,25 @@ import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, sy import { VanillaCommand, PlayerCommandOrigin, EntityCommandOrigin } from "../../../lib/canopy/Canopy"; import Understudies from "../../classes/simplayer/Understudies"; -new VanillaCommand({ - name: 'canopy:playerswapheld', - description: 'commands.playerswapheld', - mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], - permissionLevel: CommandPermissionLevel.Any, - allowedSources: [PlayerCommandOrigin, EntityCommandOrigin], - callback: (origin, playername) => { +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) return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; 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 index c8aff0a1..841a07d5 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playertp.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playertp.js @@ -3,17 +3,25 @@ import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandO import Understudies from "../../classes/simplayer/Understudies"; import { getLocationInfoFromSource } from "../../classes/simplayer/utils"; -new VanillaCommand({ - name: 'canopy:playertp', - description: 'commands.playertp', - mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], - permissionLevel: CommandPermissionLevel.Any, - allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin], - callback: (origin, playername) => { +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) return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; system.run(() => understudy.teleport(getLocationInfoFromSource(origin.getSource()))); return { status: CustomCommandStatus.Success }; } -}); +} + +export const playertpCommand = new PlayerTpCommand(); 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..4310fe2e --- /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 { noSimplayerSaving } from '../../../../../../Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving'; +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/noSimplayerSaving', () => ({ + noSimplayerSaving: { getNativeValue: vi.fn(() => false), getID: vi.fn(() => 'noSimplayerSaving') } +})); +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('noSimplayerSaving', false); + }); + + describe('get', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws when noSimplayerSaving is enabled', () => { + vi.mocked(noSimplayerSaving.getNativeValue).mockReturnValue(true); + 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 noSimplayerSaving is enabled', () => { + vi.mocked(noSimplayerSaving.getNativeValue).mockReturnValue(true); + 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 noSimplayerSaving is enabled', () => { + vi.mocked(noSimplayerSaving.getNativeValue).mockReturnValue(true); + 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 noSimplayerSaving is enabled', () => { + vi.mocked(noSimplayerSaving.getNativeValue).mockReturnValue(true); + 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..183bfa01 --- /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/noSimplayerSaving', () => ({ + noSimplayerSaving: { getNativeValue: vi.fn(() => false), getID: vi.fn(() => 'noSimplayerSaving') } +})); +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 index b51c851e..2685be2e 100644 --- a/__tests__/BP/scripts/src/classes/simplayer/Understudies.test.js +++ b/__tests__/BP/scripts/src/classes/simplayer/Understudies.test.js @@ -69,3 +69,125 @@ describe('create and get', () => { 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('[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('[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('Alice')).toBe(`§cSimplayer 'Alice' is not online.`); + }); + + it('returns the correct already-online message', () => { + expect(Understudies.getAlreadyOnlineMessage('Alice')).toBe(`§cSimplayer 'Alice' is already online.`); + }); +}); 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..f9c25fc5 --- /dev/null +++ b/__tests__/BP/scripts/src/classes/simplayer/Understudy.test.js @@ -0,0 +1,667 @@ +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/noSimplayerSaving', () => ({ + noSimplayerSaving: { getNativeValue: vi.fn(() => false), getID: vi.fn(() => 'noSimplayerSaving') } +})); +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(expect.stringContaining('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 lookAtBlock and stores block as look target', () => { + const target = new Block(); + target.location = { x: 0, y: 64, z: 0 }; + understudy.look(target); + expect(understudy.simulatedPlayer.lookAtBlock).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(expect.stringContaining('Successfully became the owner of 1 projectiles')); + }); + + 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(expect.stringContaining('No claimable projectiles found within 10 blocks')); + }); + + 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.claimProjectiles(10); + expect(world.sendMessage).toHaveBeenCalledWith(expect.stringContaining('No claimable projectiles found within 10 blocks')); + }); + + 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(expect.stringContaining('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..911eed69 --- /dev/null +++ b/__tests__/BP/scripts/src/classes/simplayer/UnderstudyInventorySaver.test.js @@ -0,0 +1,169 @@ +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/noSimplayerSaving', () => ({ + noSimplayerSaving: { getNativeValue: vi.fn(() => false), getID: vi.fn(() => 'noSimplayerSaving') } +})); +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('sets item to undefined 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, 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 index d9dcb32c..10fa75fa 100644 --- a/__tests__/BP/scripts/src/classes/simplayer/utils.test.js +++ b/__tests__/BP/scripts/src/classes/simplayer/utils.test.js @@ -1,8 +1,15 @@ -import { describe, it, expect, vi } from 'vitest'; +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 }; @@ -12,18 +19,110 @@ describe('getLookAtLocation', () => { 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: 1.62001002, z: 1 }; + 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', () => { @@ -39,9 +138,24 @@ describe('swapSlots', () => { 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', () => { @@ -52,6 +166,13 @@ describe('portOldGameModeToNewUpdate', () => { 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(); }); @@ -59,10 +180,54 @@ describe('portOldGameModeToNewUpdate', () => { 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/simplayer/playeraction.test.js b/__tests__/BP/scripts/src/commands/simplayer/playeraction.test.js new file mode 100644 index 00000000..8c59d539 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playeraction.test.js @@ -0,0 +1,97 @@ +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 } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playeraction'; +import { REPEATABLE_ACTIONS, TIMING_OPTIONS } 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 => `§cSimplayer '${name}' is not online.`), + } +})); + +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; + + beforeEach(() => { + vi.clearAllMocks(); + mockActions = { + once: vi.fn(), + repeat: vi.fn(), + remove: vi.fn(), + }; + mockUnderstudy = { name: 'TestBot', actions: mockActions }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playeractionCommand.playeractionCommand(undefined, 'TestBot', REPEATABLE_ACTIONS.ATTACK); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('queues a once action with ONCE timing (default)', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playeractionCommand.playeractionCommand(undefined, '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(undefined, 'TestBot', REPEATABLE_ACTIONS.ATTACK, TIMING_OPTIONS.AFTER, undefined); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('queues a delayed once action with AFTER timing', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playeractionCommand.playeractionCommand(undefined, '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(undefined, '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(undefined, 'TestBot', REPEATABLE_ACTIONS.ATTACK, TIMING_OPTIONS.INTERVAL, undefined); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('queues an interval repeating action with INTERVAL timing', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playeractionCommand.playeractionCommand(undefined, '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(undefined, '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(undefined, 'TestBot', REPEATABLE_ACTIONS.ATTACK, 'invalid'); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('defaults to ONCE timing when no timing option is provided', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + playeractionCommand.playeractionCommand(undefined, 'TestBot', REPEATABLE_ACTIONS.ATTACK); + expect(mockActions.once).toHaveBeenCalledWith(REPEATABLE_ACTIONS.ATTACK); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerclaimprojectiles.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerclaimprojectiles.test.js new file mode 100644 index 00000000..b0cdde8c --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerclaimprojectiles.test.js @@ -0,0 +1,45 @@ +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 { playerclaimprojectilesCommand } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playerclaimprojectiles'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + getNotOnlineMessage: vi.fn(name => `§cSimplayer '${name}' is not online.`), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playerclaimprojectilesCommand', () => { + let mockUnderstudy; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { claimProjectiles: vi.fn(), name: 'TestBot' }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playerclaimprojectilesCommand.playerclaimprojectilesCommand(undefined, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns success and queues claimProjectiles with default radius when online', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerclaimprojectilesCommand.playerclaimprojectilesCommand(undefined, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(system.run).toHaveBeenCalled(); + }); + + it('returns success and queues claimProjectiles with specified radius', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerclaimprojectilesCommand.playerclaimprojectilesCommand(undefined, 'TestBot', 50); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(system.run).toHaveBeenCalled(); + }); +}); 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..608cc333 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerinventory.test.js @@ -0,0 +1,73 @@ +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 => `§cSimplayer '${name}' is not online.`), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playerinventoryCommand', () => { + let mockUnderstudy; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { name: 'TestBot', getInventory: vi.fn() }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playerinventoryCommand.playerinventoryCommand(undefined, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + 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(undefined, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(result.message).toContain('No inventory found'); + }); + + 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(undefined, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(result.message).toContain("TestBot's inventory is empty"); + }); + + 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(undefined, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(result.message).toContain('minecraft:stone'); + expect(result.message).toContain('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); + const result = playerinventoryCommand.playerinventoryCommand(undefined, 'TestBot'); + expect(result.message).toContain('§a0'); + }); +}); 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..598b29af --- /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, CustomCommandStatus } 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 => `§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('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') })) }; + }); + + it('returns failure when the simplayer is already online', () => { + vi.mocked(Understudies.isOnline).mockReturnValue(true); + const result = playerjoinCommand.playerjoinCommand(mockOrigin, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Failure); + expect(result.message).toContain('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..2c232f21 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerleave.test.js @@ -0,0 +1,47 @@ +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 { 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 => `§cSimplayer '${name}' is not online.`), + 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; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { leave: vi.fn(), name: 'TestBot' }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playerleaveCommand.playerleaveCommand(undefined, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Failure); + expect(result.message).toContain('TestBot'); + }); + + it('queues a system.run when the simplayer is online', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + playerleaveCommand.playerleaveCommand(undefined, '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(undefined, '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..f20be729 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerlook.test.js @@ -0,0 +1,119 @@ +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 => `§cSimplayer '${name}' is not online.`), + } +})); + +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) }; + }); + + 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.status).toBe(CustomCommandStatus.Failure); + }); + + 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).toContain('entities'); + }); + + 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, { 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 = 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.status).toBe(CustomCommandStatus.Failure); + }); +}); 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..3383242b --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playermove.test.js @@ -0,0 +1,118 @@ +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 => `§cSimplayer '${name}' is not online.`), + } +})); + +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) }; + }); + + 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.status).toBe(CustomCommandStatus.Failure); + }); + + 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.status).toBe(CustomCommandStatus.Failure); + }); +}); 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..a620c396 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerprefix.test.js @@ -0,0 +1,35 @@ +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 { 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', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('clears the prefix and returns success message when "-none" is passed', () => { + const result = playerprefixCommand.playerprefixCommand(undefined, '-none'); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(result.message).toContain('removed'); + expect(system.run).toHaveBeenCalled(); + }); + + it('sets the prefix and returns success message with the new prefix', () => { + const result = playerprefixCommand.playerprefixCommand(undefined, 'Bot'); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(result.message).toContain('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..06682c92 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerrejoin.test.js @@ -0,0 +1,53 @@ +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 => `§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('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') + })) + }; + }); + + it('returns failure when the simplayer is already online', () => { + vi.mocked(Understudies.isOnline).mockReturnValue(true); + const result = playerrejoinCommand.playerrejoinCommand(mockOrigin, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Failure); + expect(result.message).toContain('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..3e245a6a --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerselect.test.js @@ -0,0 +1,57 @@ +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 => `§cSimplayer '${name}' is not online.`), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playerselectCommand', () => { + let mockUnderstudy; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { selectSlot: vi.fn(), name: 'TestBot' }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playerselectCommand.playerselectCommand(undefined, 'TestBot', 0); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns failure when slot number is less than 0', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerselectCommand.playerselectCommand(undefined, 'TestBot', -1); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns failure when slot number is greater than 8', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerselectCommand.playerselectCommand(undefined, 'TestBot', 9); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns success and queues selectSlot for valid slot 0', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerselectCommand.playerselectCommand(undefined, '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(undefined, '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..55a03e92 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playersneak.test.js @@ -0,0 +1,45 @@ +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 => `§cSimplayer '${name}' is not online.`), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playersneakCommand', () => { + let mockUnderstudy; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { sneak: vi.fn(), name: 'TestBot' }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playersneakCommand.playersneakCommand(undefined, 'TestBot', true); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + 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..d1c3df11 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playersprint.test.js @@ -0,0 +1,45 @@ +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 => `§cSimplayer '${name}' is not online.`), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playersprintCommand', () => { + let mockUnderstudy; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { sprint: vi.fn(), name: 'TestBot' }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playersprintCommand.playersprintCommand(undefined, 'TestBot', true); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + 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..0fa1ac63 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerstop.test.js @@ -0,0 +1,38 @@ +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 => `§cSimplayer '${name}' is not online.`), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playerstopCommand', () => { + let mockUnderstudy; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { stopAll: vi.fn(), name: 'TestBot' }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playerstopCommand.playerstopCommand(undefined, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + 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..3f304762 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerswapheld.test.js @@ -0,0 +1,40 @@ +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 => `§cSimplayer '${name}' is not online.`), + } +})); + +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 })) }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playerswapheldCommand.playerswapheldCommand(mockOrigin, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + 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..07a2ceac --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playertp.test.js @@ -0,0 +1,45 @@ +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 => `§cSimplayer '${name}' is not online.`), + } +})); + +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') })) }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playertpCommand.playertpCommand(mockOrigin, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + 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/simplayer/noSimplayerSaving.test.js b/__tests__/BP/scripts/src/rules/simplayer/noSimplayerSaving.test.js new file mode 100644 index 00000000..9720cd6a --- /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 { noSimplayerSaving } from '../../../../../../Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving'; + +describe('noSimplayerSaving', () => { + beforeEach(() => { + vi.clearAllMocks(); + worldDynamicPropertyStore.set('noSimplayerSaving', undefined); + }); + + describe('getID', () => { + it('returns the correct identifier', () => { + expect(noSimplayerSaving.getID()).toBe('noSimplayerSaving'); + }); + }); + + describe('getNativeValue', () => { + it('returns false by default when no value is stored', () => { + expect(noSimplayerSaving.getNativeValue()).toBe(false); + }); + + it('returns true when the rule is enabled', () => { + worldDynamicPropertyStore.set('noSimplayerSaving', true); + expect(noSimplayerSaving.getNativeValue()).toBe(true); + }); + + it('returns false when the rule is explicitly disabled', () => { + worldDynamicPropertyStore.set('noSimplayerSaving', false); + expect(noSimplayerSaving.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(); + }); + }); +}); From d0e43ce9e476b6eef5f8cd8ef01d5738502fdc71 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 12:18:18 -0700 Subject: [PATCH 014/120] feat: reintroduce look rotation command --- .../src/commands/simplayer/playerlook.js | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js b/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js index aa25f579..22d19348 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js @@ -6,7 +6,7 @@ import { Vector } from "../../../lib/Vector"; 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', STOP: 'stop' + ME: 'me', AT: 'at', ROTATION: 'rotation', STOP: 'stop' }); export const CARDINAL_ROTATIONS = { @@ -23,7 +23,9 @@ export class PlayerLookCommand extends VanillaCommand { mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], optionalParameters: [ { name: 'canopy:simplayerLookOption', type: CustomCommandParamType.Enum }, - { name: 'location', type: CustomCommandParamType.Location } + { name: 'x', type: CustomCommandParamType.Float }, + { name: 'y', type: CustomCommandParamType.Float }, + { name: 'z', type: CustomCommandParamType.Float } ], permissionLevel: CommandPermissionLevel.Any, allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], @@ -31,7 +33,8 @@ export class PlayerLookCommand extends VanillaCommand { }); } - playerlookCommand(origin, playername, lookOption, location) { + playerlookCommand(origin, playername, lookOption, x, y, z) { + const location = { x, y, z }; const understudy = Understudies.get(playername); if (!understudy) return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; @@ -47,8 +50,15 @@ export class PlayerLookCommand extends VanillaCommand { 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: '§cMissing coordinates for look at location.' }; this.#lookAtLocation(understudy, location); break; + case LOOK_OPTIONS.ROTATION: + if (x === void 0 || y === void 0) + return { status: CustomCommandStatus.Failure, message: '§cMissing yaw or pitch for look rotation.' }; + this.#lookRotation(understudy, { x: location.x, y: location.y }); + break; case LOOK_OPTIONS.STOP: this.#stopLooking(understudy); break; @@ -95,6 +105,10 @@ export class PlayerLookCommand extends VanillaCommand { system.run(() => understudy.look(Vector.from(location))); } + #lookRotation(understudy, rotation) { + system.run(() => understudy.look(rotation)); + } + #stopLooking(understudy) { system.run(() => understudy.stopLooking()); } From b334b1446d3f884daca27674c64c9c2ccfea0086 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 13:07:11 -0700 Subject: [PATCH 015/120] feat(simplayer): add en_US translation keys for simplayer commands, messages, and rules --- Canopy[RP]/texts/en_US.lang | 52 ++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/Canopy[RP]/texts/en_US.lang b/Canopy[RP]/texts/en_US.lang index a42b34d2..c8497fdf 100644 --- a/Canopy[RP]/texts/en_US.lang +++ b/Canopy[RP]/texts/en_US.lang @@ -239,6 +239,45 @@ 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.playerclaimprojectiles=Make a simplayer the owner of all nearby projectiles. +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 @@ -391,6 +430,9 @@ rules.instaminableEndstone=Makes endstone instaminable while using netherite, ef rules.minecartChunkLoading=Allows minecarts to tick the specified chunk radius (square) around them for 10 seconds after they are spawned. rules.noWelcomeMessage=Disables the §lCanopy§r§8 welcome message. rules.pistonBedrockBreaking=Allows pistons to break bedrock when facing away from a bedrock block and expanding. +rules.noSimplayerSaving=Disables saving playerdata for simplayers. Improves performance but causes simplayers to lose their inventory and location when they leave and rejoin. +rules.simplayerRejoining=Makes online simplayers rejoin when the world reloads. + rules.playerSit=Allows players to sit down after %s quick sneaks. rules.potionBoostedBreeding=Reintroduces the behavior that allows speed potions to affect breeding attributes. rules.quickFillContainer=Using an item on a container with an arrow in inventory slot 9 (top left) will deposit all of that item into the container. @@ -459,4 +501,12 @@ 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.claimprojectiles.none=<%1> §7No claimable projectiles found within %2 blocks. +simplayer.claimprojectiles.success=<%1> §7Successfully became the owner of %2 projectiles. +simplayer.swapheld.error=§cError while swapping items: %s \ No newline at end of file From 6e12d594db37b064e28e2629061427e9b0e630fa Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 13:14:24 -0700 Subject: [PATCH 016/120] feat(simplayer): localize shared Understudy/Understudies messages --- .../scripts/src/classes/simplayer/Understudies.js | 4 ++-- .../scripts/src/classes/simplayer/Understudy.js | 8 ++++---- .../src/classes/simplayer/Understudies.test.js | 4 ++-- .../scripts/src/classes/simplayer/Understudy.test.js | 11 ++++++----- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/Canopy[BP]/scripts/src/classes/simplayer/Understudies.js b/Canopy[BP]/scripts/src/classes/simplayer/Understudies.js index 49f68a97..6cf6092c 100644 --- a/Canopy[BP]/scripts/src/classes/simplayer/Understudies.js +++ b/Canopy[BP]/scripts/src/classes/simplayer/Understudies.js @@ -116,11 +116,11 @@ class Understudies { } static getNotOnlineMessage(name) { - return `§cSimplayer '${name}' is not online.`; + return { translate: 'simplayer.notonline', with: [name] }; } static getAlreadyOnlineMessage(name) { - return `§cSimplayer '${name}' is already online.`; + return { translate: 'simplayer.alreadyonline', with: [name] }; } } diff --git a/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js b/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js index 2b232a7b..82c23bc5 100644 --- a/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js +++ b/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js @@ -112,7 +112,7 @@ class Understudy { this.#simulatedPlayer = void 0; this.clearLookTarget(); this.#isConnected = false; - world.sendMessage(`§e${this.name} left the game`); + world.sendMessage({ translate: 'simplayer.leave.broadcast', with: [this.name] }); } rejoin() { @@ -209,8 +209,8 @@ class Understudy { const projectileComponents = this.#getProjectileComponentsInRange(simulatedPlayer, radius); const numChanged = this.#changeProjectileOwner(projectileComponents, simulatedPlayer); if (numChanged === 0) - return world.sendMessage(`<${simulatedPlayer.name}> §7No claimable projectiles found within ${radius} blocks.`); - world.sendMessage(`<${simulatedPlayer.name}> §7Successfully became the owner of ${numChanged} projectiles.`); + 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(); } @@ -264,7 +264,7 @@ class Understudy { try { playerInvContainer.swapItems(this.#simulatedPlayer.selectedSlotIndex, targetPlayer.selectedSlotIndex, targetInvContainer); } catch (error) { - targetPlayer.sendMessage(`§cError while swapping items: ${error.name}`); + targetPlayer.sendMessage({ translate: 'simplayer.swapheld.error', with: [error.name] }); console.warn(error); } this.refreshHeldItem(); diff --git a/__tests__/BP/scripts/src/classes/simplayer/Understudies.test.js b/__tests__/BP/scripts/src/classes/simplayer/Understudies.test.js index 2685be2e..1338ef0f 100644 --- a/__tests__/BP/scripts/src/classes/simplayer/Understudies.test.js +++ b/__tests__/BP/scripts/src/classes/simplayer/Understudies.test.js @@ -184,10 +184,10 @@ describe('addNametagPrefix', () => { describe('message helpers', () => { it('returns the correct not-online message', () => { - expect(Understudies.getNotOnlineMessage('Alice')).toBe(`§cSimplayer 'Alice' is not online.`); + expect(Understudies.getNotOnlineMessage('TestBot')).toEqual({ translate: 'simplayer.notonline', with: ['TestBot'] }); }); it('returns the correct already-online message', () => { - expect(Understudies.getAlreadyOnlineMessage('Alice')).toBe(`§cSimplayer 'Alice' is already online.`); + 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 index f9c25fc5..5fd886cb 100644 --- a/__tests__/BP/scripts/src/classes/simplayer/Understudy.test.js +++ b/__tests__/BP/scripts/src/classes/simplayer/Understudy.test.js @@ -307,7 +307,7 @@ describe('Understudy', () => { it('broadcasts a leave message', () => { understudy.leave(); - expect(world.sendMessage).toHaveBeenCalledWith(expect.stringContaining('TestBot')); + expect(world.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.leave.broadcast', with: ['TestBot'] }); }); it('throws when understudy is not connected', () => { @@ -540,14 +540,14 @@ describe('Understudy', () => { understudy.simulatedPlayer.name = 'TestBot'; understudy.claimProjectiles(10); expect(mockComponent.owner).toBe(understudy.simulatedPlayer); - expect(world.sendMessage).toHaveBeenCalledWith(expect.stringContaining('Successfully became the owner of 1 projectiles')); + 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(expect.stringContaining('No claimable projectiles found within 10 blocks')); + expect(world.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.claimprojectiles.none', with: ['TestBot', String(10)] }); }); it('ignores invalid projectile components', () => { @@ -555,8 +555,9 @@ describe('Understudy', () => { 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(expect.stringContaining('No claimable projectiles found within 10 blocks')); + expect(world.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.claimprojectiles.none', with: ['TestBot', String(10)] }); }); it('throws when understudy is not connected', () => { @@ -643,7 +644,7 @@ describe('Understudy', () => { 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(expect.stringContaining('Error')); + expect(targetPlayer.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.swapheld.error', with: ['Error'] }); }); it('throws when understudy is not connected', () => { From 84fb1422f1795ff0558c5672210dfdbbb2ade6a2 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 13:30:35 -0700 Subject: [PATCH 017/120] feat(simplayer): use translation keys for simplayer rule descriptions --- Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving.js | 2 +- Canopy[BP]/scripts/src/rules/simplayer/simplayerRejoining.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving.js b/Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving.js index a5a240f7..a09223c1 100644 --- a/Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving.js +++ b/Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving.js @@ -4,7 +4,7 @@ class NoSimplayerSaving extends BooleanRule { constructor() { super({ identifier: 'noSimplayerSaving', - description: 'Disables saving playerdata for simplayers. Improves performance but causes simplayers to lose their inventory and location when they leave and rejoin.', + description: { translate: 'rules.noSimplayerSaving' }, defaultValue: false }); } diff --git a/Canopy[BP]/scripts/src/rules/simplayer/simplayerRejoining.js b/Canopy[BP]/scripts/src/rules/simplayer/simplayerRejoining.js index c693cff6..2dc6deff 100644 --- a/Canopy[BP]/scripts/src/rules/simplayer/simplayerRejoining.js +++ b/Canopy[BP]/scripts/src/rules/simplayer/simplayerRejoining.js @@ -8,7 +8,7 @@ class SimplayerRejoining extends BooleanRule { constructor() { super({ identifier: 'simplayerRejoining', - description: 'Makes online simplayers rejoin when the world reloads.', + description: { translate: 'rules.simplayerRejoining' }, defaultValue: false, onEnableCallback: () => this.subscribeToEvent(), onDisableCallback: () => this.unsubscribeFromEvent() From a3d371a6e465aec59053be48840096f1248557cb Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 13:35:25 -0700 Subject: [PATCH 018/120] feat(simplayer): localize not-online errors for tp/stop/sneak/sprint/leave --- .../scripts/src/commands/simplayer/playerleave.js | 8 +++++--- .../scripts/src/commands/simplayer/playersneak.js | 8 +++++--- .../scripts/src/commands/simplayer/playersprint.js | 8 +++++--- .../scripts/src/commands/simplayer/playerstop.js | 8 +++++--- .../scripts/src/commands/simplayer/playertp.js | 6 ++++-- .../src/commands/simplayer/playerleave.test.js | 12 +++++++----- .../src/commands/simplayer/playersneak.test.js | 7 +++++-- .../src/commands/simplayer/playersprint.test.js | 7 +++++-- .../src/commands/simplayer/playerstop.test.js | 7 +++++-- .../scripts/src/commands/simplayer/playertp.test.js | 5 +++-- 10 files changed, 49 insertions(+), 27 deletions(-) diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerleave.js b/Canopy[BP]/scripts/src/commands/simplayer/playerleave.js index fc60ef06..7237be08 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerleave.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerleave.js @@ -14,10 +14,12 @@ export class PlayerLeaveCommand extends VanillaCommand { }); } - playerleaveCommand(_origin, playername) { + playerleaveCommand(origin, playername) { const understudy = Understudies.get(playername); - if (!understudy) - return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return { status: CustomCommandStatus.Failure }; + } system.run(() => { understudy.leave(); Understudies.remove(understudy); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playersneak.js b/Canopy[BP]/scripts/src/commands/simplayer/playersneak.js index 30da32eb..0c1ebf12 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playersneak.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playersneak.js @@ -17,10 +17,12 @@ export class PlayerSneakCommand extends VanillaCommand { }); } - playersneakCommand(_origin, playername, shouldSneak) { + playersneakCommand(origin, playername, shouldSneak) { const understudy = Understudies.get(playername); - if (!understudy) - return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return { status: CustomCommandStatus.Failure }; + } system.run(() => understudy.sneak(shouldSneak)); return { status: CustomCommandStatus.Success }; } diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playersprint.js b/Canopy[BP]/scripts/src/commands/simplayer/playersprint.js index 34ecb036..0d496002 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playersprint.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playersprint.js @@ -17,10 +17,12 @@ export class PlayerSprintCommand extends VanillaCommand { }); } - playersprintCommand(_origin, playername, shouldSprint) { + playersprintCommand(origin, playername, shouldSprint) { const understudy = Understudies.get(playername); - if (!understudy) - return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return { status: CustomCommandStatus.Failure }; + } system.run(() => understudy.sprint(shouldSprint)); return { status: CustomCommandStatus.Success }; } diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerstop.js b/Canopy[BP]/scripts/src/commands/simplayer/playerstop.js index 4c73e956..cec5a2a9 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerstop.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerstop.js @@ -14,10 +14,12 @@ export class PlayerStopCommand extends VanillaCommand { }); } - playerstopCommand(_origin, playername) { + playerstopCommand(origin, playername) { const understudy = Understudies.get(playername); - if (!understudy) - return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return { status: CustomCommandStatus.Failure }; + } system.run(() => understudy.stopAll()); return { status: CustomCommandStatus.Success }; } diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playertp.js b/Canopy[BP]/scripts/src/commands/simplayer/playertp.js index 841a07d5..3e4e9fc0 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playertp.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playertp.js @@ -17,8 +17,10 @@ export class PlayerTpCommand extends VanillaCommand { playertpCommand(origin, playername) { const understudy = Understudies.get(playername); - if (!understudy) - return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return { status: CustomCommandStatus.Failure }; + } system.run(() => understudy.teleport(getLocationInfoFromSource(origin.getSource()))); return { status: CustomCommandStatus.Success }; } diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerleave.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerleave.test.js index 2c232f21..78069527 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playerleave.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playerleave.test.js @@ -8,7 +8,7 @@ vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies get: vi.fn(), isOnline: vi.fn(() => false), remove: vi.fn(), - getNotOnlineMessage: vi.fn(name => `§cSimplayer '${name}' is not online.`), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), getAlreadyOnlineMessage: vi.fn(name => `§cSimplayer '${name}' is already online.`), } })); @@ -20,28 +20,30 @@ vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importO 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(undefined, 'TestBot'); + const result = playerleaveCommand.playerleaveCommand(mockOrigin, 'TestBot'); expect(result.status).toBe(CustomCommandStatus.Failure); - expect(result.message).toContain('TestBot'); + 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(undefined, 'TestBot'); + 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(undefined, 'TestBot'); + const result = playerleaveCommand.playerleaveCommand(mockOrigin, 'TestBot'); expect(result).toBeUndefined(); }); }); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playersneak.test.js b/__tests__/BP/scripts/src/commands/simplayer/playersneak.test.js index 55a03e92..55299198 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playersneak.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playersneak.test.js @@ -6,7 +6,7 @@ import { playersneakCommand } from '../../../../../../Canopy[BP]/scripts/src/com vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ default: { get: vi.fn(), - getNotOnlineMessage: vi.fn(name => `§cSimplayer '${name}' is not online.`), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), } })); @@ -17,16 +17,19 @@ vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importO 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(undefined, 'TestBot', true); + const result = playersneakCommand.playersneakCommand(mockOrigin, 'TestBot', true); expect(result.status).toBe(CustomCommandStatus.Failure); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); }); it('returns success and queues sneak(true) when online', () => { diff --git a/__tests__/BP/scripts/src/commands/simplayer/playersprint.test.js b/__tests__/BP/scripts/src/commands/simplayer/playersprint.test.js index d1c3df11..aa098b2e 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playersprint.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playersprint.test.js @@ -6,7 +6,7 @@ import { playersprintCommand } from '../../../../../../Canopy[BP]/scripts/src/co vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ default: { get: vi.fn(), - getNotOnlineMessage: vi.fn(name => `§cSimplayer '${name}' is not online.`), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), } })); @@ -17,16 +17,19 @@ vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importO 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(undefined, 'TestBot', true); + const result = playersprintCommand.playersprintCommand(mockOrigin, 'TestBot', true); expect(result.status).toBe(CustomCommandStatus.Failure); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); }); it('returns success and queues sprint(true) when online', () => { diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerstop.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerstop.test.js index 0fa1ac63..b15f5629 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playerstop.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playerstop.test.js @@ -6,7 +6,7 @@ import { playerstopCommand } from '../../../../../../Canopy[BP]/scripts/src/comm vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ default: { get: vi.fn(), - getNotOnlineMessage: vi.fn(name => `§cSimplayer '${name}' is not online.`), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), } })); @@ -17,16 +17,19 @@ vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importO 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(undefined, 'TestBot'); + const result = playerstopCommand.playerstopCommand(mockOrigin, 'TestBot'); expect(result.status).toBe(CustomCommandStatus.Failure); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); }); it('returns success and queues stopAll when online', () => { diff --git a/__tests__/BP/scripts/src/commands/simplayer/playertp.test.js b/__tests__/BP/scripts/src/commands/simplayer/playertp.test.js index 07a2ceac..8fed97a9 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playertp.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playertp.test.js @@ -6,7 +6,7 @@ import { playertpCommand } from '../../../../../../Canopy[BP]/scripts/src/comman vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ default: { get: vi.fn(), - getNotOnlineMessage: vi.fn(name => `§cSimplayer '${name}' is not online.`), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), } })); @@ -22,13 +22,14 @@ describe('playertpCommand', () => { 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') })) }; + 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.status).toBe(CustomCommandStatus.Failure); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); }); it('returns success when the simplayer is online', () => { From ba77d961bdfc23a566b8010a50defab4c14a45a6 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 13:37:00 -0700 Subject: [PATCH 019/120] fix: fog layers getting overloaded when left enabled --- Canopy[BP]/scripts/src/rules/infodisplay/NoFog.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/NoFog.js b/Canopy[BP]/scripts/src/rules/infodisplay/NoFog.js index 5fc27370..25be54a2 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/NoFog.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/NoFog.js @@ -26,16 +26,17 @@ export class NoFog extends InfoDisplayShapeElement { } removeFog() { + this.clearFogSettings(); this.playerFogComponent.push(this.getCurrentFogId(), NoFog.FOG_TAG); world.afterEvents.playerDimensionChange.subscribe(this.onDimensionChangeBound); } resetFog() { world.afterEvents.playerDimensionChange.unsubscribe(this.onDimensionChangeBound); - this.clearFog(); + this.clearFogSettings(); } - clearFog() { + clearFogSettings() { this.playerFogComponent.remove(NoFog.FOG_TAG); } @@ -49,7 +50,7 @@ export class NoFog extends InfoDisplayShapeElement { } onDimensionChange() { - this.clearFog(); + this.clearFogSettings(); const fogRemovalId = this.getCurrentFogId(); this.playerFogComponent.push(fogRemovalId, NoFog.FOG_TAG); } From da6f7f73f306f880bc9b6df19a5559cac5766f94 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 13:37:38 -0700 Subject: [PATCH 020/120] feat: protect against invalid player actions --- Canopy[BP]/scripts/src/commands/simplayer/playeraction.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js b/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js index 136506eb..c8c8d82b 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js @@ -30,6 +30,8 @@ export class PlayerActionCommand extends VanillaCommand { const understudy = Understudies.get(playername); if (!understudy) return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; + 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: From df507d76cbce2f8361f36507872adcae343569e2 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 13:38:49 -0700 Subject: [PATCH 021/120] fix: add color clearer to simplayer prefix --- Canopy[BP]/scripts/src/classes/simplayer/Understudies.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Canopy[BP]/scripts/src/classes/simplayer/Understudies.js b/Canopy[BP]/scripts/src/classes/simplayer/Understudies.js index 6cf6092c..a93b4498 100644 --- a/Canopy[BP]/scripts/src/classes/simplayer/Understudies.js +++ b/Canopy[BP]/scripts/src/classes/simplayer/Understudies.js @@ -103,7 +103,7 @@ class Understudies { understudy.simulatedPlayer.nameTag = understudy.name; } else { for (const understudy of Understudies.understudies) - understudy.simulatedPlayer.nameTag = `[${prefix}§r] ${understudy.name}`; + understudy.simulatedPlayer.nameTag = `§r[${prefix}§r] ${understudy.name}`; } } From 019c7228d68ca19c5681641133eee77ec1abed2c Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 13:39:18 -0700 Subject: [PATCH 022/120] fix: remove extra %2 from canopy search results --- Canopy[RP]/texts/en_US.lang | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Canopy[RP]/texts/en_US.lang b/Canopy[RP]/texts/en_US.lang index c8497fdf..520f4ae2 100644 --- a/Canopy[RP]/texts/en_US.lang +++ b/Canopy[RP]/texts/en_US.lang @@ -20,7 +20,7 @@ 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. From f1563d690513e64f06ce1d0951478192d84ce477 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 13:45:47 -0700 Subject: [PATCH 023/120] refactor: simplify nametag formatting --- .../src/classes/simplayer/Understudies.js | 17 +++++++++-------- .../src/classes/simplayer/Understudies.test.js | 4 ++-- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/Canopy[BP]/scripts/src/classes/simplayer/Understudies.js b/Canopy[BP]/scripts/src/classes/simplayer/Understudies.js index a93b4498..3e00b214 100644 --- a/Canopy[BP]/scripts/src/classes/simplayer/Understudies.js +++ b/Canopy[BP]/scripts/src/classes/simplayer/Understudies.js @@ -62,7 +62,7 @@ class Understudies { static addNametagPrefix(understudy) { const prefix = world.getDynamicProperty('nametagPrefix'); if (prefix) - understudy.simulatedPlayer.nameTag = `[${prefix}§r] ${understudy.name}`; + understudy.simulatedPlayer.nameTag = Understudies.#formatNametagWithPrefix(understudy.name, prefix); } static get(name) { @@ -98,13 +98,14 @@ class Understudies { static setNametagPrefix(prefix) { world.setDynamicProperty('nametagPrefix', prefix); - if (prefix === '') { - for (const understudy of Understudies.understudies) - understudy.simulatedPlayer.nameTag = understudy.name; - } else { - for (const understudy of Understudies.understudies) - understudy.simulatedPlayer.nameTag = `§r[${prefix}§r] ${understudy.name}`; - } + 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) { diff --git a/__tests__/BP/scripts/src/classes/simplayer/Understudies.test.js b/__tests__/BP/scripts/src/classes/simplayer/Understudies.test.js index 1338ef0f..d35f868d 100644 --- a/__tests__/BP/scripts/src/classes/simplayer/Understudies.test.js +++ b/__tests__/BP/scripts/src/classes/simplayer/Understudies.test.js @@ -144,7 +144,7 @@ describe('setNametagPrefix', () => { it('sets nameTag to [prefix] name format when prefix is non-empty', () => { Understudies.setNametagPrefix('Bot'); - expect(u.simulatedPlayer.nameTag).toBe('[Bot§r] Alice'); + expect(u.simulatedPlayer.nameTag).toBe('§r[Bot§r] Alice'); }); it('resets nameTag to just the name when prefix is empty string', () => { @@ -171,7 +171,7 @@ describe('addNametagPrefix', () => { it('sets nameTag when a prefix is stored in world properties', () => { world.getDynamicProperty.mockReturnValueOnce('Bot'); Understudies.addNametagPrefix(u); - expect(u.simulatedPlayer.nameTag).toBe('[Bot§r] Alice'); + expect(u.simulatedPlayer.nameTag).toBe('§r[Bot§r] Alice'); }); it('does not change nameTag when no prefix is stored', () => { From d7d1d186f006c8374e5fca566c0fbb70eab2f587 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 13:55:53 -0700 Subject: [PATCH 024/120] feat(simplayer): localize online-state errors for claimprojectiles/swapheld/join/rejoin --- .../src/commands/simplayer/playerclaimprojectiles.js | 8 +++++--- Canopy[BP]/scripts/src/commands/simplayer/playerjoin.js | 6 ++++-- Canopy[BP]/scripts/src/commands/simplayer/playerrejoin.js | 6 ++++-- .../scripts/src/commands/simplayer/playerswapheld.js | 6 ++++-- .../src/commands/simplayer/playerclaimprojectiles.test.js | 7 +++++-- .../BP/scripts/src/commands/simplayer/playerjoin.test.js | 6 +++--- .../scripts/src/commands/simplayer/playerrejoin.test.js | 7 ++++--- .../scripts/src/commands/simplayer/playerswapheld.test.js | 5 +++-- 8 files changed, 32 insertions(+), 19 deletions(-) diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerclaimprojectiles.js b/Canopy[BP]/scripts/src/commands/simplayer/playerclaimprojectiles.js index 9882c885..89e33808 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerclaimprojectiles.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerclaimprojectiles.js @@ -15,10 +15,12 @@ export class PlayerClaimProjectilesCommand extends VanillaCommand { }); } - playerclaimprojectilesCommand(_origin, playername, radius = 25) { + playerclaimprojectilesCommand(origin, playername, radius = 25) { const understudy = Understudies.get(playername); - if (!understudy) - return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return { status: CustomCommandStatus.Failure }; + } system.run(() => understudy.claimProjectiles(radius)); return { status: CustomCommandStatus.Success }; } diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerjoin.js b/Canopy[BP]/scripts/src/commands/simplayer/playerjoin.js index 75fe428e..0e96c2fb 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerjoin.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerjoin.js @@ -16,8 +16,10 @@ export class PlayerJoinCommand extends VanillaCommand { } playerjoinCommand(origin, playername) { - if (Understudies.isOnline(playername)) - return { status: CustomCommandStatus.Failure, message: Understudies.getAlreadyOnlineMessage(playername) }; + if (Understudies.isOnline(playername)) { + origin.sendMessage(Understudies.getAlreadyOnlineMessage(playername)); + return { status: CustomCommandStatus.Failure }; + } system.run(() => { const understudy = Understudies.create(playername); understudy.join(getLocationInfoFromSource(origin.getSource())); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerrejoin.js b/Canopy[BP]/scripts/src/commands/simplayer/playerrejoin.js index 3f577d4e..2e57d776 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerrejoin.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerrejoin.js @@ -16,8 +16,10 @@ export class PlayerRejoinCommand extends VanillaCommand { } playerrejoinCommand(origin, playername) { - if (Understudies.isOnline(playername)) - return { status: CustomCommandStatus.Failure, message: Understudies.getAlreadyOnlineMessage(playername) }; + if (Understudies.isOnline(playername)) { + origin.sendMessage(Understudies.getAlreadyOnlineMessage(playername)); + return { status: CustomCommandStatus.Failure }; + } system.run(() => this.#tryRejoin(origin, playername)); return { status: CustomCommandStatus.Success }; } diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerswapheld.js b/Canopy[BP]/scripts/src/commands/simplayer/playerswapheld.js index 6d00aa6a..4de9b108 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerswapheld.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerswapheld.js @@ -16,8 +16,10 @@ export class PlayerSwapHeldCommand extends VanillaCommand { playerswapheldCommand(origin, playername) { const understudy = Understudies.get(playername); - if (!understudy) - return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return { status: CustomCommandStatus.Failure }; + } system.run(() => understudy.swapHeldItemWithPlayer(origin.getSource())); return { status: CustomCommandStatus.Success }; } diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerclaimprojectiles.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerclaimprojectiles.test.js index b0cdde8c..bfb8d69e 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playerclaimprojectiles.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playerclaimprojectiles.test.js @@ -6,7 +6,7 @@ import { playerclaimprojectilesCommand } from '../../../../../../Canopy[BP]/scri vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ default: { get: vi.fn(), - getNotOnlineMessage: vi.fn(name => `§cSimplayer '${name}' is not online.`), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), } })); @@ -17,16 +17,19 @@ vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importO describe('playerclaimprojectilesCommand', () => { let mockUnderstudy; + let mockOrigin; beforeEach(() => { vi.clearAllMocks(); mockUnderstudy = { claimProjectiles: 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 = playerclaimprojectilesCommand.playerclaimprojectilesCommand(undefined, 'TestBot'); + const result = playerclaimprojectilesCommand.playerclaimprojectilesCommand(mockOrigin, 'TestBot'); expect(result.status).toBe(CustomCommandStatus.Failure); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); }); it('returns success and queues claimProjectiles with default radius when online', () => { diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerjoin.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerjoin.test.js index 598b29af..01abc4d0 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playerjoin.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playerjoin.test.js @@ -11,7 +11,7 @@ vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies remove: vi.fn(), addNametagPrefix: vi.fn(), getNotOnlineMessage: vi.fn(name => `§cSimplayer '${name}' is not online.`), - getAlreadyOnlineMessage: vi.fn(name => `§cSimplayer '${name}' is already online.`), + getAlreadyOnlineMessage: vi.fn(name => ({ translate: 'simplayer.alreadyonline', with: [name] })), } })); @@ -29,14 +29,14 @@ describe('playerjoinCommand', () => { 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') })) }; + 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.status).toBe(CustomCommandStatus.Failure); - expect(result.message).toContain('TestBot'); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.alreadyonline', with: ['TestBot'] }); }); it('queues a system.run when the simplayer is not online', () => { diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerrejoin.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerrejoin.test.js index 06682c92..68155cee 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playerrejoin.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playerrejoin.test.js @@ -10,7 +10,7 @@ vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies create: vi.fn(), addNametagPrefix: vi.fn(), getNotOnlineMessage: vi.fn(name => `§cSimplayer '${name}' is not online.`), - getAlreadyOnlineMessage: vi.fn(name => `§cSimplayer '${name}' is already online.`), + getAlreadyOnlineMessage: vi.fn(name => ({ translate: 'simplayer.alreadyonline', with: [name] })), } })); @@ -34,7 +34,8 @@ describe('playerrejoinCommand', () => { dimension: {}, getRotation: vi.fn(() => ({ x: 0, y: 0 })), getGameMode: vi.fn(() => 'Survival') - })) + })), + sendMessage: vi.fn() }; }); @@ -42,7 +43,7 @@ describe('playerrejoinCommand', () => { vi.mocked(Understudies.isOnline).mockReturnValue(true); const result = playerrejoinCommand.playerrejoinCommand(mockOrigin, 'TestBot'); expect(result.status).toBe(CustomCommandStatus.Failure); - expect(result.message).toContain('TestBot'); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.alreadyonline', with: ['TestBot'] }); }); it('returns success and queues rejoin when the simplayer is offline', () => { diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerswapheld.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerswapheld.test.js index 3f304762..35a08d18 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playerswapheld.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playerswapheld.test.js @@ -6,7 +6,7 @@ import { playerswapheldCommand } from '../../../../../../Canopy[BP]/scripts/src/ vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ default: { get: vi.fn(), - getNotOnlineMessage: vi.fn(name => `§cSimplayer '${name}' is not online.`), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), } })); @@ -22,13 +22,14 @@ describe('playerswapheldCommand', () => { beforeEach(() => { vi.clearAllMocks(); mockUnderstudy = { swapHeldItemWithPlayer: vi.fn(), name: 'TestBot' }; - mockOrigin = { getSource: vi.fn(() => ({ name: 'Player1', selectedSlotIndex: 0 })) }; + 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.status).toBe(CustomCommandStatus.Failure); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); }); it('returns success and queues swap when online', () => { From 22611338f3a818d25fd2eaaab2c0c32cf061ead5 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 14:08:13 -0700 Subject: [PATCH 025/120] feat(simplayer): localize playerlook messages --- .../src/commands/simplayer/playerlook.js | 23 +++++++++------- .../src/commands/simplayer/playerlook.test.js | 26 ++++++++++++++++--- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js b/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js index 22d19348..abff7791 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js @@ -36,8 +36,10 @@ export class PlayerLookCommand extends VanillaCommand { playerlookCommand(origin, playername, lookOption, x, y, z) { const location = { x, y, z }; const understudy = Understudies.get(playername); - if (!understudy) - return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return { status: CustomCommandStatus.Failure }; + } 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: @@ -51,19 +53,20 @@ export class PlayerLookCommand extends VanillaCommand { return this.#lookAtMe(origin, understudy); case LOOK_OPTIONS.AT: if (x === void 0 || y === void 0 || z === void 0) - return { status: CustomCommandStatus.Failure, message: '§cMissing coordinates for look at location.' }; + 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: '§cMissing yaw or pitch for look rotation.' }; + 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: - return { status: CustomCommandStatus.Failure, message: `§cInvalid look option: '${lookOption}'` }; + origin.sendMessage({ translate: 'commands.playerlook.invalidoption', with: [lookOption] }); + return { status: CustomCommandStatus.Failure }; } return { status: CustomCommandStatus.Success }; } @@ -75,10 +78,10 @@ export class PlayerLookCommand extends VanillaCommand { #lookAtBlock(origin, understudy) { const source = origin.getSource(); if (source instanceof Entity === false) - return { status: CustomCommandStatus.Failure, message: '§cBlock targeting may only be used by entities.' }; + return { status: CustomCommandStatus.Failure, message: 'commands.playerlook.block.entityonly' }; const block = source.getBlockFromViewDirection({ maxDistance: 16*64 })?.block; if (block === void 0) - return { status: CustomCommandStatus.Failure, message: '§cNo block in view.' }; + return { status: CustomCommandStatus.Failure, message: 'commands.playerlook.block.noblock' }; system.run(() => understudy.look(block)); return { status: CustomCommandStatus.Success }; } @@ -86,17 +89,17 @@ export class PlayerLookCommand extends VanillaCommand { #lookAtEntity(origin, understudy) { const source = origin.getSource(); if (source instanceof Entity === false) - return { status: CustomCommandStatus.Failure, message: '§cEntity targeting may only be used by entities.' }; + 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: '§cNo entity in view.' }; + 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: '§cSelf-targeting cannot be used by the server.' }; + return { status: CustomCommandStatus.Failure, message: 'commands.playerlook.me.noserver' }; system.run(() => understudy.look(origin.getSource())); return { status: CustomCommandStatus.Success }; } diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerlook.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerlook.test.js index f20be729..e3981874 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playerlook.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playerlook.test.js @@ -7,7 +7,7 @@ import { ServerCommandOrigin } from '../../../../../../Canopy[BP]/scripts/lib/ca vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ default: { get: vi.fn(), - getNotOnlineMessage: vi.fn(name => `§cSimplayer '${name}' is not online.`), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), } })); @@ -26,13 +26,14 @@ describe('playerlookCommand', () => { 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) }; + 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.status).toBe(CustomCommandStatus.Failure); + 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])( @@ -50,7 +51,7 @@ describe('playerlookCommand', () => { const serverOrigin = new ServerCommandOrigin({ sourceType: 'Server' }); const result = playerlookCommand.playerlookCommand(serverOrigin, 'TestBot', LOOK_OPTIONS.BLOCK); expect(result.status).toBe(CustomCommandStatus.Failure); - expect(result.message).toContain('entities'); + expect(result.message).toBe('commands.playerlook.block.entityonly'); }); it('returns failure for BLOCK option when no block is in view', () => { @@ -105,6 +106,24 @@ describe('playerlookCommand', () => { 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, { x: 0, y: 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); @@ -115,5 +134,6 @@ describe('playerlookCommand', () => { vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', 'invalid'); expect(result.status).toBe(CustomCommandStatus.Failure); + expect(mockEntityOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playerlook.invalidoption', with: ['invalid'] }); }); }); From 5ffb92100d24e3919cef15d4f3874a7c3fa9c054 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 14:10:09 -0700 Subject: [PATCH 026/120] tests: fix tests from rotation arg addition --- .../BP/scripts/src/commands/simplayer/playerlook.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerlook.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerlook.test.js index e3981874..aa2ab1ad 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playerlook.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playerlook.test.js @@ -102,7 +102,7 @@ describe('playerlookCommand', () => { it('returns success for AT option', () => { vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); - const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.AT, { x: 0, y: 64, z: 0 }); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.AT, 0, 64, 0); expect(result.status).toBe(CustomCommandStatus.Success); }); @@ -114,7 +114,7 @@ describe('playerlookCommand', () => { it('returns success for ROTATION option', () => { vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); - const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.ROTATION, { x: 0, y: 0 }); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.ROTATION, 0, 0); expect(result.status).toBe(CustomCommandStatus.Success); }); From 277748fdf1773fdbc94853af37ee5944b8cf13d5 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 14:16:07 -0700 Subject: [PATCH 027/120] feat: noSimplayerSaving -> simplayerSaving --- Canopy[BP]/scripts/main.js | 2 +- .../src/classes/simplayer/PlayerInfoSaver.js | 10 ++++---- .../src/rules/simplayer/noSimplayerSaving.js | 13 ---------- Canopy[RP]/texts/en_US.lang | 2 +- .../classes/simplayer/PlayerInfoSaver.test.js | 24 +++++++++---------- .../simplayer/RepeatableAction.test.js | 4 ++-- .../classes/simplayer/Understudies.test.js | 4 ++-- .../src/classes/simplayer/Understudy.test.js | 4 ++-- .../UnderstudyInventorySaver.test.js | 4 ++-- .../rules/simplayer/noSimplayerSaving.test.js | 20 ++++++++-------- 10 files changed, 37 insertions(+), 50 deletions(-) delete mode 100644 Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving.js diff --git a/Canopy[BP]/scripts/main.js b/Canopy[BP]/scripts/main.js index 4e29e03f..7ffb1c88 100644 --- a/Canopy[BP]/scripts/main.js +++ b/Canopy[BP]/scripts/main.js @@ -99,7 +99,7 @@ import './src/rules/enderPearlChunkLoading' import './src/rules/renderEndGatewayExits' // Simulated Player Rules -import './src/rules/simplayer/noSimplayerSaving' +import './src/rules/simplayer/simplayerSaving' import './src/rules/simplayer/simplayerRejoining' // Load Time Processes diff --git a/Canopy[BP]/scripts/src/classes/simplayer/PlayerInfoSaver.js b/Canopy[BP]/scripts/src/classes/simplayer/PlayerInfoSaver.js index 498df371..c7f28929 100644 --- a/Canopy[BP]/scripts/src/classes/simplayer/PlayerInfoSaver.js +++ b/Canopy[BP]/scripts/src/classes/simplayer/PlayerInfoSaver.js @@ -1,6 +1,6 @@ import { world, system, DimensionTypes, TicksPerSecond, EntityComponentTypes } from "@minecraft/server"; import { UnderstudyInventorySaver } from "./UnderstudyInventorySaver"; -import { noSimplayerSaving } from "../../rules/simplayer/noSimplayerSaving"; +import { simplayerSaving } from "../../rules/simplayer/simplayerSaving"; import { UnderstudySaveInfoError } from "../errors/UnderstudySaveInfoError"; import { UnderstudyNotConnectedError } from "../errors/UnderstudyNotConnectedError"; @@ -19,7 +19,7 @@ export class PlayerInfoSaver { } #saveOnInterval() { - if (noSimplayerSaving.getNativeValue()) + if (!simplayerSaving.getNativeValue()) return; if ((system.currentTick - this.#understudy.createdTick) % this.saveInterval === 0) { this.save(); @@ -34,8 +34,8 @@ export class PlayerInfoSaver { } get() { - if (noSimplayerSaving.getNativeValue()) - throw new UnderstudySaveInfoError(`Player ${this.#understudy.name} has no player info saved due to '${noSimplayerSaving.getID()}' rule being enabled`); + 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`)); @@ -48,7 +48,7 @@ export class PlayerInfoSaver { } save() { - if (noSimplayerSaving.getNativeValue()) + if (!simplayerSaving.getNativeValue()) return; if (!this.#understudy.isConnected()) throw new UnderstudyNotConnectedError(); diff --git a/Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving.js b/Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving.js deleted file mode 100644 index a09223c1..00000000 --- a/Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving.js +++ /dev/null @@ -1,13 +0,0 @@ -import { BooleanRule } from "../../../lib/canopy/Canopy"; - -class NoSimplayerSaving extends BooleanRule { - constructor() { - super({ - identifier: 'noSimplayerSaving', - description: { translate: 'rules.noSimplayerSaving' }, - defaultValue: false - }); - } -} - -export const noSimplayerSaving = new NoSimplayerSaving(); diff --git a/Canopy[RP]/texts/en_US.lang b/Canopy[RP]/texts/en_US.lang index 520f4ae2..5768f8d1 100644 --- a/Canopy[RP]/texts/en_US.lang +++ b/Canopy[RP]/texts/en_US.lang @@ -430,7 +430,7 @@ rules.instaminableEndstone=Makes endstone instaminable while using netherite, ef rules.minecartChunkLoading=Allows minecarts to tick the specified chunk radius (square) around them for 10 seconds after they are spawned. rules.noWelcomeMessage=Disables the §lCanopy§r§8 welcome message. rules.pistonBedrockBreaking=Allows pistons to break bedrock when facing away from a bedrock block and expanding. -rules.noSimplayerSaving=Disables saving playerdata for simplayers. Improves performance but causes simplayers to lose their inventory and location when they leave and rejoin. +rules.simplayerSaving=Disables saving playerdata for simplayers. Improves performance but causes simplayers to lose their inventory and location when they leave and rejoin. rules.simplayerRejoining=Makes online simplayers rejoin when the world reloads. rules.playerSit=Allows players to sit down after %s quick sneaks. diff --git a/__tests__/BP/scripts/src/classes/simplayer/PlayerInfoSaver.test.js b/__tests__/BP/scripts/src/classes/simplayer/PlayerInfoSaver.test.js index 4310fe2e..8b00494f 100644 --- a/__tests__/BP/scripts/src/classes/simplayer/PlayerInfoSaver.test.js +++ b/__tests__/BP/scripts/src/classes/simplayer/PlayerInfoSaver.test.js @@ -3,12 +3,12 @@ import { world, system, EntityComponentTypes, TicksPerSecond } from '@minecraft/ 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 { noSimplayerSaving } from '../../../../../../Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving'; +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/noSimplayerSaving', () => ({ - noSimplayerSaving: { getNativeValue: vi.fn(() => false), getID: vi.fn(() => 'noSimplayerSaving') } +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() } @@ -24,7 +24,7 @@ describe('PlayerInfoSaver', () => { understudy = new Understudy('TestBot'); understudy.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension('minecraft:overworld') }); infoSaver = new PlayerInfoSaver(understudy); - worldDynamicPropertyStore.set('noSimplayerSaving', false); + worldDynamicPropertyStore.set('simplayerSaving', true); }); describe('get', () => { @@ -32,8 +32,8 @@ describe('PlayerInfoSaver', () => { vi.clearAllMocks(); }); - it('throws when noSimplayerSaving is enabled', () => { - vi.mocked(noSimplayerSaving.getNativeValue).mockReturnValue(true); + it('throws when simplayerSaving is disabled', () => { + vi.mocked(simplayerSaving.getNativeValue).mockReturnValue(false); expect(() => infoSaver.get()).toThrow(UnderstudySaveInfoError); }); @@ -78,8 +78,8 @@ describe('PlayerInfoSaver', () => { expect(() => infoSaver.save()).toThrow(UnderstudyNotConnectedError); }); - it('does not save when noSimplayerSaving is enabled', () => { - vi.mocked(noSimplayerSaving.getNativeValue).mockReturnValue(true); + 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)); }); @@ -109,8 +109,8 @@ describe('PlayerInfoSaver', () => { }); describe('loadInventoryAndProjectileOwnership', () => { - it('throws when noSimplayerSaving is enabled', () => { - vi.mocked(noSimplayerSaving.getNativeValue).mockReturnValue(true); + it('throws when simplayerSaving is disabled', () => { + vi.mocked(simplayerSaving.getNativeValue).mockReturnValue(false); expect(() => infoSaver.loadInventoryAndProjectileOwnership()).toThrow(UnderstudySaveInfoError); }); @@ -144,8 +144,8 @@ describe('PlayerInfoSaver', () => { system.currentTick = 0; }); - it('does nothing when noSimplayerSaving is enabled', () => { - vi.mocked(noSimplayerSaving.getNativeValue).mockReturnValue(true); + 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)); }); diff --git a/__tests__/BP/scripts/src/classes/simplayer/RepeatableAction.test.js b/__tests__/BP/scripts/src/classes/simplayer/RepeatableAction.test.js index 183bfa01..d9d3f3a8 100644 --- a/__tests__/BP/scripts/src/classes/simplayer/RepeatableAction.test.js +++ b/__tests__/BP/scripts/src/classes/simplayer/RepeatableAction.test.js @@ -5,8 +5,8 @@ import Understudy from '../../../../../../Canopy[BP]/scripts/src/classes/simplay 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/noSimplayerSaving', () => ({ - noSimplayerSaving: { getNativeValue: vi.fn(() => false), getID: vi.fn(() => 'noSimplayerSaving') } +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() } diff --git a/__tests__/BP/scripts/src/classes/simplayer/Understudies.test.js b/__tests__/BP/scripts/src/classes/simplayer/Understudies.test.js index d35f868d..fe19f6c5 100644 --- a/__tests__/BP/scripts/src/classes/simplayer/Understudies.test.js +++ b/__tests__/BP/scripts/src/classes/simplayer/Understudies.test.js @@ -4,8 +4,8 @@ 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/noSimplayerSaving', () => ({ - noSimplayerSaving: { getNativeValue: vi.fn(() => false), getID: vi.fn(() => 'noSimplayerSaving') } +vi.mock('../../../../../../Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving', () => ({ + simplayerSaving: { getNativeValue: vi.fn(() => true), getID: vi.fn(() => 'simplayerSaving') } })); let Understudies; diff --git a/__tests__/BP/scripts/src/classes/simplayer/Understudy.test.js b/__tests__/BP/scripts/src/classes/simplayer/Understudy.test.js index 5fd886cb..adeb6ddf 100644 --- a/__tests__/BP/scripts/src/classes/simplayer/Understudy.test.js +++ b/__tests__/BP/scripts/src/classes/simplayer/Understudy.test.js @@ -4,8 +4,8 @@ import { scheduler, worldDynamicPropertyStore } from '@forestoflight/minecraft-v 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/noSimplayerSaving', () => ({ - noSimplayerSaving: { getNativeValue: vi.fn(() => false), getID: vi.fn(() => 'noSimplayerSaving') } +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() } diff --git a/__tests__/BP/scripts/src/classes/simplayer/UnderstudyInventorySaver.test.js b/__tests__/BP/scripts/src/classes/simplayer/UnderstudyInventorySaver.test.js index 911eed69..c8acd47e 100644 --- a/__tests__/BP/scripts/src/classes/simplayer/UnderstudyInventorySaver.test.js +++ b/__tests__/BP/scripts/src/classes/simplayer/UnderstudyInventorySaver.test.js @@ -4,8 +4,8 @@ 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/noSimplayerSaving', () => ({ - noSimplayerSaving: { getNativeValue: vi.fn(() => false), getID: vi.fn(() => 'noSimplayerSaving') } +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() } diff --git a/__tests__/BP/scripts/src/rules/simplayer/noSimplayerSaving.test.js b/__tests__/BP/scripts/src/rules/simplayer/noSimplayerSaving.test.js index 9720cd6a..34007054 100644 --- a/__tests__/BP/scripts/src/rules/simplayer/noSimplayerSaving.test.js +++ b/__tests__/BP/scripts/src/rules/simplayer/noSimplayerSaving.test.js @@ -1,32 +1,32 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { worldDynamicPropertyStore } from '@forestoflight/minecraft-vitest-mocks'; -import { noSimplayerSaving } from '../../../../../../Canopy[BP]/scripts/src/rules/simplayer/noSimplayerSaving'; +import { simplayerSaving } from '../../../../../../Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving'; -describe('noSimplayerSaving', () => { +describe('simplayerSaving', () => { beforeEach(() => { vi.clearAllMocks(); - worldDynamicPropertyStore.set('noSimplayerSaving', undefined); + worldDynamicPropertyStore.set('simplayerSaving', void 0); }); describe('getID', () => { it('returns the correct identifier', () => { - expect(noSimplayerSaving.getID()).toBe('noSimplayerSaving'); + expect(simplayerSaving.getID()).toBe('simplayerSaving'); }); }); describe('getNativeValue', () => { - it('returns false by default when no value is stored', () => { - expect(noSimplayerSaving.getNativeValue()).toBe(false); + 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('noSimplayerSaving', true); - expect(noSimplayerSaving.getNativeValue()).toBe(true); + worldDynamicPropertyStore.set('simplayerSaving', true); + expect(simplayerSaving.getNativeValue()).toBe(true); }); it('returns false when the rule is explicitly disabled', () => { - worldDynamicPropertyStore.set('noSimplayerSaving', false); - expect(noSimplayerSaving.getNativeValue()).toBe(false); + worldDynamicPropertyStore.set('simplayerSaving', false); + expect(simplayerSaving.getNativeValue()).toBe(false); }); }); }); From 8deab71b0f3868c97541d9897a71c72b26aec07a Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 14:23:16 -0700 Subject: [PATCH 028/120] feat(simplayer): localize playermove messages --- .../src/commands/simplayer/playermove.js | 19 +++++++++++-------- .../src/commands/simplayer/playermove.test.js | 6 ++++-- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playermove.js b/Canopy[BP]/scripts/src/commands/simplayer/playermove.js index de4a338e..f2c5d690 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playermove.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playermove.js @@ -27,8 +27,10 @@ export class PlayerMoveCommand extends VanillaCommand { playermoveCommand(origin, playername, moveOption, location) { const understudy = Understudies.get(playername); - if (!understudy) - return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return { status: CustomCommandStatus.Failure }; + } switch (moveOption) { case MOVE_OPTIONS.FORWARD: case MOVE_OPTIONS.BACKWARD: case MOVE_OPTIONS.LEFT: case MOVE_OPTIONS.RIGHT: @@ -47,7 +49,8 @@ export class PlayerMoveCommand extends VanillaCommand { this.#stopMoving(understudy); break; default: - return { status: CustomCommandStatus.Failure, message: `§cInvalid move option: '${moveOption}'` }; + origin.sendMessage({ translate: 'commands.playermove.invalidoption', with: [moveOption] }); + return { status: CustomCommandStatus.Failure }; } return { status: CustomCommandStatus.Success }; } @@ -59,10 +62,10 @@ export class PlayerMoveCommand extends VanillaCommand { #moveToBlock(origin, understudy) { const source = origin.getSource(); if (source instanceof Entity === false) - return { status: CustomCommandStatus.Failure, message: '§cMoving to a block may only be used by entities.' }; + 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: '§cNo block in view.' }; + return { status: CustomCommandStatus.Failure, message: 'commands.playermove.block.noblock' }; system.run(() => understudy.moveLocation(block)); return { status: CustomCommandStatus.Success }; } @@ -70,17 +73,17 @@ export class PlayerMoveCommand extends VanillaCommand { #moveToEntity(origin, understudy) { const source = origin.getSource(); if (source instanceof Entity === false) - return { status: CustomCommandStatus.Failure, message: '§cMoving to an entity may only be used by entities.' }; + 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: '§cNo entity in view.' }; + 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: '§cMoving to yourself cannot be used by the server.' }; + return { status: CustomCommandStatus.Failure, message: 'commands.playermove.me.noserver' }; system.run(() => understudy.moveLocation(origin.getSource())); return { status: CustomCommandStatus.Success }; } diff --git a/__tests__/BP/scripts/src/commands/simplayer/playermove.test.js b/__tests__/BP/scripts/src/commands/simplayer/playermove.test.js index 3383242b..ac2bd6af 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playermove.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playermove.test.js @@ -7,7 +7,7 @@ import { ServerCommandOrigin } from '../../../../../../Canopy[BP]/scripts/lib/ca vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ default: { get: vi.fn(), - getNotOnlineMessage: vi.fn(name => `§cSimplayer '${name}' is not online.`), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), } })); @@ -26,13 +26,14 @@ describe('playermoveCommand', () => { 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) }; + 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.status).toBe(CustomCommandStatus.Failure); + expect(mockEntityOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); }); it.each([MOVE_OPTIONS.FORWARD, MOVE_OPTIONS.BACKWARD, MOVE_OPTIONS.LEFT, MOVE_OPTIONS.RIGHT])( @@ -114,5 +115,6 @@ describe('playermoveCommand', () => { vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); const result = playermoveCommand.playermoveCommand(mockEntityOrigin, 'TestBot', 'invalid'); expect(result.status).toBe(CustomCommandStatus.Failure); + expect(mockEntityOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playermove.invalidoption', with: ['invalid'] }); }); }); From d96f477aa88dea8f024ddfcd910b619c0a5cba86 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 14:27:58 -0700 Subject: [PATCH 029/120] feat(simplayer): localize playeraction messages --- .../src/commands/simplayer/playeraction.js | 31 ++++++++++++------- .../commands/simplayer/playeraction.test.js | 28 ++++++++++------- 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js b/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js index c8c8d82b..edf09f1b 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js @@ -26,10 +26,12 @@ export class PlayerActionCommand extends VanillaCommand { }); } - playeractionCommand(_origin, playername, action, timingOption = TIMING_OPTIONS.ONCE, ticks) { + playeractionCommand(origin, playername, action, timingOption = TIMING_OPTIONS.ONCE, ticks) { const understudy = Understudies.get(playername); - if (!understudy) - return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return { status: CustomCommandStatus.Failure }; + } if (!Object.values(REPEATABLE_ACTIONS).includes(action)) return { status: CustomCommandStatus.Failure, message: `commands.generic.invalidaction` }; const actions = understudy.actions; @@ -38,31 +40,36 @@ export class PlayerActionCommand extends VanillaCommand { actions.once(action); break; case TIMING_OPTIONS.AFTER: - return this.#singleAfterAction(actions, action, timingOption, ticks); + return this.#singleAfterAction(origin, actions, action, timingOption, ticks); case TIMING_OPTIONS.CONTINUOUS: actions.repeat(action); break; case TIMING_OPTIONS.INTERVAL: - return this.#intervalAction(actions, action, timingOption, ticks); + return this.#intervalAction(origin, actions, action, timingOption, ticks); case TIMING_OPTIONS.STOP: actions.remove(action); break; default: - return { status: CustomCommandStatus.Failure, message: `§cInvalid ${action} timing: ${timingOption}.` }; + origin.sendMessage({ translate: 'commands.playeraction.invalidtiming', with: [action, timingOption] }); + return { status: CustomCommandStatus.Failure }; } return { status: CustomCommandStatus.Success }; } - #singleAfterAction(actions, action, timingOption, ticks) { - if (ticks === void 0) - return { status: CustomCommandStatus.Failure, message: `§cInvalid '${timingOption}' tick duration: ${ticks}. Expected an integer.` }; + #singleAfterAction(origin, actions, action, timingOption, ticks) { + if (ticks === void 0) { + origin.sendMessage({ translate: 'commands.playeraction.invalidticks', with: [timingOption, String(ticks)] }); + return { status: CustomCommandStatus.Failure }; + } actions.once(action, ticks); return { status: CustomCommandStatus.Success }; } - #intervalAction(actions, action, timingOption, ticks) { - if (ticks === void 0) - return { status: CustomCommandStatus.Failure, message: `§cInvalid '${timingOption}' tick duration: ${ticks}. Expected an integer.` }; + #intervalAction(origin, actions, action, timingOption, ticks) { + if (ticks === void 0) { + origin.sendMessage({ translate: 'commands.playeraction.invalidticks', with: [timingOption, String(ticks)] }); + return { status: CustomCommandStatus.Failure }; + } actions.repeat(action, ticks); return { status: CustomCommandStatus.Success }; } diff --git a/__tests__/BP/scripts/src/commands/simplayer/playeraction.test.js b/__tests__/BP/scripts/src/commands/simplayer/playeraction.test.js index 8c59d539..f7522f35 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playeraction.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playeraction.test.js @@ -7,7 +7,7 @@ import { REPEATABLE_ACTIONS, TIMING_OPTIONS } from '../../../../../../Canopy[BP] vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ default: { get: vi.fn(), - getNotOnlineMessage: vi.fn(name => `§cSimplayer '${name}' is not online.`), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), } })); @@ -19,6 +19,7 @@ vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importO describe('playeractionCommand', () => { let mockActions; let mockUnderstudy; + let mockOrigin; beforeEach(() => { vi.clearAllMocks(); @@ -28,70 +29,75 @@ describe('playeractionCommand', () => { 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(undefined, 'TestBot', REPEATABLE_ACTIONS.ATTACK); + const result = playeractionCommand.playeractionCommand(mockOrigin, 'TestBot', REPEATABLE_ACTIONS.ATTACK); expect(result.status).toBe(CustomCommandStatus.Failure); + 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(undefined, 'TestBot', REPEATABLE_ACTIONS.ATTACK, TIMING_OPTIONS.ONCE); + 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(undefined, 'TestBot', REPEATABLE_ACTIONS.ATTACK, TIMING_OPTIONS.AFTER, undefined); + const result = playeractionCommand.playeractionCommand(mockOrigin, 'TestBot', REPEATABLE_ACTIONS.ATTACK, TIMING_OPTIONS.AFTER, undefined); expect(result.status).toBe(CustomCommandStatus.Failure); + 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(undefined, 'TestBot', REPEATABLE_ACTIONS.ATTACK, TIMING_OPTIONS.AFTER, 10); + 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(undefined, 'TestBot', REPEATABLE_ACTIONS.ATTACK, TIMING_OPTIONS.CONTINUOUS); + 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(undefined, 'TestBot', REPEATABLE_ACTIONS.ATTACK, TIMING_OPTIONS.INTERVAL, undefined); + const result = playeractionCommand.playeractionCommand(mockOrigin, 'TestBot', REPEATABLE_ACTIONS.ATTACK, TIMING_OPTIONS.INTERVAL, undefined); expect(result.status).toBe(CustomCommandStatus.Failure); + 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(undefined, 'TestBot', REPEATABLE_ACTIONS.ATTACK, TIMING_OPTIONS.INTERVAL, 20); + 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(undefined, 'TestBot', REPEATABLE_ACTIONS.ATTACK, TIMING_OPTIONS.STOP); + 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(undefined, 'TestBot', REPEATABLE_ACTIONS.ATTACK, 'invalid'); + const result = playeractionCommand.playeractionCommand(mockOrigin, 'TestBot', REPEATABLE_ACTIONS.ATTACK, 'invalid'); expect(result.status).toBe(CustomCommandStatus.Failure); + 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(undefined, 'TestBot', REPEATABLE_ACTIONS.ATTACK); + playeractionCommand.playeractionCommand(mockOrigin, 'TestBot', REPEATABLE_ACTIONS.ATTACK); expect(mockActions.once).toHaveBeenCalledWith(REPEATABLE_ACTIONS.ATTACK); }); }); From ca7254b35c022afe0ca824420dc687cb59bb1fc9 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 14:32:16 -0700 Subject: [PATCH 030/120] feat(simplayer): localize playerselect messages --- .../src/commands/simplayer/playerselect.js | 14 +++++++++----- .../src/commands/simplayer/playerselect.test.js | 17 +++++++++++------ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerselect.js b/Canopy[BP]/scripts/src/commands/simplayer/playerselect.js index a7d2332e..4e1d2f66 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerselect.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerselect.js @@ -17,12 +17,16 @@ export class PlayerSelectCommand extends VanillaCommand { }); } - playerselectCommand(_origin, playername, slotNumber) { + playerselectCommand(origin, playername, slotNumber) { const understudy = Understudies.get(playername); - if (!understudy) - return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; - if (slotNumber < 0 || slotNumber > 8) - return { status: CustomCommandStatus.Failure, message: `§cInvalid slot number: ${slotNumber}. Expected a number from 0 to 8.` }; + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return { status: CustomCommandStatus.Failure }; + } + if (slotNumber < 0 || slotNumber > 8) { + origin.sendMessage({ translate: 'commands.playerselect.invalidslot', with: [String(slotNumber)] }); + return { status: CustomCommandStatus.Failure }; + } system.run(() => understudy.selectSlot(slotNumber)); return { status: CustomCommandStatus.Success }; } diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerselect.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerselect.test.js index 3e245a6a..78ad778f 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playerselect.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playerselect.test.js @@ -6,7 +6,7 @@ import { playerselectCommand } from '../../../../../../Canopy[BP]/scripts/src/co vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ default: { get: vi.fn(), - getNotOnlineMessage: vi.fn(name => `§cSimplayer '${name}' is not online.`), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), } })); @@ -17,40 +17,45 @@ vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importO 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(undefined, 'TestBot', 0); + const result = playerselectCommand.playerselectCommand(mockOrigin, 'TestBot', 0); expect(result.status).toBe(CustomCommandStatus.Failure); + 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(undefined, 'TestBot', -1); + const result = playerselectCommand.playerselectCommand(mockOrigin, 'TestBot', -1); expect(result.status).toBe(CustomCommandStatus.Failure); + 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(undefined, 'TestBot', 9); + const result = playerselectCommand.playerselectCommand(mockOrigin, 'TestBot', 9); expect(result.status).toBe(CustomCommandStatus.Failure); + 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(undefined, 'TestBot', 0); + 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(undefined, 'TestBot', 8); + const result = playerselectCommand.playerselectCommand(mockOrigin, 'TestBot', 8); expect(result.status).toBe(CustomCommandStatus.Success); expect(system.run).toHaveBeenCalled(); }); From d9bdc2ca960ce682e6e0475329a779377b2a23b7 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 14:35:35 -0700 Subject: [PATCH 031/120] feat(simplayer): localize playerprefix messages --- .../scripts/src/commands/simplayer/playerprefix.js | 7 ++++--- .../src/commands/simplayer/playerprefix.test.js | 11 +++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerprefix.js b/Canopy[BP]/scripts/src/commands/simplayer/playerprefix.js index dcaad77c..9469f24c 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerprefix.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerprefix.js @@ -14,13 +14,14 @@ export class PlayerPrefixCommand extends VanillaCommand { }); } - playerprefixCommand(_origin, prefix) { + playerprefixCommand(origin, prefix) { if (prefix === '-none') { system.run(() => Understudies.setNametagPrefix('')); - return { status: CustomCommandStatus.Success, message: '§7Simplayer prefix removed.' }; + return { status: CustomCommandStatus.Success, message: 'commands.playerprefix.removed' }; } system.run(() => Understudies.setNametagPrefix(prefix)); - return { status: CustomCommandStatus.Success, message: `§7Simplayer prefix set to "§r${prefix}§r§7".` }; + origin.sendMessage({ translate: 'commands.playerprefix.set', with: [prefix] }); + return { status: CustomCommandStatus.Success }; } } diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerprefix.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerprefix.test.js index a620c396..489d79a8 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playerprefix.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playerprefix.test.js @@ -15,21 +15,24 @@ vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importO }); 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(undefined, '-none'); + const result = playerprefixCommand.playerprefixCommand(mockOrigin, '-none'); expect(result.status).toBe(CustomCommandStatus.Success); - expect(result.message).toContain('removed'); + 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(undefined, 'Bot'); + const result = playerprefixCommand.playerprefixCommand(mockOrigin, 'Bot'); expect(result.status).toBe(CustomCommandStatus.Success); - expect(result.message).toContain('Bot'); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playerprefix.set', with: ['Bot'] }); expect(system.run).toHaveBeenCalled(); }); }); From a344ded599681874c0e33ff41c7df529895d0a00 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 14:38:55 -0700 Subject: [PATCH 032/120] feat(simplayer): localize playerinventory messages --- .../src/commands/simplayer/playerinventory.js | 23 ++++++------ .../simplayer/playerinventory.test.js | 36 +++++++++++++------ 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerinventory.js b/Canopy[BP]/scripts/src/commands/simplayer/playerinventory.js index fe0a3e25..b912c778 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerinventory.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerinventory.js @@ -14,33 +14,36 @@ export class PlayerInventoryCommand extends VanillaCommand { }); } - playerinventoryCommand(_origin, playername) { + playerinventoryCommand(origin, playername) { const understudy = Understudies.get(playername); - if (!understudy) - return { status: CustomCommandStatus.Failure, message: Understudies.getNotOnlineMessage(playername) }; + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return { status: CustomCommandStatus.Failure }; + } const playerInventory = understudy.getInventory(); if (!playerInventory) - return { status: CustomCommandStatus.Success, message: '§cNo inventory found' }; - return { status: CustomCommandStatus.Success, message: this.#getInventoryMessage(understudy, 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 `§7${understudy.name}'s inventory is empty.`; + return { translate: 'commands.playerinventory.empty', with: [understudy.name] }; return this.#getFormattedInventoryMessage(understudy, playerInventory); } #getFormattedInventoryMessage(understudy, playerInventory) { - let message = `${understudy.name}'s inventory:`; + 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' : ''; - message += ` -§7- ${colorCode}${i}§7: ${itemStack.typeId} x${itemStack.amount}`; + rawtext.push({ text: '\n' }); + rawtext.push({ translate: 'commands.playerinventory.item', with: [colorCode, String(i), itemStack.typeId, String(itemStack.amount)] }); } } - return message; + return { rawtext }; } } diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerinventory.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerinventory.test.js index 608cc333..26afb3ce 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playerinventory.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playerinventory.test.js @@ -6,7 +6,7 @@ import { playerinventoryCommand } from '../../../../../../Canopy[BP]/scripts/src vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ default: { get: vi.fn(), - getNotOnlineMessage: vi.fn(name => `§cSimplayer '${name}' is not online.`), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), } })); @@ -17,32 +17,35 @@ vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importO 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(undefined, 'TestBot'); + const result = playerinventoryCommand.playerinventoryCommand(mockOrigin, 'TestBot'); expect(result.status).toBe(CustomCommandStatus.Failure); + 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(undefined, 'TestBot'); + const result = playerinventoryCommand.playerinventoryCommand(mockOrigin, 'TestBot'); expect(result.status).toBe(CustomCommandStatus.Success); - expect(result.message).toContain('No inventory found'); + 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(undefined, 'TestBot'); + const result = playerinventoryCommand.playerinventoryCommand(mockOrigin, 'TestBot'); expect(result.status).toBe(CustomCommandStatus.Success); - expect(result.message).toContain("TestBot's inventory is empty"); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playerinventory.empty', with: ['TestBot'] }); }); it('lists items when inventory has contents', () => { @@ -53,10 +56,15 @@ describe('playerinventoryCommand', () => { }; mockUnderstudy.getInventory.mockReturnValue(mockInventory); vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); - const result = playerinventoryCommand.playerinventoryCommand(undefined, 'TestBot'); + const result = playerinventoryCommand.playerinventoryCommand(mockOrigin, 'TestBot'); expect(result.status).toBe(CustomCommandStatus.Success); - expect(result.message).toContain('minecraft:stone'); - expect(result.message).toContain('64'); + 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', () => { @@ -67,7 +75,13 @@ describe('playerinventoryCommand', () => { }; mockUnderstudy.getInventory.mockReturnValue(mockInventory); vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); - const result = playerinventoryCommand.playerinventoryCommand(undefined, 'TestBot'); - expect(result.message).toContain('§a0'); + const result = 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'] } + ] + }); }); }); From 2408066973460bba9d656696b2224797800a8f8b Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 15:22:05 -0700 Subject: [PATCH 033/120] fix: simplayer inventory saving not working --- .../lib/SRCItemDatabase/ItemDatabase.js | 36 +++++++++++-------- .../src/classes/simplayer/Understudy.js | 25 +++++++------ .../simplayer/UnderstudyInventorySaver.js | 20 +++++++---- .../UnderstudyInventorySaver.test.js | 18 ++++++++-- 4 files changed, 66 insertions(+), 33 deletions(-) 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/src/classes/simplayer/Understudy.js b/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js index 82c23bc5..50566a68 100644 --- a/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js +++ b/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js @@ -92,17 +92,20 @@ class Understudy { const updatedGameMode = portOldGameModeToNewUpdate(gameMode); this.#simulatedPlayer = spawnSimulatedPlayer({ ...location, dimension }, this.name, updatedGameMode); this.#isConnected = true; - this.teleport({ location, rotation, dimension }); - system.run(() => { - 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; - } - }); + 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() { diff --git a/Canopy[BP]/scripts/src/classes/simplayer/UnderstudyInventorySaver.js b/Canopy[BP]/scripts/src/classes/simplayer/UnderstudyInventorySaver.js index 4d01d620..ea1e5916 100644 --- a/Canopy[BP]/scripts/src/classes/simplayer/UnderstudyInventorySaver.js +++ b/Canopy[BP]/scripts/src/classes/simplayer/UnderstudyInventorySaver.js @@ -78,13 +78,18 @@ export class UnderstudyInventorySaver { if (itemsWithoutNBTStr === '{}' || itemsWithoutNBTStr === void 0) return; const itemsWithoutNBT = JSON.parse(itemsWithoutNBTStr); - const itemsWithNBT = this.itemDatabase.getItems(this.inventoryDBKey); + 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) { - itemStack = itemsWithNBT.find(item => item.typeId === itemWithoutNBT.typeId && item.amount === itemWithoutNBT.amount); - itemsWithNBT.splice(itemsWithNBT.indexOf(itemStack), 1); + 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); } @@ -98,12 +103,15 @@ export class UnderstudyInventorySaver { if (itemsWithoutNBTStr === '{}' || itemsWithoutNBTStr === void 0) return; const itemsWithoutNBT = JSON.parse(itemsWithoutNBTStr); - const itemsWithNBT = this.itemDatabase.getItems(this.equippableDBKey); + 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 (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/__tests__/BP/scripts/src/classes/simplayer/UnderstudyInventorySaver.test.js b/__tests__/BP/scripts/src/classes/simplayer/UnderstudyInventorySaver.test.js index c8acd47e..451410ff 100644 --- a/__tests__/BP/scripts/src/classes/simplayer/UnderstudyInventorySaver.test.js +++ b/__tests__/BP/scripts/src/classes/simplayer/UnderstudyInventorySaver.test.js @@ -125,7 +125,7 @@ describe('UnderstudyInventorySaver', () => { expect(understudy.getInventory().setItem).toHaveBeenCalled(); }); - it('sets item to undefined when absent from the NBT database', () => { + 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 } }) @@ -133,7 +133,21 @@ describe('UnderstudyInventorySaver', () => { ); vi.spyOn(inventorySaver.itemDatabase, 'getItems').mockReturnValue([]); inventorySaver.load(); - expect(understudy.getInventory().setItem).toHaveBeenCalledWith(0, undefined); + 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); }); }); From c60b77738d90e7b45a6f66d2f81d77afefb73264 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 15:33:27 -0700 Subject: [PATCH 034/120] fix: remove extra newlines from simplayer cmd error outputs --- Canopy[BP]/scripts/src/commands/simplayer/playeraction.js | 8 ++++---- .../src/commands/simplayer/playerclaimprojectiles.js | 2 +- .../scripts/src/commands/simplayer/playerinventory.js | 2 +- Canopy[BP]/scripts/src/commands/simplayer/playerjoin.js | 2 +- Canopy[BP]/scripts/src/commands/simplayer/playerleave.js | 4 ++-- Canopy[BP]/scripts/src/commands/simplayer/playerlook.js | 4 ++-- Canopy[BP]/scripts/src/commands/simplayer/playermove.js | 4 ++-- Canopy[BP]/scripts/src/commands/simplayer/playerrejoin.js | 2 +- Canopy[BP]/scripts/src/commands/simplayer/playerselect.js | 4 ++-- Canopy[BP]/scripts/src/commands/simplayer/playersneak.js | 2 +- Canopy[BP]/scripts/src/commands/simplayer/playersprint.js | 2 +- Canopy[BP]/scripts/src/commands/simplayer/playerstop.js | 2 +- .../scripts/src/commands/simplayer/playerswapheld.js | 2 +- Canopy[BP]/scripts/src/commands/simplayer/playertp.js | 2 +- 14 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js b/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js index edf09f1b..fc6d58dc 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js @@ -30,7 +30,7 @@ export class PlayerActionCommand extends VanillaCommand { const understudy = Understudies.get(playername); if (!understudy) { origin.sendMessage(Understudies.getNotOnlineMessage(playername)); - return { status: CustomCommandStatus.Failure }; + return; } if (!Object.values(REPEATABLE_ACTIONS).includes(action)) return { status: CustomCommandStatus.Failure, message: `commands.generic.invalidaction` }; @@ -51,7 +51,7 @@ export class PlayerActionCommand extends VanillaCommand { break; default: origin.sendMessage({ translate: 'commands.playeraction.invalidtiming', with: [action, timingOption] }); - return { status: CustomCommandStatus.Failure }; + return; } return { status: CustomCommandStatus.Success }; } @@ -59,7 +59,7 @@ export class PlayerActionCommand extends VanillaCommand { #singleAfterAction(origin, actions, action, timingOption, ticks) { if (ticks === void 0) { origin.sendMessage({ translate: 'commands.playeraction.invalidticks', with: [timingOption, String(ticks)] }); - return { status: CustomCommandStatus.Failure }; + return; } actions.once(action, ticks); return { status: CustomCommandStatus.Success }; @@ -68,7 +68,7 @@ export class PlayerActionCommand extends VanillaCommand { #intervalAction(origin, actions, action, timingOption, ticks) { if (ticks === void 0) { origin.sendMessage({ translate: 'commands.playeraction.invalidticks', with: [timingOption, String(ticks)] }); - return { status: CustomCommandStatus.Failure }; + return; } actions.repeat(action, ticks); return { status: CustomCommandStatus.Success }; diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerclaimprojectiles.js b/Canopy[BP]/scripts/src/commands/simplayer/playerclaimprojectiles.js index 89e33808..19956f83 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerclaimprojectiles.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerclaimprojectiles.js @@ -19,7 +19,7 @@ export class PlayerClaimProjectilesCommand extends VanillaCommand { const understudy = Understudies.get(playername); if (!understudy) { origin.sendMessage(Understudies.getNotOnlineMessage(playername)); - return { status: CustomCommandStatus.Failure }; + return; } system.run(() => understudy.claimProjectiles(radius)); return { status: CustomCommandStatus.Success }; diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerinventory.js b/Canopy[BP]/scripts/src/commands/simplayer/playerinventory.js index b912c778..a762fa6c 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerinventory.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerinventory.js @@ -18,7 +18,7 @@ export class PlayerInventoryCommand extends VanillaCommand { const understudy = Understudies.get(playername); if (!understudy) { origin.sendMessage(Understudies.getNotOnlineMessage(playername)); - return { status: CustomCommandStatus.Failure }; + return; } const playerInventory = understudy.getInventory(); if (!playerInventory) diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerjoin.js b/Canopy[BP]/scripts/src/commands/simplayer/playerjoin.js index 0e96c2fb..e002bbbf 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerjoin.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerjoin.js @@ -18,7 +18,7 @@ export class PlayerJoinCommand extends VanillaCommand { playerjoinCommand(origin, playername) { if (Understudies.isOnline(playername)) { origin.sendMessage(Understudies.getAlreadyOnlineMessage(playername)); - return { status: CustomCommandStatus.Failure }; + return; } system.run(() => { const understudy = Understudies.create(playername); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerleave.js b/Canopy[BP]/scripts/src/commands/simplayer/playerleave.js index 7237be08..4ca3949b 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerleave.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerleave.js @@ -1,4 +1,4 @@ -import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { CustomCommandParamType, CommandPermissionLevel, system } from "@minecraft/server"; import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; import Understudies from "../../classes/simplayer/Understudies"; @@ -18,7 +18,7 @@ export class PlayerLeaveCommand extends VanillaCommand { const understudy = Understudies.get(playername); if (!understudy) { origin.sendMessage(Understudies.getNotOnlineMessage(playername)); - return { status: CustomCommandStatus.Failure }; + return; } system.run(() => { understudy.leave(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js b/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js index abff7791..043e940e 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js @@ -38,7 +38,7 @@ export class PlayerLookCommand extends VanillaCommand { const understudy = Understudies.get(playername); if (!understudy) { origin.sendMessage(Understudies.getNotOnlineMessage(playername)); - return { status: CustomCommandStatus.Failure }; + return; } switch (lookOption) { case LOOK_OPTIONS.UP: case LOOK_OPTIONS.DOWN: case LOOK_OPTIONS.NORTH: @@ -66,7 +66,7 @@ export class PlayerLookCommand extends VanillaCommand { break; default: origin.sendMessage({ translate: 'commands.playerlook.invalidoption', with: [lookOption] }); - return { status: CustomCommandStatus.Failure }; + return; } return { status: CustomCommandStatus.Success }; } diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playermove.js b/Canopy[BP]/scripts/src/commands/simplayer/playermove.js index f2c5d690..b5060aa6 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playermove.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playermove.js @@ -29,7 +29,7 @@ export class PlayerMoveCommand extends VanillaCommand { const understudy = Understudies.get(playername); if (!understudy) { origin.sendMessage(Understudies.getNotOnlineMessage(playername)); - return { status: CustomCommandStatus.Failure }; + return; } switch (moveOption) { case MOVE_OPTIONS.FORWARD: case MOVE_OPTIONS.BACKWARD: @@ -50,7 +50,7 @@ export class PlayerMoveCommand extends VanillaCommand { break; default: origin.sendMessage({ translate: 'commands.playermove.invalidoption', with: [moveOption] }); - return { status: CustomCommandStatus.Failure }; + return; } return { status: CustomCommandStatus.Success }; } diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerrejoin.js b/Canopy[BP]/scripts/src/commands/simplayer/playerrejoin.js index 2e57d776..6b01f817 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerrejoin.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerrejoin.js @@ -18,7 +18,7 @@ export class PlayerRejoinCommand extends VanillaCommand { playerrejoinCommand(origin, playername) { if (Understudies.isOnline(playername)) { origin.sendMessage(Understudies.getAlreadyOnlineMessage(playername)); - return { status: CustomCommandStatus.Failure }; + return; } system.run(() => this.#tryRejoin(origin, playername)); return { status: CustomCommandStatus.Success }; diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerselect.js b/Canopy[BP]/scripts/src/commands/simplayer/playerselect.js index 4e1d2f66..63ff1d56 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerselect.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerselect.js @@ -21,11 +21,11 @@ export class PlayerSelectCommand extends VanillaCommand { const understudy = Understudies.get(playername); if (!understudy) { origin.sendMessage(Understudies.getNotOnlineMessage(playername)); - return { status: CustomCommandStatus.Failure }; + return; } if (slotNumber < 0 || slotNumber > 8) { origin.sendMessage({ translate: 'commands.playerselect.invalidslot', with: [String(slotNumber)] }); - return { status: CustomCommandStatus.Failure }; + return; } system.run(() => understudy.selectSlot(slotNumber)); return { status: CustomCommandStatus.Success }; diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playersneak.js b/Canopy[BP]/scripts/src/commands/simplayer/playersneak.js index 0c1ebf12..c0f432b5 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playersneak.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playersneak.js @@ -21,7 +21,7 @@ export class PlayerSneakCommand extends VanillaCommand { const understudy = Understudies.get(playername); if (!understudy) { origin.sendMessage(Understudies.getNotOnlineMessage(playername)); - return { status: CustomCommandStatus.Failure }; + return; } system.run(() => understudy.sneak(shouldSneak)); return { status: CustomCommandStatus.Success }; diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playersprint.js b/Canopy[BP]/scripts/src/commands/simplayer/playersprint.js index 0d496002..1eb78a0e 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playersprint.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playersprint.js @@ -21,7 +21,7 @@ export class PlayerSprintCommand extends VanillaCommand { const understudy = Understudies.get(playername); if (!understudy) { origin.sendMessage(Understudies.getNotOnlineMessage(playername)); - return { status: CustomCommandStatus.Failure }; + return; } system.run(() => understudy.sprint(shouldSprint)); return { status: CustomCommandStatus.Success }; diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerstop.js b/Canopy[BP]/scripts/src/commands/simplayer/playerstop.js index cec5a2a9..a32862a5 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerstop.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerstop.js @@ -18,7 +18,7 @@ export class PlayerStopCommand extends VanillaCommand { const understudy = Understudies.get(playername); if (!understudy) { origin.sendMessage(Understudies.getNotOnlineMessage(playername)); - return { status: CustomCommandStatus.Failure }; + return; } system.run(() => understudy.stopAll()); return { status: CustomCommandStatus.Success }; diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerswapheld.js b/Canopy[BP]/scripts/src/commands/simplayer/playerswapheld.js index 4de9b108..7ea6d671 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerswapheld.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerswapheld.js @@ -18,7 +18,7 @@ export class PlayerSwapHeldCommand extends VanillaCommand { const understudy = Understudies.get(playername); if (!understudy) { origin.sendMessage(Understudies.getNotOnlineMessage(playername)); - return { status: CustomCommandStatus.Failure }; + return; } system.run(() => understudy.swapHeldItemWithPlayer(origin.getSource())); return { status: CustomCommandStatus.Success }; diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playertp.js b/Canopy[BP]/scripts/src/commands/simplayer/playertp.js index 3e4e9fc0..3257046f 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playertp.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playertp.js @@ -19,7 +19,7 @@ export class PlayerTpCommand extends VanillaCommand { const understudy = Understudies.get(playername); if (!understudy) { origin.sendMessage(Understudies.getNotOnlineMessage(playername)); - return { status: CustomCommandStatus.Failure }; + return; } system.run(() => understudy.teleport(getLocationInfoFromSource(origin.getSource()))); return { status: CustomCommandStatus.Success }; From b540dfbf621bb3e5e75acf07d899581f8e4b2674 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 17:35:22 -0700 Subject: [PATCH 035/120] fix: linting errors --- Canopy[BP]/scripts/src/commands/simplayer/playerjoin.js | 2 +- .../BP/scripts/src/commands/simplayer/playerinventory.test.js | 2 +- .../BP/scripts/src/commands/simplayer/playerprefix.test.js | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerjoin.js b/Canopy[BP]/scripts/src/commands/simplayer/playerjoin.js index e002bbbf..10a5e5fc 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerjoin.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerjoin.js @@ -1,4 +1,4 @@ -import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +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"; diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerinventory.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerinventory.test.js index 26afb3ce..ee4ca219 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playerinventory.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playerinventory.test.js @@ -75,7 +75,7 @@ describe('playerinventoryCommand', () => { }; mockUnderstudy.getInventory.mockReturnValue(mockInventory); vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); - const result = playerinventoryCommand.playerinventoryCommand(mockOrigin, 'TestBot'); + playerinventoryCommand.playerinventoryCommand(mockOrigin, 'TestBot'); expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ rawtext: [ { translate: 'commands.playerinventory.header', with: ['TestBot'] }, diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerprefix.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerprefix.test.js index 489d79a8..828cb2f8 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playerprefix.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playerprefix.test.js @@ -1,6 +1,5 @@ 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 { playerprefixCommand } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playerprefix'; vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ From e748e84442c0dfdfe86a5bb2f6560c898b46449c Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 17:39:51 -0700 Subject: [PATCH 036/120] test(simplayer): align error-path assertions with bare-return contract --- .../scripts/src/commands/simplayer/playeraction.test.js | 8 ++++---- .../src/commands/simplayer/playerclaimprojectiles.test.js | 2 +- .../src/commands/simplayer/playerinventory.test.js | 2 +- .../BP/scripts/src/commands/simplayer/playerjoin.test.js | 2 +- .../BP/scripts/src/commands/simplayer/playerleave.test.js | 2 +- .../BP/scripts/src/commands/simplayer/playerlook.test.js | 4 ++-- .../BP/scripts/src/commands/simplayer/playermove.test.js | 4 ++-- .../scripts/src/commands/simplayer/playerrejoin.test.js | 2 +- .../scripts/src/commands/simplayer/playerselect.test.js | 6 +++--- .../BP/scripts/src/commands/simplayer/playersneak.test.js | 2 +- .../scripts/src/commands/simplayer/playersprint.test.js | 2 +- .../BP/scripts/src/commands/simplayer/playerstop.test.js | 2 +- .../scripts/src/commands/simplayer/playerswapheld.test.js | 2 +- .../BP/scripts/src/commands/simplayer/playertp.test.js | 2 +- 14 files changed, 21 insertions(+), 21 deletions(-) diff --git a/__tests__/BP/scripts/src/commands/simplayer/playeraction.test.js b/__tests__/BP/scripts/src/commands/simplayer/playeraction.test.js index f7522f35..3ae615c8 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playeraction.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playeraction.test.js @@ -35,7 +35,7 @@ describe('playeractionCommand', () => { 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.status).toBe(CustomCommandStatus.Failure); + expect(result).toBeUndefined(); expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); }); @@ -49,7 +49,7 @@ describe('playeractionCommand', () => { 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.status).toBe(CustomCommandStatus.Failure); + expect(result).toBeUndefined(); expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playeraction.invalidticks', with: [TIMING_OPTIONS.AFTER, 'undefined'] }); }); @@ -70,7 +70,7 @@ describe('playeractionCommand', () => { 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.status).toBe(CustomCommandStatus.Failure); + expect(result).toBeUndefined(); expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playeraction.invalidticks', with: [TIMING_OPTIONS.INTERVAL, 'undefined'] }); }); @@ -91,7 +91,7 @@ describe('playeractionCommand', () => { 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.status).toBe(CustomCommandStatus.Failure); + expect(result).toBeUndefined(); expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playeraction.invalidtiming', with: [REPEATABLE_ACTIONS.ATTACK, 'invalid'] }); }); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerclaimprojectiles.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerclaimprojectiles.test.js index bfb8d69e..1ff95bda 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playerclaimprojectiles.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playerclaimprojectiles.test.js @@ -28,7 +28,7 @@ describe('playerclaimprojectilesCommand', () => { it('returns failure when the simplayer is not online', () => { vi.mocked(Understudies.get).mockReturnValue(undefined); const result = playerclaimprojectilesCommand.playerclaimprojectilesCommand(mockOrigin, 'TestBot'); - expect(result.status).toBe(CustomCommandStatus.Failure); + expect(result).toBeUndefined(); expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); }); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerinventory.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerinventory.test.js index ee4ca219..8d80c2b2 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playerinventory.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playerinventory.test.js @@ -28,7 +28,7 @@ describe('playerinventoryCommand', () => { it('returns failure when the simplayer is not online', () => { vi.mocked(Understudies.get).mockReturnValue(undefined); const result = playerinventoryCommand.playerinventoryCommand(mockOrigin, 'TestBot'); - expect(result.status).toBe(CustomCommandStatus.Failure); + expect(result).toBeUndefined(); expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); }); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerjoin.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerjoin.test.js index 01abc4d0..fe3c7ece 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playerjoin.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playerjoin.test.js @@ -35,7 +35,7 @@ describe('playerjoinCommand', () => { it('returns failure when the simplayer is already online', () => { vi.mocked(Understudies.isOnline).mockReturnValue(true); const result = playerjoinCommand.playerjoinCommand(mockOrigin, 'TestBot'); - expect(result.status).toBe(CustomCommandStatus.Failure); + expect(result).toBeUndefined(); expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.alreadyonline', with: ['TestBot'] }); }); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerleave.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerleave.test.js index 78069527..0a2afa9e 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playerleave.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playerleave.test.js @@ -31,7 +31,7 @@ describe('playerleaveCommand', () => { it('returns failure when the simplayer is not online', () => { vi.mocked(Understudies.get).mockReturnValue(undefined); const result = playerleaveCommand.playerleaveCommand(mockOrigin, 'TestBot'); - expect(result.status).toBe(CustomCommandStatus.Failure); + expect(result).toBeUndefined(); expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); }); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerlook.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerlook.test.js index aa2ab1ad..2e6cd68d 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playerlook.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playerlook.test.js @@ -32,7 +32,7 @@ describe('playerlookCommand', () => { 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.status).toBe(CustomCommandStatus.Failure); + expect(result).toBeUndefined(); expect(mockEntityOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); }); @@ -133,7 +133,7 @@ describe('playerlookCommand', () => { it('returns failure for invalid look option', () => { vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', 'invalid'); - expect(result.status).toBe(CustomCommandStatus.Failure); + 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 index ac2bd6af..0050727b 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playermove.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playermove.test.js @@ -32,7 +32,7 @@ describe('playermoveCommand', () => { 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.status).toBe(CustomCommandStatus.Failure); + expect(result).toBeUndefined(); expect(mockEntityOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); }); @@ -114,7 +114,7 @@ describe('playermoveCommand', () => { it('returns failure for invalid move option', () => { vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); const result = playermoveCommand.playermoveCommand(mockEntityOrigin, 'TestBot', 'invalid'); - expect(result.status).toBe(CustomCommandStatus.Failure); + expect(result).toBeUndefined(); expect(mockEntityOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playermove.invalidoption', with: ['invalid'] }); }); }); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerrejoin.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerrejoin.test.js index 68155cee..a259a69c 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playerrejoin.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playerrejoin.test.js @@ -42,7 +42,7 @@ describe('playerrejoinCommand', () => { it('returns failure when the simplayer is already online', () => { vi.mocked(Understudies.isOnline).mockReturnValue(true); const result = playerrejoinCommand.playerrejoinCommand(mockOrigin, 'TestBot'); - expect(result.status).toBe(CustomCommandStatus.Failure); + expect(result).toBeUndefined(); expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.alreadyonline', with: ['TestBot'] }); }); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerselect.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerselect.test.js index 78ad778f..a8d81218 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playerselect.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playerselect.test.js @@ -28,21 +28,21 @@ describe('playerselectCommand', () => { it('returns failure when the simplayer is not online', () => { vi.mocked(Understudies.get).mockReturnValue(undefined); const result = playerselectCommand.playerselectCommand(mockOrigin, 'TestBot', 0); - expect(result.status).toBe(CustomCommandStatus.Failure); + 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.status).toBe(CustomCommandStatus.Failure); + 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.status).toBe(CustomCommandStatus.Failure); + expect(result).toBeUndefined(); expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playerselect.invalidslot', with: ['9'] }); }); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playersneak.test.js b/__tests__/BP/scripts/src/commands/simplayer/playersneak.test.js index 55299198..bda3d8c9 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playersneak.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playersneak.test.js @@ -28,7 +28,7 @@ describe('playersneakCommand', () => { it('returns failure when the simplayer is not online', () => { vi.mocked(Understudies.get).mockReturnValue(undefined); const result = playersneakCommand.playersneakCommand(mockOrigin, 'TestBot', true); - expect(result.status).toBe(CustomCommandStatus.Failure); + expect(result).toBeUndefined(); expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); }); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playersprint.test.js b/__tests__/BP/scripts/src/commands/simplayer/playersprint.test.js index aa098b2e..c1b30611 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playersprint.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playersprint.test.js @@ -28,7 +28,7 @@ describe('playersprintCommand', () => { it('returns failure when the simplayer is not online', () => { vi.mocked(Understudies.get).mockReturnValue(undefined); const result = playersprintCommand.playersprintCommand(mockOrigin, 'TestBot', true); - expect(result.status).toBe(CustomCommandStatus.Failure); + expect(result).toBeUndefined(); expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); }); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerstop.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerstop.test.js index b15f5629..a18b30c0 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playerstop.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playerstop.test.js @@ -28,7 +28,7 @@ describe('playerstopCommand', () => { it('returns failure when the simplayer is not online', () => { vi.mocked(Understudies.get).mockReturnValue(undefined); const result = playerstopCommand.playerstopCommand(mockOrigin, 'TestBot'); - expect(result.status).toBe(CustomCommandStatus.Failure); + expect(result).toBeUndefined(); expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); }); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerswapheld.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerswapheld.test.js index 35a08d18..9df76ce3 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playerswapheld.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playerswapheld.test.js @@ -28,7 +28,7 @@ describe('playerswapheldCommand', () => { it('returns failure when the simplayer is not online', () => { vi.mocked(Understudies.get).mockReturnValue(undefined); const result = playerswapheldCommand.playerswapheldCommand(mockOrigin, 'TestBot'); - expect(result.status).toBe(CustomCommandStatus.Failure); + expect(result).toBeUndefined(); expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); }); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playertp.test.js b/__tests__/BP/scripts/src/commands/simplayer/playertp.test.js index 8fed97a9..12af4c8c 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playertp.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playertp.test.js @@ -28,7 +28,7 @@ describe('playertpCommand', () => { it('returns failure when the simplayer is not online', () => { vi.mocked(Understudies.get).mockReturnValue(undefined); const result = playertpCommand.playertpCommand(mockOrigin, 'TestBot'); - expect(result.status).toBe(CustomCommandStatus.Failure); + expect(result).toBeUndefined(); expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); }); From 440b5a49977a48ea3cc5d4668da4829703c359e3 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 17:43:13 -0700 Subject: [PATCH 037/120] fix: commit untracked simplayerSaving rule --- .../scripts/src/rules/simplayer/simplayerSaving.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving.js 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..82c33c28 --- /dev/null +++ b/Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving.js @@ -0,0 +1,13 @@ +import { BooleanRule } from "../../../lib/canopy/Canopy"; + +class SimplayerSaving extends BooleanRule { + constructor() { + super({ + identifier: 'simplayerSaving', + description: { translate: 'rules.simplayerSaving' }, + defaultValue: true + }); + } +} + +export const simplayerSaving = new SimplayerSaving(); From 1d622798568f88ab4d7a377bb189da12d071e673 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 18:04:56 -0700 Subject: [PATCH 038/120] feat: remove /playerclaimprojectiles because /canopy:claimprojectiles does exactly the same thing already. --- Canopy[BP]/scripts/main.js | 1 - .../simplayer/playerclaimprojectiles.js | 29 ------------------- Canopy[RP]/texts/en_US.lang | 21 ++++++++++---- 3 files changed, 15 insertions(+), 36 deletions(-) delete mode 100644 Canopy[BP]/scripts/src/commands/simplayer/playerclaimprojectiles.js diff --git a/Canopy[BP]/scripts/main.js b/Canopy[BP]/scripts/main.js index 7ffb1c88..38153658 100644 --- a/Canopy[BP]/scripts/main.js +++ b/Canopy[BP]/scripts/main.js @@ -45,7 +45,6 @@ import './src/commands/simplayer/playermove' import './src/commands/simplayer/playerselect' import './src/commands/simplayer/playersprint' import './src/commands/simplayer/playersneak' -import './src/commands/simplayer/playerclaimprojectiles' import './src/commands/simplayer/playerstop' import './src/commands/simplayer/playerswapheld' import './src/commands/simplayer/playerinventory' diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerclaimprojectiles.js b/Canopy[BP]/scripts/src/commands/simplayer/playerclaimprojectiles.js deleted file mode 100644 index 19956f83..00000000 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerclaimprojectiles.js +++ /dev/null @@ -1,29 +0,0 @@ -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 PlayerClaimProjectilesCommand extends VanillaCommand { - constructor() { - super({ - name: 'canopy:playerclaimprojectiles', - description: 'commands.playerclaimprojectiles', - mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], - optionalParameters: [{ name: 'radius', type: CustomCommandParamType.Float }], - permissionLevel: CommandPermissionLevel.Any, - allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], - callback: (origin, ...args) => this.playerclaimprojectilesCommand(origin, ...args) - }); - } - - playerclaimprojectilesCommand(origin, playername, radius = 25) { - const understudy = Understudies.get(playername); - if (!understudy) { - origin.sendMessage(Understudies.getNotOnlineMessage(playername)); - return; - } - system.run(() => understudy.claimProjectiles(radius)); - return { status: CustomCommandStatus.Success }; - } -} - -export const playerclaimprojectilesCommand = new PlayerClaimProjectilesCommand(); diff --git a/Canopy[RP]/texts/en_US.lang b/Canopy[RP]/texts/en_US.lang index 5768f8d1..88874147 100644 --- a/Canopy[RP]/texts/en_US.lang +++ b/Canopy[RP]/texts/en_US.lang @@ -242,14 +242,17 @@ 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.playerclaimprojectiles=Make a simplayer the owner of all nearby projectiles. + 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. @@ -259,6 +262,7 @@ 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. @@ -266,16 +270,24 @@ 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. @@ -430,9 +442,6 @@ rules.instaminableEndstone=Makes endstone instaminable while using netherite, ef rules.minecartChunkLoading=Allows minecarts to tick the specified chunk radius (square) around them for 10 seconds after they are spawned. rules.noWelcomeMessage=Disables the §lCanopy§r§8 welcome message. rules.pistonBedrockBreaking=Allows pistons to break bedrock when facing away from a bedrock block and expanding. -rules.simplayerSaving=Disables saving playerdata for simplayers. Improves performance but causes simplayers to lose their inventory and location when they leave and rejoin. -rules.simplayerRejoining=Makes online simplayers rejoin when the world reloads. - rules.playerSit=Allows players to sit down after %s quick sneaks. rules.potionBoostedBreeding=Reintroduces the behavior that allows speed potions to affect breeding attributes. rules.quickFillContainer=Using an item on a container with an arrow in inventory slot 9 (top left) will deposit all of that item into the container. @@ -443,6 +452,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=Disables saving playerdata for simplayers. Improves performance but causes simplayers to lose 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. @@ -507,6 +518,4 @@ rules.infoDisplay.worldDay.display=Day: %s simplayer.notonline=§cSimplayer '%s' is not online. simplayer.alreadyonline=§cSimplayer '%s' is already online. simplayer.leave.broadcast=§e%s left the game -simplayer.claimprojectiles.none=<%1> §7No claimable projectiles found within %2 blocks. -simplayer.claimprojectiles.success=<%1> §7Successfully became the owner of %2 projectiles. simplayer.swapheld.error=§cError while swapping items: %s \ No newline at end of file From 643afbd048087c1e968e16da6276a6c6a4be8164 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 18:37:54 -0700 Subject: [PATCH 039/120] docs: make donation section slightly more important --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0ed1f7d5..bc434173 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,6 @@ We welcome community contributions! If you’re passionate about improving the t **Canopy** is a project of the **Amelix Foundation**. For more information on our SMP and other technical projects, [**join the Amelix Foundation Discord!**](https://discord.gg/FabqwVzgyD) -### Donate +## Donate If you appreciate my work here and would like to support the future development of my addons, please consider donating to me on [BuyMeACoffee](https://buymeacoffee.com/forestoflight). Your support is greatly appreciated! From 600359c4a9149b52163eaee15df31301b6b90af9 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 18:41:41 -0700 Subject: [PATCH 040/120] tests: fix unused imports --- __tests__/BP/scripts/src/commands/simplayer/playerjoin.test.js | 2 +- __tests__/BP/scripts/src/commands/simplayer/playerleave.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerjoin.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerjoin.test.js index fe3c7ece..754f1cc6 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playerjoin.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playerjoin.test.js @@ -1,5 +1,5 @@ import { vi, describe, it, expect, beforeEach } from 'vitest'; -import { system, CustomCommandStatus } from '@minecraft/server'; +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'; diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerleave.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerleave.test.js index 0a2afa9e..c92d46fd 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playerleave.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playerleave.test.js @@ -1,5 +1,5 @@ import { vi, describe, it, expect, beforeEach } from 'vitest'; -import { system, CustomCommandStatus } from '@minecraft/server'; +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'; From dea5ef3c3dacfcc693326463b46d0f19b812c3f9 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 18:42:09 -0700 Subject: [PATCH 041/120] tests: remove unused playerclaimprojectiles test --- .../simplayer/playerclaimprojectiles.test.js | 48 ------------------- 1 file changed, 48 deletions(-) delete mode 100644 __tests__/BP/scripts/src/commands/simplayer/playerclaimprojectiles.test.js diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerclaimprojectiles.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerclaimprojectiles.test.js deleted file mode 100644 index 1ff95bda..00000000 --- a/__tests__/BP/scripts/src/commands/simplayer/playerclaimprojectiles.test.js +++ /dev/null @@ -1,48 +0,0 @@ -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 { playerclaimprojectilesCommand } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playerclaimprojectiles'; - -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('playerclaimprojectilesCommand', () => { - let mockUnderstudy; - let mockOrigin; - - beforeEach(() => { - vi.clearAllMocks(); - mockUnderstudy = { claimProjectiles: 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 = playerclaimprojectilesCommand.playerclaimprojectilesCommand(mockOrigin, 'TestBot'); - expect(result).toBeUndefined(); - expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); - }); - - it('returns success and queues claimProjectiles with default radius when online', () => { - vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); - const result = playerclaimprojectilesCommand.playerclaimprojectilesCommand(undefined, 'TestBot'); - expect(result.status).toBe(CustomCommandStatus.Success); - expect(system.run).toHaveBeenCalled(); - }); - - it('returns success and queues claimProjectiles with specified radius', () => { - vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); - const result = playerclaimprojectilesCommand.playerclaimprojectilesCommand(undefined, 'TestBot', 50); - expect(result.status).toBe(CustomCommandStatus.Success); - expect(system.run).toHaveBeenCalled(); - }); -}); From d7a25167c9588dc0f362ad30f1e9764b03c6323b Mon Sep 17 00:00:00 2001 From: IdotIcom <176992055+IdotIcom@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:40:10 +0700 Subject: [PATCH 042/120] Update Indonesian language file with new commands Added new commands for simplayer actions and updated existing command messages for clarity. --- Canopy[RP]/texts/id_ID.lang | 100 ++++++++++++++++++++++++++++++++---- 1 file changed, 90 insertions(+), 10 deletions(-) diff --git a/Canopy[RP]/texts/id_ID.lang b/Canopy[RP]/texts/id_ID.lang index 92ebd0b4..c0dbf80d 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. @@ -87,6 +89,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 +152,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 +191,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 +239,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,11 +308,11 @@ 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. -commands.sit.busy=§cAnda "sibuk" dan tidak bisa duduk. +commands.sit.busy=§cAnda “sibuk“ dan tidak bisa duduk. commands.spawn=Perintah spawn untuk melacak dan mocking spawn. commands.spawn.entities=Menampilkan daftar semua entitas & posisi mereka di dunia. @@ -316,6 +371,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 +420,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=Menonaktifkan penyimpanan data pemain untuk simplayer. Meningkatkan kinerja, tetapi menyebabkan simplayer kehilangan 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 +471,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 +487,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 +513,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 From a7d3b22a6db12f879a0afb0fc2fccc3a2aef99fb Mon Sep 17 00:00:00 2001 From: IdotIcom <176992055+IdotIcom@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:50:38 +0700 Subject: [PATCH 043/120] "" --- Canopy[RP]/texts/id_ID.lang | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Canopy[RP]/texts/id_ID.lang b/Canopy[RP]/texts/id_ID.lang index c0dbf80d..54e5e5bc 100644 --- a/Canopy[RP]/texts/id_ID.lang +++ b/Canopy[RP]/texts/id_ID.lang @@ -312,7 +312,7 @@ commands.simmap.config.location=§7Lokasi Peta Simulasi di InfoDisplay diperbaru commands.simmap.config.reset=§7Lokasi Peta Simulasi di InfoDisplay diperbarui untuk mengikuti lokasi Anda saat ini. commands.sit=Membuat karakter Anda duduk. -commands.sit.busy=§cAnda “sibuk“ dan tidak bisa duduk. +commands.sit.busy=§cAnda "sibuk" dan tidak bisa duduk. commands.spawn=Perintah spawn untuk melacak dan mocking spawn. commands.spawn.entities=Menampilkan daftar semua entitas & posisi mereka di dunia. From 74115428f93db338ab170ee7b1adce12ed0bfd00 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 24 Jun 2026 23:14:20 -0700 Subject: [PATCH 044/120] feat: wiki for simplayers --- .../src/commands/simplayer/playeraction.js | 20 ++++++++++++++++++- .../src/commands/simplayer/playerlook.js | 10 +++++++++- .../src/commands/simplayer/playermove.js | 9 ++++++++- .../src/rules/simplayer/simplayerRejoining.js | 7 +++---- .../src/rules/simplayer/simplayerSaving.js | 7 +++---- Canopy[RP]/texts/en_US.lang | 2 +- package-lock.json | 11 ++++++++++ package.json | 1 + vite.config.js | 1 + vitest.wiki.config.js | 1 + 10 files changed, 57 insertions(+), 12 deletions(-) diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js b/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js index fc6d58dc..11327e3c 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js @@ -22,7 +22,25 @@ export class PlayerActionCommand extends VanillaCommand { ], permissionLevel: CommandPermissionLevel.Any, allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], - callback: (origin, ...args) => this.playeractionCommand(origin, ...args) + 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." }); } diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js b/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js index 043e940e..09916b39 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js @@ -29,7 +29,15 @@ export class PlayerLookCommand extends VanillaCommand { ], permissionLevel: CommandPermissionLevel.Any, allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], - callback: (origin, ...args) => this.playerlookCommand(origin, ...args) + 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." }); } diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playermove.js b/Canopy[BP]/scripts/src/commands/simplayer/playermove.js index b5060aa6..22552aaf 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playermove.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playermove.js @@ -21,7 +21,14 @@ export class PlayerMoveCommand extends VanillaCommand { ], permissionLevel: CommandPermissionLevel.Any, allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], - callback: (origin, ...args) => this.playermoveCommand(origin, ...args) + 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." }); } diff --git a/Canopy[BP]/scripts/src/rules/simplayer/simplayerRejoining.js b/Canopy[BP]/scripts/src/rules/simplayer/simplayerRejoining.js index 2dc6deff..89756646 100644 --- a/Canopy[BP]/scripts/src/rules/simplayer/simplayerRejoining.js +++ b/Canopy[BP]/scripts/src/rules/simplayer/simplayerRejoining.js @@ -1,4 +1,4 @@ -import { BooleanRule } from "../../../lib/canopy/Canopy"; +import { BooleanRule, GlobalRule } from "../../../lib/canopy/Canopy"; import { system, world } from "@minecraft/server"; import Understudies from "../../classes/simplayer/Understudies"; @@ -6,13 +6,12 @@ class SimplayerRejoining extends BooleanRule { simplayersToRejoinDP = 'simplayersToRejoin'; constructor() { - super({ + super(GlobalRule.morphOptions({ identifier: 'simplayerRejoining', - description: { translate: 'rules.simplayerRejoining' }, defaultValue: false, onEnableCallback: () => this.subscribeToEvent(), onDisableCallback: () => this.unsubscribeFromEvent() - }); + })); this.onShutdownBound = this.onShutdown.bind(this); } diff --git a/Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving.js b/Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving.js index 82c33c28..fc41561f 100644 --- a/Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving.js +++ b/Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving.js @@ -1,12 +1,11 @@ -import { BooleanRule } from "../../../lib/canopy/Canopy"; +import { BooleanRule, GlobalRule } from "../../../lib/canopy/Canopy"; class SimplayerSaving extends BooleanRule { constructor() { - super({ + super(GlobalRule.morphOptions({ identifier: 'simplayerSaving', - description: { translate: 'rules.simplayerSaving' }, defaultValue: true - }); + })); } } diff --git a/Canopy[RP]/texts/en_US.lang b/Canopy[RP]/texts/en_US.lang index 88874147..11b564f6 100644 --- a/Canopy[RP]/texts/en_US.lang +++ b/Canopy[RP]/texts/en_US.lang @@ -452,7 +452,7 @@ 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=Disables saving playerdata for simplayers. Improves performance but causes simplayers to lose their inventory and location when they leave and rejoin. +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. diff --git a/package-lock.json b/package-lock.json index 7aaf0ade..d6544985 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "dependencies": { "@minecraft/debug-utilities": "^1.0.0-beta.1.26.30-stable", "@minecraft/server": "^2.9.0-beta.1.26.30-stable", + "@minecraft/server-gametest": "^1.0.0-beta.1.26.30-stable", "@minecraft/server-ui": "^2.2.0-beta.1.26.30-stable" }, "devDependencies": { @@ -896,6 +897,16 @@ "@minecraft/vanilla-data": ">=1.20.70" } }, + "node_modules/@minecraft/server-gametest": { + "version": "1.0.0-beta.1.26.30-stable", + "resolved": "https://registry.npmjs.org/@minecraft/server-gametest/-/server-gametest-1.0.0-beta.1.26.30-stable.tgz", + "integrity": "sha512-DZ85TMUB8Kjzhfb7AGKqxLNCqDSlaVNyVRi19JBTPwXR8PuMzO3P2hg5KOhseOfGInE9puRDf6L5udl+sxW9og==", + "license": "MIT", + "peerDependencies": { + "@minecraft/common": "^1.0.0", + "@minecraft/server": "^1.17.0 || ^2.0.0 || ^2.9.0-beta.1.26.30-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", diff --git a/package.json b/package.json index b5ff4e21..163bc84b 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@minecraft/debug-utilities": "^1.0.0-beta.1.26.30-stable", "@minecraft/server": "^2.9.0-beta.1.26.30-stable", + "@minecraft/server-gametest": "^1.0.0-beta.1.26.30-stable", "@minecraft/server-ui": "^2.2.0-beta.1.26.30-stable" }, "scripts": { 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.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`, From cd209825f1c80ee56c1d76034cac2757f2c8786c Mon Sep 17 00:00:00 2001 From: TickPoints Date: Thu, 25 Jun 2026 18:53:53 +0800 Subject: [PATCH 045/120] chore(i18n): update Chinese translations --- Canopy[RP]/texts/zh_CN.lang | 971 +++++++++++++++++++----------------- 1 file changed, 521 insertions(+), 450 deletions(-) diff --git a/Canopy[RP]/texts/zh_CN.lang b/Canopy[RP]/texts/zh_CN.lang index cee7e01c..c730b6ae 100644 --- a/Canopy[RP]/texts/zh_CN.lang +++ b/Canopy[RP]/texts/zh_CN.lang @@ -1,450 +1,521 @@ -## 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.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.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 From 912e742a698a8d4f8c010bfbcbc05c5a1fb5268d Mon Sep 17 00:00:00 2001 From: IdotIcom <176992055+IdotIcom@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:11:54 +0700 Subject: [PATCH 046/120] Update id_ID.lang Updated id_ID.lang --- Canopy[RP]/texts/id_ID.lang | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Canopy[RP]/texts/id_ID.lang b/Canopy[RP]/texts/id_ID.lang index 54e5e5bc..c11b1e95 100644 --- a/Canopy[RP]/texts/id_ID.lang +++ b/Canopy[RP]/texts/id_ID.lang @@ -452,7 +452,7 @@ rules.renderEndGatewayExits=Menampilkan lokasi keluar dari gerbang akhir setelah 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=Menonaktifkan penyimpanan data pemain untuk simplayer. Meningkatkan kinerja, tetapi menyebabkan simplayer kehilangan inventaris dan lokasinya saat mereka keluar dan masuk dari permainan. +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. From 52745f5866577b9737205dd59cc25d08a1991f4d Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Sat, 27 Jun 2026 14:49:10 -0700 Subject: [PATCH 047/120] fix: "stop" not in playeraction command autocomplete --- .../src/classes/simplayer/RepeatableAction.js | 8 -------- .../scripts/src/commands/simplayer/playeraction.js | 12 ++++++++++-- .../src/commands/simplayer/playeraction.test.js | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Canopy[BP]/scripts/src/classes/simplayer/RepeatableAction.js b/Canopy[BP]/scripts/src/classes/simplayer/RepeatableAction.js index 54cefad9..51a9d2b1 100644 --- a/Canopy[BP]/scripts/src/classes/simplayer/RepeatableAction.js +++ b/Canopy[BP]/scripts/src/classes/simplayer/RepeatableAction.js @@ -14,14 +14,6 @@ export const REPEATABLE_ACTIONS = Object.freeze({ JUMP: 'jump' }); -export const TIMING_OPTIONS = Object.freeze({ - ONCE: 'once', - CONTINUOUS: 'continuous', - INTERVAL: 'interval', - AFTER: 'after', - STOP: 'stop' -}); - export class RepeatableAction { understudy; type; diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js b/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js index 11327e3c..23cfc01b 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js @@ -1,7 +1,15 @@ 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, TIMING_OPTIONS } from "../../classes/simplayer/RepeatableAction"; +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() { @@ -9,7 +17,7 @@ export class PlayerActionCommand extends VanillaCommand { name: 'canopy:playeraction', description: 'commands.playeraction', enums: [ - { name: 'canopy:simplayerAction', values: Object.values(REPEATABLE_ACTIONS) }, + { name: 'canopy:simplayerAction', values: [ ...Object.values(REPEATABLE_ACTIONS), "stop" ] }, { name: 'canopy:simplayerTimingOption', values: Object.values(TIMING_OPTIONS) } ], mandatoryParameters: [ diff --git a/__tests__/BP/scripts/src/commands/simplayer/playeraction.test.js b/__tests__/BP/scripts/src/commands/simplayer/playeraction.test.js index 3ae615c8..b5841964 100644 --- a/__tests__/BP/scripts/src/commands/simplayer/playeraction.test.js +++ b/__tests__/BP/scripts/src/commands/simplayer/playeraction.test.js @@ -1,8 +1,8 @@ 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 } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playeraction'; -import { REPEATABLE_ACTIONS, TIMING_OPTIONS } from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/RepeatableAction'; +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: { From 31255b7dfc31ea60f59ea2413402443ae7c858e1 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Sun, 28 Jun 2026 09:34:31 -0700 Subject: [PATCH 048/120] feat: filter out sulfur cubes with blocks inside from creativeOneHitKill --- .../scripts/src/rules/creativeOneHitKill.js | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/Canopy[BP]/scripts/src/rules/creativeOneHitKill.js b/Canopy[BP]/scripts/src/rules/creativeOneHitKill.js index a9e81227..e89178f3 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 frictionComponent?.value !== 1 && !ageableComponent; +} \ No newline at end of file From 02a2055960bd0a792af84b2712f30dce9d684de8 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Sun, 28 Jun 2026 09:39:35 -0700 Subject: [PATCH 049/120] docs: update README for simplayers --- README.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index bc434173..abb78e74 100644 --- a/README.md +++ b/README.md @@ -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: From d45d5a41b90853e20e04f11621c285a8535cb205 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Sun, 28 Jun 2026 09:58:41 -0700 Subject: [PATCH 050/120] feat: add AABB to /data --- Canopy[BP]/scripts/src/commands/data.js | 17 ++++++++++++----- Canopy[RP]/texts/en_US.lang | 2 +- 2 files changed, 13 insertions(+), 6 deletions(-) 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[RP]/texts/en_US.lang b/Canopy[RP]/texts/en_US.lang index 11b564f6..ec468914 100644 --- a/Canopy[RP]/texts/en_US.lang +++ b/Canopy[RP]/texts/en_US.lang @@ -118,7 +118,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. From d41aa2669adf85d23173e247db6ae0d56df004f0 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Sun, 28 Jun 2026 10:08:34 -0700 Subject: [PATCH 051/120] feat: color ping based on connection health --- .../scripts/src/rules/infodisplay/Ping.js | 10 +++++++++- .../src/rules/infodisplay/Ping.test.js | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/Ping.js b/Canopy[BP]/scripts/src/rules/infodisplay/Ping.js index d3c4932d..55516624 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Ping.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Ping.js @@ -11,7 +11,8 @@ export class Ping extends InfoDisplayTextElement { } 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 +22,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/__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'); + }); }); From d67ab2fe6ab12e379f873fdb66ead0d4d943e47d Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Sun, 28 Jun 2026 10:58:00 -0700 Subject: [PATCH 052/120] perf: lazy-init creativeNoTileDrops events --- .../scripts/src/rules/creativeNoTileDrops.js | 93 ++++++++++++------- 1 file changed, 58 insertions(+), 35 deletions(-) diff --git a/Canopy[BP]/scripts/src/rules/creativeNoTileDrops.js b/Canopy[BP]/scripts/src/rules/creativeNoTileDrops.js index 1d93dfeb..8ec87db7 100644 --- a/Canopy[BP]/scripts/src/rules/creativeNoTileDrops.js +++ b/Canopy[BP]/scripts/src/rules/creativeNoTileDrops.js @@ -1,43 +1,66 @@ -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() { + 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 From 362d104cae808a0735d6f3a1d4545b6066a889ba Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Sun, 28 Jun 2026 11:18:09 -0700 Subject: [PATCH 053/120] fix: unsubscribe error --- Canopy[BP]/scripts/src/rules/creativeNoTileDrops.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Canopy[BP]/scripts/src/rules/creativeNoTileDrops.js b/Canopy[BP]/scripts/src/rules/creativeNoTileDrops.js index 8ec87db7..ab29956e 100644 --- a/Canopy[BP]/scripts/src/rules/creativeNoTileDrops.js +++ b/Canopy[BP]/scripts/src/rules/creativeNoTileDrops.js @@ -29,8 +29,10 @@ export class CreativeNoTileDrops extends BooleanRule { } unsubscribeFromEvents() { - system.clearRun(this.#runner); - this.#runner = void 0; + 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); } From d9f454c53131ac06d9cce096543de8f432fd6e9c Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Sun, 28 Jun 2026 12:02:42 -0700 Subject: [PATCH 054/120] feat: renderLightLevel rule --- .../scripts/src/classes/LightLevelRenderer.js | 78 ++++++++++++++++++ .../src/rules/infodisplay/InfoDisplay.js | 2 + .../src/rules/infodisplay/RenderLightLevel.js | 82 +++++++++++++++++++ Canopy[RP]/texts/en_US.lang | 1 + 4 files changed, 163 insertions(+) create mode 100644 Canopy[BP]/scripts/src/classes/LightLevelRenderer.js create mode 100644 Canopy[BP]/scripts/src/rules/infodisplay/RenderLightLevel.js 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/rules/infodisplay/InfoDisplay.js b/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplay.js index ccfadf05..f2f990de 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplay.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplay.js @@ -34,6 +34,7 @@ import { HeldItemDurability } from './HeldItemDurability'; import { RenderSignalStrength } from './RenderSignalStrength'; import { NoFog } from './NoFog'; import { Ping } from './Ping'; +import { RenderLightLevel } from './RenderLightLevel'; class InfoDisplay { player; @@ -77,6 +78,7 @@ class InfoDisplay { new LiquidStates(player, 26), new RenderSignalStrength(player), + new RenderLightLevel(player), new NoFog(player) ]; InfoDisplay.playerToInfoDisplayMap[player.id] = this; 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..2d29bfa5 --- /dev/null +++ b/Canopy[BP]/scripts/src/rules/infodisplay/RenderLightLevel.js @@ -0,0 +1,82 @@ +import { InfoDisplayShapeElement } from './InfoDisplayShapeElement'; +import { BlockVolume, LiquidType } from '@minecraft/server'; +import { LightLevelRenderer } from '../../classes/LightLevelRenderer'; +import { Vector } from '../../../lib/Vector'; + +class RenderLightLevel extends InfoDisplayShapeElement { + player; + playerId; + static RENDER_DISTANCE = 4; + signalStrengthRenderers = {}; + + constructor(player) { + const ruleData = { + identifier: 'renderLightLevel', + 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[RP]/texts/en_US.lang b/Canopy[RP]/texts/en_US.lang index ec468914..0f025151 100644 --- a/Canopy[RP]/texts/en_US.lang +++ b/Canopy[RP]/texts/en_US.lang @@ -492,6 +492,7 @@ 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 From 8870b8a7712f76d55778e3a48bdb62d7af96d610 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Mon, 29 Jun 2026 14:08:17 -0700 Subject: [PATCH 055/120] feat: /cs to auto-tp (op only) --- Canopy[BP]/scripts/src/commands/camera.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) 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); } From b9fa56acf2a1b1039756260fcd8bbaa70adde811 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Mon, 29 Jun 2026 14:56:46 -0700 Subject: [PATCH 056/120] fix: Duplicate rule error on reload with multiple players online --- Canopy[BP]/scripts/lib/canopy/rules/Rules.js | 10 +++++++--- .../src/rules/infodisplay/InfoDisplayElement.js | 4 ++-- .../BP/scripts/lib/canopy/rules/Rules.test.js | 17 +++++++++++++++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/Canopy[BP]/scripts/lib/canopy/rules/Rules.js b/Canopy[BP]/scripts/lib/canopy/rules/Rules.js index a6ba089c..1e1da531 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); } } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplayElement.js b/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplayElement.js index e08aa8de..19a9b6fd 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplayElement.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplayElement.js @@ -1,4 +1,4 @@ -import { InfoDisplayRule, Rules } from '../../../lib/canopy/Canopy'; +import { InfoDisplayRule } from '../../../lib/canopy/Canopy'; class InfoDisplayElement { identifier; @@ -11,7 +11,7 @@ class InfoDisplayElement { 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 }); + this.rule = InfoDisplayRule.get(this.identifier) || new InfoDisplayRule({ identifier: this.identifier, ...ruleData }); this.isWorldwide = isWorldwide; } } diff --git a/__tests__/BP/scripts/lib/canopy/rules/Rules.test.js b/__tests__/BP/scripts/lib/canopy/rules/Rules.test.js index 5641dbe6..5a263842 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', () => { From 34ab37be77a4dbf6619d8ae1f1b121532ceb0f16 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Mon, 29 Jun 2026 16:06:43 -0700 Subject: [PATCH 057/120] feat: support function-valued enums in VanillaCommand --- .../lib/canopy/commands/VanillaCommand.js | 8 ++++-- .../canopy/commands/VanillaCommands.test.js | 26 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/Canopy[BP]/scripts/lib/canopy/commands/VanillaCommand.js b/Canopy[BP]/scripts/lib/canopy/commands/VanillaCommand.js index e6c73354..a9d19517 100644 --- a/Canopy[BP]/scripts/lib/canopy/commands/VanillaCommand.js +++ b/Canopy[BP]/scripts/lib/canopy/commands/VanillaCommand.js @@ -48,8 +48,12 @@ 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; + customCommandRegistry.registerEnum(customEnum.name, values); + } } } 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']); + }); +}); From b1e8dd05f4213f32ca0698cbea825cdcdce5162a Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Mon, 29 Jun 2026 16:10:17 -0700 Subject: [PATCH 058/120] feat: add Rules.getSettableRuleIDs accessor --- Canopy[BP]/scripts/lib/canopy/rules/Rules.js | 7 +++++++ .../BP/scripts/lib/canopy/rules/Rules.test.js | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/Canopy[BP]/scripts/lib/canopy/rules/Rules.js b/Canopy[BP]/scripts/lib/canopy/rules/Rules.js index 1e1da531..acaca701 100644 --- a/Canopy[BP]/scripts/lib/canopy/rules/Rules.js +++ b/Canopy[BP]/scripts/lib/canopy/rules/Rules.js @@ -83,6 +83,13 @@ class Rules { return this.getAll().filter(rule => rule.getCategory() === category); } + static getSettableRuleIDs() { + const registered = this.getByCategory("Rules"); + const queued = this.rulesToRegister.filter(rule => rule.getCategory() === "Rules"); + const ids = new Set([...registered, ...queued].map(rule => rule.getID())); + return [...ids]; + } + static registerQueuedRules() { for (const rule of this.rulesToRegister) this.register(rule); diff --git a/__tests__/BP/scripts/lib/canopy/rules/Rules.test.js b/__tests__/BP/scripts/lib/canopy/rules/Rules.test.js index 5a263842..821fd1d7 100644 --- a/__tests__/BP/scripts/lib/canopy/rules/Rules.test.js +++ b/__tests__/BP/scripts/lib/canopy/rules/Rules.test.js @@ -332,4 +332,24 @@ 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']); + }); + }); }); From 386f2e238a3ca3f0354a69ed34a3c03a4032803d Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Mon, 29 Jun 2026 16:18:20 -0700 Subject: [PATCH 059/120] feat: migrate canopy command to VanillaCommand class --- Canopy[BP]/scripts/src/commands/canopy.js | 403 ++++++++++-------- .../BP/scripts/src/commands/canopy.test.js | 76 ++++ 2 files changed, 300 insertions(+), 179 deletions(-) create mode 100644 __tests__/BP/scripts/src/commands/canopy.test.js diff --git a/Canopy[BP]/scripts/src/commands/canopy.js b/Canopy[BP]/scripts/src/commands/canopy.js index 99866aa1..8ab538a8 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.", + 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/__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' }); + }); +}); From d8c0be11e0c257c079b6fea350530a7347e675ce Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Mon, 29 Jun 2026 16:25:52 -0700 Subject: [PATCH 060/120] chore: remove obsolete commands.canopy.multiple key --- Canopy[RP]/texts/cy_GB.lang | 1 - Canopy[RP]/texts/de_DE.lang | 1 - Canopy[RP]/texts/en_US.lang | 1 - Canopy[RP]/texts/id_ID.lang | 1 - Canopy[RP]/texts/ja_JP.lang | 1 - Canopy[RP]/texts/zh_CN.lang | 1 - 6 files changed, 6 deletions(-) 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 0f025151..7fa7a765 100644 --- a/Canopy[RP]/texts/en_US.lang +++ b/Canopy[RP]/texts/en_US.lang @@ -68,7 +68,6 @@ 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. diff --git a/Canopy[RP]/texts/id_ID.lang b/Canopy[RP]/texts/id_ID.lang index c11b1e95..7a36b21b 100644 --- a/Canopy[RP]/texts/id_ID.lang +++ b/Canopy[RP]/texts/id_ID.lang @@ -68,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. 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 c730b6ae..dc6a1e23 100644 --- a/Canopy[RP]/texts/zh_CN.lang +++ b/Canopy[RP]/texts/zh_CN.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' 是 信息显示 的一部分, 并且必须使用%2信息进行切换. 有关详细信息, 请使用%2帮助. commands.changedimension=传送你到指定的维度. From e84589ea3790e2e573e05449469cee8810959911 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Mon, 29 Jun 2026 16:56:08 -0700 Subject: [PATCH 061/120] docs: add numeric quote dependency to /canopy --- Canopy[BP]/scripts/src/commands/canopy.js | 2 +- Canopy[RP]/texts/en_US.lang | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Canopy[BP]/scripts/src/commands/canopy.js b/Canopy[BP]/scripts/src/commands/canopy.js index 8ab538a8..31643722 100644 --- a/Canopy[BP]/scripts/src/commands/canopy.js +++ b/Canopy[BP]/scripts/src/commands/canopy.js @@ -19,7 +19,7 @@ export class CanopyCommand extends VanillaCommand { 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.", + 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: { diff --git a/Canopy[RP]/texts/en_US.lang b/Canopy[RP]/texts/en_US.lang index 7fa7a765..0d815610 100644 --- a/Canopy[RP]/texts/en_US.lang +++ b/Canopy[RP]/texts/en_US.lang @@ -58,7 +58,7 @@ 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=Enable, disable, or set a rule. 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: From a9716516d600a0e7ebc29f9b3503bef93fb9c9e2 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Mon, 29 Jun 2026 17:09:34 -0700 Subject: [PATCH 062/120] docs: modify usage for /canopy for rule setting --- Canopy[BP]/scripts/src/commands/canopy.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Canopy[BP]/scripts/src/commands/canopy.js b/Canopy[BP]/scripts/src/commands/canopy.js index 31643722..a76a927c 100644 --- a/Canopy[BP]/scripts/src/commands/canopy.js +++ b/Canopy[BP]/scripts/src/commands/canopy.js @@ -18,7 +18,7 @@ export class CanopyCommand extends VanillaCommand { 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'] }, From 1517bca14441873ec0061780db8f2ad465235cb9 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Mon, 29 Jun 2026 17:22:24 -0700 Subject: [PATCH 063/120] refactor: generalize Rules accessor to getRuleIDsByCategory --- Canopy[BP]/scripts/lib/canopy/rules/Rules.js | 10 +++-- .../BP/scripts/lib/canopy/rules/Rules.test.js | 41 +++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/Canopy[BP]/scripts/lib/canopy/rules/Rules.js b/Canopy[BP]/scripts/lib/canopy/rules/Rules.js index acaca701..bf276f76 100644 --- a/Canopy[BP]/scripts/lib/canopy/rules/Rules.js +++ b/Canopy[BP]/scripts/lib/canopy/rules/Rules.js @@ -83,13 +83,17 @@ class Rules { return this.getAll().filter(rule => rule.getCategory() === category); } - static getSettableRuleIDs() { - const registered = this.getByCategory("Rules"); - const queued = this.rulesToRegister.filter(rule => rule.getCategory() === "Rules"); + 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/__tests__/BP/scripts/lib/canopy/rules/Rules.test.js b/__tests__/BP/scripts/lib/canopy/rules/Rules.test.js index 821fd1d7..7d1e9e8f 100644 --- a/__tests__/BP/scripts/lib/canopy/rules/Rules.test.js +++ b/__tests__/BP/scripts/lib/canopy/rules/Rules.test.js @@ -352,4 +352,45 @@ describe('Rules', () => { 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']); + }); + }); }); From 0fe19e326b6a515a6ea70ad1e0f5fe9d04c62955 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Mon, 29 Jun 2026 17:28:39 -0700 Subject: [PATCH 064/120] feat: migrate /info to a VanillaCommand --- Canopy[BP]/scripts/src/commands/info.js | 293 +++++++++--------- .../BP/scripts/src/commands/info.test.js | 71 +++++ 2 files changed, 210 insertions(+), 154 deletions(-) create mode 100644 __tests__/BP/scripts/src/commands/info.test.js diff --git a/Canopy[BP]/scripts/src/commands/info.js b/Canopy[BP]/scripts/src/commands/info.js index d35f622d..347b5ef0 100644 --- a/Canopy[BP]/scripts/src/commands/info.js +++ b/Canopy[BP]/scripts/src/commands/info.js @@ -1,154 +1,139 @@ -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"; + +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 [...Rules.getRuleIDsByCategory("InfoDisplay"), '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/__tests__/BP/scripts/src/commands/info.test.js b/__tests__/BP/scripts/src/commands/info.test.js new file mode 100644 index 00000000..18a47a99 --- /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'; + +describe('InfoDisplayCommand.getRuleEnumValues', () => { + it('appends menu to the InfoDisplay rule IDs', () => { + vi.spyOn(Rules, 'getRuleIDsByCategory').mockReturnValue(['showCoords', 'showBiome']); + expect(InfoDisplayCommand.getRuleEnumValues()).toEqual(['showCoords', 'showBiome', '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', './'] + }); + }); +}); From 1b668be9f0cc5fb5b25662216d3bab60e100e598 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Mon, 29 Jun 2026 21:31:36 -0700 Subject: [PATCH 065/120] docs: update help book usage for /info and /canopy slash commands --- Canopy[BP]/scripts/src/commands/help.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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); From ac4d4478080ee9ebce36aaaf45bd39bb53aaa3c7 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Mon, 29 Jun 2026 21:36:00 -0700 Subject: [PATCH 066/120] chore: remove obsolete info command translation keys (en_US) --- Canopy[RP]/texts/en_US.lang | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Canopy[RP]/texts/en_US.lang b/Canopy[RP]/texts/en_US.lang index 0d815610..9b0ce843 100644 --- a/Canopy[RP]/texts/en_US.lang +++ b/Canopy[RP]/texts/en_US.lang @@ -182,12 +182,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) From f78ac626cd58ebf32c88c0e0c2fe758101e56c6b Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Mon, 29 Jun 2026 21:58:25 -0700 Subject: [PATCH 067/120] feat: reword canopy cmd description --- Canopy[RP]/texts/en_US.lang | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Canopy[RP]/texts/en_US.lang b/Canopy[RP]/texts/en_US.lang index 9b0ce843..57765318 100644 --- a/Canopy[RP]/texts/en_US.lang +++ b/Canopy[RP]/texts/en_US.lang @@ -58,11 +58,11 @@ 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, disable, or set a rule. Wrap numeric values in quotes, e.g. "16". +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. From 5cb27996ca41ba2ca30fe06918c64cb0c573f13a Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Mon, 29 Jun 2026 22:26:55 -0700 Subject: [PATCH 068/120] fix: populate /info rule enum from per-class identifier constants InfoDisplay rules are constructed per-player at runtime, so they are not registered when the custom-command enum is frozen at the startup event, leaving the /info enum empty (only `menu`). Export each rule's identifier as a `*_IDENTIFIER` constant from its element class and collect them in infoDisplayIdentifiers.js, which the enum reads at registration time. The strings are available at import without instantiating any rule, so the enum populates while the per-player registration path is unchanged. A both-directions drift-guard test builds an InfoDisplay for a mock player and asserts the registered InfoDisplay rules match the identifier list. Also fixes EventTrackers.js to import trackevent via a relative path (matching its sibling elements) instead of a bundler-only `src/` alias, which vitest could not resolve once the collector pulled it into the graph. --- Canopy[BP]/scripts/src/commands/info.js | 3 +- .../scripts/src/rules/infodisplay/Biome.js | 4 +- .../src/rules/infodisplay/BlockStates.js | 4 +- .../src/rules/infodisplay/CardinalFacing.js | 4 +- .../src/rules/infodisplay/ChunkCoords.js | 4 +- .../scripts/src/rules/infodisplay/Coords.js | 4 +- .../src/rules/infodisplay/Dimension.js | 4 +- .../scripts/src/rules/infodisplay/Entities.js | 4 +- .../src/rules/infodisplay/EventTrackers.js | 6 +- .../scripts/src/rules/infodisplay/Facing.js | 4 +- .../rules/infodisplay/HeldItemDurability.js | 4 +- .../rules/infodisplay/HopperCounterCounts.js | 4 +- .../scripts/src/rules/infodisplay/Light.js | 4 +- .../src/rules/infodisplay/LiquidStates.js | 4 +- .../src/rules/infodisplay/LiquidTarget.js | 4 +- .../src/rules/infodisplay/MoonPhase.js | 4 +- .../scripts/src/rules/infodisplay/NoFog.js | 4 +- .../src/rules/infodisplay/PeekInventory.js | 4 +- .../scripts/src/rules/infodisplay/Ping.js | 4 +- .../src/rules/infodisplay/RenderLightLevel.js | 4 +- .../rules/infodisplay/RenderSignalStrength.js | 4 +- .../src/rules/infodisplay/SessionTime.js | 4 +- .../src/rules/infodisplay/SignalStrength.js | 4 +- .../src/rules/infodisplay/SimulationMap.js | 4 +- .../src/rules/infodisplay/SlimeChunk.js | 4 +- .../scripts/src/rules/infodisplay/Speed.js | 4 +- .../src/rules/infodisplay/Structures.js | 4 +- .../scripts/src/rules/infodisplay/TPS.js | 4 +- .../scripts/src/rules/infodisplay/Target.js | 4 +- .../src/rules/infodisplay/TimeOfDay.js | 4 +- .../scripts/src/rules/infodisplay/Velocity.js | 4 +- .../scripts/src/rules/infodisplay/Weather.js | 4 +- .../scripts/src/rules/infodisplay/WorldDay.js | 4 +- .../infodisplay/infoDisplayIdentifiers.js | 73 +++++++++++++++++++ .../BP/scripts/src/commands/info.test.js | 6 +- .../infoDisplayIdentifiers.test.js | 47 ++++++++++++ 36 files changed, 222 insertions(+), 37 deletions(-) create mode 100644 Canopy[BP]/scripts/src/rules/infodisplay/infoDisplayIdentifiers.js create mode 100644 __tests__/BP/scripts/src/rules/infodisplay/infoDisplayIdentifiers.test.js diff --git a/Canopy[BP]/scripts/src/commands/info.js b/Canopy[BP]/scripts/src/commands/info.js index 347b5ef0..798310b4 100644 --- a/Canopy[BP]/scripts/src/commands/info.js +++ b/Canopy[BP]/scripts/src/commands/info.js @@ -2,6 +2,7 @@ import { VanillaCommand, PlayerCommandOrigin, InfoDisplayRule, Commands, Rules } import { CommandPermissionLevel, CustomCommandParamType, CustomCommandStatus, system } from "@minecraft/server"; import { ModalFormData } from "@minecraft/server-ui"; import { forceShow } from "../../include/utils"; +import { INFODISPLAY_RULE_IDENTIFIERS } from "../rules/infodisplay/infoDisplayIdentifiers"; export class InfoDisplayCommand extends VanillaCommand { constructor() { @@ -29,7 +30,7 @@ export class InfoDisplayCommand extends VanillaCommand { } static getRuleEnumValues() { - return [...Rules.getRuleIDsByCategory("InfoDisplay"), 'menu']; + return [...INFODISPLAY_RULE_IDENTIFIERS, 'menu']; } infoCommand(origin, rule, value) { diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/Biome.js b/Canopy[BP]/scripts/src/rules/infodisplay/Biome.js index 0b07f8eb..67acfbb9 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Biome.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Biome.js @@ -1,11 +1,13 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; +export const BIOME_IDENTIFIER = 'biome'; + class Biome extends InfoDisplayTextElement { player; constructor(player, displayLine) { const ruleData = { - identifier: 'biome', + identifier: BIOME_IDENTIFIER, description: { translate: 'rules.infoDisplay.biome' }, wikiDescription: 'Shows the biome at your current location.' }; diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/BlockStates.js b/Canopy[BP]/scripts/src/rules/infodisplay/BlockStates.js index 8b290946..c85591d1 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/BlockStates.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/BlockStates.js @@ -2,12 +2,14 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; import { getRaycastResults } from '../../../include/utils.js'; import { LiquidType } from '@minecraft/server'; +export const BLOCK_STATES_IDENTIFIER = 'blockStates'; + export class BlockStates extends InfoDisplayTextElement { player; constructor(player, displayLine) { const ruleData = { - identifier: 'blockStates', + identifier: BLOCK_STATES_IDENTIFIER, 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.' } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/CardinalFacing.js b/Canopy[BP]/scripts/src/rules/infodisplay/CardinalFacing.js index 441578aa..accfa105 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/CardinalFacing.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/CardinalFacing.js @@ -1,10 +1,12 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; +export const CARDINAL_FACING_IDENTIFIER = 'cardinalFacing'; + class CardinalFacing extends InfoDisplayTextElement { 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 = { identifier: CARDINAL_FACING_IDENTIFIER, 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..68dcd07f 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/ChunkCoords.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/ChunkCoords.js @@ -1,8 +1,10 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; +export const CHUNK_COORDS_IDENTIFIER = 'chunkCoords'; + class ChunkCoords extends InfoDisplayTextElement { 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 = { identifier: CHUNK_COORDS_IDENTIFIER, 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..0a09c828 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Coords.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Coords.js @@ -1,10 +1,12 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; +export const COORDS_IDENTIFIER = 'coords'; + class Coords extends InfoDisplayTextElement { player; constructor(player, displayLine) { - const ruleData = { identifier: 'coords', description: { translate: 'rules.infoDisplay.coords' }, wikiDescription: 'Shows your coordinates truncated at 2 decimal places.' }; + const ruleData = { identifier: COORDS_IDENTIFIER, 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..677ed977 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Dimension.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Dimension.js @@ -1,9 +1,11 @@ import { getColorByDimension } from '../../../include/utils.js'; import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; +export const DIMENSION_IDENTIFIER = 'dimension'; + class Dimension extends InfoDisplayTextElement { constructor(player, displayLine) { - const ruleData = { identifier: 'dimension', description: { translate: 'rules.infoDisplay.dimension' }, wikiDescription: 'Shows your current dimension\'s identifier.' }; + const ruleData = { identifier: DIMENSION_IDENTIFIER, 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..47666e28 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Entities.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Entities.js @@ -1,11 +1,13 @@ import { InfoDisplayTextElement } from "./InfoDisplayTextElement.js"; import { Vector } from "../../../lib/Vector.js"; +export const ENTITIES_IDENTIFIER = 'entities'; + class Entities extends InfoDisplayTextElement { 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 = { identifier: ENTITIES_IDENTIFIER, 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..8a5714ab 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/EventTrackers.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/EventTrackers.js @@ -1,9 +1,11 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; -import { getAllTrackerInfoString } from 'src/commands/trackevent'; +import { getAllTrackerInfoString } from '../../commands/trackevent'; + +export const EVENT_TRACKERS_IDENTIFIER = 'eventTrackers'; class EventTrackers extends InfoDisplayTextElement { 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 = { identifier: EVENT_TRACKERS_IDENTIFIER, 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..ec53e9a2 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Facing.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Facing.js @@ -1,10 +1,12 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; +export const FACING_IDENTIFIER = 'facing'; + class Facing extends InfoDisplayTextElement { 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 = { identifier: FACING_IDENTIFIER, 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..57726e64 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/HeldItemDurability.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/HeldItemDurability.js @@ -1,12 +1,14 @@ import { EntityComponentTypes, EquipmentSlot, ItemComponentTypes } from '@minecraft/server'; import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; +export const HELD_ITEM_DURABILITY_IDENTIFIER = 'heldItemDurability'; + export class HeldItemDurability extends InfoDisplayTextElement { player; constructor(player, displayLine) { const ruleData = { - identifier: 'heldItemDurability', + identifier: HELD_ITEM_DURABILITY_IDENTIFIER, 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..09779b8f 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/HopperCounterCounts.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/HopperCounterCounts.js @@ -2,9 +2,11 @@ import { InfoDisplayTextElement } from "./InfoDisplayTextElement"; import { counterChannels } from "../../classes/CounterChannels"; import { getColorCode } from "../../../include/utils"; +export const HOPPER_COUNTER_COUNTS_IDENTIFIER = 'hopperCounterCounts'; + class HopperCounterCounts extends InfoDisplayTextElement { 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 = { identifier: HOPPER_COUNTER_COUNTS_IDENTIFIER, 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/Light.js b/Canopy[BP]/scripts/src/rules/infodisplay/Light.js index ab4d2ac2..f737fd34 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Light.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Light.js @@ -1,11 +1,13 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; +export const LIGHT_IDENTIFIER = 'light'; + class Light extends InfoDisplayTextElement { player; constructor(player, displayLine) { const ruleData = { - identifier: 'light', + identifier: LIGHT_IDENTIFIER, 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..99b46e80 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/LiquidStates.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/LiquidStates.js @@ -1,12 +1,14 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; import { LiquidType } from '@minecraft/server'; +export const LIQUID_STATES_IDENTIFIER = 'liquidStates'; + export class LiquidStates extends InfoDisplayTextElement { player; constructor(player, displayLine) { const ruleData = { - identifier: 'liquidStates', + identifier: LIQUID_STATES_IDENTIFIER, description: { translate: 'rules.infoDisplay.liquidStates' }, wikiDescription: 'Shows the states of the liquid you are targeting.' } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/LiquidTarget.js b/Canopy[BP]/scripts/src/rules/infodisplay/LiquidTarget.js index 49cf841f..29f85579 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/LiquidTarget.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/LiquidTarget.js @@ -1,9 +1,11 @@ import { InfoDisplayTextElement } from "./InfoDisplayTextElement"; import { parseName, stringifyLocation } from "../../../include/utils"; +export const LIQUID_TARGET_IDENTIFIER = 'liquidTarget'; + export class LiquidTarget extends InfoDisplayTextElement { constructor(player, displayLine) { - const ruleData = { identifier: 'liquidTarget', description: { translate: 'rules.infoDisplay.liquidTarget' }, wikiDescription: 'Shows the identifier of the liquid you are targeting.' }; + const ruleData = { identifier: LIQUID_TARGET_IDENTIFIER, description: { translate: 'rules.infoDisplay.liquidTarget' }, wikiDescription: 'Shows the identifier of the liquid you are targeting.' }; super(ruleData, displayLine); this.player = player; } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/MoonPhase.js b/Canopy[BP]/scripts/src/rules/infodisplay/MoonPhase.js index fea59f97..fc52faa9 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/MoonPhase.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/MoonPhase.js @@ -1,9 +1,11 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; import { world } from '@minecraft/server'; +export const MOON_PHASE_IDENTIFIER = 'moonPhase'; + class MoonPhase extends InfoDisplayTextElement { constructor(displayLine) { - const ruleData = { identifier: 'moonPhase', description: { translate: 'rules.infoDisplay.moonPhase' }, wikiDescription: 'Shows the current phase of the moon.' }; + const ruleData = { identifier: MOON_PHASE_IDENTIFIER, 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 25be54a2..0721781e 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/NoFog.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/NoFog.js @@ -1,6 +1,8 @@ import { EntityComponentTypes, world } from "@minecraft/server"; import { InfoDisplayShapeElement } from "./InfoDisplayShapeElement"; +export const NO_FOG_IDENTIFIER = 'noFog'; + export class NoFog extends InfoDisplayShapeElement { static FOG_REMOVAL_IDS = { "minecraft:overworld": "canopy:overworld_no_fog", @@ -13,7 +15,7 @@ export class NoFog extends InfoDisplayShapeElement { constructor(player) { const ruleData = { - identifier: 'noFog', + identifier: NO_FOG_IDENTIFIER, description: { translate: 'rules.infoDisplay.noFog' }, wikiDescription: `Disables the fog effect for the player. Water and lava are unaffected.`, onEnableCallback: () => this.removeFog(), diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/PeekInventory.js b/Canopy[BP]/scripts/src/rules/infodisplay/PeekInventory.js index bf9c8b8e..b99857fe 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/PeekInventory.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/PeekInventory.js @@ -3,11 +3,13 @@ import { getRaycastResults, getClosestTarget } from "../../../include/utils"; import { currentQuery } from "../../commands/peek"; import { ItemStack } from "@minecraft/server"; +export const PEEK_INVENTORY_IDENTIFIER = 'peekInventory'; + class PeekInventory extends InfoDisplayTextElement { player; constructor(player, displayLine) { - const ruleData = { identifier: 'peekInventory', + const ruleData = { identifier: PEEK_INVENTORY_IDENTIFIER, description: { translate: 'rules.infoDisplay.peekInventory' }, contingentRules: ['target'], globalContingentRules: ['allowPeekInventory'], diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/Ping.js b/Canopy[BP]/scripts/src/rules/infodisplay/Ping.js index 55516624..420bd027 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Ping.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Ping.js @@ -1,10 +1,12 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; +export const PING_IDENTIFIER = 'ping'; + export class Ping extends InfoDisplayTextElement { player; constructor(player, displayLine) { - const ruleData = { identifier: 'ping', description: { translate: 'rules.infoDisplay.ping' }, wikiDescription: 'Shows your current network latency to the server.' }; + const ruleData = { identifier: PING_IDENTIFIER, 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()); diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/RenderLightLevel.js b/Canopy[BP]/scripts/src/rules/infodisplay/RenderLightLevel.js index 2d29bfa5..0ea54f1e 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/RenderLightLevel.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/RenderLightLevel.js @@ -3,6 +3,8 @@ import { BlockVolume, LiquidType } from '@minecraft/server'; import { LightLevelRenderer } from '../../classes/LightLevelRenderer'; import { Vector } from '../../../lib/Vector'; +export const RENDER_LIGHT_LEVEL_IDENTIFIER = 'renderLightLevel'; + class RenderLightLevel extends InfoDisplayShapeElement { player; playerId; @@ -11,7 +13,7 @@ class RenderLightLevel extends InfoDisplayShapeElement { constructor(player) { const ruleData = { - identifier: 'renderLightLevel', + identifier: RENDER_LIGHT_LEVEL_IDENTIFIER, 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(), diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/RenderSignalStrength.js b/Canopy[BP]/scripts/src/rules/infodisplay/RenderSignalStrength.js index 877cd20e..fd5adcfa 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/RenderSignalStrength.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/RenderSignalStrength.js @@ -3,6 +3,8 @@ import { BlockVolume } from '@minecraft/server'; import { SignalStrengthRenderer } from '../../classes/SignalStrengthRenderer'; import { Vector } from '../../../lib/Vector'; +export const RENDER_SIGNAL_STRENGTH_IDENTIFIER = 'renderSignalStrength'; + class RenderSignalStrength extends InfoDisplayShapeElement { player; playerId; @@ -11,7 +13,7 @@ class RenderSignalStrength extends InfoDisplayShapeElement { constructor(player) { const ruleData = { - identifier: 'renderSignalStrength', + identifier: RENDER_SIGNAL_STRENGTH_IDENTIFIER, 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..16da4747 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/SessionTime.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/SessionTime.js @@ -1,10 +1,12 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; +export const SESSION_TIME_IDENTIFIER = 'sessionTime'; + class SessionTime extends InfoDisplayTextElement { 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 = { identifier: SESSION_TIME_IDENTIFIER, 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..02db8f42 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/SignalStrength.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/SignalStrength.js @@ -1,11 +1,13 @@ import { InfoDisplayTextElement } from "./InfoDisplayTextElement"; import { getRaycastResults } from "../../../include/utils"; +export const SIGNAL_STRENGTH_IDENTIFIER = 'signalStrength'; + class SignalStrength extends InfoDisplayTextElement { 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 = { identifier: SIGNAL_STRENGTH_IDENTIFIER, 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..2bbba7de 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/SimulationMap.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/SimulationMap.js @@ -2,11 +2,13 @@ import { world } from '@minecraft/server'; import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; import { getConfig, getLoadedChunksMessage } from '../../commands/simmap.js'; +export const SIMULATION_MAP_IDENTIFIER = 'simulationMap'; + class SimulationMap extends InfoDisplayTextElement { 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 = { identifier: SIMULATION_MAP_IDENTIFIER, 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..49ba38ba 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/SlimeChunk.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/SlimeChunk.js @@ -1,12 +1,14 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js' import { playerChangeSubChunkEvent } from '../../events/PlayerChangeSubChunkEvent.js' +export const SLIME_CHUNK_IDENTIFIER = 'slimeChunk'; + export class SlimeChunk extends InfoDisplayTextElement { 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 = { identifier: SLIME_CHUNK_IDENTIFIER, 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..054720c0 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Speed.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Speed.js @@ -2,12 +2,14 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; import { Vector } from '../../../lib/Vector.js'; import { TicksPerSecond } from '@minecraft/server'; +export const SPEED_IDENTIFIER = 'speed'; + export class Speed extends InfoDisplayTextElement { player; constructor(player, displayLine) { const ruleData = { - identifier: 'speed', + identifier: SPEED_IDENTIFIER, 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..f175ff8c 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Structures.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Structures.js @@ -1,11 +1,13 @@ import { InfoDisplayTextElement } from "./InfoDisplayTextElement"; +export const STRUCTURES_IDENTIFIER = 'structures'; + export class Structures extends InfoDisplayTextElement { player; constructor(player, displayLine) { const ruleData = { - identifier: 'structures', + identifier: STRUCTURES_IDENTIFIER, description: { translate: 'rules.infoDisplay.structures' }, wikiDescription: 'Shows naturally generated structures present at your current location.' } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/TPS.js b/Canopy[BP]/scripts/src/rules/infodisplay/TPS.js index 0e43ce29..6d8e3661 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/TPS.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/TPS.js @@ -2,9 +2,11 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; import { Profiler } from '../../classes/Profiler.js'; import { TicksPerSecond } from '@minecraft/server'; +export const TPS_IDENTIFIER = 'tps'; + class TPS extends InfoDisplayTextElement { constructor(displayLine) { - const ruleData = { identifier: 'tps', description: { translate: 'rules.infoDisplay.tps' }, wikiDescription: 'Shows the server\'s current ticks per second (TPS).' }; + const ruleData = { identifier: TPS_IDENTIFIER, 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..90841ad1 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Target.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Target.js @@ -1,9 +1,11 @@ import { InfoDisplayTextElement } from "./InfoDisplayTextElement"; import { getRaycastResults, parseName, stringifyLocation } from "../../../include/utils"; +export const TARGET_IDENTIFIER = 'target'; + export class Target extends InfoDisplayTextElement { 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 = { identifier: TARGET_IDENTIFIER, description: { translate: 'rules.infoDisplay.target' }, wikiDescription: 'Shows the identifier of the block or entity you are targeting.' }; super(ruleData, displayLine); this.player = player; } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/TimeOfDay.js b/Canopy[BP]/scripts/src/rules/infodisplay/TimeOfDay.js index db266019..8c876b0f 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/TimeOfDay.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/TimeOfDay.js @@ -1,9 +1,11 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; import { world } from '@minecraft/server'; +export const TIME_OF_DAY_IDENTIFIER = 'timeOfDay'; + class TimeOfDay extends InfoDisplayTextElement { 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 = { identifier: TIME_OF_DAY_IDENTIFIER, 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..d4b25e45 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Velocity.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Velocity.js @@ -1,12 +1,14 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; import { Vector } from '../../../lib/Vector.js'; +export const VELOCITY_IDENTIFIER = 'velocity'; + export class Velocity extends InfoDisplayTextElement { player; constructor(player, displayLine) { const ruleData = { - identifier: 'velocity', + identifier: VELOCITY_IDENTIFIER, 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..1d49b867 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Weather.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Weather.js @@ -1,8 +1,10 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; +export const WEATHER_IDENTIFIER = 'weather'; + class Weather extends InfoDisplayTextElement { constructor(player, displayLine) { - const ruleData = { identifier: 'weather', description: { translate: 'rules.infoDisplay.weather' }, wikiDescription: 'Shows the current weather in your dimension.' }; + const ruleData = { identifier: WEATHER_IDENTIFIER, 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..8bd7fe59 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/WorldDay.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/WorldDay.js @@ -1,9 +1,11 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; import { world } from '@minecraft/server'; +export const WORLD_DAY_IDENTIFIER = 'worldDay'; + class WorldDay extends InfoDisplayTextElement { 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 = { identifier: WORLD_DAY_IDENTIFIER, 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/infodisplay/infoDisplayIdentifiers.js b/Canopy[BP]/scripts/src/rules/infodisplay/infoDisplayIdentifiers.js new file mode 100644 index 00000000..996c0769 --- /dev/null +++ b/Canopy[BP]/scripts/src/rules/infodisplay/infoDisplayIdentifiers.js @@ -0,0 +1,73 @@ +import { BIOME_IDENTIFIER } from './Biome'; +import { BLOCK_STATES_IDENTIFIER } from './BlockStates'; +import { CARDINAL_FACING_IDENTIFIER } from './CardinalFacing'; +import { CHUNK_COORDS_IDENTIFIER } from './ChunkCoords'; +import { COORDS_IDENTIFIER } from './Coords'; +import { DIMENSION_IDENTIFIER } from './Dimension'; +import { ENTITIES_IDENTIFIER } from './Entities'; +import { EVENT_TRACKERS_IDENTIFIER } from './EventTrackers'; +import { FACING_IDENTIFIER } from './Facing'; +import { HELD_ITEM_DURABILITY_IDENTIFIER } from './HeldItemDurability'; +import { HOPPER_COUNTER_COUNTS_IDENTIFIER } from './HopperCounterCounts'; +import { LIGHT_IDENTIFIER } from './Light'; +import { LIQUID_STATES_IDENTIFIER } from './LiquidStates'; +import { LIQUID_TARGET_IDENTIFIER } from './LiquidTarget'; +import { MOON_PHASE_IDENTIFIER } from './MoonPhase'; +import { NO_FOG_IDENTIFIER } from './NoFog'; +import { PEEK_INVENTORY_IDENTIFIER } from './PeekInventory'; +import { PING_IDENTIFIER } from './Ping'; +import { RENDER_LIGHT_LEVEL_IDENTIFIER } from './RenderLightLevel'; +import { RENDER_SIGNAL_STRENGTH_IDENTIFIER } from './RenderSignalStrength'; +import { SESSION_TIME_IDENTIFIER } from './SessionTime'; +import { SIGNAL_STRENGTH_IDENTIFIER } from './SignalStrength'; +import { SIMULATION_MAP_IDENTIFIER } from './SimulationMap'; +import { SLIME_CHUNK_IDENTIFIER } from './SlimeChunk'; +import { SPEED_IDENTIFIER } from './Speed'; +import { STRUCTURES_IDENTIFIER } from './Structures'; +import { TARGET_IDENTIFIER } from './Target'; +import { TIME_OF_DAY_IDENTIFIER } from './TimeOfDay'; +import { TPS_IDENTIFIER } from './TPS'; +import { VELOCITY_IDENTIFIER } from './Velocity'; +import { WEATHER_IDENTIFIER } from './Weather'; +import { WORLD_DAY_IDENTIFIER } from './WorldDay'; + +// Single source of truth for the set of InfoDisplay rule IDs available at startup. +// Each identifier is owned by its element class (exported as *_IDENTIFIER); this module +// collects them so the /info command enum can be populated at command-registration time, +// before any per-player InfoDisplay (and therefore any InfoDisplay rule) has been built. +// Add a new InfoDisplay rule here when you add its element class; the drift-guard test in +// infoDisplayIdentifiers.test.js fails if this list and the registered rules diverge. +export const INFODISPLAY_RULE_IDENTIFIERS = [ + BIOME_IDENTIFIER, + BLOCK_STATES_IDENTIFIER, + CARDINAL_FACING_IDENTIFIER, + CHUNK_COORDS_IDENTIFIER, + COORDS_IDENTIFIER, + DIMENSION_IDENTIFIER, + ENTITIES_IDENTIFIER, + EVENT_TRACKERS_IDENTIFIER, + FACING_IDENTIFIER, + HELD_ITEM_DURABILITY_IDENTIFIER, + HOPPER_COUNTER_COUNTS_IDENTIFIER, + LIGHT_IDENTIFIER, + LIQUID_STATES_IDENTIFIER, + LIQUID_TARGET_IDENTIFIER, + MOON_PHASE_IDENTIFIER, + NO_FOG_IDENTIFIER, + PEEK_INVENTORY_IDENTIFIER, + PING_IDENTIFIER, + RENDER_LIGHT_LEVEL_IDENTIFIER, + RENDER_SIGNAL_STRENGTH_IDENTIFIER, + SESSION_TIME_IDENTIFIER, + SIGNAL_STRENGTH_IDENTIFIER, + SIMULATION_MAP_IDENTIFIER, + SLIME_CHUNK_IDENTIFIER, + SPEED_IDENTIFIER, + STRUCTURES_IDENTIFIER, + TARGET_IDENTIFIER, + TIME_OF_DAY_IDENTIFIER, + TPS_IDENTIFIER, + VELOCITY_IDENTIFIER, + WEATHER_IDENTIFIER, + WORLD_DAY_IDENTIFIER +]; diff --git a/__tests__/BP/scripts/src/commands/info.test.js b/__tests__/BP/scripts/src/commands/info.test.js index 18a47a99..8871c89f 100644 --- a/__tests__/BP/scripts/src/commands/info.test.js +++ b/__tests__/BP/scripts/src/commands/info.test.js @@ -2,11 +2,11 @@ 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_RULE_IDENTIFIERS } from '../../../../../Canopy[BP]/scripts/src/rules/infodisplay/infoDisplayIdentifiers'; describe('InfoDisplayCommand.getRuleEnumValues', () => { - it('appends menu to the InfoDisplay rule IDs', () => { - vi.spyOn(Rules, 'getRuleIDsByCategory').mockReturnValue(['showCoords', 'showBiome']); - expect(InfoDisplayCommand.getRuleEnumValues()).toEqual(['showCoords', 'showBiome', 'menu']); + it('returns every InfoDisplay rule identifier followed by menu', () => { + expect(InfoDisplayCommand.getRuleEnumValues()).toEqual([...INFODISPLAY_RULE_IDENTIFIERS, 'menu']); }); }); diff --git a/__tests__/BP/scripts/src/rules/infodisplay/infoDisplayIdentifiers.test.js b/__tests__/BP/scripts/src/rules/infodisplay/infoDisplayIdentifiers.test.js new file mode 100644 index 00000000..561b5d0e --- /dev/null +++ b/__tests__/BP/scripts/src/rules/infodisplay/infoDisplayIdentifiers.test.js @@ -0,0 +1,47 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Rules } from '../../../../../../Canopy[BP]/scripts/lib/canopy/rules/Rules'; +import { INFODISPLAY_RULE_IDENTIFIERS } from '../../../../../../Canopy[BP]/scripts/src/rules/infodisplay/infoDisplayIdentifiers'; +import { InfoDisplay } from '../../../../../../Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplay'; + +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: 'drift-test-player', + getComponent: vi.fn(() => ({ push: vi.fn(), remove: vi.fn() })), + getDynamicProperty: vi.fn(() => undefined), + setDynamicProperty: vi.fn() + }; +} + +describe('INFODISPLAY_RULE_IDENTIFIERS registry', () => { + beforeEach(() => { + Rules.clear(); + Rules.rulesToRegister = []; + }); + + it('contains no duplicate identifiers', () => { + expect(new Set(INFODISPLAY_RULE_IDENTIFIERS).size).toBe(INFODISPLAY_RULE_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()); + + // Both directions: every registered rule is in the list, and every list entry is registered. + expect(new Set(registered)).toEqual(new Set(INFODISPLAY_RULE_IDENTIFIERS)); + expect(registered).toHaveLength(INFODISPLAY_RULE_IDENTIFIERS.length); + }); +}); From da01ffc21c088c8df303210e5ebd7ce3d4583789 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 30 Jun 2026 01:00:42 -0700 Subject: [PATCH 069/120] refactor: derive /info rule enum from InfoDisplay element classes Replaces the standalone INFODISPLAY_RULE_IDENTIFIERS collector (a third source of truth) with a single source: InfoDisplay.elementSpecs. Each element now declares its identifier via a static getRuleIdentifier(), enforced by InfoDisplayElement (the base both throws when a subclass omits it and injects it as the rule identifier, so `identifier` leaves ruleData entirely). InfoDisplay.elementSpecs pairs each element class with an args-thunk and drives both per-player instantiation and the new static InfoDisplay.getRuleIdentifiers(), which the /info enum consumes. Because the registered rule ID and the enum value now come from the same static method off the same list, drift is structurally impossible. The old both-directions drift test is replaced by InfoDisplay.test.js: base enforcement plus a no-duplicates/constructible sanity check. Deletes infoDisplayIdentifiers.js and the per-class *_IDENTIFIER consts. --- Canopy[BP]/scripts/src/commands/info.js | 4 +- .../scripts/src/rules/infodisplay/Biome.js | 7 +- .../src/rules/infodisplay/BlockStates.js | 7 +- .../src/rules/infodisplay/CardinalFacing.js | 8 +- .../src/rules/infodisplay/ChunkCoords.js | 8 +- .../scripts/src/rules/infodisplay/Coords.js | 8 +- .../src/rules/infodisplay/Dimension.js | 8 +- .../scripts/src/rules/infodisplay/Entities.js | 8 +- .../src/rules/infodisplay/EventTrackers.js | 8 +- .../scripts/src/rules/infodisplay/Facing.js | 8 +- .../rules/infodisplay/HeldItemDurability.js | 7 +- .../rules/infodisplay/HopperCounterCounts.js | 8 +- .../src/rules/infodisplay/InfoDisplay.js | 78 ++++++++++--------- .../rules/infodisplay/InfoDisplayElement.js | 12 ++- .../scripts/src/rules/infodisplay/Light.js | 7 +- .../src/rules/infodisplay/LiquidStates.js | 7 +- .../src/rules/infodisplay/LiquidTarget.js | 8 +- .../src/rules/infodisplay/MoonPhase.js | 8 +- .../scripts/src/rules/infodisplay/NoFog.js | 7 +- .../src/rules/infodisplay/PeekInventory.js | 9 ++- .../scripts/src/rules/infodisplay/Ping.js | 8 +- .../src/rules/infodisplay/RenderLightLevel.js | 7 +- .../rules/infodisplay/RenderSignalStrength.js | 7 +- .../src/rules/infodisplay/SessionTime.js | 8 +- .../src/rules/infodisplay/SignalStrength.js | 8 +- .../src/rules/infodisplay/SimulationMap.js | 8 +- .../src/rules/infodisplay/SlimeChunk.js | 8 +- .../scripts/src/rules/infodisplay/Speed.js | 7 +- .../src/rules/infodisplay/Structures.js | 7 +- .../scripts/src/rules/infodisplay/TPS.js | 8 +- .../scripts/src/rules/infodisplay/Target.js | 8 +- .../src/rules/infodisplay/TimeOfDay.js | 8 +- .../scripts/src/rules/infodisplay/Velocity.js | 7 +- .../scripts/src/rules/infodisplay/Weather.js | 8 +- .../scripts/src/rules/infodisplay/WorldDay.js | 8 +- .../infodisplay/infoDisplayIdentifiers.js | 73 ----------------- .../BP/scripts/src/commands/info.test.js | 4 +- ...dentifiers.test.js => InfoDisplay.test.js} | 25 ++++-- 38 files changed, 221 insertions(+), 221 deletions(-) delete mode 100644 Canopy[BP]/scripts/src/rules/infodisplay/infoDisplayIdentifiers.js rename __tests__/BP/scripts/src/rules/infodisplay/{infoDisplayIdentifiers.test.js => InfoDisplay.test.js} (52%) diff --git a/Canopy[BP]/scripts/src/commands/info.js b/Canopy[BP]/scripts/src/commands/info.js index 798310b4..b2721ab4 100644 --- a/Canopy[BP]/scripts/src/commands/info.js +++ b/Canopy[BP]/scripts/src/commands/info.js @@ -2,7 +2,7 @@ import { VanillaCommand, PlayerCommandOrigin, InfoDisplayRule, Commands, Rules } import { CommandPermissionLevel, CustomCommandParamType, CustomCommandStatus, system } from "@minecraft/server"; import { ModalFormData } from "@minecraft/server-ui"; import { forceShow } from "../../include/utils"; -import { INFODISPLAY_RULE_IDENTIFIERS } from "../rules/infodisplay/infoDisplayIdentifiers"; +import { InfoDisplay } from "../rules/infodisplay/InfoDisplay"; export class InfoDisplayCommand extends VanillaCommand { constructor() { @@ -30,7 +30,7 @@ export class InfoDisplayCommand extends VanillaCommand { } static getRuleEnumValues() { - return [...INFODISPLAY_RULE_IDENTIFIERS, 'menu']; + return [...InfoDisplay.getRuleIdentifiers(), 'menu']; } infoCommand(origin, rule, value) { diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/Biome.js b/Canopy[BP]/scripts/src/rules/infodisplay/Biome.js index 67acfbb9..6a465599 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Biome.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Biome.js @@ -1,13 +1,14 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; -export const BIOME_IDENTIFIER = 'biome'; - class Biome extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'biome'; + } + player; constructor(player, displayLine) { const ruleData = { - identifier: BIOME_IDENTIFIER, description: { translate: 'rules.infoDisplay.biome' }, wikiDescription: 'Shows the biome at your current location.' }; diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/BlockStates.js b/Canopy[BP]/scripts/src/rules/infodisplay/BlockStates.js index c85591d1..9725eeac 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/BlockStates.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/BlockStates.js @@ -2,14 +2,15 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; import { getRaycastResults } from '../../../include/utils.js'; import { LiquidType } from '@minecraft/server'; -export const BLOCK_STATES_IDENTIFIER = 'blockStates'; - export class BlockStates extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'blockStates'; + } + player; constructor(player, displayLine) { const ruleData = { - identifier: BLOCK_STATES_IDENTIFIER, 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.' } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/CardinalFacing.js b/Canopy[BP]/scripts/src/rules/infodisplay/CardinalFacing.js index accfa105..cef0dacf 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/CardinalFacing.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/CardinalFacing.js @@ -1,12 +1,14 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; -export const CARDINAL_FACING_IDENTIFIER = 'cardinalFacing'; - class CardinalFacing extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'cardinalFacing'; + } + player; constructor(player, displayLine) { - const ruleData = { identifier: CARDINAL_FACING_IDENTIFIER, 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 68dcd07f..eb28d1d0 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/ChunkCoords.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/ChunkCoords.js @@ -1,10 +1,12 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; -export const CHUNK_COORDS_IDENTIFIER = 'chunkCoords'; - class ChunkCoords extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'chunkCoords'; + } + constructor(player, displayLine) { - const ruleData = { identifier: CHUNK_COORDS_IDENTIFIER, 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 0a09c828..bb39f90a 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Coords.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Coords.js @@ -1,12 +1,14 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; -export const COORDS_IDENTIFIER = 'coords'; - class Coords extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'coords'; + } + player; constructor(player, displayLine) { - const ruleData = { identifier: COORDS_IDENTIFIER, 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 677ed977..f6953a7f 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Dimension.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Dimension.js @@ -1,11 +1,13 @@ import { getColorByDimension } from '../../../include/utils.js'; import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; -export const DIMENSION_IDENTIFIER = 'dimension'; - class Dimension extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'dimension'; + } + constructor(player, displayLine) { - const ruleData = { identifier: DIMENSION_IDENTIFIER, 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 47666e28..6e5b8b0d 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Entities.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Entities.js @@ -1,13 +1,15 @@ import { InfoDisplayTextElement } from "./InfoDisplayTextElement.js"; import { Vector } from "../../../lib/Vector.js"; -export const ENTITIES_IDENTIFIER = 'entities'; - class Entities extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'entities'; + } + player; constructor(player, displayLine) { - const ruleData = { identifier: ENTITIES_IDENTIFIER, 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 8a5714ab..5e11a809 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/EventTrackers.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/EventTrackers.js @@ -1,11 +1,13 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; import { getAllTrackerInfoString } from '../../commands/trackevent'; -export const EVENT_TRACKERS_IDENTIFIER = 'eventTrackers'; - class EventTrackers extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'eventTrackers'; + } + constructor(displayLine) { - const ruleData = { identifier: EVENT_TRACKERS_IDENTIFIER, 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 ec53e9a2..de347de4 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Facing.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Facing.js @@ -1,12 +1,14 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; -export const FACING_IDENTIFIER = 'facing'; - class Facing extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'facing'; + } + player; constructor(player, displayLine) { - const ruleData = { identifier: FACING_IDENTIFIER, 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 57726e64..f09dc893 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/HeldItemDurability.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/HeldItemDurability.js @@ -1,14 +1,15 @@ import { EntityComponentTypes, EquipmentSlot, ItemComponentTypes } from '@minecraft/server'; import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; -export const HELD_ITEM_DURABILITY_IDENTIFIER = 'heldItemDurability'; - export class HeldItemDurability extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'heldItemDurability'; + } + player; constructor(player, displayLine) { const ruleData = { - identifier: HELD_ITEM_DURABILITY_IDENTIFIER, 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 09779b8f..16372453 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/HopperCounterCounts.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/HopperCounterCounts.js @@ -2,11 +2,13 @@ import { InfoDisplayTextElement } from "./InfoDisplayTextElement"; import { counterChannels } from "../../classes/CounterChannels"; import { getColorCode } from "../../../include/utils"; -export const HOPPER_COUNTER_COUNTS_IDENTIFIER = 'hopperCounterCounts'; - class HopperCounterCounts extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'hopperCounterCounts'; + } + constructor(displayLine) { - const ruleData = { identifier: HOPPER_COUNTER_COUNTS_IDENTIFIER, 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/InfoDisplay.js b/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplay.js index f2f990de..e2d2f4dc 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplay.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplay.js @@ -44,43 +44,51 @@ class InfoDisplay { static playerToInfoDisplayMap = {}; static currentTickWorldwideElementData = {}; + // Single source of truth for the InfoDisplay rules: each entry pairs an element class + // with a function producing its per-player constructor arguments. The constructor maps + // this to instances; getRuleIdentifiers() maps it to identifiers (no player needed). + 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]], + [HeldItemDurability, (player) => [player, 21]], + [Target, (player) => [player, 22]], + [SignalStrength, (player) => [player, 22]], + [BlockStates, (player) => [player, 23]], + [PeekInventory, (player) => [player, 24]], + [LiquidTarget, (player) => [player, 25]], + [LiquidStates, (player) => [player, 26]], + [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 RenderLightLevel(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 19a9b6fd..75813df7 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplayElement.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplayElement.js @@ -5,12 +5,16 @@ class InfoDisplayElement { 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; + 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 f737fd34..665b1525 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Light.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Light.js @@ -1,13 +1,14 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; -export const LIGHT_IDENTIFIER = 'light'; - class Light extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'light'; + } + player; constructor(player, displayLine) { const ruleData = { - identifier: LIGHT_IDENTIFIER, 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 99b46e80..2a8da7c2 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/LiquidStates.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/LiquidStates.js @@ -1,14 +1,15 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; import { LiquidType } from '@minecraft/server'; -export const LIQUID_STATES_IDENTIFIER = 'liquidStates'; - export class LiquidStates extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'liquidStates'; + } + player; constructor(player, displayLine) { const ruleData = { - identifier: LIQUID_STATES_IDENTIFIER, description: { translate: 'rules.infoDisplay.liquidStates' }, wikiDescription: 'Shows the states of the liquid you are targeting.' } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/LiquidTarget.js b/Canopy[BP]/scripts/src/rules/infodisplay/LiquidTarget.js index 29f85579..3bac381a 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/LiquidTarget.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/LiquidTarget.js @@ -1,11 +1,13 @@ import { InfoDisplayTextElement } from "./InfoDisplayTextElement"; import { parseName, stringifyLocation } from "../../../include/utils"; -export const LIQUID_TARGET_IDENTIFIER = 'liquidTarget'; - export class LiquidTarget extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'liquidTarget'; + } + constructor(player, displayLine) { - const ruleData = { identifier: LIQUID_TARGET_IDENTIFIER, 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; } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/MoonPhase.js b/Canopy[BP]/scripts/src/rules/infodisplay/MoonPhase.js index fc52faa9..ba378145 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/MoonPhase.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/MoonPhase.js @@ -1,11 +1,13 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; import { world } from '@minecraft/server'; -export const MOON_PHASE_IDENTIFIER = 'moonPhase'; - class MoonPhase extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'moonPhase'; + } + constructor(displayLine) { - const ruleData = { identifier: MOON_PHASE_IDENTIFIER, 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 0721781e..82073830 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/NoFog.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/NoFog.js @@ -1,9 +1,11 @@ import { EntityComponentTypes, world } from "@minecraft/server"; import { InfoDisplayShapeElement } from "./InfoDisplayShapeElement"; -export const NO_FOG_IDENTIFIER = 'noFog'; - 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", @@ -15,7 +17,6 @@ export class NoFog extends InfoDisplayShapeElement { constructor(player) { const ruleData = { - identifier: NO_FOG_IDENTIFIER, description: { translate: 'rules.infoDisplay.noFog' }, wikiDescription: `Disables the fog effect for the player. Water and lava are unaffected.`, onEnableCallback: () => this.removeFog(), diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/PeekInventory.js b/Canopy[BP]/scripts/src/rules/infodisplay/PeekInventory.js index b99857fe..91d3e02d 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/PeekInventory.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/PeekInventory.js @@ -3,14 +3,15 @@ import { getRaycastResults, getClosestTarget } from "../../../include/utils"; import { currentQuery } from "../../commands/peek"; import { ItemStack } from "@minecraft/server"; -export const PEEK_INVENTORY_IDENTIFIER = 'peekInventory'; - class PeekInventory extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'peekInventory'; + } + player; constructor(player, displayLine) { - const ruleData = { identifier: PEEK_INVENTORY_IDENTIFIER, - 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 420bd027..920c7fe5 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Ping.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Ping.js @@ -1,12 +1,14 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; -export const PING_IDENTIFIER = 'ping'; - export class Ping extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'ping'; + } + player; constructor(player, displayLine) { - const ruleData = { identifier: PING_IDENTIFIER, 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()); diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/RenderLightLevel.js b/Canopy[BP]/scripts/src/rules/infodisplay/RenderLightLevel.js index 0ea54f1e..510bc984 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/RenderLightLevel.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/RenderLightLevel.js @@ -3,9 +3,11 @@ import { BlockVolume, LiquidType } from '@minecraft/server'; import { LightLevelRenderer } from '../../classes/LightLevelRenderer'; import { Vector } from '../../../lib/Vector'; -export const RENDER_LIGHT_LEVEL_IDENTIFIER = 'renderLightLevel'; - class RenderLightLevel extends InfoDisplayShapeElement { + static getRuleIdentifier() { + return 'renderLightLevel'; + } + player; playerId; static RENDER_DISTANCE = 4; @@ -13,7 +15,6 @@ class RenderLightLevel extends InfoDisplayShapeElement { constructor(player) { const ruleData = { - identifier: RENDER_LIGHT_LEVEL_IDENTIFIER, 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(), diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/RenderSignalStrength.js b/Canopy[BP]/scripts/src/rules/infodisplay/RenderSignalStrength.js index fd5adcfa..aeb13679 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/RenderSignalStrength.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/RenderSignalStrength.js @@ -3,9 +3,11 @@ import { BlockVolume } from '@minecraft/server'; import { SignalStrengthRenderer } from '../../classes/SignalStrengthRenderer'; import { Vector } from '../../../lib/Vector'; -export const RENDER_SIGNAL_STRENGTH_IDENTIFIER = 'renderSignalStrength'; - class RenderSignalStrength extends InfoDisplayShapeElement { + static getRuleIdentifier() { + return 'renderSignalStrength'; + } + player; playerId; static RENDER_DISTANCE = 10; @@ -13,7 +15,6 @@ class RenderSignalStrength extends InfoDisplayShapeElement { constructor(player) { const ruleData = { - identifier: RENDER_SIGNAL_STRENGTH_IDENTIFIER, 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 16da4747..bbc6825c 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/SessionTime.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/SessionTime.js @@ -1,12 +1,14 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; -export const SESSION_TIME_IDENTIFIER = 'sessionTime'; - class SessionTime extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'sessionTime'; + } + player; constructor(player, displayLine) { - const ruleData = { identifier: SESSION_TIME_IDENTIFIER, 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 02db8f42..ef5ce944 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/SignalStrength.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/SignalStrength.js @@ -1,13 +1,15 @@ import { InfoDisplayTextElement } from "./InfoDisplayTextElement"; import { getRaycastResults } from "../../../include/utils"; -export const SIGNAL_STRENGTH_IDENTIFIER = 'signalStrength'; - class SignalStrength extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'signalStrength'; + } + player; constructor(player, displayLine) { - const ruleData = { identifier: SIGNAL_STRENGTH_IDENTIFIER, 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 2bbba7de..7290db3b 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/SimulationMap.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/SimulationMap.js @@ -2,13 +2,15 @@ import { world } from '@minecraft/server'; import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; import { getConfig, getLoadedChunksMessage } from '../../commands/simmap.js'; -export const SIMULATION_MAP_IDENTIFIER = 'simulationMap'; - class SimulationMap extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'simulationMap'; + } + player; constructor(player, displayLine) { - const ruleData = { identifier: SIMULATION_MAP_IDENTIFIER, 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 49ba38ba..45927089 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/SlimeChunk.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/SlimeChunk.js @@ -1,14 +1,16 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js' import { playerChangeSubChunkEvent } from '../../events/PlayerChangeSubChunkEvent.js' -export const SLIME_CHUNK_IDENTIFIER = 'slimeChunk'; - export class SlimeChunk extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'slimeChunk'; + } + player; infoMessage = { text: '' }; constructor(player, displayLine) { - const ruleData = { identifier: SLIME_CHUNK_IDENTIFIER, 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 054720c0..57eb5f0a 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Speed.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Speed.js @@ -2,14 +2,15 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; import { Vector } from '../../../lib/Vector.js'; import { TicksPerSecond } from '@minecraft/server'; -export const SPEED_IDENTIFIER = 'speed'; - export class Speed extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'speed'; + } + player; constructor(player, displayLine) { const ruleData = { - identifier: SPEED_IDENTIFIER, 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 f175ff8c..256cee63 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Structures.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Structures.js @@ -1,13 +1,14 @@ import { InfoDisplayTextElement } from "./InfoDisplayTextElement"; -export const STRUCTURES_IDENTIFIER = 'structures'; - export class Structures extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'structures'; + } + player; constructor(player, displayLine) { const ruleData = { - identifier: STRUCTURES_IDENTIFIER, description: { translate: 'rules.infoDisplay.structures' }, wikiDescription: 'Shows naturally generated structures present at your current location.' } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/TPS.js b/Canopy[BP]/scripts/src/rules/infodisplay/TPS.js index 6d8e3661..4ac39f6e 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/TPS.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/TPS.js @@ -2,11 +2,13 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; import { Profiler } from '../../classes/Profiler.js'; import { TicksPerSecond } from '@minecraft/server'; -export const TPS_IDENTIFIER = 'tps'; - class TPS extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'tps'; + } + constructor(displayLine) { - const ruleData = { identifier: TPS_IDENTIFIER, 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 90841ad1..846e5992 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Target.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Target.js @@ -1,11 +1,13 @@ import { InfoDisplayTextElement } from "./InfoDisplayTextElement"; import { getRaycastResults, parseName, stringifyLocation } from "../../../include/utils"; -export const TARGET_IDENTIFIER = 'target'; - export class Target extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'target'; + } + constructor(player, displayLine) { - const ruleData = { identifier: TARGET_IDENTIFIER, 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; } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/TimeOfDay.js b/Canopy[BP]/scripts/src/rules/infodisplay/TimeOfDay.js index 8c876b0f..4c9d1921 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/TimeOfDay.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/TimeOfDay.js @@ -1,11 +1,13 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; import { world } from '@minecraft/server'; -export const TIME_OF_DAY_IDENTIFIER = 'timeOfDay'; - class TimeOfDay extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'timeOfDay'; + } + constructor(displayLine) { - const ruleData = { identifier: TIME_OF_DAY_IDENTIFIER, 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 d4b25e45..902d93dc 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Velocity.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Velocity.js @@ -1,14 +1,15 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; import { Vector } from '../../../lib/Vector.js'; -export const VELOCITY_IDENTIFIER = 'velocity'; - export class Velocity extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'velocity'; + } + player; constructor(player, displayLine) { const ruleData = { - identifier: VELOCITY_IDENTIFIER, 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 1d49b867..c4d781ce 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Weather.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Weather.js @@ -1,10 +1,12 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; -export const WEATHER_IDENTIFIER = 'weather'; - class Weather extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'weather'; + } + constructor(player, displayLine) { - const ruleData = { identifier: WEATHER_IDENTIFIER, 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 8bd7fe59..bdb20880 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/WorldDay.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/WorldDay.js @@ -1,11 +1,13 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; import { world } from '@minecraft/server'; -export const WORLD_DAY_IDENTIFIER = 'worldDay'; - class WorldDay extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'worldDay'; + } + constructor(displayLine) { - const ruleData = { identifier: WORLD_DAY_IDENTIFIER, 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/infodisplay/infoDisplayIdentifiers.js b/Canopy[BP]/scripts/src/rules/infodisplay/infoDisplayIdentifiers.js deleted file mode 100644 index 996c0769..00000000 --- a/Canopy[BP]/scripts/src/rules/infodisplay/infoDisplayIdentifiers.js +++ /dev/null @@ -1,73 +0,0 @@ -import { BIOME_IDENTIFIER } from './Biome'; -import { BLOCK_STATES_IDENTIFIER } from './BlockStates'; -import { CARDINAL_FACING_IDENTIFIER } from './CardinalFacing'; -import { CHUNK_COORDS_IDENTIFIER } from './ChunkCoords'; -import { COORDS_IDENTIFIER } from './Coords'; -import { DIMENSION_IDENTIFIER } from './Dimension'; -import { ENTITIES_IDENTIFIER } from './Entities'; -import { EVENT_TRACKERS_IDENTIFIER } from './EventTrackers'; -import { FACING_IDENTIFIER } from './Facing'; -import { HELD_ITEM_DURABILITY_IDENTIFIER } from './HeldItemDurability'; -import { HOPPER_COUNTER_COUNTS_IDENTIFIER } from './HopperCounterCounts'; -import { LIGHT_IDENTIFIER } from './Light'; -import { LIQUID_STATES_IDENTIFIER } from './LiquidStates'; -import { LIQUID_TARGET_IDENTIFIER } from './LiquidTarget'; -import { MOON_PHASE_IDENTIFIER } from './MoonPhase'; -import { NO_FOG_IDENTIFIER } from './NoFog'; -import { PEEK_INVENTORY_IDENTIFIER } from './PeekInventory'; -import { PING_IDENTIFIER } from './Ping'; -import { RENDER_LIGHT_LEVEL_IDENTIFIER } from './RenderLightLevel'; -import { RENDER_SIGNAL_STRENGTH_IDENTIFIER } from './RenderSignalStrength'; -import { SESSION_TIME_IDENTIFIER } from './SessionTime'; -import { SIGNAL_STRENGTH_IDENTIFIER } from './SignalStrength'; -import { SIMULATION_MAP_IDENTIFIER } from './SimulationMap'; -import { SLIME_CHUNK_IDENTIFIER } from './SlimeChunk'; -import { SPEED_IDENTIFIER } from './Speed'; -import { STRUCTURES_IDENTIFIER } from './Structures'; -import { TARGET_IDENTIFIER } from './Target'; -import { TIME_OF_DAY_IDENTIFIER } from './TimeOfDay'; -import { TPS_IDENTIFIER } from './TPS'; -import { VELOCITY_IDENTIFIER } from './Velocity'; -import { WEATHER_IDENTIFIER } from './Weather'; -import { WORLD_DAY_IDENTIFIER } from './WorldDay'; - -// Single source of truth for the set of InfoDisplay rule IDs available at startup. -// Each identifier is owned by its element class (exported as *_IDENTIFIER); this module -// collects them so the /info command enum can be populated at command-registration time, -// before any per-player InfoDisplay (and therefore any InfoDisplay rule) has been built. -// Add a new InfoDisplay rule here when you add its element class; the drift-guard test in -// infoDisplayIdentifiers.test.js fails if this list and the registered rules diverge. -export const INFODISPLAY_RULE_IDENTIFIERS = [ - BIOME_IDENTIFIER, - BLOCK_STATES_IDENTIFIER, - CARDINAL_FACING_IDENTIFIER, - CHUNK_COORDS_IDENTIFIER, - COORDS_IDENTIFIER, - DIMENSION_IDENTIFIER, - ENTITIES_IDENTIFIER, - EVENT_TRACKERS_IDENTIFIER, - FACING_IDENTIFIER, - HELD_ITEM_DURABILITY_IDENTIFIER, - HOPPER_COUNTER_COUNTS_IDENTIFIER, - LIGHT_IDENTIFIER, - LIQUID_STATES_IDENTIFIER, - LIQUID_TARGET_IDENTIFIER, - MOON_PHASE_IDENTIFIER, - NO_FOG_IDENTIFIER, - PEEK_INVENTORY_IDENTIFIER, - PING_IDENTIFIER, - RENDER_LIGHT_LEVEL_IDENTIFIER, - RENDER_SIGNAL_STRENGTH_IDENTIFIER, - SESSION_TIME_IDENTIFIER, - SIGNAL_STRENGTH_IDENTIFIER, - SIMULATION_MAP_IDENTIFIER, - SLIME_CHUNK_IDENTIFIER, - SPEED_IDENTIFIER, - STRUCTURES_IDENTIFIER, - TARGET_IDENTIFIER, - TIME_OF_DAY_IDENTIFIER, - TPS_IDENTIFIER, - VELOCITY_IDENTIFIER, - WEATHER_IDENTIFIER, - WORLD_DAY_IDENTIFIER -]; diff --git a/__tests__/BP/scripts/src/commands/info.test.js b/__tests__/BP/scripts/src/commands/info.test.js index 8871c89f..c8374045 100644 --- a/__tests__/BP/scripts/src/commands/info.test.js +++ b/__tests__/BP/scripts/src/commands/info.test.js @@ -2,11 +2,11 @@ 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_RULE_IDENTIFIERS } from '../../../../../Canopy[BP]/scripts/src/rules/infodisplay/infoDisplayIdentifiers'; +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_RULE_IDENTIFIERS, 'menu']); + expect(InfoDisplayCommand.getRuleEnumValues()).toEqual([...InfoDisplay.getRuleIdentifiers(), 'menu']); }); }); diff --git a/__tests__/BP/scripts/src/rules/infodisplay/infoDisplayIdentifiers.test.js b/__tests__/BP/scripts/src/rules/infodisplay/InfoDisplay.test.js similarity index 52% rename from __tests__/BP/scripts/src/rules/infodisplay/infoDisplayIdentifiers.test.js rename to __tests__/BP/scripts/src/rules/infodisplay/InfoDisplay.test.js index 561b5d0e..a12c3cf3 100644 --- a/__tests__/BP/scripts/src/rules/infodisplay/infoDisplayIdentifiers.test.js +++ b/__tests__/BP/scripts/src/rules/infodisplay/InfoDisplay.test.js @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Rules } from '../../../../../../Canopy[BP]/scripts/lib/canopy/rules/Rules'; -import { INFODISPLAY_RULE_IDENTIFIERS } from '../../../../../../Canopy[BP]/scripts/src/rules/infodisplay/infoDisplayIdentifiers'; 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(); @@ -19,29 +19,38 @@ vi.mock('@minecraft/server', async (importOriginal) => { function createMockPlayer() { return { - id: 'drift-test-player', + id: 'info-display-test-player', getComponent: vi.fn(() => ({ push: vi.fn(), remove: vi.fn() })), getDynamicProperty: vi.fn(() => undefined), setDynamicProperty: vi.fn() }; } -describe('INFODISPLAY_RULE_IDENTIFIERS registry', () => { +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('contains no duplicate identifiers', () => { - expect(new Set(INFODISPLAY_RULE_IDENTIFIERS).size).toBe(INFODISPLAY_RULE_IDENTIFIERS.length); + 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()); - // Both directions: every registered rule is in the list, and every list entry is registered. - expect(new Set(registered)).toEqual(new Set(INFODISPLAY_RULE_IDENTIFIERS)); - expect(registered).toHaveLength(INFODISPLAY_RULE_IDENTIFIERS.length); + // 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); }); }); From 6d4f32ac4ca57aa84b27d1e68e997e7a197142f9 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 30 Jun 2026 01:04:09 -0700 Subject: [PATCH 070/120] docs: remove unnecessary comment --- Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplay.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplay.js b/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplay.js index e2d2f4dc..ece983f7 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplay.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplay.js @@ -44,9 +44,6 @@ class InfoDisplay { static playerToInfoDisplayMap = {}; static currentTickWorldwideElementData = {}; - // Single source of truth for the InfoDisplay rules: each entry pairs an element class - // with a function producing its per-player constructor arguments. The constructor maps - // this to instances; getRuleIdentifiers() maps it to identifiers (no player needed). static elementSpecs = [ [TPS, () => [1]], [Ping, (player) => [player, 2]], From a859b6a48822f82fd98cca940abfd5c2797202d4 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 7 Jul 2026 16:24:08 -0700 Subject: [PATCH 071/120] refactor: remove unused particle assets --- .../fortress_hss_marker.particle.json | 46 ------------------ .../monument_hss_marker.particle.json | 46 ------------------ .../pillager_outpost_hss_marker.particle.json | 46 ------------------ .../swamp_hut_hss_marker.particle.json | 46 ------------------ .../textures/particle/fortress_hss_marker.png | Bin 176 -> 0 bytes .../particle/ocean_monument_hss_marker.png | Bin 413 -> 0 bytes .../textures/particle/outpost_hss_marker.png | Bin 236 -> 0 bytes .../particle/witch_hut_hss_marker.png | Bin 232 -> 0 bytes 8 files changed, 184 deletions(-) delete mode 100644 Canopy[RP]/particles/fortress_hss_marker.particle.json delete mode 100644 Canopy[RP]/particles/monument_hss_marker.particle.json delete mode 100644 Canopy[RP]/particles/pillager_outpost_hss_marker.particle.json delete mode 100644 Canopy[RP]/particles/swamp_hut_hss_marker.particle.json delete mode 100644 Canopy[RP]/textures/particle/fortress_hss_marker.png delete mode 100644 Canopy[RP]/textures/particle/ocean_monument_hss_marker.png delete mode 100644 Canopy[RP]/textures/particle/outpost_hss_marker.png delete mode 100644 Canopy[RP]/textures/particle/witch_hut_hss_marker.png 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]/textures/particle/fortress_hss_marker.png b/Canopy[RP]/textures/particle/fortress_hss_marker.png deleted file mode 100644 index f71efeebc98b235e6b4677513f70a80f48258fb4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 176 zcmeAS@N?(olHy`uVBq!ia0vp^LLkh+1SD^+kpz+q<(@8%AsQ3cPTI(IK!L~Qv-;fK zIv$aMYxuWR;IhR;dt{9~4#KMW>Byx;cVf6S8Pj>*Lx#k|$g#rK!)FqSwZ&Fd_E zN9ca?qC~-t-h=*Y-^tFHDABz$n5k|3!!?O2#SB8nUMF`3%w?Wn5VzVQDP&{AhPI{K auE?kE`FH6~jof*V8$4b8T-G@yGywoM(M7WW 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 a512736850d6f6a31d494bf151531ea43ff38a2f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 413 zcmV;O0b>4%P)AF4xZ?Jr` zXL@&-h2&&+=FB~Ff9Bq#?6=qZyJeZg_@4*lo{Wt)F=O+Vpd_x{HjJ*t2G7AR4>|Gs0Y71{>UM3?h zlT1p!c(cTSytxb`S(v>!F-G2g$u3R~lnjCW3679qwvkn3q(r(5WRuY<|LARPgVkdC zHXeybuufYlX2Q>{(w0I8#qzz#=U$f2QHpmptQ=I&y$RBamW^MEeD?J@D;87q+2qxd z^g$*;N%R0#eXEN62)-ntOd7>Q4NPOtU>SOuC_R+wrhs*kZR-r1L8_I)*x1T>9Hi^c zMk~8cPg_wd%hJjGs^5+V2VN5p@|v1@-kRg6_jLUbp|bx0VL{RT@E9EO00000NkvXX Hu0mjf30=D1 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 7b78b14ff769b1f1b09471867f1485473a06554d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 236 zcmVz#o)lrXNP)vJ1_#X%xPQh+B!vPT%3a?|TF9pdMEy zVmMm%^Y6}czF!r7GelTk>S@a!b*;RdQ{G*tlvQQG<6#-~EHkoOQ79}!lw}4S3X>s9 mT($wr_~HhCEL)HeqRKuVIvFl7Zic4-0000|k1SIp4_|F5W^`0({As)QRKmN~e%N*nWeHJi z<&QYF^Q?=HcAmmxBBaAC)z`&#%V Date: Tue, 7 Jul 2026 16:24:50 -0700 Subject: [PATCH 072/120] fix: decorated pot flippinArrows duplication ignore decorated pots for flippinArrows --- Canopy[BP]/scripts/src/rules/flippinArrows.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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(); From d384e613e2bf8b8ec0bd5e9ad595c8760046eef2 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 7 Jul 2026 17:03:20 -0700 Subject: [PATCH 073/120] feat: playerlook block looks at block face --- .../scripts/src/classes/simplayer/Understudy.js | 2 +- Canopy[BP]/scripts/src/classes/simplayer/utils.js | 14 +++++++++++++- .../scripts/src/commands/simplayer/playerlook.js | 6 ++++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js b/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js index 50566a68..de79cd12 100644 --- a/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js +++ b/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js @@ -141,7 +141,7 @@ class Understudy { look(target) { if (target instanceof Block) { - this.simulatedPlayer.lookAtBlock(target); + this.simulatedPlayer.lookAt(target); this.#lookTarget = target; } else if (target instanceof Entity) { this.simulatedPlayer.lookAtEntity(target); diff --git a/Canopy[BP]/scripts/src/classes/simplayer/utils.js b/Canopy[BP]/scripts/src/classes/simplayer/utils.js index e80d6c4e..8a72c61d 100644 --- a/Canopy[BP]/scripts/src/classes/simplayer/utils.js +++ b/Canopy[BP]/scripts/src/classes/simplayer/utils.js @@ -1,4 +1,5 @@ -import { Block, Entity, GameMode, Player } from "@minecraft/server"; +import { Block, Direction, Entity, GameMode, Player } from "@minecraft/server"; +import { Vector } from "../../../lib/Vector"; const PLAYER_EYE_HEIGHT = 1.62001002; @@ -54,3 +55,14 @@ export function getLocationInfoFromSource(source) { 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/simplayer/playerlook.js b/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js index 09916b39..6c06951c 100644 --- a/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js @@ -2,6 +2,7 @@ import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, En 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', @@ -87,10 +88,11 @@ export class PlayerLookCommand extends VanillaCommand { const source = origin.getSource(); if (source instanceof Entity === false) return { status: CustomCommandStatus.Failure, message: 'commands.playerlook.block.entityonly' }; - const block = source.getBlockFromViewDirection({ maxDistance: 16*64 })?.block; + 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(block)); + system.run(() => understudy.look(getBlockFaceLocationFromRaycastHit(raycastHit))); return { status: CustomCommandStatus.Success }; } From 14a89ba004337870dcc239f0452bfdabe43fc1a8 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 7 Jul 2026 17:11:05 -0700 Subject: [PATCH 074/120] fix: tick command missing non-player names in feedback --- .../scripts/lib/canopy/commands/BlockCommandOrigin.js | 4 ++++ Canopy[BP]/scripts/lib/canopy/commands/CommandOrigin.js | 4 ++++ .../scripts/lib/canopy/commands/EntityCommandOrigin.js | 5 +++++ .../scripts/lib/canopy/commands/PlayerCommandOrigin.js | 5 +++++ .../scripts/lib/canopy/commands/ServerCommandOrigin.js | 4 ++++ Canopy[BP]/scripts/src/commands/tick.js | 8 ++++---- 6 files changed, 26 insertions(+), 4 deletions(-) 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..575c2159 100644 --- a/Canopy[BP]/scripts/lib/canopy/commands/PlayerCommandOrigin.js +++ b/Canopy[BP]/scripts/lib/canopy/commands/PlayerCommandOrigin.js @@ -6,6 +6,11 @@ export class PlayerCommandOrigin extends CommandOrigin { return "Player"; } + getName() { + const source = this.getSource(); + return source.nameTag || source.typeId; + } + 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/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) From 35b3053e03ae0e1a2c9380bace7b3f46e7086f43 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 7 Jul 2026 17:11:22 -0700 Subject: [PATCH 075/120] refactor: simplify for getting playername from command origin --- Canopy[BP]/scripts/lib/canopy/commands/PlayerCommandOrigin.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Canopy[BP]/scripts/lib/canopy/commands/PlayerCommandOrigin.js b/Canopy[BP]/scripts/lib/canopy/commands/PlayerCommandOrigin.js index 575c2159..29854e72 100644 --- a/Canopy[BP]/scripts/lib/canopy/commands/PlayerCommandOrigin.js +++ b/Canopy[BP]/scripts/lib/canopy/commands/PlayerCommandOrigin.js @@ -7,8 +7,7 @@ export class PlayerCommandOrigin extends CommandOrigin { } getName() { - const source = this.getSource(); - return source.nameTag || source.typeId; + return this.getSource().name; } getSource() { From 3e49ff613b791cc027631e38f45f290814938602 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 7 Jul 2026 17:23:40 -0700 Subject: [PATCH 076/120] fix: ignore thrown error when traveling through portals --- Canopy[BP]/scripts/src/rules/infodisplay/Target.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/Target.js b/Canopy[BP]/scripts/src/rules/infodisplay/Target.js index 846e5992..adb070b2 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Target.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Target.js @@ -1,5 +1,6 @@ import { InfoDisplayTextElement } from "./InfoDisplayTextElement"; import { getRaycastResults, parseName, stringifyLocation } from "../../../include/utils"; +import { LocationInUnloadedChunkError } from "@minecraft/server"; export class Target extends InfoDisplayTextElement { static getRuleIdentifier() { @@ -22,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) { @@ -34,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'; From c8a8b65dab4c5e4377095da156ac4a17ce84430d Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 7 Jul 2026 17:26:45 -0700 Subject: [PATCH 077/120] fix: ignore thrown error when travelling through portals for all raycasting infodisplay rules --- .../src/rules/infodisplay/BlockStates.js | 18 ++++++++++++------ .../src/rules/infodisplay/LiquidStates.js | 16 +++++++++++----- .../src/rules/infodisplay/LiquidTarget.js | 13 ++++++++++--- 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/BlockStates.js b/Canopy[BP]/scripts/src/rules/infodisplay/BlockStates.js index 9725eeac..1a4ed60d 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/BlockStates.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/BlockStates.js @@ -1,6 +1,6 @@ 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() { @@ -27,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/LiquidStates.js b/Canopy[BP]/scripts/src/rules/infodisplay/LiquidStates.js index 2a8da7c2..b4c78468 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/LiquidStates.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/LiquidStates.js @@ -1,5 +1,5 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; -import { LiquidType } from '@minecraft/server'; +import { LiquidType, LocationInUnloadedChunkError } from '@minecraft/server'; export class LiquidStates extends InfoDisplayTextElement { static getRuleIdentifier() { @@ -26,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 3bac381a..66d9ea53 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/LiquidTarget.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/LiquidTarget.js @@ -1,5 +1,6 @@ import { InfoDisplayTextElement } from "./InfoDisplayTextElement"; import { parseName, stringifyLocation } from "../../../include/utils"; +import { LocationInUnloadedChunkError } from "@minecraft/server"; export class LiquidTarget extends InfoDisplayTextElement { static getRuleIdentifier() { @@ -21,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) { @@ -34,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'; From d24d6dc603cab3d22b5e38550485a9b2567b5e44 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 7 Jul 2026 21:18:49 -0700 Subject: [PATCH 078/120] chore: vendor jsep ESM build for expression parsing --- Canopy[BP]/scripts/lib/jsep/README.md | 7 + Canopy[BP]/scripts/lib/jsep/jsep.js | 1126 ++++++++++++++++++++ __tests__/BP/scripts/lib/jsep/jsep.test.js | 14 + 3 files changed, 1147 insertions(+) create mode 100644 Canopy[BP]/scripts/lib/jsep/README.md create mode 100644 Canopy[BP]/scripts/lib/jsep/jsep.js create mode 100644 __tests__/BP/scripts/lib/jsep/jsep.test.js 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..ffa8196c --- /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 (let 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) { + let nodes = [], ch_i, 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, biop, prec, stack, biop_info, left, right, i, 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, to_check, tc_len, 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 = '', ch, 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, 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(); + let 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++; + let 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; + } + else { + return { + type: Jsep.SEQUENCE_EXP, + expressions: nodes, + }; + } + } + else { + 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'; + +var 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/__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(); + }); +}); From de5ec0f578b0a67c464faea6d922e0be793de021 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 7 Jul 2026 21:21:49 -0700 Subject: [PATCH 079/120] feat: add region geometry helpers for analyzearea --- .../src/classes/analyzearea/regionMath.js | 22 +++++++++++++++++++ .../classes/analyzearea/regionMath.test.js | 20 +++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 Canopy[BP]/scripts/src/classes/analyzearea/regionMath.js create mode 100644 __tests__/BP/scripts/src/classes/analyzearea/regionMath.test.js diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/regionMath.js b/Canopy[BP]/scripts/src/classes/analyzearea/regionMath.js new file mode 100644 index 00000000..40e85f70 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/analyzearea/regionMath.js @@ -0,0 +1,22 @@ +export function 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)) + } + }; +} + +export function regionCapacity(min, max) { + return (max.x - min.x + 1) * (max.y - min.y + 1) * (max.z - min.z + 1); +} + +export function sameCorner(a, b) { + return a.x === b.x && a.y === b.y && a.z === b.z; +} diff --git a/__tests__/BP/scripts/src/classes/analyzearea/regionMath.test.js b/__tests__/BP/scripts/src/classes/analyzearea/regionMath.test.js new file mode 100644 index 00000000..1371dc85 --- /dev/null +++ b/__tests__/BP/scripts/src/classes/analyzearea/regionMath.test.js @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeCorners, regionCapacity, sameCorner } from '../../../../../../Canopy[BP]/scripts/src/classes/analyzearea/regionMath.js'; + +describe('regionMath', () => { + it('normalizes swapped/negative corners to floored min/max', () => { + const { min, max } = normalizeCorners({ x: 5.9, y: 2, z: -3 }, { x: -1, y: 10.2, z: 4 }); + expect(min).toEqual({ x: -1, y: 2, z: -3 }); + expect(max).toEqual({ x: 5, y: 10, z: 4 }); + }); + + it('computes inclusive capacity', () => { + expect(regionCapacity({ x: 0, y: 0, z: 0 }, { x: 1, y: 1, z: 1 })).toBe(8); + expect(regionCapacity({ x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 0 })).toBe(1); + }); + + it('compares corners exactly', () => { + expect(sameCorner({ x: 1, y: 2, z: 3 }, { x: 1, y: 2, z: 3 })).toBe(true); + expect(sameCorner({ x: 1, y: 2, z: 3 }, { x: 1, y: 2, z: 4 })).toBe(false); + }); +}); From 27a064c57617ddceb261d9bc4357e097ec6ef495 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 7 Jul 2026 21:24:20 -0700 Subject: [PATCH 080/120] feat: add ExpressionEvaluator for analyzearea block expressions --- .../analyzearea/ExpressionEvaluator.js | 85 +++++++++++++++++++ .../analyzearea/ExpressionEvaluator.test.js | 53 ++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 Canopy[BP]/scripts/src/classes/analyzearea/ExpressionEvaluator.js create mode 100644 __tests__/BP/scripts/src/classes/analyzearea/ExpressionEvaluator.test.js 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..03430e2c --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/analyzearea/ExpressionEvaluator.js @@ -0,0 +1,85 @@ +import jsep from '../../../lib/jsep/jsep.js'; + +export class ExpressionEvaluator { + constructor(expression) { + this.expression = expression; + this.ast = jsep(expression); // throws on syntax error + } + + 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 'UnaryExpression': + return this.#evalUnary(node, block); + case 'BinaryExpression': + case 'LogicalExpression': + return this.#evalBinary(node, block); + default: + throw new Error(`Unsupported expression node: ${node.type}`); + } + } + + // Returns { object, value } so CallExpression can bind `this` to `object`. + #evalMember(node, block) { + const object = this.#evalNode(node.object, block); + const key = node.computed ? this.#evalNode(node.property, block) : node.property.name; + return { object, value: object?.[key] }; + } + + #evalCall(node, block) { + if (node.callee.type === 'MemberExpression') { + const { object, value: fn } = this.#evalMember(node.callee, block); + const args = node.arguments.map((arg) => this.#evalNode(arg, block)); + return fn.apply(object, args); + } + // bare-identifier call, e.g. getTags() -> block.getTags(), bound to block + const fn = this.#evalNode(node.callee, block); + const args = node.arguments.map((arg) => this.#evalNode(arg, block)); + return fn.apply(block, 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/__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..60e161cf --- /dev/null +++ b/__tests__/BP/scripts/src/classes/analyzearea/ExpressionEvaluator.test.js @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +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 ||', () => { + const truthy = new ExpressionEvaluator("typeId === 'minecraft:redstone_wire' && permutation.getState('redstone_signal') === 7"); + expect(truthy.evaluate(makeBlock())).toBe(true); + const falsy = new ExpressionEvaluator("typeId === 'minecraft:air' || permutation.getState('redstone_signal') > 10"); + expect(falsy.evaluate(makeBlock())).toBe(false); + }); + + 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('boom()'); + const block = { boom: () => { throw new Error('restricted'); } }; + expect(() => evaluator.evaluate(block)).toThrow('restricted'); + }); +}); From 5a9784495deaa06a1ede0a6424fd9d932f664288 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 7 Jul 2026 21:28:36 -0700 Subject: [PATCH 081/120] test: prove && / || short-circuit lazy evaluation in ExpressionEvaluator --- .../analyzearea/ExpressionEvaluator.test.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/__tests__/BP/scripts/src/classes/analyzearea/ExpressionEvaluator.test.js b/__tests__/BP/scripts/src/classes/analyzearea/ExpressionEvaluator.test.js index 60e161cf..85068342 100644 --- a/__tests__/BP/scripts/src/classes/analyzearea/ExpressionEvaluator.test.js +++ b/__tests__/BP/scripts/src/classes/analyzearea/ExpressionEvaluator.test.js @@ -28,11 +28,19 @@ describe('ExpressionEvaluator', () => { expect(evaluator.evaluate(makeBlock())).toBe(true); }); - it('short-circuits && and ||', () => { - const truthy = new ExpressionEvaluator("typeId === 'minecraft:redstone_wire' && permutation.getState('redstone_signal') === 7"); - expect(truthy.evaluate(makeBlock())).toBe(true); - const falsy = new ExpressionEvaluator("typeId === 'minecraft:air' || permutation.getState('redstone_signal') > 10"); - expect(falsy.evaluate(makeBlock())).toBe(false); + 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', () => { From 2fc20359d570acdd2c18519f3f3b37f926dee51d Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 7 Jul 2026 21:30:06 -0700 Subject: [PATCH 082/120] feat: add RegionLoader ticking-area wrapper for analyzearea --- .../src/classes/analyzearea/RegionLoader.js | 30 +++++++++++++ .../classes/analyzearea/RegionLoader.test.js | 42 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 Canopy[BP]/scripts/src/classes/analyzearea/RegionLoader.js create mode 100644 __tests__/BP/scripts/src/classes/analyzearea/RegionLoader.test.js diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/RegionLoader.js b/Canopy[BP]/scripts/src/classes/analyzearea/RegionLoader.js new file mode 100644 index 00000000..de6d40ba --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/analyzearea/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; + } + + #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 { + // area may already be gone; unload is best-effort + } + } +} diff --git a/__tests__/BP/scripts/src/classes/analyzearea/RegionLoader.test.js b/__tests__/BP/scripts/src/classes/analyzearea/RegionLoader.test.js new file mode 100644 index 00000000..d2b1073c --- /dev/null +++ b/__tests__/BP/scripts/src/classes/analyzearea/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/analyzearea/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'); + }); +}); From 1ead256d9f57b95c11e7d117aa69c32bdf5ae0b4 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 7 Jul 2026 21:32:35 -0700 Subject: [PATCH 083/120] feat: add AreaAnalyzer block-region scanner for analyzearea --- .../src/classes/analyzearea/AreaAnalyzer.js | 50 +++++++++++++++++++ .../classes/analyzearea/AreaAnalyzer.test.js | 47 +++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 Canopy[BP]/scripts/src/classes/analyzearea/AreaAnalyzer.js create mode 100644 __tests__/BP/scripts/src/classes/analyzearea/AreaAnalyzer.test.js 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..5a19305d --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/analyzearea/AreaAnalyzer.js @@ -0,0 +1,50 @@ +export const MATCH_CAP = 1000; +const YIELD_EVERY = 4096; // spread work across ticks under system.runJob + +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 x = this.min.x; x <= this.max.x; x++) { + for (let y = this.min.y; y <= this.max.y; y++) { + for (let z = this.min.z; z <= this.max.z; z++) { + this.scanned++; + const loc = { x, y, z }; + try { + const block = this.dimension.getBlock(loc); + if (block === undefined) { + this.errorCount++; + } else if (this.evaluator.evaluate(block)) { + this.matches.push(loc); + if (this.matches.length >= this.matchCap) { + this.capped = true; + return; + } + } + } catch { + this.errorCount++; + } + if (++sinceYield >= YIELD_EVERY) { + sinceYield = 0; + yield; + } + } + } + } + } + + runToCompletion() { + for (const _ of this.scan()) { /* drain */ } + } +} 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..beddb375 --- /dev/null +++ b/__tests__/BP/scripts/src/classes/analyzearea/AreaAnalyzer.test.js @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { AreaAnalyzer, MATCH_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(1000); + }); +}); From 1bdb18436b800dcfd393fc429a08b69ec22e5d27 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 7 Jul 2026 21:35:41 -0700 Subject: [PATCH 084/120] feat: add AnalyzeAreaRenderer debug-box renderer for analyzearea --- .../analyzearea/AnalyzeAreaRenderer.js | 35 ++++++++++++++++++ .../analyzearea/AnalyzeAreaRenderer.test.js | 36 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaRenderer.js create mode 100644 __tests__/BP/scripts/src/classes/analyzearea/AnalyzeAreaRenderer.test.js 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..3f4854f5 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaRenderer.js @@ -0,0 +1,35 @@ +import { debugDrawer, DebugBox } from '@minecraft/debug-utilities'; + +const MATCH_COLOR = { red: 0, green: 1, blue: 0, alpha: 1 }; + +export class AnalyzeAreaRenderer { + constructor(dimension, locations) { + this.dimension = dimension; + this.locations = locations; + this.debugShapes = []; + this.visible = false; + } + + show() { + if (this.visible) 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 = MATCH_COLOR; + this.debugShapes.push(box); + debugDrawer.addShape(box); + } + this.visible = true; + } + + hide() { + for (const shape of this.debugShapes) shape.remove(); + this.debugShapes = []; + this.visible = false; + } + + destroy() { + this.hide(); + } +} 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..0195dc85 --- /dev/null +++ b/__tests__/BP/scripts/src/classes/analyzearea/AnalyzeAreaRenderer.test.js @@ -0,0 +1,36 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { debugDrawer } from '@minecraft/debug-utilities'; +import { AnalyzeAreaRenderer } from '../../../../../../Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaRenderer.js'; + +describe('AnalyzeAreaRenderer', () => { + const dimension = { id: 'minecraft:overworld' }; + const locations = [{ x: 0, y: 0, z: 0 }, { x: 1, y: 2, z: 3 }]; + let renderer; + + beforeEach(() => { + debugDrawer.addShape.mockClear(); + renderer = new AnalyzeAreaRenderer(dimension, locations); + }); + + it('draws one box per location on show', () => { + renderer.show(); + expect(debugDrawer.addShape).toHaveBeenCalledTimes(2); + expect(renderer.visible).toBe(true); + }); + + it('show is idempotent', () => { + renderer.show(); + renderer.show(); + expect(debugDrawer.addShape).toHaveBeenCalledTimes(2); + }); + + it('hide removes all shapes and can be re-shown', () => { + renderer.show(); + const shapes = [...renderer.debugShapes]; + renderer.hide(); + shapes.forEach((s) => expect(s.remove).toHaveBeenCalled()); + expect(renderer.visible).toBe(false); + renderer.show(); + expect(renderer.visible).toBe(true); + }); +}); From 9f25482bd34445d19bfe109f4ecc86a018d80c7e Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 7 Jul 2026 21:38:52 -0700 Subject: [PATCH 085/120] feat: add Analysis model for analyzearea --- .../src/classes/analyzearea/Analysis.js | 119 ++++++++++++++++++ .../src/classes/analyzearea/Analysis.test.js | 59 +++++++++ 2 files changed, 178 insertions(+) create mode 100644 Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js create mode 100644 __tests__/BP/scripts/src/classes/analyzearea/Analysis.test.js 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..3dfa7a63 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js @@ -0,0 +1,119 @@ +import { system } from '@minecraft/server'; +import { normalizeCorners, regionCapacity, sameCorner } from './regionMath.js'; +import { ExpressionEvaluator } from './ExpressionEvaluator.js'; +import { AreaAnalyzer } from './AreaAnalyzer.js'; +import { RegionLoader } from './RegionLoader.js'; +import { AnalyzeAreaRenderer } from './AnalyzeAreaRenderer.js'; + +export class Analysis { + constructor({ id, from, to, dimensionId, expression, createdAt }) { + const { min, max } = 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 = null; + this.boxesVisible = false; + this.hasRun = false; + this.jobId = undefined; + this.loader = null; + } + + 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() }); + } + + 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 } = normalizeCorners(from, to); + return sameCorner(min, this.min) && sameCorner(max, this.max); + } + + capacity() { + return regionCapacity(this.min, this.max); + } + + tickingId() { + return `canopy_analyzearea_${this.id}`; + } + + #cancelJob() { + if (this.jobId !== undefined) { + system.clearJob(this.jobId); + this.jobId = undefined; + } + } + + // Runs inside system.run (unrestricted). Returns a promise resolving when the scan finishes. + run(dimension) { + this.dimension = dimension; + this.#cancelJob(); + if (this.loader) this.loader.unload(); + this.loader = new RegionLoader(dimension, this.min, this.max, this.tickingId()); + if (!this.loader.hasCapacity()) + return Promise.reject(new Error('loadcapacity')); + + return this.loader.load().then(() => new Promise((resolve) => { + const evaluator = new ExpressionEvaluator(this.expression); + const analyzer = new AreaAnalyzer(dimension, this.min, this.max, evaluator); + const generator = (() => { + const inner = analyzer.scan(); + return (function* driver() { + yield* inner; + finish(); + })(); + })(); + const finish = () => { + this.jobId = undefined; + this.matches = analyzer.matches; + this.capped = analyzer.capped; + this.hasRun = true; + this.#refreshRender(); + this.loader.unload(); + resolve(); + }; + this.jobId = system.runJob(generator); + })); + } + + #refreshRender() { + const wasVisible = this.boxesVisible; + if (this.renderer) this.renderer.destroy(); + this.renderer = new AnalyzeAreaRenderer(this.dimension, this.matches); + if (wasVisible) this.renderer.show(); + this.boxesVisible = wasVisible; + } + + toggleBoxes() { + if (!this.renderer) return; + if (this.boxesVisible) this.renderer.hide(); + else this.renderer.show(); + this.boxesVisible = !this.boxesVisible; + } + + destroy() { + this.#cancelJob(); + if (this.renderer) this.renderer.destroy(); + if (this.loader) this.loader.unload(); + } +} 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..919dc105 --- /dev/null +++ b/__tests__/BP/scripts/src/classes/analyzearea/Analysis.test.js @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { Analysis } from '../../../../../../Canopy[BP]/scripts/src/classes/analyzearea/Analysis.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('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('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'); + }); +}); From 4f028e7ce04186b940354146aeeb05525f16d745 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 7 Jul 2026 21:41:56 -0700 Subject: [PATCH 086/120] fix: unload ticking area on all run() paths in Analysis --- .../src/classes/analyzearea/Analysis.js | 59 ++++++++++++------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js b/Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js index 3dfa7a63..e6a16df6 100644 --- a/Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js +++ b/Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js @@ -68,31 +68,46 @@ export class Analysis { run(dimension) { this.dimension = dimension; this.#cancelJob(); - if (this.loader) this.loader.unload(); - this.loader = new RegionLoader(dimension, this.min, this.max, this.tickingId()); - if (!this.loader.hasCapacity()) + if (this.loader) { + this.loader.unload(); + this.loader = null; + } + const loader = new RegionLoader(dimension, this.min, this.max, this.tickingId()); + if (!loader.hasCapacity()) return Promise.reject(new Error('loadcapacity')); + this.loader = loader; - return this.loader.load().then(() => new Promise((resolve) => { - const evaluator = new ExpressionEvaluator(this.expression); + 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 = null; + reject(error); + return; + } const analyzer = new AreaAnalyzer(dimension, this.min, this.max, evaluator); - const generator = (() => { - const inner = analyzer.scan(); - return (function* driver() { - yield* inner; - finish(); - })(); - })(); - const finish = () => { - this.jobId = undefined; - this.matches = analyzer.matches; - this.capped = analyzer.capped; - this.hasRun = true; - this.#refreshRender(); - this.loader.unload(); - resolve(); - }; - this.jobId = system.runJob(generator); + const self = this; + function* driver() { + let error = null; + try { + yield* analyzer.scan(); + self.matches = analyzer.matches; + self.capped = analyzer.capped; + self.hasRun = true; + self.#refreshRender(); + } catch (thrown) { + error = thrown; + } finally { + self.jobId = undefined; + loader.unload(); + if (self.loader === loader) self.loader = null; + } + if (error) reject(error); + else resolve(); + } + this.jobId = system.runJob(driver()); })); } From 8f692fbe9d0a829f876903b5fd2aeaf4a3065a60 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 7 Jul 2026 23:14:41 -0700 Subject: [PATCH 087/120] feat: add AreaAnalysisManager world persistence for analyzearea --- .../analyzearea/AreaAnalysisManager.js | 53 +++++++++++++++++++ .../analyzearea/AreaAnalysisManager.test.js | 42 +++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 Canopy[BP]/scripts/src/classes/analyzearea/AreaAnalysisManager.js create mode 100644 __tests__/BP/scripts/src/classes/analyzearea/AreaAnalysisManager.test.js 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..19927158 --- /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 const PROPERTY_KEY = 'areaanalyses'; + +export class AreaAnalysisManager { + static #instance; + + constructor() { + this.analyses = this.#load(); + } + + static getInstance() { + if (!AreaAnalysisManager.#instance) + AreaAnalysisManager.#instance = new AreaAnalysisManager(); + return AreaAnalysisManager.#instance; + } + + #load() { + const raw = world.getDynamicProperty(PROPERTY_KEY); + if (typeof raw !== 'string' || raw.length === 0) return []; + try { + return JSON.parse(raw).map((obj) => Analysis.deserialize(obj)); + } catch { + return []; + } + } + + #save() { + world.setDynamicProperty(PROPERTY_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/__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(); + }); +}); From 8d5e51a0f9d2acf1dc5366a7848e408973ef3843 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 7 Jul 2026 23:17:16 -0700 Subject: [PATCH 088/120] feat: add AnalyzeAreaUI DDUI forms for analyzearea --- .../src/classes/analyzearea/AnalyzeAreaUI.js | 146 ++++++++++++++++++ .../classes/analyzearea/AnalyzeAreaUI.test.js | 14 ++ 2 files changed, 160 insertions(+) create mode 100644 Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI.js create mode 100644 __tests__/BP/scripts/src/classes/analyzearea/AnalyzeAreaUI.test.js 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..2cfabb99 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI.js @@ -0,0 +1,146 @@ +import { CustomForm, ObservableString, ObservableNumber, ObservableBoolean } from '@minecraft/server-ui'; +import { GameMode } from '@minecraft/server'; +import { Analysis } from './Analysis.js'; +import { ExpressionEvaluator } from './ExpressionEvaluator.js'; +import { stringifyLocation, getColoredDimensionName } from '../../../include/utils'; + +export const LIST_PAGE_SIZE = 50; + +const DIMENSIONS = ['minecraft:overworld', 'minecraft:nether', 'minecraft:the_end']; + +function writable() { + return { clientWritable: true }; +} + +export function showSelector(player, manager) { + const form = new CustomForm(player, { translate: 'commands.analyzearea.ui.selector.title' }); + form.button({ translate: 'commands.analyzearea.ui.selector.new' }, () => showCreateForm(player, manager, null)); + const analyses = manager.list(); + if (analyses.length === 0) + form.label({ translate: 'commands.analyzearea.ui.selector.empty' }); + for (const analysis of analyses) { + const label = `${getColoredDimensionName(analysis.dimensionId.replace('minecraft:', ''))} §7${stringifyLocation(analysis.min, 0)}→${stringifyLocation(analysis.max, 0)}\n§8${truncate(analysis.expression, 40)}`; + form.button(label, () => showAnalysisPage(player, manager, analysis)); + } + form.show(); +} + +export function showCreateForm(player, manager, prefill) { + const from = prefill?.from ?? player.location; + const to = prefill?.to ?? player.location; + const form = new CustomForm(player, { translate: 'commands.analyzearea.ui.create.title' }); + const fields = { + fromX: new ObservableString(String(Math.floor(from.x)), writable()), + fromY: new ObservableString(String(Math.floor(from.y)), writable()), + fromZ: new ObservableString(String(Math.floor(from.z)), writable()), + toX: new ObservableString(String(Math.floor(to.x)), writable()), + toY: new ObservableString(String(Math.floor(to.y)), writable()), + toZ: new ObservableString(String(Math.floor(to.z)), writable()) + }; + const dimIndex = Math.max(0, DIMENSIONS.indexOf(player.dimension.id)); + const dimObservable = new ObservableNumber(dimIndex, writable()); + const expression = new ObservableString('', writable()); + + 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, DIMENSIONS); + form.textField({ translate: 'commands.analyzearea.ui.create.expression' }, expression); + + form.button({ translate: 'commands.analyzearea.ui.create.submit' }, () => { + const parsedFrom = parseCorner(fields.fromX, fields.fromY, fields.fromZ); + const parsedTo = parseCorner(fields.toX, fields.toY, fields.toZ); + const expr = expression.getData().trim(); + if (!parsedFrom || !parsedTo || expr.length === 0) { + player.sendMessage({ translate: 'commands.analyzearea.create.invalid' }); + return; + } + try { + void new ExpressionEvaluator(expr); // throws on syntax error + } catch { + player.sendMessage({ translate: 'commands.analyzearea.syntaxerror' }); + return; + } + const analysis = Analysis.create(parsedFrom, parsedTo, DIMENSIONS[dimObservable.getData()], expr); + manager.add(analysis); + analysis.run(player.dimension) + .then(() => showAnalysisPage(player, manager, analysis)) + .catch(() => player.sendMessage({ translate: 'commands.analyzearea.loadcapacity' })); + }); + form.closeButton(); + form.show(); +} + +export function showAnalysisPage(player, manager, analysis) { + const form = new CustomForm(player, { translate: 'commands.analyzearea.ui.page.title' }); + form.label(pageHeader(analysis)); + + 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: null }); + form.button(label, () => teleportTo(player, slots[i].location), { visible }); + } + + const pageIndicator = new ObservableString(''); + 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 ?? null; + slots[i].label.setData(match ? stringifyLocation(match, 0) : ''); + slots[i].visible.setData(Boolean(match)); + } + pageIndicator.setData(`${page + 1} / ${totalPages()}`); + }; + + form.label(pageIndicator); + form.button({ translate: 'commands.analyzearea.ui.page.prev' }, () => { if (page > 0) { page--; renderPage(); } }); + form.button({ translate: 'commands.analyzearea.ui.page.next' }, () => { if (page < totalPages() - 1) { page++; renderPage(); } }); + form.divider(); + form.button({ translate: 'commands.analyzearea.ui.page.reanalyze' }, () => { + analysis.run(player.dimension) + .then(() => { page = 0; renderPage(); }) + .catch(() => player.sendMessage({ translate: 'commands.analyzearea.loadcapacity' })); + }); + form.button({ translate: 'commands.analyzearea.ui.page.toggleboxes' }, () => analysis.toggleBoxes()); + form.button({ translate: 'commands.analyzearea.ui.page.remove' }, () => { manager.remove(analysis); showSelector(player, manager); }); + form.button({ translate: 'commands.analyzearea.ui.page.back' }, () => showSelector(player, manager)); + form.closeButton(); + + renderPage(); + form.show(); +} + +function pageHeader(analysis) { + if (!analysis.hasRun) + return { translate: 'commands.analyzearea.ui.page.notrun' }; + return { translate: 'commands.analyzearea.ui.page.header', with: [analysis.expression, String(analysis.matches.length)] }; +} + +function teleportTo(player, location) { + if (!location) return; + const mode = player.getGameMode(); + if (mode === GameMode.Creative || mode === GameMode.Spectator) + player.teleport({ x: location.x + 0.5, y: location.y, z: location.z + 0.5 }, { dimension: player.dimension }); + else + player.sendMessage({ translate: 'commands.analyzearea.teleport.gamemode' }); +} + +function 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 null; + return { x, y, z }; +} + +function truncate(text, max) { + return text.length > max ? `${text.slice(0, max - 1)}…` : text; +} 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..7a01d4c8 --- /dev/null +++ b/__tests__/BP/scripts/src/classes/analyzearea/AnalyzeAreaUI.test.js @@ -0,0 +1,14 @@ +import { describe, it, expect } from 'vitest'; +import { showSelector, showCreateForm, showAnalysisPage, LIST_PAGE_SIZE } from '../../../../../../Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI.js'; + +describe('AnalyzeAreaUI', () => { + it('exports the three page builders', () => { + expect(typeof showSelector).toBe('function'); + expect(typeof showCreateForm).toBe('function'); + expect(typeof showAnalysisPage).toBe('function'); + }); + + it('uses a 50-item page size', () => { + expect(LIST_PAGE_SIZE).toBe(50); + }); +}); From 765822375416977f678bb11e86fd4e56270f9483 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 7 Jul 2026 23:19:47 -0700 Subject: [PATCH 089/120] fix: pass DropdownItemData objects to CustomForm.dropdown in AnalyzeAreaUI --- Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI.js b/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI.js index 2cfabb99..68582925 100644 --- a/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI.js +++ b/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI.js @@ -7,6 +7,9 @@ import { stringifyLocation, getColoredDimensionName } from '../../../include/uti export const LIST_PAGE_SIZE = 50; const DIMENSIONS = ['minecraft:overworld', 'minecraft:nether', 'minecraft:the_end']; +// CustomForm.dropdown requires DropdownItemData objects ({ label, value }), not raw strings. +// value === index, so DIMENSIONS[observable.getData()] maps the selection back to a dimension id. +const DIMENSION_ITEMS = DIMENSIONS.map((id, index) => ({ label: id.replace('minecraft:', ''), value: index })); function writable() { return { clientWritable: true }; @@ -47,7 +50,7 @@ export function showCreateForm(player, manager, prefill) { 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, DIMENSIONS); + form.dropdown({ translate: 'commands.analyzearea.ui.create.dimension' }, dimObservable, DIMENSION_ITEMS); form.textField({ translate: 'commands.analyzearea.ui.create.expression' }, expression); form.button({ translate: 'commands.analyzearea.ui.create.submit' }, () => { From b81dc28aa0415dfffc107e866145ce9a0d4c7eaf Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 7 Jul 2026 23:29:18 -0700 Subject: [PATCH 090/120] feat: add /analyzearea command with DDUI and world persistence --- Canopy[BP]/scripts/main.js | 1 + .../scripts/src/commands/analyzearea.js | 110 ++++++++++++++++++ Canopy[RP]/texts/en_US.lang | 34 +++++- .../scripts/src/commands/analyzearea.test.js | 71 +++++++++++ 4 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 Canopy[BP]/scripts/src/commands/analyzearea.js create mode 100644 __tests__/BP/scripts/src/commands/analyzearea.test.js diff --git a/Canopy[BP]/scripts/main.js b/Canopy[BP]/scripts/main.js index 38153658..7e9090d7 100644 --- a/Canopy[BP]/scripts/main.js +++ b/Canopy[BP]/scripts/main.js @@ -8,6 +8,7 @@ import './src/commands/warp' import './src/commands/gamemode' import './src/commands/camera' import './src/commands/canopy' +import './src/commands/analyzearea' import './src/commands/distance' import './src/commands/log' import './src/commands/entitydensity' diff --git a/Canopy[BP]/scripts/src/commands/analyzearea.js b/Canopy[BP]/scripts/src/commands/analyzearea.js new file mode 100644 index 00000000..8208b0b8 --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/analyzearea.js @@ -0,0 +1,110 @@ +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 { ExpressionEvaluator } from "../classes/analyzearea/ExpressionEvaluator"; +import { regionCapacity, normalizeCorners } from "../classes/analyzearea/regionMath"; +import { showSelector, showCreateForm, showAnalysisPage } from "../classes/analyzearea/AnalyzeAreaUI"; + +const SCAN_CAP = 32767 * 4; +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). ' + + 'Run with no arguments to open the analyses menu. ` ` opens the matching saved analysis ' + + '(or a prefilled create form). ` ` creates and runs an analysis directly; ' + + 'use the expression `remove` to delete the analysis with those coordinates.', + subCommandWikiDescription: { + '': { description: 'Open the area-analyses menu.', params: [] }, + ' ': { + description: 'Open the saved analysis for those coordinates, or a prefilled create form.', + params: ['from', 'to'] + }, + ' ': { + description: 'Create and run an analysis; the reserved expression `remove` deletes the matching analysis.', + params: ['from', 'to', 'expression'] + } + } + }); + } + + analyzeAreaCommand(origin, from, to, expression) { + const manager = AreaAnalysisManager.getInstance(); + + // 3-arg form works for all origins. + if (from && to && expression !== undefined) { + if (expression === REMOVE_TOKEN) + return this.#removeAnalysis(origin, manager, from, to); + return this.#createAndRun(origin, manager, from, to, expression); + } + + // UI-only forms require a player. + if (origin.getType() !== 'Player') + return { status: CustomCommandStatus.Failure, message: 'commands.analyzearea.playeronly' }; + const player = origin.getSource(); + + if (from && to) { + const existing = manager.findByCoords(from, to, player.dimension.id); + system.run(() => { + if (existing) showAnalysisPage(player, manager, existing); + else showCreateForm(player, manager, { from, to }); + }); + return { status: CustomCommandStatus.Success }; + } + + system.run(() => showSelector(player, manager)); + 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' }; + manager.remove(existing); + return { status: CustomCommandStatus.Success, message: 'commands.analyzearea.removed' }; + } + + #createAndRun(origin, manager, from, to, expression) { + const source = origin.getSource(); + const dimensionId = source.dimension.id; + const { min, max } = normalizeCorners(from, to); + if (regionCapacity(min, max) > SCAN_CAP) + return { status: CustomCommandStatus.Failure, message: 'commands.analyzearea.overcapacity' }; + + let analysis; + try { + // Validate expression syntax before persisting (throws on parse error). + void new ExpressionEvaluator(expression); + analysis = Analysis.create(from, to, dimensionId, expression); + } catch { + return { status: CustomCommandStatus.Failure, message: 'commands.analyzearea.syntaxerror' }; + } + + const isPlayer = origin.getType() === 'Player'; + system.run(() => { + manager.add(analysis); + analysis.run(source.dimension) + .then(() => { + if (isPlayer) showAnalysisPage(source, manager, analysis); + else source.sendMessage({ translate: 'commands.analyzearea.completed', with: [String(analysis.matches.length)] }); + }) + .catch(() => source.sendMessage({ translate: 'commands.analyzearea.loadcapacity' })); + }); + return { status: CustomCommandStatus.Success }; + } +} + +export const analyzeAreaCommand = new AnalyzeAreaCommand(); diff --git a/Canopy[RP]/texts/en_US.lang b/Canopy[RP]/texts/en_US.lang index 57765318..10e8b71f 100644 --- a/Canopy[RP]/texts/en_US.lang +++ b/Canopy[RP]/texts/en_US.lang @@ -515,4 +515,36 @@ rules.infoDisplay.worldDay.display=Day: %s 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 +simplayer.swapheld.error=§cError while swapping items: %s +commands.analyzearea=Analyze a region of blocks with a JavaScript expression. +commands.analyzearea.playeronly=§cThis form of /analyzearea must be run by a player. +commands.analyzearea.overcapacity=§cToo many blocks in the specified area. +commands.analyzearea.loadcapacity=§cCould not load the region (ticking-area capacity exceeded). +commands.analyzearea.syntaxerror=§cThe expression could not be parsed. +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.ui.selector.title=§2Area Analyses +commands.analyzearea.ui.selector.new=§a+ 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.header=§7Expression: §f%1§r §7(§a%2§7 matches) +commands.analyzearea.ui.page.notrun=§7Not run this session — press Re-analyze. +commands.analyzearea.ui.page.prev=§7◀ Prev +commands.analyzearea.ui.page.next=§7Next ▶ +commands.analyzearea.ui.page.reanalyze=§bRe-analyze +commands.analyzearea.ui.page.toggleboxes=§eToggle boxes +commands.analyzearea.ui.page.remove=§cRemove +commands.analyzearea.ui.page.back=§7Back \ No newline at end of file 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..94ecd25d --- /dev/null +++ b/__tests__/BP/scripts/src/commands/analyzearea.test.js @@ -0,0 +1,71 @@ +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 showSelector = vi.fn(); +const showCreateForm = vi.fn(); +const showAnalysisPage = vi.fn(); +vi.mock('../../../../../Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI', () => ({ + showSelector: (...a) => showSelector(...a), + showCreateForm: (...a) => showCreateForm(...a), + showAnalysisPage: (...a) => showAnalysisPage(...a), + LIST_PAGE_SIZE: 50 +})); + +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(showSelector).toHaveBeenCalled(); + }); + + 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(showAnalysisPage).toHaveBeenCalledWith(player, managerApi, 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(player, managerApi, { 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'); + 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.analyzearea.playeronly' }); + }); +}); From 61b4e3b73f80aa5c6584495df86ec434f876136c Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 7 Jul 2026 23:31:42 -0700 Subject: [PATCH 091/120] fix: guard headless feedback for origins without sendMessage in analyzearea --- Canopy[BP]/scripts/src/commands/analyzearea.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Canopy[BP]/scripts/src/commands/analyzearea.js b/Canopy[BP]/scripts/src/commands/analyzearea.js index 8208b0b8..e0a8c845 100644 --- a/Canopy[BP]/scripts/src/commands/analyzearea.js +++ b/Canopy[BP]/scripts/src/commands/analyzearea.js @@ -94,14 +94,19 @@ export class AnalyzeAreaCommand extends VanillaCommand { } const isPlayer = origin.getType() === 'Player'; + // Non-player origins (e.g. command blocks) resolve to a source without sendMessage; guard it. + const notify = (message) => { + if (typeof source.sendMessage === 'function') + source.sendMessage(message); + }; system.run(() => { manager.add(analysis); analysis.run(source.dimension) .then(() => { if (isPlayer) showAnalysisPage(source, manager, analysis); - else source.sendMessage({ translate: 'commands.analyzearea.completed', with: [String(analysis.matches.length)] }); + else notify({ translate: 'commands.analyzearea.completed', with: [String(analysis.matches.length)] }); }) - .catch(() => source.sendMessage({ translate: 'commands.analyzearea.loadcapacity' })); + .catch(() => notify({ translate: 'commands.analyzearea.loadcapacity' })); }); return { status: CustomCommandStatus.Success }; } From c04a399ee15bbf6912c9cfae0a7521dedf6cc69f Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 7 Jul 2026 23:43:03 -0700 Subject: [PATCH 092/120] fix: scan stored dimension, defer remove teardown, surface match cap in analyzearea --- .../scripts/src/classes/analyzearea/Analysis.js | 6 ++++-- .../src/classes/analyzearea/AnalyzeAreaUI.js | 15 ++++++++------- Canopy[BP]/scripts/src/commands/analyzearea.js | 4 ++-- Canopy[RP]/texts/en_US.lang | 1 + .../BP/scripts/src/commands/analyzearea.test.js | 1 + 5 files changed, 16 insertions(+), 11 deletions(-) diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js b/Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js index e6a16df6..034aac6c 100644 --- a/Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js +++ b/Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js @@ -1,4 +1,4 @@ -import { system } from '@minecraft/server'; +import { system, world } from '@minecraft/server'; import { normalizeCorners, regionCapacity, sameCorner } from './regionMath.js'; import { ExpressionEvaluator } from './ExpressionEvaluator.js'; import { AreaAnalyzer } from './AreaAnalyzer.js'; @@ -19,6 +19,7 @@ export class Analysis { this.renderer = null; this.boxesVisible = false; this.hasRun = false; + this.capped = false; this.jobId = undefined; this.loader = null; } @@ -65,7 +66,8 @@ export class Analysis { } // Runs inside system.run (unrestricted). Returns a promise resolving when the scan finishes. - run(dimension) { + run() { + const dimension = world.getDimension(this.dimensionId); this.dimension = dimension; this.#cancelJob(); if (this.loader) { diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI.js b/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI.js index 68582925..2e429300 100644 --- a/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI.js +++ b/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI.js @@ -1,5 +1,5 @@ import { CustomForm, ObservableString, ObservableNumber, ObservableBoolean } from '@minecraft/server-ui'; -import { GameMode } from '@minecraft/server'; +import { GameMode, world } from '@minecraft/server'; import { Analysis } from './Analysis.js'; import { ExpressionEvaluator } from './ExpressionEvaluator.js'; import { stringifyLocation, getColoredDimensionName } from '../../../include/utils'; @@ -69,7 +69,7 @@ export function showCreateForm(player, manager, prefill) { } const analysis = Analysis.create(parsedFrom, parsedTo, DIMENSIONS[dimObservable.getData()], expr); manager.add(analysis); - analysis.run(player.dimension) + analysis.run() .then(() => showAnalysisPage(player, manager, analysis)) .catch(() => player.sendMessage({ translate: 'commands.analyzearea.loadcapacity' })); }); @@ -86,7 +86,7 @@ export function showAnalysisPage(player, manager, analysis) { const label = new ObservableString(''); const visible = new ObservableBoolean(false); slots.push({ label, visible, location: null }); - form.button(label, () => teleportTo(player, slots[i].location), { visible }); + form.button(label, () => teleportTo(player, slots[i].location, analysis.dimensionId), { visible }); } const pageIndicator = new ObservableString(''); @@ -108,7 +108,7 @@ export function showAnalysisPage(player, manager, analysis) { form.button({ translate: 'commands.analyzearea.ui.page.next' }, () => { if (page < totalPages() - 1) { page++; renderPage(); } }); form.divider(); form.button({ translate: 'commands.analyzearea.ui.page.reanalyze' }, () => { - analysis.run(player.dimension) + analysis.run() .then(() => { page = 0; renderPage(); }) .catch(() => player.sendMessage({ translate: 'commands.analyzearea.loadcapacity' })); }); @@ -124,14 +124,15 @@ export function showAnalysisPage(player, manager, analysis) { function pageHeader(analysis) { if (!analysis.hasRun) return { translate: 'commands.analyzearea.ui.page.notrun' }; - return { translate: 'commands.analyzearea.ui.page.header', with: [analysis.expression, String(analysis.matches.length)] }; + const key = analysis.capped ? 'commands.analyzearea.ui.page.headercapped' : 'commands.analyzearea.ui.page.header'; + return { translate: key, with: [analysis.expression, String(analysis.matches.length)] }; } -function teleportTo(player, location) { +function teleportTo(player, location, dimensionId) { if (!location) return; const mode = player.getGameMode(); if (mode === GameMode.Creative || mode === GameMode.Spectator) - player.teleport({ x: location.x + 0.5, y: location.y, z: location.z + 0.5 }, { dimension: player.dimension }); + player.teleport({ x: location.x + 0.5, y: location.y, z: location.z + 0.5 }, { dimension: world.getDimension(dimensionId) }); else player.sendMessage({ translate: 'commands.analyzearea.teleport.gamemode' }); } diff --git a/Canopy[BP]/scripts/src/commands/analyzearea.js b/Canopy[BP]/scripts/src/commands/analyzearea.js index e0a8c845..ab62a96c 100644 --- a/Canopy[BP]/scripts/src/commands/analyzearea.js +++ b/Canopy[BP]/scripts/src/commands/analyzearea.js @@ -73,7 +73,7 @@ export class AnalyzeAreaCommand extends VanillaCommand { const existing = manager.findByCoords(from, to, dimensionId); if (!existing) return { status: CustomCommandStatus.Failure, message: 'commands.analyzearea.removenotfound' }; - manager.remove(existing); + system.run(() => manager.remove(existing)); return { status: CustomCommandStatus.Success, message: 'commands.analyzearea.removed' }; } @@ -101,7 +101,7 @@ export class AnalyzeAreaCommand extends VanillaCommand { }; system.run(() => { manager.add(analysis); - analysis.run(source.dimension) + analysis.run() .then(() => { if (isPlayer) showAnalysisPage(source, manager, analysis); else notify({ translate: 'commands.analyzearea.completed', with: [String(analysis.matches.length)] }); diff --git a/Canopy[RP]/texts/en_US.lang b/Canopy[RP]/texts/en_US.lang index 10e8b71f..27c99a74 100644 --- a/Canopy[RP]/texts/en_US.lang +++ b/Canopy[RP]/texts/en_US.lang @@ -541,6 +541,7 @@ commands.analyzearea.ui.create.expression=Expression commands.analyzearea.ui.create.submit=Create commands.analyzearea.ui.page.title=§2Area Analysis commands.analyzearea.ui.page.header=§7Expression: §f%1§r §7(§a%2§7 matches) +commands.analyzearea.ui.page.headercapped=§7Expression: §f%1§r §7(§e%2+§7 matches — cap reached) commands.analyzearea.ui.page.notrun=§7Not run this session — press Re-analyze. commands.analyzearea.ui.page.prev=§7◀ Prev commands.analyzearea.ui.page.next=§7Next ▶ diff --git a/__tests__/BP/scripts/src/commands/analyzearea.test.js b/__tests__/BP/scripts/src/commands/analyzearea.test.js index 94ecd25d..fa12b60a 100644 --- a/__tests__/BP/scripts/src/commands/analyzearea.test.js +++ b/__tests__/BP/scripts/src/commands/analyzearea.test.js @@ -60,6 +60,7 @@ describe('analyzeAreaCommand', () => { 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); }); From de91e41dae1384846a4e30796cc4b02621fe8342 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 8 Jul 2026 13:42:28 -0700 Subject: [PATCH 093/120] feat: add generate_readonly_methods regolith filter --- .../classes/analyzearea/readOnlyMethods.js | 189 ++++++++++++++++++ config.json | 5 + filters/generate_readonly_methods/main.js | 62 ++++++ 3 files changed, 256 insertions(+) create mode 100644 Canopy[BP]/scripts/src/classes/analyzearea/readOnlyMethods.js create mode 100644 filters/generate_readonly_methods/main.js 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/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/filters/generate_readonly_methods/main.js b/filters/generate_readonly_methods/main.js new file mode 100644 index 00000000..44de0e91 --- /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', 'dimension']); +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(); From 11e8e789a4cab8276f330ab6e4eff31b9b85fd13 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 8 Jul 2026 13:53:17 -0700 Subject: [PATCH 094/120] feat: tons of tweaks and improvements for /analyzearea --- Canopy[BP]/scripts/lib/jsep/jsep.js | 176 ++++++------- Canopy[BP]/scripts/main.js | 2 +- .../src/classes/analyzearea/Analysis.js | 195 +++++++++++---- .../analyzearea/AnalyzeAreaRenderer.js | 88 +++++-- .../src/classes/analyzearea/AnalyzeAreaUI.js | 231 +++++++++++------- .../analyzearea/AreaAnalysisManager.js | 10 +- .../src/classes/analyzearea/AreaAnalyzer.js | 44 ++-- .../analyzearea/ExpressionEvaluator.js | 63 ++++- .../src/classes/analyzearea/RegionLoader.js | 8 +- .../scripts/src/commands/analyzearea.js | 51 ++-- Canopy[RP]/texts/en_US.lang | 72 +++--- .../src/classes/analyzearea/Analysis.test.js | 42 +++- .../analyzearea/AnalyzeAreaRenderer.test.js | 106 ++++++-- .../classes/analyzearea/AreaAnalyzer.test.js | 8 +- .../analyzearea/ExpressionEvaluator.test.js | 32 ++- .../scripts/src/commands/analyzearea.test.js | 2 +- eslint.config.js | 3 +- 17 files changed, 782 insertions(+), 351 deletions(-) diff --git a/Canopy[BP]/scripts/lib/jsep/jsep.js b/Canopy[BP]/scripts/lib/jsep/jsep.js index ffa8196c..b28ee42d 100644 --- a/Canopy[BP]/scripts/lib/jsep/jsep.js +++ b/Canopy[BP]/scripts/lib/jsep/jsep.js @@ -23,17 +23,17 @@ class Hooks { add(name, callback, first) { if (typeof arguments[0] != 'string') { // Multiple hook callbacks, keyed by name - for (let name in arguments[0]) { + 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) { + if (callback) this[name][first ? 'unshift' : 'push'](callback); - } + }, this); } } @@ -79,9 +79,9 @@ class Plugins { */ register(...plugins) { plugins.forEach((plugin) => { - if (typeof plugin !== 'object' || !plugin.name || !plugin.init) { + if (typeof plugin !== 'object' || !plugin.name || !plugin.init) throw new Error('Invalid JSEP plugin format'); - } + if (this.registered[plugin.name]) { // already registered. Ignore. return; @@ -132,12 +132,12 @@ class 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) { + if (isRightAssociative) Jsep.right_associative.add(op_name); - } - else { + + else Jsep.right_associative.delete(op_name); - } + return Jsep; } @@ -169,9 +169,9 @@ class Jsep { */ static removeUnaryOp(op_name) { delete Jsep.unary_ops[op_name]; - if (op_name.length === Jsep.max_unop_len) { + if (op_name.length === Jsep.max_unop_len) Jsep.max_unop_len = Jsep.getMaxKeyLen(Jsep.unary_ops); - } + return Jsep; } @@ -204,9 +204,9 @@ class Jsep { static removeBinaryOp(op_name) { delete Jsep.binary_ops[op_name]; - if (op_name.length === Jsep.max_binop_len) { + 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; @@ -378,9 +378,9 @@ class Jsep { while (ch === Jsep.SPACE_CODE || ch === Jsep.TAB_CODE || ch === Jsep.LF_CODE - || ch === Jsep.CR_CODE) { + || ch === Jsep.CR_CODE) ch = this.expr.charCodeAt(++this.index); - } + this.runHook('gobble-spaces'); } @@ -408,7 +408,7 @@ class Jsep { * @returns {jsep.Expression[]} */ gobbleExpressions(untilICode) { - let nodes = [], ch_i, node; + const nodes = []; let ch_i; let node; while (this.index < this.expr.length) { ch_i = this.code; @@ -426,9 +426,9 @@ class Jsep { // the expression passed in probably has too much } else if (this.index < this.expr.length) { - if (ch_i === untilICode) { + if (ch_i === untilICode) break; - } + this.throwError('Unexpected "' + this.char + '"'); } } @@ -482,21 +482,21 @@ class Jsep { * @returns {?jsep.BinaryExpression} */ gobbleBinaryExpression() { - let node, biop, prec, stack, biop_info, left, right, i, cur_biop; + 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) { + if (!left) return left; - } + biop = this.gobbleBinaryOp(); // If there wasn't a binary operator, just return the leftmost node - if (!biop) { + if (!biop) return left; - } + // Otherwise, we need to start a stack to properly place the binary operations in their // precedence structure @@ -504,9 +504,9 @@ class Jsep { right = this.gobbleToken(); - if (!right) { + if (!right) this.throwError("Expected expression after " + biop); - } + stack = [left, biop_info, right]; @@ -542,9 +542,9 @@ class Jsep { node = this.gobbleToken(); - if (!node) { + if (!node) this.throwError("Expected expression after " + cur_biop); - } + stack.push(biop_info, node); } @@ -571,13 +571,13 @@ class Jsep { * @returns {boolean|jsep.Expression} */ gobbleToken() { - let ch, to_check, tc_len, node; + let ch; let to_check; let tc_len; let node; this.gobbleSpaces(); node = this.searchHook('gobble-token'); - if (node) { + if (node) return this.runHook('after-token', node); - } + ch = this.code; @@ -607,9 +607,9 @@ class Jsep { )) { this.index += tc_len; const argument = this.gobbleToken(); - if (!argument) { + if (!argument) this.throwError('missing unaryOp argument'); - } + return this.runHook('after-token', { type: Jsep.UNARY_EXP, operator: to_check, @@ -639,9 +639,9 @@ class Jsep { } } - if (!node) { + if (!node) return this.runHook('after-token', false); - } + node = this.gobbleTokenProperty(node); return this.runHook('after-token', node); @@ -662,9 +662,9 @@ class Jsep { 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) { + if (this.expr.charCodeAt(this.index + 1) !== Jsep.PERIOD_CODE) break; - } + optional = true; this.index += 2; this.gobbleSpaces(); @@ -679,14 +679,14 @@ class Jsep { object: node, property: this.gobbleExpression() }; - if (!node.property) { + if (!node.property) this.throwError('Unexpected "' + this.char + '"'); - } + this.gobbleSpaces(); ch = this.code; - if (ch !== Jsep.CBRACK_CODE) { + if (ch !== Jsep.CBRACK_CODE) this.throwError('Unclosed ['); - } + this.index++; } else if (ch === Jsep.OPAREN_CODE) { @@ -698,9 +698,9 @@ class Jsep { }; } else if (ch === Jsep.PERIOD_CODE || optional) { - if (optional) { + if (optional) this.index--; - } + this.gobbleSpaces(); node = { type: Jsep.MEMBER_EXP, @@ -710,9 +710,9 @@ class Jsep { }; } - if (optional) { + if (optional) node.optional = true; - } // else leave undefined for compatibility with esprima + // else leave undefined for compatibility with esprima this.gobbleSpaces(); ch = this.code; @@ -727,18 +727,18 @@ class Jsep { * @returns {jsep.Literal} */ gobbleNumericLiteral() { - let number = '', ch, chCode; + let number = ''; let ch; let chCode; - while (Jsep.isDecimalDigit(this.code)) { + 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)) { + while (Jsep.isDecimalDigit(this.code)) number += this.expr.charAt(this.index++); - } + } ch = this.char; @@ -755,9 +755,9 @@ class Jsep { number += this.expr.charAt(this.index++); } - if (!Jsep.isDecimalDigit(this.expr.charCodeAt(this.index - 1)) ) { + if (!Jsep.isDecimalDigit(this.expr.charCodeAt(this.index - 1)) ) this.throwError('Expected exponent (' + number + this.char + ')'); - } + } chCode = this.code; @@ -815,9 +815,9 @@ class Jsep { } } - if (!closed) { + if (!closed) this.throwError('Unclosed quote after "' + str + '"'); - } + return { type: Jsep.LITERAL, @@ -834,24 +834,24 @@ class Jsep { * @returns {jsep.Identifier} */ gobbleIdentifier() { - let ch = this.code, start = this.index; + let ch = this.code; const start = this.index; - if (Jsep.isIdentifierStart(ch)) { + if (Jsep.isIdentifierStart(ch)) this.index++; - } - else { + + else this.throwError('Unexpected ' + this.char); - } + while (this.index < this.expr.length) { ch = this.code; - if (Jsep.isIdentifierPart(ch)) { + if (Jsep.isIdentifierPart(ch)) this.index++; - } - else { + + else break; - } + } return { type: Jsep.IDENTIFIER, @@ -875,15 +875,15 @@ class Jsep { while (this.index < this.expr.length) { this.gobbleSpaces(); - let ch_i = this.code; + 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){ + if (termination === Jsep.CPAREN_CODE && separator_count && separator_count >= args.length) this.throwError('Unexpected token ' + String.fromCharCode(termination)); - } + break; } @@ -896,9 +896,9 @@ class Jsep { this.throwError('Unexpected token ,'); } else if (termination === Jsep.CBRACK_CODE) { - for (let arg = args.length; arg < separator_count; arg++) { + for (let arg = args.length; arg < separator_count; arg++) args.push(null); - } + } } } @@ -909,17 +909,17 @@ class Jsep { else { const node = this.gobbleExpression(); - if (!node || node.type === Jsep.COMPOUND) { + if (!node || node.type === Jsep.COMPOUND) this.throwError('Expected comma'); - } + args.push(node); } } - if (!closed) { + if (!closed) this.throwError('Expected ' + String.fromCharCode(termination)); - } + return args; } @@ -935,25 +935,25 @@ class Jsep { */ gobbleGroup() { this.index++; - let nodes = this.gobbleExpressions(Jsep.CPAREN_CODE); + const nodes = this.gobbleExpressions(Jsep.CPAREN_CODE); if (this.code === Jsep.CPAREN_CODE) { this.index++; - if (nodes.length === 1) { + if (nodes.length === 1) return nodes[0]; - } - else if (!nodes.length) { + + else if (!nodes.length) return false; - } - else { + + return { type: Jsep.SEQUENCE_EXP, expressions: nodes, }; - } + } - else { + this.throwError('Unclosed ('); - } + } /** @@ -1068,7 +1068,7 @@ jsep.Jsep = Jsep; // allows for const { Jsep } = require('jsep'); const CONDITIONAL_EXP = 'ConditionalExpression'; -var ternary = { +const ternary = { name: 'ternary', init(jsep) { @@ -1079,9 +1079,9 @@ var ternary = { const test = env.node; const consequent = this.gobbleExpression(); - if (!consequent) { + if (!consequent) this.throwError('Expected expression'); - } + this.gobbleSpaces(); @@ -1089,9 +1089,9 @@ var ternary = { this.index++; const alternate = this.gobbleExpression(); - if (!alternate) { + if (!alternate) this.throwError('Expected expression'); - } + env.node = { type: CONDITIONAL_EXP, test, @@ -1103,9 +1103,9 @@ var ternary = { // 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) { + 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; diff --git a/Canopy[BP]/scripts/main.js b/Canopy[BP]/scripts/main.js index 7e9090d7..fba6fb81 100644 --- a/Canopy[BP]/scripts/main.js +++ b/Canopy[BP]/scripts/main.js @@ -8,7 +8,6 @@ import './src/commands/warp' import './src/commands/gamemode' import './src/commands/camera' import './src/commands/canopy' -import './src/commands/analyzearea' import './src/commands/distance' import './src/commands/log' import './src/commands/entitydensity' @@ -35,6 +34,7 @@ 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' diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js b/Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js index 034aac6c..3bca6f6d 100644 --- a/Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js +++ b/Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js @@ -4,6 +4,16 @@ import { ExpressionEvaluator } from './ExpressionEvaluator.js'; import { AreaAnalyzer } from './AreaAnalyzer.js'; import { RegionLoader } from './RegionLoader.js'; import { AnalyzeAreaRenderer } from './AnalyzeAreaRenderer.js'; +import { stringifyLocation } from '../../../include/utils'; + +export const LOAD_CAPACITY_ERROR = 'loadcapacity'; + +export function analysisErrorMessage(error) { + if (error?.message === LOAD_CAPACITY_ERROR) + return { translate: 'commands.analyzearea.loadcapacity' }; + console.warn('[Canopy] AnalyzeArea error:', error, error?.stack); + return { translate: 'commands.analyzearea.unknownerror' }; +} export class Analysis { constructor({ id, from, to, dimensionId, expression, createdAt }) { @@ -16,12 +26,30 @@ export class Analysis { this.createdAt = createdAt; this.matches = []; - this.renderer = null; - this.boxesVisible = false; + this.renderer = void 0; + this.boxesVisible = true; this.hasRun = false; this.capped = false; - this.jobId = undefined; - this.loader = null; + 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) { @@ -59,78 +87,157 @@ export class Analysis { } #cancelJob() { - if (this.jobId !== undefined) { + if (this.jobId !== void 0) { system.clearJob(this.jobId); - this.jobId = undefined; + this.jobId = void 0; } } - // Runs inside system.run (unrestricted). Returns a promise resolving when the scan finishes. - run() { - const dimension = world.getDimension(this.dimensionId); - this.dimension = dimension; - this.#cancelJob(); + #initializeLoader(dimension) { if (this.loader) { this.loader.unload(); - this.loader = null; + this.loader = void 0; } const loader = new RegionLoader(dimension, this.min, this.max, this.tickingId()); if (!loader.hasCapacity()) - return Promise.reject(new Error('loadcapacity')); + return void 0; this.loader = loader; + return loader; + } + + #createDriver(analyzer, loader, onProgress, total, resolve, reject) { + const self = this; + function* driver() { + let error = null; + try { + for (const scan = analyzer.scan(); !scan.next().done;) { + if (onProgress) + onProgress(Math.min(analyzer.scanned / total, 1)); + yield; + } + self.matches = analyzer.matches; + self.capped = analyzer.capped; + self.hasRun = true; + if (onProgress) + onProgress(1); + self.running = false; + self.#finishRender(); + } catch (thrown) { + error = thrown; + } finally { + self.jobId = undefined; + loader.unload(); + if (self.loader === loader) + self.loader = void 0; + } + if (error) + reject(error); + else + resolve(); + } + return driver; + } + run(onProgress) { + const dimension = world.getDimension(this.dimensionId); + this.dimension = dimension; + this.#cancelJob(); + const loader = this.#initializeLoader(dimension); + if (!loader) { + const error = new Error(LOAD_CAPACITY_ERROR); + 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 = null; + 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 self = this; - function* driver() { - let error = null; - try { - yield* analyzer.scan(); - self.matches = analyzer.matches; - self.capped = analyzer.capped; - self.hasRun = true; - self.#refreshRender(); - } catch (thrown) { - error = thrown; - } finally { - self.jobId = undefined; - loader.unload(); - if (self.loader === loader) self.loader = null; - } - if (error) reject(error); - else resolve(); - } + const total = regionCapacity(this.min, this.max); + const done = () => { this.running = false; this.#emit('onDone'); resolve(); }; + const fail = (error) => { this.#fail(error); reject(error); }; + const driver = this.#createDriver(analyzer, loader, progress, total, done, fail); this.jobId = system.runJob(driver()); })); } - #refreshRender() { - const wasVisible = this.boxesVisible; - if (this.renderer) this.renderer.destroy(); - this.renderer = new AnalyzeAreaRenderer(this.dimension, this.matches); - if (wasVisible) this.renderer.show(); - this.boxesVisible = wasVisible; + #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.hide(); - else this.renderer.show(); + 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(); + if (this.renderer) + this.renderer.destroy(); + if (this.loader) + this.loader.unload(); } } diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaRenderer.js b/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaRenderer.js index 3f4854f5..745d671f 100644 --- a/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaRenderer.js +++ b/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaRenderer.js @@ -1,35 +1,91 @@ -import { debugDrawer, DebugBox } from '@minecraft/debug-utilities'; - -const MATCH_COLOR = { red: 0, green: 1, blue: 0, alpha: 1 }; +import { debugDrawer, DebugBox, DebugText } from '@minecraft/debug-utilities'; export class AnalyzeAreaRenderer { - constructor(dimension, locations) { + 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.debugShapes = []; - this.visible = false; + 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; + } } - show() { - if (this.visible) return; + 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 = MATCH_COLOR; - this.debugShapes.push(box); + box.color = { red: 0, green: 1, blue: 0, alpha: 1 }; + this.matchShapes.push(box); debugDrawer.addShape(box); } - this.visible = true; + this.matchesVisible = true; } - hide() { - for (const shape of this.debugShapes) shape.remove(); - this.debugShapes = []; - this.visible = false; + hideMatches() { + for (const shape of this.matchShapes) shape.remove(); + this.matchShapes = []; + this.matchesVisible = false; } destroy() { - this.hide(); + this.hideOutline(); + this.hideMatches(); } } diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI.js b/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI.js index 2e429300..d4e7936e 100644 --- a/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI.js +++ b/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI.js @@ -1,48 +1,74 @@ -import { CustomForm, ObservableString, ObservableNumber, ObservableBoolean } from '@minecraft/server-ui'; -import { GameMode, world } from '@minecraft/server'; -import { Analysis } from './Analysis.js'; +import { CustomForm, ObservableString, ObservableNumber, ObservableBoolean, ObservableUIRawMessage } from '@minecraft/server-ui'; +import { DimensionTypes, GameMode, system, world } from '@minecraft/server'; +import { Analysis, analysisErrorMessage } from './Analysis.js'; import { ExpressionEvaluator } from './ExpressionEvaluator.js'; -import { stringifyLocation, getColoredDimensionName } from '../../../include/utils'; +import { SCAN_CAP } from './AreaAnalyzer.js'; +import { stringifyLocation } from '../../../include/utils'; export const LIST_PAGE_SIZE = 50; -const DIMENSIONS = ['minecraft:overworld', 'minecraft:nether', 'minecraft:the_end']; -// CustomForm.dropdown requires DropdownItemData objects ({ label, value }), not raw strings. -// value === index, so DIMENSIONS[observable.getData()] maps the selection back to a dimension id. -const DIMENSION_ITEMS = DIMENSIONS.map((id, index) => ({ label: id.replace('minecraft:', ''), value: index })); +function 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); + }; +} -function writable() { - return { clientWritable: true }; +function toggleBoxesMessage(analysis) { + return { translate: analysis.boxesVisible ? 'commands.analyzearea.ui.page.disableboxes' : 'commands.analyzearea.ui.page.enableboxes' }; } export function showSelector(player, manager) { const form = new CustomForm(player, { translate: 'commands.analyzearea.ui.selector.title' }); - form.button({ translate: 'commands.analyzearea.ui.selector.new' }, () => showCreateForm(player, manager, null)); const analyses = manager.list(); - if (analyses.length === 0) + 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(() => showCreateForm(player, manager, null)); + }); for (const analysis of analyses) { - const label = `${getColoredDimensionName(analysis.dimensionId.replace('minecraft:', ''))} §7${stringifyLocation(analysis.min, 0)}→${stringifyLocation(analysis.max, 0)}\n§8${truncate(analysis.expression, 40)}`; - form.button(label, () => showAnalysisPage(player, manager, analysis)); + const label = `${stringifyLocation(analysis.min, 0)} -> ${stringifyLocation(analysis.max, 0)} (${analysis.dimensionId})`; + form.button(label, () => { + form.close(); + system.run(() => showAnalysisPage(player, manager, analysis)); + }); } form.show(); } -export function showCreateForm(player, manager, prefill) { +export function showCreateForm(player, manager, prefill, initialError) { const from = prefill?.from ?? player.location; const to = prefill?.to ?? player.location; const form = new CustomForm(player, { translate: 'commands.analyzearea.ui.create.title' }); + const inputs = buildCreateInputs(form, from, to, player.dimension.id); + const showError = addErrorLabel(form); + if (initialError) + showError(initialError); + form.button({ translate: 'commands.analyzearea.ui.create.submit' }, () => submitCreate(player, manager, form, inputs, showError)); + form.closeButton(); + form.show(); +} + +function buildCreateInputs(form, from, to, currentDimensionId) { const fields = { - fromX: new ObservableString(String(Math.floor(from.x)), writable()), - fromY: new ObservableString(String(Math.floor(from.y)), writable()), - fromZ: new ObservableString(String(Math.floor(from.z)), writable()), - toX: new ObservableString(String(Math.floor(to.x)), writable()), - toY: new ObservableString(String(Math.floor(to.y)), writable()), - toZ: new ObservableString(String(Math.floor(to.z)), writable()) + 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 dimIndex = Math.max(0, DIMENSIONS.indexOf(player.dimension.id)); - const dimObservable = new ObservableNumber(dimIndex, writable()); - const expression = new ObservableString('', writable()); + const dimensions = DimensionTypes.getAll().map((dimensionType) => dimensionType.typeId); + const dimObservable = new ObservableNumber(Math.max(0, dimensions.indexOf(currentDimensionId)), { 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); @@ -50,46 +76,96 @@ export function showCreateForm(player, manager, prefill) { 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, DIMENSION_ITEMS); + 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 }; +} - form.button({ translate: 'commands.analyzearea.ui.create.submit' }, () => { - const parsedFrom = parseCorner(fields.fromX, fields.fromY, fields.fromZ); - const parsedTo = parseCorner(fields.toX, fields.toY, fields.toZ); - const expr = expression.getData().trim(); - if (!parsedFrom || !parsedTo || expr.length === 0) { - player.sendMessage({ translate: 'commands.analyzearea.create.invalid' }); - return; - } - try { - void new ExpressionEvaluator(expr); // throws on syntax error - } catch { - player.sendMessage({ translate: 'commands.analyzearea.syntaxerror' }); - return; +function submitCreate(player, manager, form, inputs, showError) { + const parsedFrom = parseCorner(inputs.fields.fromX, inputs.fields.fromY, inputs.fields.fromZ); + const parsedTo = 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; + } + try { + void new ExpressionEvaluator(expr); + } catch { + showError({ translate: 'commands.analyzearea.syntaxerror' }); + return; + } + const analysis = Analysis.create(parsedFrom, parsedTo, inputs.dimensions[inputs.dimObservable.getData()], expr); + if (analysis.capacity() > SCAN_CAP) { + showError({ translate: 'commands.analyzearea.overcapacity' }); + return; + } + manager.add(analysis); + form.close(); + system.run(() => showAnalysisPage(player, manager, analysis, true)); +} + +export function showAnalysisPage(player, manager, analysis, autoRun) { + const form = new CustomForm(player, { translate: 'commands.analyzearea.ui.page.title' }); + const status = new ObservableUIRawMessage(analysis.statusMessage()); + form.label(status); + form.spacer(); + const showError = addErrorLabel(form); + const list = { refresh: () => {} }; + const syncStatus = () => status.setData(analysis.statusMessage()); + const unsubscribe = analysis.subscribe({ + onProgress: syncStatus, + onDone: () => list.refresh(), + onError: (error) => { + syncStatus(); + showError(analysisErrorMessage(error)); } - const analysis = Analysis.create(parsedFrom, parsedTo, DIMENSIONS[dimObservable.getData()], expr); - manager.add(analysis); - analysis.run() - .then(() => showAnalysisPage(player, manager, analysis)) - .catch(() => player.sendMessage({ translate: 'commands.analyzearea.loadcapacity' })); }); + const runAnalysis = () => { + analysis.run().catch(() => {}); + syncStatus(); + }; + form.button({ translate: 'commands.analyzearea.ui.page.reanalyze' }, runAnalysis); + const toggleLabel = new ObservableUIRawMessage(toggleBoxesMessage(analysis)); + form.button(toggleLabel, () => { + analysis.toggleBoxes(); + toggleLabel.setData(toggleBoxesMessage(analysis)); + }); + form.button({ translate: 'commands.analyzearea.ui.page.remove' }, () => { + manager.remove(analysis); + form.close(); + system.run(() => showSelector(player, manager)); + }); + form.button({ translate: 'commands.analyzearea.ui.page.back' }, () => { + form.close(); + system.run(() => showSelector(player, manager)); + }); + form.divider(); + + list.refresh = buildLocationList(form, player, analysis, showError, status); form.closeButton(); - form.show(); -} -export function showAnalysisPage(player, manager, analysis) { - const form = new CustomForm(player, { translate: 'commands.analyzearea.ui.page.title' }); - form.label(pageHeader(analysis)); + list.refresh(); + if (autoRun && !analysis.running) + runAnalysis(); + form.show().then(unsubscribe, unsubscribe); +} +function buildLocationList(form, player, analysis, showError, status) { 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: null }); - form.button(label, () => teleportTo(player, slots[i].location, analysis.dimensionId), { visible }); + form.button(label, () => { + if (!teleportTo(player, slots[i].location, analysis.dimensionId)) + showError({ translate: 'commands.analyzearea.teleport.gamemode' }); + }, { visible }); } 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 = () => { @@ -101,50 +177,41 @@ export function showAnalysisPage(player, manager, analysis) { slots[i].visible.setData(Boolean(match)); } pageIndicator.setData(`${page + 1} / ${totalPages()}`); + pagingVisible.setData(totalPages() > 1); + status.setData(analysis.statusMessage()); }; - form.label(pageIndicator); - form.button({ translate: 'commands.analyzearea.ui.page.prev' }, () => { if (page > 0) { page--; renderPage(); } }); - form.button({ translate: 'commands.analyzearea.ui.page.next' }, () => { if (page < totalPages() - 1) { page++; renderPage(); } }); - form.divider(); - form.button({ translate: 'commands.analyzearea.ui.page.reanalyze' }, () => { - analysis.run() - .then(() => { page = 0; renderPage(); }) - .catch(() => player.sendMessage({ translate: 'commands.analyzearea.loadcapacity' })); - }); - form.button({ translate: 'commands.analyzearea.ui.page.toggleboxes' }, () => analysis.toggleBoxes()); - form.button({ translate: 'commands.analyzearea.ui.page.remove' }, () => { manager.remove(analysis); showSelector(player, manager); }); - form.button({ translate: 'commands.analyzearea.ui.page.back' }, () => showSelector(player, manager)); - form.closeButton(); - - renderPage(); - form.show(); -} - -function pageHeader(analysis) { - if (!analysis.hasRun) - return { translate: 'commands.analyzearea.ui.page.notrun' }; - const key = analysis.capped ? 'commands.analyzearea.ui.page.headercapped' : 'commands.analyzearea.ui.page.header'; - return { translate: key, with: [analysis.expression, String(analysis.matches.length)] }; + 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(); }; } function teleportTo(player, location, dimensionId) { - if (!location) return; + if (!location) + return true; const mode = player.getGameMode(); - if (mode === GameMode.Creative || mode === GameMode.Spectator) - player.teleport({ x: location.x + 0.5, y: location.y, z: location.z + 0.5 }, { dimension: world.getDimension(dimensionId) }); - else - player.sendMessage({ translate: 'commands.analyzearea.teleport.gamemode' }); + if (mode !== GameMode.Creative && mode !== GameMode.Spectator) + return false; + player.teleport({ x: location.x + 0.5, y: location.y, z: location.z + 0.5 }, { dimension: world.getDimension(dimensionId) }); + return true; } function 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 null; + if ([x, y, z].some((n) => !Number.isFinite(n))) + return null; return { x, y, z }; } - -function truncate(text, max) { - return text.length > max ? `${text.slice(0, max - 1)}…` : text; -} diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/AreaAnalysisManager.js b/Canopy[BP]/scripts/src/classes/analyzearea/AreaAnalysisManager.js index 19927158..586fb081 100644 --- a/Canopy[BP]/scripts/src/classes/analyzearea/AreaAnalysisManager.js +++ b/Canopy[BP]/scripts/src/classes/analyzearea/AreaAnalysisManager.js @@ -1,9 +1,8 @@ import { world } from '@minecraft/server'; import { Analysis } from './Analysis.js'; -export const PROPERTY_KEY = 'areaanalyses'; - export class AreaAnalysisManager { + static #DP_KEY = 'areaanalyses'; static #instance; constructor() { @@ -17,8 +16,9 @@ export class AreaAnalysisManager { } #load() { - const raw = world.getDynamicProperty(PROPERTY_KEY); - if (typeof raw !== 'string' || raw.length === 0) return []; + 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 { @@ -27,7 +27,7 @@ export class AreaAnalysisManager { } #save() { - world.setDynamicProperty(PROPERTY_KEY, JSON.stringify(this.analyses.map((a) => a.serialize()))); + world.setDynamicProperty(AreaAnalysisManager.#DP_KEY, JSON.stringify(this.analyses.map((a) => a.serialize()))); } list() { diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/AreaAnalyzer.js b/Canopy[BP]/scripts/src/classes/analyzearea/AreaAnalyzer.js index 5a19305d..ef6dd5f5 100644 --- a/Canopy[BP]/scripts/src/classes/analyzearea/AreaAnalyzer.js +++ b/Canopy[BP]/scripts/src/classes/analyzearea/AreaAnalyzer.js @@ -1,5 +1,6 @@ -export const MATCH_CAP = 1000; -const YIELD_EVERY = 4096; // spread work across ticks under system.runJob +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 } = {}) { @@ -16,25 +17,13 @@ export class AreaAnalyzer { *scan() { let sinceYield = 0; - for (let x = this.min.x; x <= this.max.x; x++) { - for (let y = this.min.y; y <= this.max.y; y++) { + 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 }; - try { - const block = this.dimension.getBlock(loc); - if (block === undefined) { - this.errorCount++; - } else if (this.evaluator.evaluate(block)) { - this.matches.push(loc); - if (this.matches.length >= this.matchCap) { - this.capped = true; - return; - } - } - } catch { - this.errorCount++; - } + if (this.#evaluateLocation(loc)) + return; if (++sinceYield >= YIELD_EVERY) { sinceYield = 0; yield; @@ -44,7 +33,26 @@ export class AreaAnalyzer { } } + #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() { + // eslint-disable-next-line no-unused-vars for (const _ of this.scan()) { /* drain */ } } } diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/ExpressionEvaluator.js b/Canopy[BP]/scripts/src/classes/analyzearea/ExpressionEvaluator.js index 03430e2c..02fec279 100644 --- a/Canopy[BP]/scripts/src/classes/analyzearea/ExpressionEvaluator.js +++ b/Canopy[BP]/scripts/src/classes/analyzearea/ExpressionEvaluator.js @@ -1,9 +1,53 @@ import jsep from '../../../lib/jsep/jsep.js'; +import { readOnlyMethods } from './readOnlyMethods.js'; + +const FORBIDDEN_KEYS = new Set(['constructor', '__proto__', 'prototype', 'dimension']); export class ExpressionEvaluator { constructor(expression) { this.expression = expression; - this.ast = jsep(expression); // throws on syntax error + 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)) + throw new Error(`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 !== null && !readOnlyMethods.has(name)) + throw new Error(`Forbidden method call: ${name}`); + 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 null; } evaluate(block) { @@ -30,20 +74,25 @@ export class ExpressionEvaluator { } } - // Returns { object, value } so CallExpression can bind `this` to `object`. #evalMember(node, block) { const object = this.#evalNode(node.object, block); const key = node.computed ? this.#evalNode(node.property, block) : node.property.name; - return { object, value: object?.[key] }; + if (FORBIDDEN_KEYS.has(key)) + throw new Error(`Forbidden property access: ${key}`); + return { object, key, value: object?.[key] }; } #evalCall(node, block) { if (node.callee.type === 'MemberExpression') { - const { object, value: fn } = this.#evalMember(node.callee, block); + const { object, key, value: fn } = this.#evalMember(node.callee, block); + if (!readOnlyMethods.has(key)) + throw new Error(`Forbidden method call: ${key}`); const args = node.arguments.map((arg) => this.#evalNode(arg, block)); return fn.apply(object, args); } - // bare-identifier call, e.g. getTags() -> block.getTags(), bound to block + const name = node.callee.name; + if (!readOnlyMethods.has(name)) + throw new Error(`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); @@ -68,8 +117,8 @@ export class ExpressionEvaluator { 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; diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/RegionLoader.js b/Canopy[BP]/scripts/src/classes/analyzearea/RegionLoader.js index de6d40ba..9ec87b00 100644 --- a/Canopy[BP]/scripts/src/classes/analyzearea/RegionLoader.js +++ b/Canopy[BP]/scripts/src/classes/analyzearea/RegionLoader.js @@ -8,23 +8,23 @@ export class RegionLoader { this.id = id; } - #options() { + get #options() { return { dimension: this.dimension, from: this.min, to: this.max }; } hasCapacity() { - return world.tickingAreaManager.hasCapacity(this.#options()); + return world.tickingAreaManager.hasCapacity(this.#options); } load() { - return world.tickingAreaManager.createTickingArea(this.id, this.#options()); + return world.tickingAreaManager.createTickingArea(this.id, this.#options); } unload() { try { world.tickingAreaManager.removeTickingArea(this.id); } catch { - // area may already be gone; unload is best-effort + /* pass */ } } } diff --git a/Canopy[BP]/scripts/src/commands/analyzearea.js b/Canopy[BP]/scripts/src/commands/analyzearea.js index ab62a96c..433bc485 100644 --- a/Canopy[BP]/scripts/src/commands/analyzearea.js +++ b/Canopy[BP]/scripts/src/commands/analyzearea.js @@ -1,12 +1,12 @@ 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 { Analysis, analysisErrorMessage } from "../classes/analyzearea/Analysis"; import { ExpressionEvaluator } from "../classes/analyzearea/ExpressionEvaluator"; +import { SCAN_CAP } from "../classes/analyzearea/AreaAnalyzer"; import { regionCapacity, normalizeCorners } from "../classes/analyzearea/regionMath"; import { showSelector, showCreateForm, showAnalysisPage } from "../classes/analyzearea/AnalyzeAreaUI"; -const SCAN_CAP = 32767 * 4; const REMOVE_TOKEN = 'remove'; export class AnalyzeAreaCommand extends VanillaCommand { @@ -27,14 +27,18 @@ export class AnalyzeAreaCommand extends VanillaCommand { '(or a prefilled create form). ` ` creates and runs an analysis directly; ' + 'use the expression `remove` to delete the analysis with those coordinates.', subCommandWikiDescription: { - '': { description: 'Open the area-analyses menu.', params: [] }, + '': { + description: 'Open the area-analyses menu.', + params: [] + }, ' ': { - description: 'Open the saved analysis for those coordinates, or a prefilled create form.', - params: ['from', 'to'] + 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; the reserved expression `remove` deletes the matching analysis.', - params: ['from', 'to', 'expression'] + description: 'Create and run an analysis; the reserved expression `remove` deletes the matching analysis.' } } }); @@ -43,23 +47,23 @@ export class AnalyzeAreaCommand extends VanillaCommand { analyzeAreaCommand(origin, from, to, expression) { const manager = AreaAnalysisManager.getInstance(); - // 3-arg form works for all origins. - if (from && to && expression !== undefined) { + 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); } - // UI-only forms require a player. - if (origin.getType() !== 'Player') - return { status: CustomCommandStatus.Failure, message: 'commands.analyzearea.playeronly' }; + if (!(origin instanceof PlayerCommandOrigin)) + return { status: CustomCommandStatus.Failure, message: 'commands.generic.invalidsource' }; const player = origin.getSource(); if (from && to) { const existing = manager.findByCoords(from, to, player.dimension.id); system.run(() => { - if (existing) showAnalysisPage(player, manager, existing); - else showCreateForm(player, manager, { from, to }); + if (existing) + showAnalysisPage(player, manager, existing); + else + showCreateForm(player, manager, { from, to }); }); return { status: CustomCommandStatus.Success }; } @@ -86,27 +90,22 @@ export class AnalyzeAreaCommand extends VanillaCommand { let analysis; try { - // Validate expression syntax before persisting (throws on parse error). void new ExpressionEvaluator(expression); analysis = Analysis.create(from, to, dimensionId, expression); } catch { return { status: CustomCommandStatus.Failure, message: 'commands.analyzearea.syntaxerror' }; } - const isPlayer = origin.getType() === 'Player'; - // Non-player origins (e.g. command blocks) resolve to a source without sendMessage; guard it. - const notify = (message) => { - if (typeof source.sendMessage === 'function') - source.sendMessage(message); - }; + const isPlayer = origin instanceof PlayerCommandOrigin; system.run(() => { manager.add(analysis); + if (isPlayer) { + showAnalysisPage(source, manager, analysis, true); + return; + } analysis.run() - .then(() => { - if (isPlayer) showAnalysisPage(source, manager, analysis); - else notify({ translate: 'commands.analyzearea.completed', with: [String(analysis.matches.length)] }); - }) - .catch(() => notify({ translate: 'commands.analyzearea.loadcapacity' })); + .then(() => origin.sendMessage({ translate: 'commands.analyzearea.completed', with: [String(analysis.matches.length)] })) + .catch((error) => origin.sendMessage(analysisErrorMessage(error))); }); return { status: CustomCommandStatus.Success }; } diff --git a/Canopy[RP]/texts/en_US.lang b/Canopy[RP]/texts/en_US.lang index 27c99a74..58ca4d46 100644 --- a/Canopy[RP]/texts/en_US.lang +++ b/Canopy[RP]/texts/en_US.lang @@ -27,6 +27,43 @@ 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=Analyze a region of blocks with 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.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. @@ -515,37 +552,4 @@ rules.infoDisplay.worldDay.display=Day: %s 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 -commands.analyzearea=Analyze a region of blocks with a JavaScript expression. -commands.analyzearea.playeronly=§cThis form of /analyzearea must be run by a player. -commands.analyzearea.overcapacity=§cToo many blocks in the specified area. -commands.analyzearea.loadcapacity=§cCould not load the region (ticking-area capacity exceeded). -commands.analyzearea.syntaxerror=§cThe expression could not be parsed. -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.ui.selector.title=§2Area Analyses -commands.analyzearea.ui.selector.new=§a+ 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.header=§7Expression: §f%1§r §7(§a%2§7 matches) -commands.analyzearea.ui.page.headercapped=§7Expression: §f%1§r §7(§e%2+§7 matches — cap reached) -commands.analyzearea.ui.page.notrun=§7Not run this session — press Re-analyze. -commands.analyzearea.ui.page.prev=§7◀ Prev -commands.analyzearea.ui.page.next=§7Next ▶ -commands.analyzearea.ui.page.reanalyze=§bRe-analyze -commands.analyzearea.ui.page.toggleboxes=§eToggle boxes -commands.analyzearea.ui.page.remove=§cRemove -commands.analyzearea.ui.page.back=§7Back \ No newline at end of file +simplayer.swapheld.error=§cError while swapping items: %s \ No newline at end of file diff --git a/__tests__/BP/scripts/src/classes/analyzearea/Analysis.test.js b/__tests__/BP/scripts/src/classes/analyzearea/Analysis.test.js index 919dc105..f00d2433 100644 --- a/__tests__/BP/scripts/src/classes/analyzearea/Analysis.test.js +++ b/__tests__/BP/scripts/src/classes/analyzearea/Analysis.test.js @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { Analysis } from '../../../../../../Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js'; +import { Analysis, analysisErrorMessage, LOAD_CAPACITY_ERROR } from '../../../../../../Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js'; +import { stringifyLocation } from '../../../../../../Canopy[BP]/scripts/include/utils.js'; const identity = { id: 'a1', @@ -50,6 +51,45 @@ describe('Analysis', () => { 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('maps the load-capacity error to its message and anything else to unknown', () => { + expect(analysisErrorMessage(new Error(LOAD_CAPACITY_ERROR))).toEqual({ translate: 'commands.analyzearea.loadcapacity' }); + expect(analysisErrorMessage(new Error('boom'))).toEqual({ translate: 'commands.analyzearea.unknownerror' }); + expect(analysisErrorMessage(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'); diff --git a/__tests__/BP/scripts/src/classes/analyzearea/AnalyzeAreaRenderer.test.js b/__tests__/BP/scripts/src/classes/analyzearea/AnalyzeAreaRenderer.test.js index 0195dc85..7df24630 100644 --- a/__tests__/BP/scripts/src/classes/analyzearea/AnalyzeAreaRenderer.test.js +++ b/__tests__/BP/scripts/src/classes/analyzearea/AnalyzeAreaRenderer.test.js @@ -1,36 +1,104 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { debugDrawer } from '@minecraft/debug-utilities'; +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, locations); + renderer = new AnalyzeAreaRenderer(dimension, min, max, locations, statsText); }); - it('draws one box per location on show', () => { - renderer.show(); - expect(debugDrawer.addShape).toHaveBeenCalledTimes(2); - expect(renderer.visible).toBe(true); + 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('show is idempotent', () => { - renderer.show(); - renderer.show(); - expect(debugDrawer.addShape).toHaveBeenCalledTimes(2); + 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('hide removes all shapes and can be re-shown', () => { - renderer.show(); - const shapes = [...renderer.debugShapes]; - renderer.hide(); - shapes.forEach((s) => expect(s.remove).toHaveBeenCalled()); - expect(renderer.visible).toBe(false); - renderer.show(); - expect(renderer.visible).toBe(true); + 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/AreaAnalyzer.test.js b/__tests__/BP/scripts/src/classes/analyzearea/AreaAnalyzer.test.js index beddb375..3d7fdf59 100644 --- a/__tests__/BP/scripts/src/classes/analyzearea/AreaAnalyzer.test.js +++ b/__tests__/BP/scripts/src/classes/analyzearea/AreaAnalyzer.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { AreaAnalyzer, MATCH_CAP } from '../../../../../../Canopy[BP]/scripts/src/classes/analyzearea/AreaAnalyzer.js'; +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) { @@ -42,6 +42,10 @@ describe('AreaAnalyzer', () => { }); it('exposes the default match cap', () => { - expect(MATCH_CAP).toBe(1000); + 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 index 85068342..da659df3 100644 --- a/__tests__/BP/scripts/src/classes/analyzearea/ExpressionEvaluator.test.js +++ b/__tests__/BP/scripts/src/classes/analyzearea/ExpressionEvaluator.test.js @@ -54,8 +54,36 @@ describe('ExpressionEvaluator', () => { }); it('surfaces runtime errors from the block to the caller', () => { - const evaluator = new ExpressionEvaluator('boom()'); - const block = { boom: () => { throw new Error('restricted'); } }; + 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 any access to dimension', () => { + expect(() => new ExpressionEvaluator('block.dimension')).toThrow(/Forbidden property access: dimension/); + }); + + it('rejects computed access to a forbidden key at runtime', () => { + const evaluator = new ExpressionEvaluator("block['dimen' + 'sion']"); + expect(() => evaluator.evaluate(makeBlock())).toThrow(/Forbidden property access: dimension/); + }); + + 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); + }); + }); }); diff --git a/__tests__/BP/scripts/src/commands/analyzearea.test.js b/__tests__/BP/scripts/src/commands/analyzearea.test.js index fa12b60a..a942ba41 100644 --- a/__tests__/BP/scripts/src/commands/analyzearea.test.js +++ b/__tests__/BP/scripts/src/commands/analyzearea.test.js @@ -67,6 +67,6 @@ describe('analyzeAreaCommand', () => { 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.analyzearea.playeronly' }); + expect(result).toEqual({ status: 'Failure', message: 'commands.generic.invalidsource' }); }); }); 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, From 65f2d9d486eff7c29533f7e56a8767c2305bf49c Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 8 Jul 2026 14:40:17 -0700 Subject: [PATCH 095/120] feat: add wiki description if subcommandwikidescription is defined --- docs/scripts/generate-wiki.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/scripts/generate-wiki.js b/docs/scripts/generate-wiki.js index 42e59ca3..8a1fd13e 100644 --- a/docs/scripts/generate-wiki.js +++ b/docs/scripts/generate-wiki.js @@ -51,6 +51,7 @@ 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 +68,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 +93,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) { From f1dee60c798363673167d20598e1f7bb350f2ccb Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 8 Jul 2026 14:42:44 -0700 Subject: [PATCH 096/120] docs: update wiki entry for /analyzearea --- Canopy[BP]/scripts/src/commands/analyzearea.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/Canopy[BP]/scripts/src/commands/analyzearea.js b/Canopy[BP]/scripts/src/commands/analyzearea.js index 433bc485..524ab625 100644 --- a/Canopy[BP]/scripts/src/commands/analyzearea.js +++ b/Canopy[BP]/scripts/src/commands/analyzearea.js @@ -22,13 +22,18 @@ export class AnalyzeAreaCommand extends VanillaCommand { 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). ' + - 'Run with no arguments to open the analyses menu. ` ` opens the matching saved analysis ' + - '(or a prefilled create form). ` ` creates and runs an analysis directly; ' + - 'use the expression `remove` to delete the analysis with those coordinates.', + 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 ' + + '(does not include the dimension property). 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 area analyses menu.', params: [] }, ' ': { @@ -38,7 +43,7 @@ export class AnalyzeAreaCommand extends VanillaCommand { description: 'Remove the saved analysis for those coordinates.' }, ' ': { - description: 'Create and run an analysis; the reserved expression `remove` deletes the matching analysis.' + description: 'Create and run an analysis.' } } }); From cc17c3129ef53bafa0afd0e52b23bf19834467a6 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 8 Jul 2026 15:31:54 -0700 Subject: [PATCH 097/120] docs: add missing enums to cmds --- Canopy[BP]/scripts/src/commands/analyzearea.js | 3 +-- .../scripts/src/commands/changedimension.js | 1 + Canopy[BP]/scripts/src/commands/debugentity.js | 4 ++-- Canopy[BP]/scripts/src/commands/entitydensity.js | 4 +++- .../scripts/src/commands/lifetimequeryitem.js | 3 ++- docs/scripts/generate-wiki.js | 16 ++-------------- package-lock.json | 8 ++++---- package.json | 2 +- 8 files changed, 16 insertions(+), 25 deletions(-) diff --git a/Canopy[BP]/scripts/src/commands/analyzearea.js b/Canopy[BP]/scripts/src/commands/analyzearea.js index 524ab625..5da13031 100644 --- a/Canopy[BP]/scripts/src/commands/analyzearea.js +++ b/Canopy[BP]/scripts/src/commands/analyzearea.js @@ -33,8 +33,7 @@ export class AnalyzeAreaCommand extends VanillaCommand { + "- Liquid source block: `permutation.getState('liquid_depth') == 0`", subCommandWikiDescription: { '': { - description: 'Open the area analyses menu.', - params: [] + description: 'Open the area analyses menu.' }, ' ': { description: 'Open the saved analysis for those coordinates, or a prefilled create form.' 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/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/lifetimequeryitem.js b/Canopy[BP]/scripts/src/commands/lifetimequeryitem.js index 520c554e..3b2a9349 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,6 +9,7 @@ export class LifetimeQueryItem extends VanillaCommand { super({ name: 'canopy:lifetimequeryitem', description: 'commands.lifetime.query.item', + enums: [{ name: 'canopy:lifetimeQueryActions', values: Object.values(LIFETIME_QUERY_ACTIONS) }], optionalParameters: [ { name: 'itemType', type: CustomCommandParamType.ItemType }, { name: 'canopy:lifetimeQueryActions', type: CustomCommandParamType.Enum }, diff --git a/docs/scripts/generate-wiki.js b/docs/scripts/generate-wiki.js index 8a1fd13e..f85b6bd7 100644 --- a/docs/scripts/generate-wiki.js +++ b/docs/scripts/generate-wiki.js @@ -27,24 +27,12 @@ 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) { diff --git a/package-lock.json b/package-lock.json index d6544985..46851e0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,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", @@ -727,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": "*" diff --git a/package.json b/package.json index 163bc84b..7c73b2a1 100644 --- a/package.json +++ b/package.json @@ -23,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", From 16265f5b48476dbcffdca0531833de3927003a53 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 8 Jul 2026 16:52:24 -0700 Subject: [PATCH 098/120] test: fix lookAtBlock test --- __tests__/BP/scripts/src/classes/simplayer/Understudy.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/__tests__/BP/scripts/src/classes/simplayer/Understudy.test.js b/__tests__/BP/scripts/src/classes/simplayer/Understudy.test.js index adeb6ddf..15db2655 100644 --- a/__tests__/BP/scripts/src/classes/simplayer/Understudy.test.js +++ b/__tests__/BP/scripts/src/classes/simplayer/Understudy.test.js @@ -351,11 +351,11 @@ describe('Understudy', () => { }); describe('look', () => { - it('calls lookAtBlock and stores block as look target', () => { + 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.lookAtBlock).toHaveBeenCalledWith(target); + expect(understudy.simulatedPlayer.lookAt).toHaveBeenCalledWith(target); expect(understudy.lookTarget).toBe(target); }); From 444597acd82ebfb170917ca77db6008a01fac55e Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 8 Jul 2026 16:52:39 -0700 Subject: [PATCH 099/120] refactor: /analyzearea --- .../classes/{analyzearea => }/RegionLoader.js | 0 .../src/classes/analyzearea/Analysis.js | 133 +++--- .../src/classes/analyzearea/AnalyzeAreaUI.js | 392 +++++++++--------- .../src/classes/analyzearea/AreaAnalyzer.js | 4 +- .../analyzearea/ExpressionEvaluator.js | 21 +- .../analyzearea/ExpressionForbiddenError.js | 1 + .../classes/analyzearea/LoadCapacityError.js | 1 + .../src/classes/analyzearea/regionMath.js | 22 - .../scripts/src/commands/analyzearea.js | 38 +- Canopy[RP]/texts/en_US.lang | 3 +- .../{analyzearea => }/RegionLoader.test.js | 2 +- .../src/classes/analyzearea/Analysis.test.js | 44 +- .../classes/analyzearea/AnalyzeAreaUI.test.js | 11 +- .../analyzearea/ExpressionEvaluator.test.js | 8 +- .../classes/analyzearea/regionMath.test.js | 20 - .../scripts/src/commands/analyzearea.test.js | 20 +- filters/generate_readonly_methods/main.js | 2 +- 17 files changed, 378 insertions(+), 344 deletions(-) rename Canopy[BP]/scripts/src/classes/{analyzearea => }/RegionLoader.js (100%) create mode 100644 Canopy[BP]/scripts/src/classes/analyzearea/ExpressionForbiddenError.js create mode 100644 Canopy[BP]/scripts/src/classes/analyzearea/LoadCapacityError.js delete mode 100644 Canopy[BP]/scripts/src/classes/analyzearea/regionMath.js rename __tests__/BP/scripts/src/classes/{analyzearea => }/RegionLoader.test.js (93%) delete mode 100644 __tests__/BP/scripts/src/classes/analyzearea/regionMath.test.js diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/RegionLoader.js b/Canopy[BP]/scripts/src/classes/RegionLoader.js similarity index 100% rename from Canopy[BP]/scripts/src/classes/analyzearea/RegionLoader.js rename to Canopy[BP]/scripts/src/classes/RegionLoader.js diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js b/Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js index 3bca6f6d..342d4904 100644 --- a/Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js +++ b/Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js @@ -1,23 +1,15 @@ import { system, world } from '@minecraft/server'; -import { normalizeCorners, regionCapacity, sameCorner } from './regionMath.js'; import { ExpressionEvaluator } from './ExpressionEvaluator.js'; -import { AreaAnalyzer } from './AreaAnalyzer.js'; -import { RegionLoader } from './RegionLoader.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 const LOAD_CAPACITY_ERROR = 'loadcapacity'; - -export function analysisErrorMessage(error) { - if (error?.message === LOAD_CAPACITY_ERROR) - return { translate: 'commands.analyzearea.loadcapacity' }; - console.warn('[Canopy] AnalyzeArea error:', error, error?.stack); - return { translate: 'commands.analyzearea.unknownerror' }; -} - export class Analysis { constructor({ id, from, to, dimensionId, expression, createdAt }) { - const { min, max } = normalizeCorners(from, to); + const { min, max } = Analysis.#normalizeCorners(from, to); this.id = id; this.min = min; this.max = max; @@ -57,6 +49,28 @@ export class Analysis { 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, @@ -73,13 +87,14 @@ export class Analysis { } matchesCoords(from, to, dimensionId) { - if (dimensionId !== this.dimensionId) return false; - const { min, max } = normalizeCorners(from, to); - return sameCorner(min, this.min) && sameCorner(max, this.max); + 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 regionCapacity(this.min, this.max); + return Analysis.#regionCapacity(this.min, this.max); } tickingId() { @@ -105,37 +120,31 @@ export class Analysis { return loader; } - #createDriver(analyzer, loader, onProgress, total, resolve, reject) { - const self = this; - function* driver() { - let error = null; - try { - for (const scan = analyzer.scan(); !scan.next().done;) { - if (onProgress) - onProgress(Math.min(analyzer.scanned / total, 1)); - yield; - } - self.matches = analyzer.matches; - self.capped = analyzer.capped; - self.hasRun = true; - if (onProgress) - onProgress(1); - self.running = false; - self.#finishRender(); - } catch (thrown) { - error = thrown; - } finally { - self.jobId = undefined; - loader.unload(); - if (self.loader === loader) - self.loader = void 0; + *#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; } - if (error) - reject(error); - else - resolve(); + 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; } - return driver; + if (error) + fail(error); + else + done(); } run(onProgress) { @@ -144,7 +153,7 @@ export class Analysis { this.#cancelJob(); const loader = this.#initializeLoader(dimension); if (!loader) { - const error = new Error(LOAD_CAPACITY_ERROR); + const error = new LoadCapacityError(); this.#fail(error); return Promise.reject(error); } @@ -171,11 +180,10 @@ export class Analysis { return; } const analyzer = new AreaAnalyzer(dimension, this.min, this.max, evaluator); - const total = regionCapacity(this.min, this.max); - const done = () => { this.running = false; this.#emit('onDone'); resolve(); }; + const total = Analysis.#regionCapacity(this.min, this.max); + const done = () => { this.#emit('onDone'); resolve(); }; const fail = (error) => { this.#fail(error); reject(error); }; - const driver = this.#createDriver(analyzer, loader, progress, total, done, fail); - this.jobId = system.runJob(driver()); + this.jobId = system.runJob(this.#runScan(analyzer, loader, total, progress, done, fail)); })); } @@ -240,4 +248,27 @@ export class Analysis { 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/AnalyzeAreaUI.js b/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI.js index d4e7936e..c0f6b748 100644 --- a/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI.js +++ b/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI.js @@ -1,217 +1,229 @@ import { CustomForm, ObservableString, ObservableNumber, ObservableBoolean, ObservableUIRawMessage } from '@minecraft/server-ui'; import { DimensionTypes, GameMode, system, world } from '@minecraft/server'; -import { Analysis, analysisErrorMessage } from './Analysis.js'; -import { ExpressionEvaluator } from './ExpressionEvaluator.js'; -import { SCAN_CAP } from './AreaAnalyzer.js'; +import { Analysis } from './Analysis.js'; import { stringifyLocation } from '../../../include/utils'; export const LIST_PAGE_SIZE = 50; -function 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); - }; -} - -function toggleBoxesMessage(analysis) { - return { translate: analysis.boxesVisible ? 'commands.analyzearea.ui.page.disableboxes' : 'commands.analyzearea.ui.page.enableboxes' }; -} - -export function showSelector(player, manager) { - const form = new CustomForm(player, { translate: 'commands.analyzearea.ui.selector.title' }); - const analyses = manager.list(); - if (analyses.length === 0) { - form.label({ translate: 'commands.analyzearea.ui.selector.empty' }); - form.divider(); +export class AnalyzeAreaUI { + constructor(player, manager) { + this.player = player; + this.manager = manager; } - form.button({ translate: 'commands.analyzearea.ui.selector.new' }, () => { - form.close(); - system.run(() => showCreateForm(player, manager, null)); - }); - for (const analysis of analyses) { - const label = `${stringifyLocation(analysis.min, 0)} -> ${stringifyLocation(analysis.max, 0)} (${analysis.dimensionId})`; - form.button(label, () => { + + 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(() => showAnalysisPage(player, manager, analysis)); + 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(); } - form.show(); -} - -export function showCreateForm(player, manager, prefill, initialError) { - const from = prefill?.from ?? player.location; - const to = prefill?.to ?? player.location; - const form = new CustomForm(player, { translate: 'commands.analyzearea.ui.create.title' }); - const inputs = buildCreateInputs(form, from, to, player.dimension.id); - const showError = addErrorLabel(form); - if (initialError) - showError(initialError); - form.button({ translate: 'commands.analyzearea.ui.create.submit' }, () => submitCreate(player, manager, form, inputs, showError)); - form.closeButton(); - form.show(); -} -function buildCreateInputs(form, from, to, currentDimensionId) { - 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(currentDimensionId)), { 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 }; -} - -function submitCreate(player, manager, form, inputs, showError) { - const parsedFrom = parseCorner(inputs.fields.fromX, inputs.fields.fromY, inputs.fields.fromZ); - const parsedTo = 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; - } - try { - void new ExpressionEvaluator(expr); - } catch { - showError({ translate: 'commands.analyzearea.syntaxerror' }); - return; + 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(); } - const analysis = Analysis.create(parsedFrom, parsedTo, inputs.dimensions[inputs.dimObservable.getData()], expr); - if (analysis.capacity() > SCAN_CAP) { - showError({ translate: 'commands.analyzearea.overcapacity' }); - return; + + #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 }; } - manager.add(analysis); - form.close(); - system.run(() => showAnalysisPage(player, manager, analysis, true)); -} -export function showAnalysisPage(player, manager, analysis, autoRun) { - const form = new CustomForm(player, { translate: 'commands.analyzearea.ui.page.title' }); - const status = new ObservableUIRawMessage(analysis.statusMessage()); - form.label(status); - form.spacer(); - const showError = addErrorLabel(form); - const list = { refresh: () => {} }; - const syncStatus = () => status.setData(analysis.statusMessage()); - const unsubscribe = analysis.subscribe({ - onProgress: syncStatus, - onDone: () => list.refresh(), - onError: (error) => { - syncStatus(); - showError(analysisErrorMessage(error)); + #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 runAnalysis = () => { - analysis.run().catch(() => {}); - syncStatus(); - }; - form.button({ translate: 'commands.analyzearea.ui.page.reanalyze' }, runAnalysis); - const toggleLabel = new ObservableUIRawMessage(toggleBoxesMessage(analysis)); - form.button(toggleLabel, () => { - analysis.toggleBoxes(); - toggleLabel.setData(toggleBoxesMessage(analysis)); - }); - form.button({ translate: 'commands.analyzearea.ui.page.remove' }, () => { - manager.remove(analysis); - form.close(); - system.run(() => showSelector(player, manager)); - }); - form.button({ translate: 'commands.analyzearea.ui.page.back' }, () => { + 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(() => showSelector(player, manager)); - }); - form.divider(); + system.run(() => this.showAnalysisPage(result.analysis, true)); + } - list.refresh = buildLocationList(form, player, analysis, showError, status); - form.closeButton(); + 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(); - if (autoRun && !analysis.running) - runAnalysis(); - form.show().then(unsubscribe, unsubscribe); -} + list.refresh = this.#buildLocationList(form, analysis, showError, status); + form.closeButton(); -function buildLocationList(form, player, analysis, showError, status) { - 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: null }); - form.button(label, () => { - if (!teleportTo(player, slots[i].location, analysis.dimensionId)) - showError({ translate: 'commands.analyzearea.teleport.gamemode' }); - }, { visible }); + list.refresh(); + if (autoRun && !analysis.running) + runAnalysis(); + form.show().then(unsubscribe, unsubscribe); } - 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; + #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 match = analysis.matches[start + i]; - slots[i].location = match ?? null; - slots[i].label.setData(match ? stringifyLocation(match, 0) : ''); - slots[i].visible.setData(Boolean(match)); + 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 }); } - 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(); }; -} + 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(); }; + } -function teleportTo(player, location, dimensionId) { - if (!location) + #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; - const mode = player.getGameMode(); - if (mode !== GameMode.Creative && mode !== GameMode.Spectator) - return false; - player.teleport({ x: location.x + 0.5, y: location.y, z: location.z + 0.5 }, { dimension: world.getDimension(dimensionId) }); - return true; -} + } -function 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 null; - return { x, y, z }; + #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/AreaAnalyzer.js b/Canopy[BP]/scripts/src/classes/analyzearea/AreaAnalyzer.js index ef6dd5f5..8178ad91 100644 --- a/Canopy[BP]/scripts/src/classes/analyzearea/AreaAnalyzer.js +++ b/Canopy[BP]/scripts/src/classes/analyzearea/AreaAnalyzer.js @@ -52,7 +52,7 @@ export class AreaAnalyzer { } runToCompletion() { - // eslint-disable-next-line no-unused-vars - for (const _ of this.scan()) { /* drain */ } + 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 index 02fec279..1a2c2b56 100644 --- a/Canopy[BP]/scripts/src/classes/analyzearea/ExpressionEvaluator.js +++ b/Canopy[BP]/scripts/src/classes/analyzearea/ExpressionEvaluator.js @@ -1,7 +1,8 @@ import jsep from '../../../lib/jsep/jsep.js'; import { readOnlyMethods } from './readOnlyMethods.js'; +import { ExpressionForbiddenError } from './ExpressionForbiddenError.js'; -const FORBIDDEN_KEYS = new Set(['constructor', '__proto__', 'prototype', 'dimension']); +const FORBIDDEN_KEYS = new Set(['constructor', '__proto__', 'prototype']); export class ExpressionEvaluator { constructor(expression) { @@ -16,16 +17,18 @@ export class ExpressionEvaluator { case 'Identifier': return; case 'MemberExpression': - if (!node.computed && FORBIDDEN_KEYS.has(node.property.name)) - throw new Error(`Forbidden property access: ${node.property.name}`); + 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 !== null && !readOnlyMethods.has(name)) - throw new Error(`Forbidden method call: ${name}`); + if (name !== void 0 && !readOnlyMethods.has(name)) + throw new ExpressionForbiddenError(`Forbidden method call: ${name}`); node.arguments.forEach((arg) => this.#assertSafe(arg)); return; } @@ -47,7 +50,7 @@ export class ExpressionEvaluator { return callee.name; if (callee.type === 'MemberExpression' && !callee.computed) return callee.property.name; - return null; + return void 0; } evaluate(block) { @@ -78,7 +81,7 @@ export class ExpressionEvaluator { 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 Error(`Forbidden property access: ${key}`); + throw new ExpressionForbiddenError(`Forbidden property access: ${key}`); return { object, key, value: object?.[key] }; } @@ -86,13 +89,13 @@ export class ExpressionEvaluator { if (node.callee.type === 'MemberExpression') { const { object, key, value: fn } = this.#evalMember(node.callee, block); if (!readOnlyMethods.has(key)) - throw new Error(`Forbidden method call: ${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 (!readOnlyMethods.has(name)) - throw new Error(`Forbidden method call: ${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); 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/regionMath.js b/Canopy[BP]/scripts/src/classes/analyzearea/regionMath.js deleted file mode 100644 index 40e85f70..00000000 --- a/Canopy[BP]/scripts/src/classes/analyzearea/regionMath.js +++ /dev/null @@ -1,22 +0,0 @@ -export function 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)) - } - }; -} - -export function regionCapacity(min, max) { - return (max.x - min.x + 1) * (max.y - min.y + 1) * (max.z - min.z + 1); -} - -export function sameCorner(a, b) { - return a.x === b.x && a.y === b.y && a.z === b.z; -} diff --git a/Canopy[BP]/scripts/src/commands/analyzearea.js b/Canopy[BP]/scripts/src/commands/analyzearea.js index 5da13031..0ff7ea28 100644 --- a/Canopy[BP]/scripts/src/commands/analyzearea.js +++ b/Canopy[BP]/scripts/src/commands/analyzearea.js @@ -1,11 +1,8 @@ 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, analysisErrorMessage } from "../classes/analyzearea/Analysis"; -import { ExpressionEvaluator } from "../classes/analyzearea/ExpressionEvaluator"; -import { SCAN_CAP } from "../classes/analyzearea/AreaAnalyzer"; -import { regionCapacity, normalizeCorners } from "../classes/analyzearea/regionMath"; -import { showSelector, showCreateForm, showAnalysisPage } from "../classes/analyzearea/AnalyzeAreaUI"; +import { Analysis } from "../classes/analyzearea/Analysis"; +import { AnalyzeAreaUI } from "../classes/analyzearea/AnalyzeAreaUI"; const REMOVE_TOKEN = 'remove'; @@ -25,8 +22,8 @@ export class AnalyzeAreaCommand extends VanillaCommand { 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 ' - + '(does not include the dimension property). The `block` source keyword can be included or not.\n\n' + + '[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" @@ -60,19 +57,20 @@ export class AnalyzeAreaCommand extends VanillaCommand { 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) - showAnalysisPage(player, manager, existing); + ui.showAnalysisPage(existing); else - showCreateForm(player, manager, { from, to }); + ui.showCreateForm({ from, to }); }); return { status: CustomCommandStatus.Success }; } - system.run(() => showSelector(player, manager)); + system.run(() => ui.showSelector()); return { status: CustomCommandStatus.Success }; } @@ -87,29 +85,21 @@ export class AnalyzeAreaCommand extends VanillaCommand { #createAndRun(origin, manager, from, to, expression) { const source = origin.getSource(); - const dimensionId = source.dimension.id; - const { min, max } = normalizeCorners(from, to); - if (regionCapacity(min, max) > SCAN_CAP) - return { status: CustomCommandStatus.Failure, message: 'commands.analyzearea.overcapacity' }; - - let analysis; - try { - void new ExpressionEvaluator(expression); - analysis = Analysis.create(from, to, dimensionId, expression); - } catch { - return { status: CustomCommandStatus.Failure, message: 'commands.analyzearea.syntaxerror' }; - } + 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) { - showAnalysisPage(source, manager, analysis, true); + 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(analysisErrorMessage(error))); + .catch((error) => origin.sendMessage(Analysis.errorMessage(error))); }); return { status: CustomCommandStatus.Success }; } diff --git a/Canopy[RP]/texts/en_US.lang b/Canopy[RP]/texts/en_US.lang index 58ca4d46..b0052894 100644 --- a/Canopy[RP]/texts/en_US.lang +++ b/Canopy[RP]/texts/en_US.lang @@ -27,11 +27,12 @@ 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=Analyze a region of blocks with a JavaScript expression. +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. diff --git a/__tests__/BP/scripts/src/classes/analyzearea/RegionLoader.test.js b/__tests__/BP/scripts/src/classes/RegionLoader.test.js similarity index 93% rename from __tests__/BP/scripts/src/classes/analyzearea/RegionLoader.test.js rename to __tests__/BP/scripts/src/classes/RegionLoader.test.js index d2b1073c..1ab8f81f 100644 --- a/__tests__/BP/scripts/src/classes/analyzearea/RegionLoader.test.js +++ b/__tests__/BP/scripts/src/classes/RegionLoader.test.js @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { world } from '@minecraft/server'; -import { RegionLoader } from '../../../../../../Canopy[BP]/scripts/src/classes/analyzearea/RegionLoader.js'; +import { RegionLoader } from '../../../../../Canopy[BP]/scripts/src/classes/RegionLoader.js'; describe('RegionLoader', () => { const dimension = { id: 'minecraft:overworld' }; diff --git a/__tests__/BP/scripts/src/classes/analyzearea/Analysis.test.js b/__tests__/BP/scripts/src/classes/analyzearea/Analysis.test.js index f00d2433..b51f804c 100644 --- a/__tests__/BP/scripts/src/classes/analyzearea/Analysis.test.js +++ b/__tests__/BP/scripts/src/classes/analyzearea/Analysis.test.js @@ -1,5 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { Analysis, analysisErrorMessage, LOAD_CAPACITY_ERROR } from '../../../../../../Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js'; +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 = { @@ -18,6 +20,12 @@ describe('Analysis', () => { 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 }]; @@ -62,10 +70,11 @@ describe('Analysis', () => { expect(analysis.subscribers.size).toBe(0); }); - it('maps the load-capacity error to its message and anything else to unknown', () => { - expect(analysisErrorMessage(new Error(LOAD_CAPACITY_ERROR))).toEqual({ translate: 'commands.analyzearea.loadcapacity' }); - expect(analysisErrorMessage(new Error('boom'))).toEqual({ translate: 'commands.analyzearea.unknownerror' }); - expect(analysisErrorMessage(undefined)).toEqual({ translate: 'commands.analyzearea.unknownerror' }); + 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', () => { @@ -96,4 +105,29 @@ describe('Analysis', () => { 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/AnalyzeAreaUI.test.js b/__tests__/BP/scripts/src/classes/analyzearea/AnalyzeAreaUI.test.js index 7a01d4c8..aa939a6d 100644 --- a/__tests__/BP/scripts/src/classes/analyzearea/AnalyzeAreaUI.test.js +++ b/__tests__/BP/scripts/src/classes/analyzearea/AnalyzeAreaUI.test.js @@ -1,11 +1,12 @@ import { describe, it, expect } from 'vitest'; -import { showSelector, showCreateForm, showAnalysisPage, LIST_PAGE_SIZE } from '../../../../../../Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI.js'; +import { AnalyzeAreaUI, LIST_PAGE_SIZE } from '../../../../../../Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI.js'; describe('AnalyzeAreaUI', () => { - it('exports the three page builders', () => { - expect(typeof showSelector).toBe('function'); - expect(typeof showCreateForm).toBe('function'); - expect(typeof showAnalysisPage).toBe('function'); + 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', () => { diff --git a/__tests__/BP/scripts/src/classes/analyzearea/ExpressionEvaluator.test.js b/__tests__/BP/scripts/src/classes/analyzearea/ExpressionEvaluator.test.js index da659df3..5e21d566 100644 --- a/__tests__/BP/scripts/src/classes/analyzearea/ExpressionEvaluator.test.js +++ b/__tests__/BP/scripts/src/classes/analyzearea/ExpressionEvaluator.test.js @@ -66,13 +66,9 @@ describe('ExpressionEvaluator', () => { expect(() => new ExpressionEvaluator('hasTag.prototype')).toThrow(/Forbidden property access: prototype/); }); - it('rejects any access to dimension', () => { - expect(() => new ExpressionEvaluator('block.dimension')).toThrow(/Forbidden property access: dimension/); - }); - it('rejects computed access to a forbidden key at runtime', () => { - const evaluator = new ExpressionEvaluator("block['dimen' + 'sion']"); - expect(() => evaluator.evaluate(makeBlock())).toThrow(/Forbidden property access: dimension/); + 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', () => { diff --git a/__tests__/BP/scripts/src/classes/analyzearea/regionMath.test.js b/__tests__/BP/scripts/src/classes/analyzearea/regionMath.test.js deleted file mode 100644 index 1371dc85..00000000 --- a/__tests__/BP/scripts/src/classes/analyzearea/regionMath.test.js +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { normalizeCorners, regionCapacity, sameCorner } from '../../../../../../Canopy[BP]/scripts/src/classes/analyzearea/regionMath.js'; - -describe('regionMath', () => { - it('normalizes swapped/negative corners to floored min/max', () => { - const { min, max } = normalizeCorners({ x: 5.9, y: 2, z: -3 }, { x: -1, y: 10.2, z: 4 }); - expect(min).toEqual({ x: -1, y: 2, z: -3 }); - expect(max).toEqual({ x: 5, y: 10, z: 4 }); - }); - - it('computes inclusive capacity', () => { - expect(regionCapacity({ x: 0, y: 0, z: 0 }, { x: 1, y: 1, z: 1 })).toBe(8); - expect(regionCapacity({ x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 0 })).toBe(1); - }); - - it('compares corners exactly', () => { - expect(sameCorner({ x: 1, y: 2, z: 3 }, { x: 1, y: 2, z: 3 })).toBe(true); - expect(sameCorner({ x: 1, y: 2, z: 3 }, { x: 1, y: 2, z: 4 })).toBe(false); - }); -}); diff --git a/__tests__/BP/scripts/src/commands/analyzearea.test.js b/__tests__/BP/scripts/src/commands/analyzearea.test.js index a942ba41..2fcf5d5f 100644 --- a/__tests__/BP/scripts/src/commands/analyzearea.test.js +++ b/__tests__/BP/scripts/src/commands/analyzearea.test.js @@ -3,14 +3,18 @@ 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', () => ({ - showSelector: (...a) => showSelector(...a), - showCreateForm: (...a) => showCreateForm(...a), - showAnalysisPage: (...a) => showAnalysisPage(...a), - LIST_PAGE_SIZE: 50 + 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(() => []) }; @@ -38,7 +42,8 @@ describe('analyzeAreaCommand', () => { analyzeAreaCommand.analyzeAreaCommand(origin); scheduler.advanceTicks(1); world.getDimension('minecraft:overworld'); // noop to keep world imported - expect(showSelector).toHaveBeenCalled(); + expect(uiConstructor).toHaveBeenCalledWith(player, managerApi); + expect(showSelector).toHaveBeenCalledWith(); }); it('opens a matching analysis page for ', () => { @@ -46,14 +51,15 @@ describe('analyzeAreaCommand', () => { managerApi.findByCoords.mockReturnValue(analysis); analyzeAreaCommand.analyzeAreaCommand(origin, { x: 0, y: 0, z: 0 }, { x: 1, y: 1, z: 1 }); scheduler.advanceTicks(1); - expect(showAnalysisPage).toHaveBeenCalledWith(player, managerApi, analysis); + 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(player, managerApi, { from: { x: 0, y: 0, z: 0 }, to: { x: 1, y: 1, z: 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', () => { diff --git a/filters/generate_readonly_methods/main.js b/filters/generate_readonly_methods/main.js index 44de0e91..a1deb9c1 100644 --- a/filters/generate_readonly_methods/main.js +++ b/filters/generate_readonly_methods/main.js @@ -3,7 +3,7 @@ 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', 'dimension']); +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'; From 09be2613c751be7dfe2406230f87e07177da7634 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 8 Jul 2026 17:23:20 -0700 Subject: [PATCH 100/120] fix: ignore secondary registrations of the same enum --- .../scripts/lib/canopy/commands/VanillaCommand.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Canopy[BP]/scripts/lib/canopy/commands/VanillaCommand.js b/Canopy[BP]/scripts/lib/canopy/commands/VanillaCommand.js index a9d19517..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"; @@ -52,7 +52,13 @@ export class VanillaCommand { const values = typeof customEnum.values === 'function' ? customEnum.values() : customEnum.values; - customCommandRegistry.registerEnum(customEnum.name, values); + try { + customCommandRegistry.registerEnum(customEnum.name, values); + } catch (error) { + if (error instanceof CustomCommandError && error.reason === CustomCommandErrorReason.AlreadyRegistered) + continue; + throw error; + } } } } From 1d8c904699bb4705188d03cc4b8caebbb0067c90 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 8 Jul 2026 18:11:19 -0700 Subject: [PATCH 101/120] feat: allow benign javascript methods in /analyzearea --- .../analyzearea/ExpressionEvaluator.js | 42 +++++++++++++++-- .../src/classes/analyzearea/safeJsMethods.js | 47 +++++++++++++++++++ .../analyzearea/ExpressionEvaluator.test.js | 44 ++++++++++++++++- 3 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 Canopy[BP]/scripts/src/classes/analyzearea/safeJsMethods.js diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/ExpressionEvaluator.js b/Canopy[BP]/scripts/src/classes/analyzearea/ExpressionEvaluator.js index 1a2c2b56..6fa4d34d 100644 --- a/Canopy[BP]/scripts/src/classes/analyzearea/ExpressionEvaluator.js +++ b/Canopy[BP]/scripts/src/classes/analyzearea/ExpressionEvaluator.js @@ -1,8 +1,30 @@ 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) { @@ -27,11 +49,16 @@ export class ExpressionEvaluator { case 'CallExpression': { this.#assertSafe(node.callee); const name = this.#staticCalleeName(node.callee); - if (name !== void 0 && !readOnlyMethods.has(name)) + 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; @@ -67,6 +94,8 @@ export class ExpressionEvaluator { 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': @@ -88,19 +117,26 @@ export class ExpressionEvaluator { #evalCall(node, block) { if (node.callee.type === 'MemberExpression') { const { object, key, value: fn } = this.#evalMember(node.callee, block); - if (!readOnlyMethods.has(key)) + 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 (!readOnlyMethods.has(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) { 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/__tests__/BP/scripts/src/classes/analyzearea/ExpressionEvaluator.test.js b/__tests__/BP/scripts/src/classes/analyzearea/ExpressionEvaluator.test.js index 5e21d566..1c1a2b4c 100644 --- a/__tests__/BP/scripts/src/classes/analyzearea/ExpressionEvaluator.test.js +++ b/__tests__/BP/scripts/src/classes/analyzearea/ExpressionEvaluator.test.js @@ -1,4 +1,14 @@ -import { describe, it, expect } from 'vitest'; +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() { @@ -81,5 +91,37 @@ describe('ExpressionEvaluator', () => { 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(); + }); }); }); From 857e653652e9fe5f6d0fb5f2647c1f12c2b0a9fc Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Sat, 11 Jul 2026 18:46:28 -0700 Subject: [PATCH 102/120] fix: guard renderSignalStrength against undefined values --- Canopy[BP]/scripts/src/classes/SignalStrengthRenderer.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 From 6a93fa69559963451556dbfaea44371e4500c15d Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Sat, 11 Jul 2026 19:00:42 -0700 Subject: [PATCH 103/120] fix: creativeOneHitKill not killing non-sulfur cubes --- Canopy[BP]/scripts/src/rules/creativeOneHitKill.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Canopy[BP]/scripts/src/rules/creativeOneHitKill.js b/Canopy[BP]/scripts/src/rules/creativeOneHitKill.js index e89178f3..dc85b8fc 100644 --- a/Canopy[BP]/scripts/src/rules/creativeOneHitKill.js +++ b/Canopy[BP]/scripts/src/rules/creativeOneHitKill.js @@ -34,5 +34,5 @@ world.afterEvents.entityHitEntity.subscribe((event) => { function isSulfurCubeWithBlockInside(entity) { const frictionComponent = entity.getComponent(EntityComponentTypes.FrictionModifier); const ageableComponent = entity.getComponent(EntityComponentTypes.Ageable); - return frictionComponent?.value !== 1 && !ageableComponent; + return entity.typeId === 'minecraft:sulfur_cube' && (frictionComponent?.value !== 1 && !ageableComponent); } \ No newline at end of file From 3ee5ca01676bc7586f6478eed25b88458fbdb46d Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 22 Jul 2026 16:22:22 -0700 Subject: [PATCH 104/120] fix: lang key inconsistancies --- Canopy[BP]/scripts/src/rules/infodisplay/Structures.js | 4 ++-- Canopy[RP]/texts/en_US.lang | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/Structures.js b/Canopy[BP]/scripts/src/rules/infodisplay/Structures.js index 256cee63..e9819089 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Structures.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Structures.js @@ -19,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[RP]/texts/en_US.lang b/Canopy[RP]/texts/en_US.lang index b0052894..fb149158 100644 --- a/Canopy[RP]/texts/en_US.lang +++ b/Canopy[RP]/texts/en_US.lang @@ -499,7 +499,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. @@ -535,9 +535,9 @@ rules.infoDisplay.simulationMap=Shows a map of the loaded chunks around you. The 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. From ff50b57ba605409fc22b8ecfb70ce7e2036ca0e0 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 22 Jul 2026 16:22:46 -0700 Subject: [PATCH 105/120] feat: blockBreakSpeed InfoDisplay rule --- .../src/rules/infodisplay/BlockBreakSpeed.js | 71 +++++++++++++++++++ .../src/rules/infodisplay/InfoDisplay.js | 15 ++-- Canopy[RP]/texts/en_US.lang | 2 + 3 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 Canopy[BP]/scripts/src/rules/infodisplay/BlockBreakSpeed.js 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/InfoDisplay.js b/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplay.js index ece983f7..946462e6 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplay.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplay.js @@ -30,6 +30,7 @@ import { Weather } from './Weather'; import { LiquidTarget } from './LiquidTarget'; import { LiquidStates } from './LiquidStates'; import { HeldItemDurability } from './HeldItemDurability'; +import { BlockBreakSpeed } from './BlockBreakSpeed'; import { RenderSignalStrength } from './RenderSignalStrength'; import { NoFog } from './NoFog'; @@ -68,12 +69,14 @@ class InfoDisplay { [HopperCounterCounts, () => [19]], [SimulationMap, (player) => [player, 20]], [HeldItemDurability, (player) => [player, 21]], - [Target, (player) => [player, 22]], - [SignalStrength, (player) => [player, 22]], - [BlockStates, (player) => [player, 23]], - [PeekInventory, (player) => [player, 24]], - [LiquidTarget, (player) => [player, 25]], - [LiquidStates, (player) => [player, 26]], + [BlockBreakSpeed, (player) => [player, 22]], + [Target, (player) => [player, 23]], + [SignalStrength, (player) => [player, 24]], + [BlockStates, (player) => [player, 25]], + [PeekInventory, (player) => [player, 26]], + [LiquidTarget, (player) => [player, 27]], + [LiquidStates, (player) => [player, 28]], + [RenderSignalStrength, (player) => [player]], [RenderLightLevel, (player) => [player]], [NoFog, (player) => [player]] diff --git a/Canopy[RP]/texts/en_US.lang b/Canopy[RP]/texts/en_US.lang index fb149158..4d2d889c 100644 --- a/Canopy[RP]/texts/en_US.lang +++ b/Canopy[RP]/texts/en_US.lang @@ -507,6 +507,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) From bad22ae5392d84951088e171270c4af505a08bc0 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 22 Jul 2026 16:28:50 -0700 Subject: [PATCH 106/120] fix: make horse info clearer --- Canopy[BP]/scripts/src/classes/debugdisplay/Horse.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Canopy[BP]/scripts/src/classes/debugdisplay/Horse.js b/Canopy[BP]/scripts/src/classes/debugdisplay/Horse.js index 27475ec0..20c0e125 100644 --- a/Canopy[BP]/scripts/src/classes/debugdisplay/Horse.js +++ b/Canopy[BP]/scripts/src/classes/debugdisplay/Horse.js @@ -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 * UNITS_TO_MPS} m/s§7, Max Health: §c${this.healthComponent.effectiveMax}`; } } \ No newline at end of file From d01724d182162ac29e3fda011de13cfa60ca96dd Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 22 Jul 2026 16:55:44 -0700 Subject: [PATCH 107/120] perf: allow cleaning up removed debugdisplay text objects --- .../src/classes/debugdisplay/DebugDisplay.js | 3 +-- .../classes/debugdisplay/DebugDisplayElement.js | 4 ++++ .../scripts/src/classes/debugdisplay/GrowUp.js | 15 +++++++++++++-- 3 files changed, 18 insertions(+), 4 deletions(-) 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); From 80832700b946dba7eb723c3dcc5e7e3bc793aa1e Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 22 Jul 2026 16:57:31 -0700 Subject: [PATCH 108/120] fix: /debugdisplay's horse now displays missing stats was missing skeleton and zombie horses. --- Canopy[BP]/scripts/src/classes/debugdisplay/Horse.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Canopy[BP]/scripts/src/classes/debugdisplay/Horse.js b/Canopy[BP]/scripts/src/classes/debugdisplay/Horse.js index 20c0e125..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, Max 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 From c5abce2b7462a68b6120b7f1a7816bed44a9201b Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Wed, 22 Jul 2026 16:58:00 -0700 Subject: [PATCH 109/120] feat: add horse stats to infodisplay --- .../scripts/src/rules/infodisplay/Horse.js | 40 +++++++++++++++++++ .../src/rules/infodisplay/InfoDisplay.js | 20 +++++----- Canopy[RP]/texts/en_US.lang | 2 + 3 files changed, 53 insertions(+), 9 deletions(-) create mode 100644 Canopy[BP]/scripts/src/rules/infodisplay/Horse.js 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 946462e6..1caf5a77 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplay.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplay.js @@ -31,6 +31,7 @@ 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'; @@ -68,15 +69,16 @@ class InfoDisplay { [EventTrackers, () => [18]], [HopperCounterCounts, () => [19]], [SimulationMap, (player) => [player, 20]], - [HeldItemDurability, (player) => [player, 21]], - [BlockBreakSpeed, (player) => [player, 22]], - [Target, (player) => [player, 23]], - [SignalStrength, (player) => [player, 24]], - [BlockStates, (player) => [player, 25]], - [PeekInventory, (player) => [player, 26]], - [LiquidTarget, (player) => [player, 27]], - [LiquidStates, (player) => [player, 28]], - + [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]] diff --git a/Canopy[RP]/texts/en_US.lang b/Canopy[RP]/texts/en_US.lang index 4d2d889c..5affdfaf 100644 --- a/Canopy[RP]/texts/en_US.lang +++ b/Canopy[RP]/texts/en_US.lang @@ -534,6 +534,8 @@ 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. From 78f76d70632b46e4d057012cc7173579507beaa8 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Sun, 26 Jul 2026 15:51:47 -0700 Subject: [PATCH 110/120] feat: /playerglide --- Canopy[BP]/scripts/main.js | 1 + .../src/classes/simplayer/Understudy.js | 7 +++++ .../src/commands/simplayer/playerglide.js | 31 +++++++++++++++++++ Canopy[RP]/texts/en_US.lang | 2 ++ 4 files changed, 41 insertions(+) create mode 100644 Canopy[BP]/scripts/src/commands/simplayer/playerglide.js diff --git a/Canopy[BP]/scripts/main.js b/Canopy[BP]/scripts/main.js index fba6fb81..2f844b1d 100644 --- a/Canopy[BP]/scripts/main.js +++ b/Canopy[BP]/scripts/main.js @@ -51,6 +51,7 @@ 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' diff --git a/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js b/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js index de79cd12..4a3c3e14 100644 --- a/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js +++ b/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js @@ -207,6 +207,13 @@ class Understudy { 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); 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[RP]/texts/en_US.lang b/Canopy[RP]/texts/en_US.lang index 5affdfaf..1ce5853d 100644 --- a/Canopy[RP]/texts/en_US.lang +++ b/Canopy[RP]/texts/en_US.lang @@ -277,6 +277,8 @@ 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. From 1876ee6ed688891c48b9caa829d083eddeafb0a0 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Sun, 26 Jul 2026 16:32:00 -0700 Subject: [PATCH 111/120] fix: despawn timer start for spawned items added default for uninitialized starttick --- Canopy[BP]/scripts/src/classes/debugdisplay/Item.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; From 1bfd90c1b11141f3e3b3ff424172865190fd57ab Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Sun, 2 Aug 2026 10:50:40 -0700 Subject: [PATCH 112/120] fix: hss calculated off by one in some cases --- Canopy[BP]/scripts/src/classes/HSSFinder.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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); } From df33208c4a97bd784db3020485ea252b93d37a6f Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 4 Aug 2026 13:58:57 -0700 Subject: [PATCH 113/120] feat: bump to 1.6.0 and MC 26.40 --- Canopy[BP]/manifest.json | 12 +++++------ Canopy[BP]/scripts/constants.js | 4 ++-- Canopy[RP]/manifest.json | 12 +++++------ README.md | 2 +- package-lock.json | 38 ++++++++++++++++----------------- package.json | 8 +++---- 6 files changed, 38 insertions(+), 38 deletions(-) diff --git a/Canopy[BP]/manifest.json b/Canopy[BP]/manifest.json index 3424d1b9..461619b1 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": [ @@ -60,8 +60,8 @@ "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[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/README.md b/README.md index abb78e74..530badf3 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) diff --git a/package-lock.json b/package-lock.json index 46851e0c..bddde019 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,10 +7,10 @@ "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-gametest": "^1.0.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", @@ -878,19 +878,19 @@ "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", @@ -898,23 +898,23 @@ } }, "node_modules/@minecraft/server-gametest": { - "version": "1.0.0-beta.1.26.30-stable", - "resolved": "https://registry.npmjs.org/@minecraft/server-gametest/-/server-gametest-1.0.0-beta.1.26.30-stable.tgz", - "integrity": "sha512-DZ85TMUB8Kjzhfb7AGKqxLNCqDSlaVNyVRi19JBTPwXR8PuMzO3P2hg5KOhseOfGInE9puRDf6L5udl+sxW9og==", + "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.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-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 7c73b2a1..e43843b2 100644 --- a/package.json +++ b/package.json @@ -10,10 +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-gametest": "^1.0.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", From ad0e50cfffa836beae8eebcdf64d25f86118fe11 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 4 Aug 2026 13:59:28 -0700 Subject: [PATCH 114/120] feat: bump API version to 2.10.0-beta --- Canopy[BP]/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Canopy[BP]/manifest.json b/Canopy[BP]/manifest.json index 461619b1..5e212a9e 100644 --- a/Canopy[BP]/manifest.json +++ b/Canopy[BP]/manifest.json @@ -42,7 +42,7 @@ "dependencies": [ { "module_name": "@minecraft/server", - "version": "2.9.0-beta" + "version": "2.10.0-beta" }, { "module_name": "@minecraft/server-ui", From 8531fdb5eadaddb25c0ffc27e6cc41ff15fd1567 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 4 Aug 2026 14:02:35 -0700 Subject: [PATCH 115/120] fix: upgrade API contract for fog settings --- Canopy[BP]/scripts/src/rules/infodisplay/NoFog.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/NoFog.js b/Canopy[BP]/scripts/src/rules/infodisplay/NoFog.js index 82073830..f32605a6 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/NoFog.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/NoFog.js @@ -1,4 +1,4 @@ -import { EntityComponentTypes, world } from "@minecraft/server"; +import { world } from "@minecraft/server"; import { InfoDisplayShapeElement } from "./InfoDisplayShapeElement"; export class NoFog extends InfoDisplayShapeElement { @@ -13,7 +13,7 @@ export class NoFog extends InfoDisplayShapeElement { }; static FOG_TAG = "canopy_no_fog"; - playerFogComponent; + playerFogSettings; constructor(player) { const ruleData = { @@ -24,13 +24,13 @@ 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.clearFogSettings(); - this.playerFogComponent.push(this.getCurrentFogId(), NoFog.FOG_TAG); + this.playerFogSettings.push(this.getCurrentFogId(), NoFog.FOG_TAG); world.afterEvents.playerDimensionChange.subscribe(this.onDimensionChangeBound); } @@ -40,7 +40,7 @@ export class NoFog extends InfoDisplayShapeElement { } clearFogSettings() { - this.playerFogComponent.remove(NoFog.FOG_TAG); + this.playerFogSettings.remove(NoFog.FOG_TAG); } getCurrentFogId() { @@ -55,6 +55,6 @@ export class NoFog extends InfoDisplayShapeElement { onDimensionChange() { 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 From c596e5919d99c8d7c4973b83591a0fc6f868800a Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 4 Aug 2026 14:20:18 -0700 Subject: [PATCH 116/120] feat: retire custom PlayerTameEntity event EntityTamedAfterEvent was added to API 2.10.0 --- Canopy[BP]/scripts/include/utils.js | 2 +- .../scripts/src/classes/debugdisplay/Tame.js | 26 +++-- .../src/events/PlayerTameEntityEvent.js | 99 ------------------- 3 files changed, 19 insertions(+), 108 deletions(-) delete mode 100644 Canopy[BP]/scripts/src/events/PlayerTameEntityEvent.js 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/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/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 From 8d86f00be43f152fac028ed83b627b5d01ef66bb Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 4 Aug 2026 14:42:15 -0700 Subject: [PATCH 117/120] feat: add entity pickup item removal cause to lifetime tracking --- .../src/classes/WorldLifetimeTracker.js | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/Canopy[BP]/scripts/src/classes/WorldLifetimeTracker.js b/Canopy[BP]/scripts/src/classes/WorldLifetimeTracker.js index 29371a42..2c2b4dc1 100644 --- a/Canopy[BP]/scripts/src/classes/WorldLifetimeTracker.js +++ b/Canopy[BP]/scripts/src/classes/WorldLifetimeTracker.js @@ -11,6 +11,12 @@ export class WorldLifetimeTracker { dimensionToEntityLifetimeRecordMap = {}; localizationKeys = {}; + onEntitySpawnBound = this.onEntitySpawn.bind(this); + onEntityLoadBound = this.onEntityLoad.bind(this); + onEntityDieBound = this.onEntityDie.bind(this); + onEntityRemoveBound = this.onEntityRemove.bind(this); + onEntityItemPickupBound = this.onEntityItemPickup.bind(this); + constructor() { this.createDimensionRecords(); this.startCollecting(); @@ -104,7 +110,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,17 +124,19 @@ 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.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.entityDie.unsubscribe(this.onEntityDieBound); + world.beforeEvents.entityRemove.unsubscribe(this.onEntityRemoveBound); + world.beforeEvents.entityItemPickup.unsubscribe(this.onEntityItemPickupBound); } onEntitySpawn(event) { @@ -153,12 +161,17 @@ export class WorldLifetimeTracker { this.collectRemoval(event); } + onEntityItemPickup(event) { + const removalEvent = { entity: event.item, cause: `Picked up by ${event.entity?.typeId || "unknown"}` } + this.collectRemoval(removalEvent); + } + collectSpawn(event) { try { this.dimensionToEntityLifetimeRecordMap[event.entity.dimension.id].collectSpawn(event.entity, event.cause); - } catch(error) { + } 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; } From 4abb58d944520bcca4ec527785daa916178cf873 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 4 Aug 2026 15:14:58 -0700 Subject: [PATCH 118/120] feat: add item dropped spawn reason to lifetime tracking --- .../src/classes/EntityLifetimeRecord.js | 8 +++- .../src/classes/EntityLifetimeRecords.js | 44 ++++++++++++++++--- .../scripts/src/classes/ItemLifetimeRecord.js | 4 +- .../src/classes/WorldLifetimeTracker.js | 28 ++++++++++-- .../scripts/src/commands/lifetimequeryitem.js | 4 +- 5 files changed, 74 insertions(+), 14 deletions(-) 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/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/WorldLifetimeTracker.js b/Canopy[BP]/scripts/src/classes/WorldLifetimeTracker.js index 2c2b4dc1..a14568e4 100644 --- a/Canopy[BP]/scripts/src/classes/WorldLifetimeTracker.js +++ b/Canopy[BP]/scripts/src/classes/WorldLifetimeTracker.js @@ -13,6 +13,7 @@ export class WorldLifetimeTracker { 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); @@ -126,6 +127,7 @@ export class WorldLifetimeTracker { subscribeToEvents() { 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) @@ -134,41 +136,61 @@ export class WorldLifetimeTracker { unsubscribeFromEvents() { 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"}` } + 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); + 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 be collected.'); @@ -178,6 +200,6 @@ export class WorldLifetimeTracker { } 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/commands/lifetimequeryitem.js b/Canopy[BP]/scripts/src/commands/lifetimequeryitem.js index 3b2a9349..fdb1b549 100644 --- a/Canopy[BP]/scripts/src/commands/lifetimequeryitem.js +++ b/Canopy[BP]/scripts/src/commands/lifetimequeryitem.js @@ -10,8 +10,10 @@ export class LifetimeQueryItem extends VanillaCommand { name: 'canopy:lifetimequeryitem', description: 'commands.lifetime.query.item', enums: [{ name: 'canopy:lifetimeQueryActions', values: Object.values(LIFETIME_QUERY_ACTIONS) }], - optionalParameters: [ + mandatoryParameters: [ { name: 'itemType', type: CustomCommandParamType.ItemType }, + ], + optionalParameters: [ { name: 'canopy:lifetimeQueryActions', type: CustomCommandParamType.Enum }, { name: 'useRealTime', type: CustomCommandParamType.Boolean } ], From 18a488676e6a386f42933e0f0c3b31010ad83bcc Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 4 Aug 2026 17:47:14 -0700 Subject: [PATCH 119/120] feat: fix noFog being inaccessible on servers --- Canopy[BP]/scripts/src/rules/infodisplay/NoFog.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/NoFog.js b/Canopy[BP]/scripts/src/rules/infodisplay/NoFog.js index f32605a6..3402e114 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/NoFog.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/NoFog.js @@ -40,7 +40,7 @@ export class NoFog extends InfoDisplayShapeElement { } clearFogSettings() { - this.playerFogSettings.remove(NoFog.FOG_TAG); + this.playerFogSettings?.remove(NoFog.FOG_TAG); } getCurrentFogId() { From cc40d2eebece0b44d8d12f462a2ee14841724bc7 Mon Sep 17 00:00:00 2001 From: ForestOfLight Date: Tue, 4 Aug 2026 18:18:11 -0700 Subject: [PATCH 120/120] fix: tests with mismatched translation strings --- .../BP/scripts/src/rules/infodisplay/Structures.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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" }, ] }); }); });