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/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/main.js b/Canopy[BP]/scripts/main.js index 909284c4..38153658 100644 --- a/Canopy[BP]/scripts/main.js +++ b/Canopy[BP]/scripts/main.js @@ -35,6 +35,22 @@ 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/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 +97,10 @@ import './src/rules/entitySeparation' import './src/rules/enderPearlChunkLoading' import './src/rules/renderEndGatewayExits' +// Simulated Player Rules +import './src/rules/simplayer/simplayerSaving' +import './src/rules/simplayer/simplayerRejoining' + // Load Time Processes import './src/onStart' import './src/onReload' diff --git a/Canopy[BP]/scripts/src/classes/errors/UnderstudyConnectedError.js b/Canopy[BP]/scripts/src/classes/errors/UnderstudyConnectedError.js new file mode 100644 index 00000000..1d8d245f --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/errors/UnderstudyConnectedError.js @@ -0,0 +1,6 @@ +export class UnderstudyConnectedError extends Error { + constructor(name) { + super(`[Canopy] Simulated player '${name}' is already connected.`); + this.name = 'UnderstudyConnectedError'; + } +} diff --git a/Canopy[BP]/scripts/src/classes/errors/UnderstudyNotConnectedError.js b/Canopy[BP]/scripts/src/classes/errors/UnderstudyNotConnectedError.js new file mode 100644 index 00000000..2303858a --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/errors/UnderstudyNotConnectedError.js @@ -0,0 +1,6 @@ +export class UnderstudyNotConnectedError extends Error { + constructor(name) { + super(`[Canopy] Simulated player '${name}' is not connected.`); + this.name = 'UnderstudyNotConnectedError'; + } +} diff --git a/Canopy[BP]/scripts/src/classes/errors/UnderstudySaveInfoError.js b/Canopy[BP]/scripts/src/classes/errors/UnderstudySaveInfoError.js new file mode 100644 index 00000000..e3b47536 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/errors/UnderstudySaveInfoError.js @@ -0,0 +1,6 @@ +export class UnderstudySaveInfoError extends Error { + constructor(message) { + super(message); + this.name = 'UnderstudySaveInfoError'; + } +} diff --git a/Canopy[BP]/scripts/src/classes/errors/UnknownRepeatingActionError.js b/Canopy[BP]/scripts/src/classes/errors/UnknownRepeatingActionError.js new file mode 100644 index 00000000..612a4791 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/errors/UnknownRepeatingActionError.js @@ -0,0 +1,6 @@ +export class UnknownRepeatingActionError extends Error { + constructor(name, type) { + super(`[Canopy] Unknown repeating action '${type}' for simulated player '${name}'.`); + this.name = 'UnknownRepeatingActionError'; + } +} diff --git a/Canopy[BP]/scripts/src/classes/simplayer/Actions.js b/Canopy[BP]/scripts/src/classes/simplayer/Actions.js new file mode 100644 index 00000000..eced3559 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/simplayer/Actions.js @@ -0,0 +1,55 @@ +import { system } from "@minecraft/server"; +import { RepeatableAction } from "./RepeatableAction"; + +export class Actions { + #singleActions = []; + #repeatingActions = []; + + constructor(understudy) { + this.understudy = understudy; + } + + onTick() { + for (const singleAction of this.#singleActions) + singleAction.perform(); + this.#singleActions.length = 0; + for (const repeatingAction of this.#repeatingActions) + repeatingAction.onTick(); + } + + once(type, afterTicks = void 0) { + const repeatableAction = new RepeatableAction(this.understudy, type); + if (afterTicks === void 0) + this.#singleActions.push(repeatableAction); + else + system.runTimeout(() => this.#singleActions.push(repeatableAction), afterTicks); + } + + repeat(type, intervalTicks = 0) { + if (this.has(type)) + this.remove(type); + const repeatingAction = new RepeatableAction(this.understudy, type, intervalTicks); + this.#repeatingActions.push(repeatingAction); + } + + get(type) { + return this.#repeatingActions.find(action => action.type === type); + } + + has(type) { + return this.#repeatingActions.some(action => action.type === type); + } + + isEmpty() { + return this.#singleActions.length === 0 && this.#repeatingActions.length === 0; + } + + remove(type) { + this.#repeatingActions = this.#repeatingActions.filter(action => action.type !== type); + } + + clear() { + this.#repeatingActions.length = 0; + this.#singleActions.length = 0; + } +} diff --git a/Canopy[BP]/scripts/src/classes/simplayer/PlayerInfoSaver.js b/Canopy[BP]/scripts/src/classes/simplayer/PlayerInfoSaver.js new file mode 100644 index 00000000..c7f28929 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/simplayer/PlayerInfoSaver.js @@ -0,0 +1,94 @@ +import { world, system, DimensionTypes, TicksPerSecond, EntityComponentTypes } from "@minecraft/server"; +import { UnderstudyInventorySaver } from "./UnderstudyInventorySaver"; +import { simplayerSaving } from "../../rules/simplayer/simplayerSaving"; +import { UnderstudySaveInfoError } from "../errors/UnderstudySaveInfoError"; +import { UnderstudyNotConnectedError } from "../errors/UnderstudyNotConnectedError"; + +export class PlayerInfoSaver { + saveInterval = 600; + #understudy; + #inventory; + + constructor(understudy) { + this.#understudy = understudy; + this.#inventory = new UnderstudyInventorySaver(understudy); + } + + onConnectedTick() { + this.#saveOnInterval(); + } + + #saveOnInterval() { + if (!simplayerSaving.getNativeValue()) + return; + if ((system.currentTick - this.#understudy.createdTick) % this.saveInterval === 0) { + this.save(); + return; + } + if (!this.#understudy.actions.isEmpty()) { + if ((system.currentTick - this.#understudy.createdTick) % (TicksPerSecond * 5) === 0) + this.save(); + else + this.#inventory.saveWithoutNBT(); + } + } + + get() { + if (!simplayerSaving.getNativeValue()) + throw new UnderstudySaveInfoError(`Player ${this.#understudy.name} has no player info saved due to '${simplayerSaving.getID()}' rule being disabled.`); + let playerInfo; + try { + playerInfo = JSON.parse(world.getDynamicProperty(`${this.#understudy.name}:playerinfo`)); + } catch (error) { + if (error.name === 'SyntaxError') + throw new UnderstudySaveInfoError(`Player ${this.#understudy.name} has corrupted player info saved, unable to parse player info.`); + throw error; + } + return playerInfo; + } + + save() { + if (!simplayerSaving.getNativeValue()) + return; + if (!this.#understudy.isConnected()) + throw new UnderstudyNotConnectedError(); + const simulatedPlayer = this.#understudy.simulatedPlayer; + const playerInfo = { + location: simulatedPlayer.location, + rotation: this.#understudy.headRotation, + dimensionId: simulatedPlayer.dimension.id, + gameMode: simulatedPlayer.getGameMode(), + projectileIds: this.#findOwnedProjectileIds() + }; + world.setDynamicProperty(`${this.#understudy.name}:playerinfo`, JSON.stringify(playerInfo)); + this.#inventory.save(); + } + + #findOwnedProjectileIds() { + let projectileIds = []; + for (const dimensionType of DimensionTypes.getAll()) { + const dimension = world.getDimension(dimensionType.typeId); + const projectiles = dimension.getEntities().filter(entity => { + const projectileComponent = entity.getComponent(EntityComponentTypes.Projectile); + return projectileComponent?.owner === this.#understudy.simulatedPlayer; + }); + projectileIds = projectileIds.concat(projectiles.map(projectile => projectile.id)); + } + return projectileIds; + } + + loadInventoryAndProjectileOwnership() { + const playerInfo = this.get(); + this.#claimProjectileIds(playerInfo.projectileIds); + this.#inventory.load(); + } + + #claimProjectileIds(projectileIds) { + projectileIds?.forEach(projectileId => { + const projectile = world.getEntity(projectileId); + const projectileComponent = projectile?.getComponent(EntityComponentTypes.Projectile); + if (projectileComponent) + projectileComponent.owner = this.#understudy.simulatedPlayer; + }); + } +} diff --git a/Canopy[BP]/scripts/src/classes/simplayer/RepeatableAction.js b/Canopy[BP]/scripts/src/classes/simplayer/RepeatableAction.js new file mode 100644 index 00000000..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; + } +} diff --git a/Canopy[BP]/scripts/src/classes/simplayer/Understudies.js b/Canopy[BP]/scripts/src/classes/simplayer/Understudies.js new file mode 100644 index 00000000..3e00b214 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/simplayer/Understudies.js @@ -0,0 +1,128 @@ +import { UnderstudyNotConnectedError } from "../errors/UnderstudyNotConnectedError"; +import Understudy from "./Understudy"; +import { system, world } from "@minecraft/server"; + +class Understudies { + static understudies = []; + static #runner = null; + static #entityDieHandle = null; + static #playerGameModeChangeHandle = null; + + static onConnect() { + if (Understudies.#runner === null) + Understudies.#startProcessing(); + } + + static #startProcessing() { + Understudies.#runner = system.runInterval(() => { + for (const understudy of Understudies.understudies) { + if (understudy.isConnected()) + understudy.onConnectedTick(); + } + }); + Understudies.#entityDieHandle = Understudies.onEntityDie.bind(Understudies); + world.afterEvents.entityDie.subscribe(Understudies.#entityDieHandle); + Understudies.#playerGameModeChangeHandle = Understudies.onPlayerGameModeChange.bind(Understudies); + world.afterEvents.playerGameModeChange.subscribe(Understudies.#playerGameModeChangeHandle); + } + + static #stopProcessing() { + system.clearRun(Understudies.#runner); + Understudies.#runner = null; + world.afterEvents.entityDie.unsubscribe(Understudies.#entityDieHandle); + world.afterEvents.playerGameModeChange.unsubscribe(Understudies.#playerGameModeChangeHandle); + Understudies.#entityDieHandle = null; + Understudies.#playerGameModeChangeHandle = null; + } + + static onEntityDie(event) { + if (event.deadEntity.typeId !== 'minecraft:player') + return; + const understudy = Understudies.get(event.deadEntity?.name); + if (understudy !== void 0) { + understudy.leave(); + Understudies.remove(understudy); + } + } + + static onPlayerGameModeChange(event) { + const understudy = Understudies.get(event.player?.name); + if (understudy !== void 0) + understudy.savePlayerInfo(); + } + + static create(name) { + if (Understudies.isOnline(name)) + throw new Error(`[Canopy] Simulated player with name ${name} already exists.`); + const understudy = new Understudy(name); + Understudies.understudies.push(understudy); + return understudy; + } + + static addNametagPrefix(understudy) { + const prefix = world.getDynamicProperty('nametagPrefix'); + if (prefix) + understudy.simulatedPlayer.nameTag = Understudies.#formatNametagWithPrefix(understudy.name, prefix); + } + + static get(name) { + return Understudies.understudies.find(p => p.name === name); + } + + static remove(understudy) { + try { + understudy.leave(); + } catch (error) { + if (!(error instanceof UnderstudyNotConnectedError)) + throw error; + } + const runner = system.runInterval(() => { + if (!understudy.isConnected()) { + system.clearRun(runner); + const index = Understudies.understudies.indexOf(understudy); + Understudies.understudies.splice(index, 1); + if (Understudies.understudies.length === 0) + Understudies.#stopProcessing(); + } + }); + } + + static removeAll() { + for (const understudy of [...Understudies.understudies]) + Understudies.remove(understudy); + } + + static length() { + return Understudies.understudies.length; + } + + static setNametagPrefix(prefix) { + world.setDynamicProperty('nametagPrefix', prefix); + for (const understudy of Understudies.understudies) + understudy.simulatedPlayer.nameTag = Understudies.#formatNametagWithPrefix(understudy.name, prefix); + } + + static #formatNametagWithPrefix(name, prefix) { + if (prefix === '') + return name; + return `§r[${prefix}§r] ${name}`; + } + + static isOnline(name) { + return Understudies.get(name) !== void 0; + } + + static isUnderstudy(player) { + return Understudies.understudies.some(u => u.isConnected() && u.name === player?.name); + } + + static getNotOnlineMessage(name) { + return { translate: 'simplayer.notonline', with: [name] }; + } + + static getAlreadyOnlineMessage(name) { + return { translate: 'simplayer.alreadyonline', with: [name] }; + } +} + +export default Understudies; diff --git a/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js b/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js new file mode 100644 index 00000000..50566a68 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js @@ -0,0 +1,292 @@ +import { Block, Entity, Player, world, system, GameMode, EntityComponentTypes } from "@minecraft/server"; +import { spawnSimulatedPlayer } from "@minecraft/server-gametest"; +import { getLookAtLocation, getLookAtRotation, portOldGameModeToNewUpdate } from "./utils"; +import { Vector } from "../../../lib/Vector"; +import { PlayerInfoSaver } from "./PlayerInfoSaver"; +import { Actions } from "./Actions"; +import { UnderstudyNotConnectedError } from "../errors/UnderstudyNotConnectedError"; +import { UnderstudyConnectedError } from "../errors/UnderstudyConnectedError"; +import { UnderstudySaveInfoError } from "../errors/UnderstudySaveInfoError"; +import Understudies from "./Understudies"; + +class Understudy { + name; + #simulatedPlayer = null; + #createdTick; + #isConnected = false; + #lookTarget; + #actions; + #playerInfoSaver; + + constructor(name) { + this.name = name; + this.#createdTick = system.currentTick; + this.#playerInfoSaver = new PlayerInfoSaver(this); + this.#actions = new Actions(this); + } + + isConnected() { + return this.#isConnected; + } + + onConnectedTick() { + this.#playerInfoSaver.onConnectedTick(); + if (!this.#lookTarget?.isValid) + this.clearLookTarget(); + if (this.#simulatedPlayer !== null) + this.refreshHeldItem(); + this.#actions.onTick(); + } + + get createdTick() { + return this.#createdTick; + } + + get simulatedPlayer() { + this.#assertConnected(); + return this.#simulatedPlayer; + } + + get actions() { + this.#assertConnected(); + return this.#actions; + } + + get lookTarget() { + this.#assertConnected(); + return this.#lookTarget; + } + + clearLookTarget() { + this.#assertConnected(); + this.#lookTarget = void 0; + } + + get headRotation() { + this.#assertConnected(); + if (!this.#lookTarget?.isValid) + this.clearLookTarget(); + if (this.#lookTarget === void 0) + return this.#simulatedPlayer.headRotation; + let targetLocation; + if (this.#lookTarget instanceof Entity) { + try { + targetLocation = this.#lookTarget.getHeadLocation(); + } catch { + return this.#simulatedPlayer.headRotation; + } + } else { + targetLocation = this.#lookTarget.location; + } + return getLookAtRotation(this.#simulatedPlayer.location, targetLocation); + } + + savePlayerInfo() { + this.#assertConnected(); + this.#playerInfoSaver.save(); + } + + join({ location, dimension, rotation = { x: 0, y: 0 }, gameMode = GameMode.Survival }) { + this.#assertNotConnected(); + Understudies.onConnect(); + const updatedGameMode = portOldGameModeToNewUpdate(gameMode); + this.#simulatedPlayer = spawnSimulatedPlayer({ ...location, dimension }, this.name, updatedGameMode); + this.#isConnected = true; + const teleportOptions = { + dimension, + facingLocation: getLookAtLocation(location, rotation), + rotation + }; + this.#simulatedPlayer.teleport(location, teleportOptions); + try { + this.#playerInfoSaver.loadInventoryAndProjectileOwnership(); + } catch (error) { + if (error instanceof UnderstudySaveInfoError) + console.warn(`[Canopy] Failed to load player info for ${this.name}:`, error); + else + throw error; + } + } + + leave() { + this.#assertConnected(); + this.savePlayerInfo(); + this.#simulatedPlayer.remove(); + this.#simulatedPlayer = void 0; + this.clearLookTarget(); + this.#isConnected = false; + world.sendMessage({ translate: 'simplayer.leave.broadcast', with: [this.name] }); + } + + rejoin() { + this.#assertNotConnected(); + const playerInfo = this.#playerInfoSaver.get(); + this.join({ + location: playerInfo.location, + rotation: playerInfo.rotation, + dimension: world.getDimension(playerInfo.dimensionId), + gameMode: playerInfo.gameMode + }); + } + + teleport({ location, dimension, rotation = { x: 0, y: 0 } }) { + const teleportOptions = { + dimension, + facingLocation: getLookAtLocation(location, rotation), + rotation + }; + this.simulatedPlayer.teleport(location, teleportOptions); + this.savePlayerInfo(); + } + + look(target) { + if (target instanceof Block) { + this.simulatedPlayer.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({ translate: 'simplayer.claimprojectiles.none', with: [simulatedPlayer.name, String(radius)] }); + world.sendMessage({ translate: 'simplayer.claimprojectiles.success', with: [simulatedPlayer.name, String(numChanged)] }); + this.savePlayerInfo(); + } + + #getProjectileComponentsInRange(player, radius) { + const projectileComponents = []; + const radiusEntities = player.dimension.getEntities({ location: player.location, maxDistance: radius }); + for (const entity of radiusEntities) { + const projectileComponent = entity?.getComponent(EntityComponentTypes.Projectile); + if (projectileComponent) + projectileComponents.push(projectileComponent); + } + return projectileComponents; + } + + #changeProjectileOwner(projectileComponents, newOwner) { + const successfullyChanged = []; + for (const projectileComponent of projectileComponents) { + if (!projectileComponent?.isValid) + continue; + projectileComponent.owner = newOwner; + successfullyChanged.push(projectileComponent); + } + return successfullyChanged.length; + } + + stopAll() { + this.actions.clear(); + this.stopMoving(); + this.#simulatedPlayer.stopBuild(); + this.#simulatedPlayer.stopInteracting(); + this.#simulatedPlayer.stopBreakingBlock(); + this.#simulatedPlayer.stopUsingItem(); + this.#simulatedPlayer.stopSwimming(); + this.#simulatedPlayer.stopGliding(); + this.#simulatedPlayer.stopUsingItem(); + this.sprint(false); + this.sneak(false); + this.clearLookTarget(); + this.savePlayerInfo(); + } + + getInventory() { + const simulatedPlayer = this.simulatedPlayer; + const inventoryComponent = simulatedPlayer.getComponent(EntityComponentTypes.Inventory); + return inventoryComponent?.container; + } + + swapHeldItemWithPlayer(targetPlayer) { + const playerInvContainer = this.getInventory(); + const targetInvContainer = targetPlayer.getComponent(EntityComponentTypes.Inventory)?.container; + try { + playerInvContainer.swapItems(this.#simulatedPlayer.selectedSlotIndex, targetPlayer.selectedSlotIndex, targetInvContainer); + } catch (error) { + targetPlayer.sendMessage({ translate: 'simplayer.swapheld.error', with: [error.name] }); + console.warn(error); + } + this.refreshHeldItem(); + this.savePlayerInfo(); + } + + refreshHeldItem() { + this.#simulatedPlayer.selectedSlotIndex = this.simulatedPlayer.selectedSlotIndex; + } + + #assertConnected() { + if (!this.isConnected()) + throw new UnderstudyNotConnectedError(this.name); + } + + #assertNotConnected() { + if (this.isConnected()) + throw new UnderstudyConnectedError(this.name); + } +} + +export default Understudy; diff --git a/Canopy[BP]/scripts/src/classes/simplayer/UnderstudyInventorySaver.js b/Canopy[BP]/scripts/src/classes/simplayer/UnderstudyInventorySaver.js new file mode 100644 index 00000000..ea1e5916 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/simplayer/UnderstudyInventorySaver.js @@ -0,0 +1,118 @@ +import { EntityComponentTypes, EquipmentSlot, world } from "@minecraft/server"; +import SRCItemDatabase from "../../../lib/SRCItemDatabase/ItemDatabase.js"; + +export class UnderstudyInventorySaver { + constructor(understudy) { + this.understudy = understudy; + const tableName = `bot_${understudy.name.substr(0, 8)}`; + this.itemDatabase = new SRCItemDatabase(tableName); + this.inventoryDP = `${tableName}_inventory`; + this.equippableDP = `${tableName}_equippable`; + this.inventoryDBKey = 'inv'; + this.equippableDBKey = 'equ'; + } + + save() { + this.#saveInventoryItems({ saveNBT: true }); + this.#saveEquippableItems({ saveNBT: true }); + } + + saveWithoutNBT() { + this.#saveInventoryItems({ saveNBT: false }); + this.#saveEquippableItems({ saveNBT: false }); + } + + load() { + this.#loadInventoryItems(); + this.#loadEquippableItems(); + } + + #saveInventoryItems({ saveNBT = true } = {}) { + const inventoryItems = {}; + const inventoryContainer = this.understudy.getInventory(); + if (inventoryContainer !== void 0) { + for (let i = 0; i < inventoryContainer.size; i++) { + const itemStack = inventoryContainer.getItem(i); + inventoryItems[i] = itemStack ?? void 0; + } + this.#saveItemsWithoutNBT(this.inventoryDP, inventoryItems); + if (saveNBT) + this.#saveItemsWithNBT(this.inventoryDBKey, inventoryItems); + } + } + + #saveEquippableItems({ saveNBT = true } = {}) { + const equippableItems = {}; + const equippable = this.understudy.simulatedPlayer.getComponent(EntityComponentTypes.Equippable); + if (equippable !== void 0) { + for (const equipmentSlot in EquipmentSlot) { + const itemStack = equippable.getEquipment(equipmentSlot); + if (itemStack !== void 0) + equippableItems[equipmentSlot] = itemStack; + } + this.#saveItemsWithoutNBT(this.equippableDP, equippableItems); + if (saveNBT) + this.#saveItemsWithNBT(this.equippableDBKey, equippableItems); + } + } + + #saveItemsWithoutNBT(dynamicProperty, itemStacks) { + const items = {}; + for (const [key, itemStack] of Object.entries(itemStacks)) { + if (itemStack) + items[key] = { typeId: itemStack.typeId, amount: itemStack.amount }; + } + world.setDynamicProperty(dynamicProperty, JSON.stringify(items)); + } + + #saveItemsWithNBT(DBKey, itemStacks) { + const itemsWithNBT = Object.values(itemStacks).filter(item => item !== void 0); + this.itemDatabase.setItems(DBKey, itemsWithNBT); + } + + #loadInventoryItems() { + const inventoryContainer = this.understudy.getInventory(); + if (inventoryContainer === void 0) + return; + const itemsWithoutNBTStr = world.getDynamicProperty(this.inventoryDP); + if (itemsWithoutNBTStr === '{}' || itemsWithoutNBTStr === void 0) + return; + const itemsWithoutNBT = JSON.parse(itemsWithoutNBTStr); + const itemsWithNBT = this.itemDatabase.getItems(this.inventoryDBKey) ?? []; + for (let i = 0; i < inventoryContainer.size; i++) { + const itemWithoutNBT = itemsWithoutNBT[i]; + let itemStack = void 0; + if (itemWithoutNBT !== void 0) { + const foundIndex = itemsWithNBT.findIndex(item => item?.typeId === itemWithoutNBT?.typeId && item?.amount === itemWithoutNBT?.amount); + if (foundIndex >= 0) { + itemStack = itemsWithNBT[foundIndex]; + itemsWithNBT.splice(foundIndex, 1); + } else if (itemWithoutNBT && typeof itemWithoutNBT.typeId === 'string' && Number.isInteger(itemWithoutNBT.amount) && itemWithoutNBT.amount > 0) { + itemStack = { typeId: itemWithoutNBT.typeId, amount: itemWithoutNBT.amount }; + } + } + inventoryContainer.setItem(i, itemStack); + } + } + + #loadEquippableItems() { + const equippable = this.understudy.simulatedPlayer.getComponent(EntityComponentTypes.Equippable); + if (equippable === void 0) + return; + const itemsWithoutNBTStr = world.getDynamicProperty(this.equippableDP); + if (itemsWithoutNBTStr === '{}' || itemsWithoutNBTStr === void 0) + return; + const itemsWithoutNBT = JSON.parse(itemsWithoutNBTStr); + const itemsWithNBT = this.itemDatabase.getItems(this.equippableDBKey) ?? []; + for (const equipmentSlot in EquipmentSlot) { + const itemWithoutNBT = itemsWithoutNBT[equipmentSlot]; + let itemStack = void 0; + if (itemWithoutNBT !== void 0) { + itemStack = itemsWithNBT.find(item => item?.typeId === itemWithoutNBT?.typeId && item?.amount === itemWithoutNBT?.amount); + if (itemStack === void 0 && itemWithoutNBT && typeof itemWithoutNBT.typeId === 'string' && Number.isInteger(itemWithoutNBT.amount) && itemWithoutNBT.amount > 0) + itemStack = { typeId: itemWithoutNBT.typeId, amount: itemWithoutNBT.amount }; + } + equippable.setEquipment(equipmentSlot, itemStack); + } + } +} diff --git a/Canopy[BP]/scripts/src/classes/simplayer/utils.js b/Canopy[BP]/scripts/src/classes/simplayer/utils.js new file mode 100644 index 00000000..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/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js b/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js new file mode 100644 index 00000000..fc6d58dc --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js @@ -0,0 +1,78 @@ +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"; + +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) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return; + } + if (!Object.values(REPEATABLE_ACTIONS).includes(action)) + return { status: CustomCommandStatus.Failure, message: `commands.generic.invalidaction` }; + const actions = understudy.actions; + switch (timingOption) { + case TIMING_OPTIONS.ONCE: + actions.once(action); + break; + case TIMING_OPTIONS.AFTER: + return this.#singleAfterAction(origin, actions, action, timingOption, ticks); + case TIMING_OPTIONS.CONTINUOUS: + actions.repeat(action); + break; + case TIMING_OPTIONS.INTERVAL: + return this.#intervalAction(origin, actions, action, timingOption, ticks); + case TIMING_OPTIONS.STOP: + actions.remove(action); + break; + default: + origin.sendMessage({ translate: 'commands.playeraction.invalidtiming', with: [action, timingOption] }); + return; + } + return { status: CustomCommandStatus.Success }; + } + + #singleAfterAction(origin, actions, action, timingOption, ticks) { + if (ticks === void 0) { + origin.sendMessage({ translate: 'commands.playeraction.invalidticks', with: [timingOption, String(ticks)] }); + return; + } + actions.once(action, ticks); + return { status: CustomCommandStatus.Success }; + } + + #intervalAction(origin, actions, action, timingOption, ticks) { + if (ticks === void 0) { + origin.sendMessage({ translate: 'commands.playeraction.invalidticks', with: [timingOption, String(ticks)] }); + return; + } + actions.repeat(action, ticks); + return { status: CustomCommandStatus.Success }; + } +} + +export const playeractionCommand = new PlayerActionCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerinventory.js b/Canopy[BP]/scripts/src/commands/simplayer/playerinventory.js new file mode 100644 index 00000000..a762fa6c --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerinventory.js @@ -0,0 +1,50 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +export class PlayerInventoryCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playerinventory', + description: 'commands.playerinventory', + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, ...args) => this.playerinventoryCommand(origin, ...args) + }); + } + + playerinventoryCommand(origin, playername) { + const understudy = Understudies.get(playername); + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return; + } + const playerInventory = understudy.getInventory(); + if (!playerInventory) + return { status: CustomCommandStatus.Success, message: 'commands.playerinventory.noinventory' }; + origin.sendMessage(this.#getInventoryMessage(understudy, playerInventory)); + return { status: CustomCommandStatus.Success }; + } + + #getInventoryMessage(understudy, playerInventory) { + if (playerInventory.size === playerInventory.emptySlotsCount) + return { translate: 'commands.playerinventory.empty', with: [understudy.name] }; + return this.#getFormattedInventoryMessage(understudy, playerInventory); + } + + #getFormattedInventoryMessage(understudy, playerInventory) { + const rawtext = [{ translate: 'commands.playerinventory.header', with: [understudy.name] }]; + for (let i = 0; i < playerInventory.size; i++) { + const itemStack = playerInventory.getItem(i); + if (itemStack !== void 0) { + const colorCode = i < 10 ? '§a' : ''; + rawtext.push({ text: '\n' }); + rawtext.push({ translate: 'commands.playerinventory.item', with: [colorCode, String(i), itemStack.typeId, String(itemStack.amount)] }); + } + } + return { rawtext }; + } +} + +export const playerinventoryCommand = new PlayerInventoryCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerjoin.js b/Canopy[BP]/scripts/src/commands/simplayer/playerjoin.js new file mode 100644 index 00000000..10a5e5fc --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerjoin.js @@ -0,0 +1,31 @@ +import { CustomCommandParamType, CommandPermissionLevel, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; +import { getLocationInfoFromSource } from "../../classes/simplayer/utils"; + +export class PlayerJoinCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playerjoin', + description: 'commands.playerjoin', + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin], + callback: (origin, ...args) => this.playerjoinCommand(origin, ...args) + }); + } + + playerjoinCommand(origin, playername) { + if (Understudies.isOnline(playername)) { + origin.sendMessage(Understudies.getAlreadyOnlineMessage(playername)); + return; + } + system.run(() => { + const understudy = Understudies.create(playername); + understudy.join(getLocationInfoFromSource(origin.getSource())); + Understudies.addNametagPrefix(understudy); + }); + } +} + +export const playerjoinCommand = new PlayerJoinCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerleave.js b/Canopy[BP]/scripts/src/commands/simplayer/playerleave.js new file mode 100644 index 00000000..4ca3949b --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerleave.js @@ -0,0 +1,30 @@ +import { CustomCommandParamType, CommandPermissionLevel, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +export class PlayerLeaveCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playerleave', + description: 'commands.playerleave', + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, ...args) => this.playerleaveCommand(origin, ...args) + }); + } + + playerleaveCommand(origin, playername) { + const understudy = Understudies.get(playername); + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return; + } + system.run(() => { + understudy.leave(); + Understudies.remove(understudy); + }); + } +} + +export const playerleaveCommand = new PlayerLeaveCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js b/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js new file mode 100644 index 00000000..043e940e --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js @@ -0,0 +1,120 @@ +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 LOOK_OPTIONS = Object.freeze({ + UP: 'up', DOWN: 'down', NORTH: 'north', SOUTH: 'south', + EAST: 'east', WEST: 'west', BLOCK: 'block', ENTITY: 'entity', + ME: 'me', AT: 'at', ROTATION: 'rotation', STOP: 'stop' +}); + +export const CARDINAL_ROTATIONS = { + up: { x: -90, y: 0 }, down: { x: 90, y: 0 }, north: { x: 0, y: 180 }, + south: { x: 0, y: 0 }, east: { x: 0, y: -90 }, west: { x: 0, y: 90 } +}; + +export class PlayerLookCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playerlook', + description: 'commands.playerlook', + enums: [{ name: 'canopy:simplayerLookOption', values: Object.values(LOOK_OPTIONS) }], + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + optionalParameters: [ + { name: 'canopy:simplayerLookOption', type: CustomCommandParamType.Enum }, + { name: 'x', type: CustomCommandParamType.Float }, + { name: 'y', type: CustomCommandParamType.Float }, + { name: 'z', type: CustomCommandParamType.Float } + ], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, ...args) => this.playerlookCommand(origin, ...args) + }); + } + + playerlookCommand(origin, playername, lookOption, x, y, z) { + const location = { x, y, z }; + const understudy = Understudies.get(playername); + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return; + } + switch (lookOption) { + case LOOK_OPTIONS.UP: case LOOK_OPTIONS.DOWN: case LOOK_OPTIONS.NORTH: + case LOOK_OPTIONS.SOUTH: case LOOK_OPTIONS.EAST: case LOOK_OPTIONS.WEST: + this.#lookAtCardinal(understudy, lookOption); + break; + case LOOK_OPTIONS.BLOCK: + return this.#lookAtBlock(origin, understudy); + case LOOK_OPTIONS.ENTITY: + return this.#lookAtEntity(origin, understudy); + case LOOK_OPTIONS.ME: + return this.#lookAtMe(origin, understudy); + case LOOK_OPTIONS.AT: + if (x === void 0 || y === void 0 || z === void 0) + return { status: CustomCommandStatus.Failure, message: 'commands.playerlook.at.missing' }; + this.#lookAtLocation(understudy, location); + break; + case LOOK_OPTIONS.ROTATION: + if (x === void 0 || y === void 0) + return { status: CustomCommandStatus.Failure, message: 'commands.playerlook.rotation.missing' }; + this.#lookRotation(understudy, { x: location.x, y: location.y }); + break; + case LOOK_OPTIONS.STOP: + this.#stopLooking(understudy); + break; + default: + origin.sendMessage({ translate: 'commands.playerlook.invalidoption', with: [lookOption] }); + return; + } + return { status: CustomCommandStatus.Success }; + } + + #lookAtCardinal(understudy, direction) { + system.run(() => understudy.look(CARDINAL_ROTATIONS[direction])); + } + + #lookAtBlock(origin, understudy) { + const source = origin.getSource(); + if (source instanceof Entity === false) + return { status: CustomCommandStatus.Failure, message: 'commands.playerlook.block.entityonly' }; + const block = source.getBlockFromViewDirection({ maxDistance: 16*64 })?.block; + if (block === void 0) + return { status: CustomCommandStatus.Failure, message: 'commands.playerlook.block.noblock' }; + 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: 'commands.playerlook.entity.entityonly' }; + const entity = source.getEntitiesFromViewDirection({ maxDistance: 16*64 })[0]?.entity; + if (entity === void 0) + return { status: CustomCommandStatus.Failure, message: 'commands.playerlook.entity.noentity' }; + system.run(() => understudy.look(entity)); + return { status: CustomCommandStatus.Success }; + } + + #lookAtMe(origin, understudy) { + if (origin instanceof ServerCommandOrigin) + return { status: CustomCommandStatus.Failure, message: 'commands.playerlook.me.noserver' }; + system.run(() => understudy.look(origin.getSource())); + return { status: CustomCommandStatus.Success }; + } + + #lookAtLocation(understudy, location) { + system.run(() => understudy.look(Vector.from(location))); + } + + #lookRotation(understudy, rotation) { + system.run(() => understudy.look(rotation)); + } + + #stopLooking(understudy) { + system.run(() => understudy.stopLooking()); + } +} + +export const playerlookCommand = new PlayerLookCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playermove.js b/Canopy[BP]/scripts/src/commands/simplayer/playermove.js new file mode 100644 index 00000000..b5060aa6 --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playermove.js @@ -0,0 +1,100 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, Entity, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; +import { Vector } from "../../../lib/Vector"; + +export const MOVE_OPTIONS = Object.freeze({ + FORWARD: 'forward', BACKWARD: 'backward', LEFT: 'left', RIGHT: 'right', + BLOCK: 'block', ENTITY: 'entity', ME: 'me', TO: 'to', STOP: 'stop' +}); + +export class PlayerMoveCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playermove', + description: 'commands.playermove', + enums: [{ name: 'canopy:simplayerMoveOption', values: Object.values(MOVE_OPTIONS) }], + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + optionalParameters: [ + { name: 'canopy:simplayerMoveOption', type: CustomCommandParamType.Enum }, + { name: 'location', type: CustomCommandParamType.Location } + ], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, ...args) => this.playermoveCommand(origin, ...args) + }); + } + + playermoveCommand(origin, playername, moveOption, location) { + const understudy = Understudies.get(playername); + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return; + } + switch (moveOption) { + case MOVE_OPTIONS.FORWARD: case MOVE_OPTIONS.BACKWARD: + case MOVE_OPTIONS.LEFT: case MOVE_OPTIONS.RIGHT: + this.#moveRelatively(understudy, moveOption); + break; + case MOVE_OPTIONS.BLOCK: + return this.#moveToBlock(origin, understudy); + case MOVE_OPTIONS.ENTITY: + return this.#moveToEntity(origin, understudy); + case MOVE_OPTIONS.ME: + return this.#moveToMe(origin, understudy); + case MOVE_OPTIONS.TO: + this.#moveToLocation(understudy, location); + break; + case MOVE_OPTIONS.STOP: + this.#stopMoving(understudy); + break; + default: + origin.sendMessage({ translate: 'commands.playermove.invalidoption', with: [moveOption] }); + return; + } + return { status: CustomCommandStatus.Success }; + } + + #moveRelatively(understudy, moveOption) { + system.run(() => understudy.moveRelative(moveOption)); + } + + #moveToBlock(origin, understudy) { + const source = origin.getSource(); + if (source instanceof Entity === false) + return { status: CustomCommandStatus.Failure, message: 'commands.playermove.block.entityonly' }; + const block = source.getBlockFromViewDirection({ maxDistance: 16*64 })?.block; + if (block === void 0) + return { status: CustomCommandStatus.Failure, message: 'commands.playermove.block.noblock' }; + system.run(() => understudy.moveLocation(block)); + return { status: CustomCommandStatus.Success }; + } + + #moveToEntity(origin, understudy) { + const source = origin.getSource(); + if (source instanceof Entity === false) + return { status: CustomCommandStatus.Failure, message: 'commands.playermove.entity.entityonly' }; + const entity = source.getEntitiesFromViewDirection({ maxDistance: 16*64 })[0]?.entity; + if (entity === void 0) + return { status: CustomCommandStatus.Failure, message: 'commands.playermove.entity.noentity' }; + system.run(() => understudy.moveLocation(entity)); + return { status: CustomCommandStatus.Success }; + } + + #moveToMe(origin, understudy) { + if (origin instanceof ServerCommandOrigin) + return { status: CustomCommandStatus.Failure, message: 'commands.playermove.me.noserver' }; + system.run(() => understudy.moveLocation(origin.getSource())); + return { status: CustomCommandStatus.Success }; + } + + #moveToLocation(understudy, location) { + system.run(() => understudy.moveLocation(Vector.from(location))); + } + + #stopMoving(understudy) { + system.run(() => understudy.stopMoving()); + } +} + +export const playermoveCommand = new PlayerMoveCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerprefix.js b/Canopy[BP]/scripts/src/commands/simplayer/playerprefix.js new file mode 100644 index 00000000..9469f24c --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerprefix.js @@ -0,0 +1,28 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +export class PlayerPrefixCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playerprefix', + description: 'commands.playerprefix', + mandatoryParameters: [{ name: 'prefix', type: CustomCommandParamType.String }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, ...args) => this.playerprefixCommand(origin, ...args) + }); + } + + playerprefixCommand(origin, prefix) { + if (prefix === '-none') { + system.run(() => Understudies.setNametagPrefix('')); + return { status: CustomCommandStatus.Success, message: 'commands.playerprefix.removed' }; + } + system.run(() => Understudies.setNametagPrefix(prefix)); + origin.sendMessage({ translate: 'commands.playerprefix.set', with: [prefix] }); + return { status: CustomCommandStatus.Success }; + } +} + +export const playerprefixCommand = new PlayerPrefixCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerrejoin.js b/Canopy[BP]/scripts/src/commands/simplayer/playerrejoin.js new file mode 100644 index 00000000..6b01f817 --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerrejoin.js @@ -0,0 +1,39 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; +import { getLocationInfoFromSource } from "../../classes/simplayer/utils"; + +export class PlayerRejoinCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playerrejoin', + description: 'commands.playerrejoin', + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, ...args) => this.playerrejoinCommand(origin, ...args) + }); + } + + playerrejoinCommand(origin, playername) { + if (Understudies.isOnline(playername)) { + origin.sendMessage(Understudies.getAlreadyOnlineMessage(playername)); + return; + } + system.run(() => this.#tryRejoin(origin, playername)); + return { status: CustomCommandStatus.Success }; + } + + #tryRejoin(origin, playername) { + const understudy = Understudies.create(playername); + try { + understudy.rejoin(); + } catch (error) { + console.warn(`[Canopy] Error while rejoining. Joining normally instead. Error: ${String(error)}`); + understudy.join(getLocationInfoFromSource(origin.getSource())); + } + Understudies.addNametagPrefix(understudy); + } +} + +export const playerrejoinCommand = new PlayerRejoinCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerselect.js b/Canopy[BP]/scripts/src/commands/simplayer/playerselect.js new file mode 100644 index 00000000..63ff1d56 --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerselect.js @@ -0,0 +1,35 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +export class PlayerSelectCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playerselect', + description: 'commands.playerselect', + mandatoryParameters: [ + { name: 'playername', type: CustomCommandParamType.String }, + { name: 'slotNumber', type: CustomCommandParamType.Integer } + ], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, ...args) => this.playerselectCommand(origin, ...args) + }); + } + + playerselectCommand(origin, playername, slotNumber) { + const understudy = Understudies.get(playername); + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return; + } + if (slotNumber < 0 || slotNumber > 8) { + origin.sendMessage({ translate: 'commands.playerselect.invalidslot', with: [String(slotNumber)] }); + return; + } + system.run(() => understudy.selectSlot(slotNumber)); + return { status: CustomCommandStatus.Success }; + } +} + +export const playerselectCommand = new PlayerSelectCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playersneak.js b/Canopy[BP]/scripts/src/commands/simplayer/playersneak.js new file mode 100644 index 00000000..c0f432b5 --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playersneak.js @@ -0,0 +1,31 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +export class PlayerSneakCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playersneak', + description: 'commands.playersneak', + mandatoryParameters: [ + { name: 'playername', type: CustomCommandParamType.String }, + { name: 'shouldSneak', type: CustomCommandParamType.Boolean } + ], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, ...args) => this.playersneakCommand(origin, ...args) + }); + } + + playersneakCommand(origin, playername, shouldSneak) { + const understudy = Understudies.get(playername); + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return; + } + system.run(() => understudy.sneak(shouldSneak)); + return { status: CustomCommandStatus.Success }; + } +} + +export const playersneakCommand = new PlayerSneakCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playersprint.js b/Canopy[BP]/scripts/src/commands/simplayer/playersprint.js new file mode 100644 index 00000000..1eb78a0e --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playersprint.js @@ -0,0 +1,31 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +export class PlayerSprintCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playersprint', + description: 'commands.playersprint', + mandatoryParameters: [ + { name: 'playername', type: CustomCommandParamType.String }, + { name: 'shouldSprint', type: CustomCommandParamType.Boolean } + ], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, ...args) => this.playersprintCommand(origin, ...args) + }); + } + + playersprintCommand(origin, playername, shouldSprint) { + const understudy = Understudies.get(playername); + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return; + } + system.run(() => understudy.sprint(shouldSprint)); + return { status: CustomCommandStatus.Success }; + } +} + +export const playersprintCommand = new PlayerSprintCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerstop.js b/Canopy[BP]/scripts/src/commands/simplayer/playerstop.js new file mode 100644 index 00000000..a32862a5 --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerstop.js @@ -0,0 +1,28 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +export class PlayerStopCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playerstop', + description: 'commands.playerstop', + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, ...args) => this.playerstopCommand(origin, ...args) + }); + } + + playerstopCommand(origin, playername) { + const understudy = Understudies.get(playername); + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return; + } + system.run(() => understudy.stopAll()); + return { status: CustomCommandStatus.Success }; + } +} + +export const playerstopCommand = new PlayerStopCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerswapheld.js b/Canopy[BP]/scripts/src/commands/simplayer/playerswapheld.js new file mode 100644 index 00000000..7ea6d671 --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerswapheld.js @@ -0,0 +1,28 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, EntityCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +export class PlayerSwapHeldCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playerswapheld', + description: 'commands.playerswapheld', + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, EntityCommandOrigin], + callback: (origin, ...args) => this.playerswapheldCommand(origin, ...args) + }); + } + + playerswapheldCommand(origin, playername) { + const understudy = Understudies.get(playername); + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return; + } + system.run(() => understudy.swapHeldItemWithPlayer(origin.getSource())); + return { status: CustomCommandStatus.Success }; + } +} + +export const playerswapheldCommand = new PlayerSwapHeldCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playertp.js b/Canopy[BP]/scripts/src/commands/simplayer/playertp.js new file mode 100644 index 00000000..3257046f --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playertp.js @@ -0,0 +1,29 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; +import { getLocationInfoFromSource } from "../../classes/simplayer/utils"; + +export class PlayerTpCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playertp', + description: 'commands.playertp', + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin], + callback: (origin, ...args) => this.playertpCommand(origin, ...args) + }); + } + + playertpCommand(origin, playername) { + const understudy = Understudies.get(playername); + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return; + } + system.run(() => understudy.teleport(getLocationInfoFromSource(origin.getSource()))); + return { status: CustomCommandStatus.Success }; + } +} + +export const playertpCommand = new PlayerTpCommand(); diff --git a/Canopy[BP]/scripts/src/onReload.js b/Canopy[BP]/scripts/src/onReload.js index 94592138..82f433b5 100644 --- a/Canopy[BP]/scripts/src/onReload.js +++ b/Canopy[BP]/scripts/src/onReload.js @@ -1,8 +1,11 @@ import { world } from '@minecraft/server'; import { broadcastActionBar } from "../include/utils"; +import { simplayerRejoining } from "./rules/simplayer/simplayerRejoining"; world.afterEvents.worldLoad.subscribe(() => { -const players = world.getAllPlayers(); - if (players[0]?.isValid) + const players = world.getAllPlayers(); + if (players[0]?.isValid) { broadcastActionBar('§aBehavior packs have been reloaded.'); + simplayerRejoining.onStartup(); + } }); \ No newline at end of file diff --git a/Canopy[BP]/scripts/src/onStart.js b/Canopy[BP]/scripts/src/onStart.js index 98c5906c..0ad2e69a 100644 --- a/Canopy[BP]/scripts/src/onStart.js +++ b/Canopy[BP]/scripts/src/onStart.js @@ -1,5 +1,6 @@ import { world, system } from "@minecraft/server"; import { displayWelcome } from "./rules/noWelcomeMessage"; +import { simplayerRejoining } from "./rules/simplayer/simplayerRejoining"; let worldIsValid = false; @@ -24,5 +25,5 @@ function onValidPlayer(player) { } function onValidWorld() { - + simplayerRejoining.onStartup(); } diff --git a/Canopy[BP]/scripts/src/rules/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); } 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..2dc6deff --- /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: { translate: 'rules.simplayerRejoining' }, + defaultValue: false, + onEnableCallback: () => this.subscribeToEvent(), + onDisableCallback: () => this.unsubscribeFromEvent() + }); + this.onShutdownBound = this.onShutdown.bind(this); + } + + subscribeToEvent() { + system.beforeEvents.shutdown.subscribe(this.onShutdownBound); + } + + unsubscribeFromEvent() { + system.beforeEvents.shutdown.unsubscribe(this.onShutdownBound); + } + + onStartup() { + if (!this.getNativeValue()) + return; + const simplayersToRejoinStr = world.getDynamicProperty(this.simplayersToRejoinDP); + let playersToRejoin; + try { + const parsedPlayers = JSON.parse(simplayersToRejoinStr); + if (Array.isArray(parsedPlayers)) + playersToRejoin = parsedPlayers; + } catch (error) { + console.error(`[Canopy] Error parsing ${this.simplayersToRejoinDP} DP:`, error); + } + if (playersToRejoin) { + playersToRejoin.forEach(name => { + const simPlayer = Understudies.create(name); + system.runTimeout(() => { + Understudies.addNametagPrefix(simPlayer); + }, 5); + try { + simPlayer.rejoin(); + } catch (error) { + console.error(`[Canopy] Error rejoining player ${name}:`, error); + } + }); + } + } + + onShutdown() { + if (this.getNativeValue()) + world.setDynamicProperty(this.simplayersToRejoinDP, JSON.stringify(Understudies.understudies.map(player => player.name))); + else + world.setDynamicProperty(this.simplayersToRejoinDP, JSON.stringify([])); + } +} + +export const simplayerRejoining = new SimplayerRejoining(); diff --git a/Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving.js b/Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving.js new file mode 100644 index 00000000..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(); diff --git a/Canopy[RP]/texts/en_US.lang b/Canopy[RP]/texts/en_US.lang index a42b34d2..88874147 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. @@ -239,6 +239,57 @@ 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.playerinventory=Print the inventory of a simplayer. +commands.playerinventory.noinventory=§cNo inventory found +commands.playerinventory.empty=§7%s's inventory is empty. +commands.playerinventory.header=%s's inventory: +commands.playerinventory.item=§7- %1%2§7: %3 x%4 + +commands.playerjoin=Make a new simplayer join at your location. + +commands.playerleave=Make a simplayer leave the game. + +commands.playerlook=Make a simplayer look in specified directions. +commands.playerlook.at.missing=§cMissing coordinates for look at location. +commands.playerlook.rotation.missing=§cMissing yaw or pitch for look rotation. +commands.playerlook.invalidoption=§cInvalid look option: '%s' +commands.playerlook.block.entityonly=§cBlock targeting may only be used by entities. +commands.playerlook.block.noblock=§cNo block in view. +commands.playerlook.entity.entityonly=§cEntity targeting may only be used by entities. +commands.playerlook.entity.noentity=§cNo entity in view. +commands.playerlook.me.noserver=§cSelf-targeting cannot be used by the server. + +commands.playermove=Make a simplayer move in specified directions. +commands.playermove.invalidoption=§cInvalid move option: '%s' +commands.playermove.block.entityonly=§cMoving to a block may only be used by entities. +commands.playermove.block.noblock=§cNo block in view. +commands.playermove.entity.entityonly=§cMoving to an entity may only be used by entities. +commands.playermove.entity.noentity=§cNo entity in view. +commands.playermove.me.noserver=§cMoving to yourself cannot be used by the server. + +commands.playerprefix=Set a prefix for simplayer nametags. Use '-none' to clear. +commands.playerprefix.removed=§7Simplayer prefix removed. +commands.playerprefix.set=§7Simplayer prefix set to "§r%s§r§7". + +commands.playerrejoin=Make a simplayer rejoin at its last location. + +commands.playerselect=Make a simplayer select a hotbar slot. +commands.playerselect.invalidslot=§cInvalid slot number: %s. Expected a number from 0 to 8. + +commands.playersneak=Make a simplayer start or stop sneaking. + +commands.playersprint=Make a simplayer start or stop sprinting. + +commands.playerstop=Make a simplayer stop doing all actions. + +commands.playerswapheld=Swap the held item of a simplayer with your held item. + +commands.playertp=Make a simplayer teleport to you. + commands.pos=Shows your current position, or the positions of other players. commands.pos.self=§aYour position: §f%s commands.pos.other=§a%1's position: §f%2 @@ -401,6 +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. @@ -459,4 +512,10 @@ rules.infoDisplay.velocity=Shows your current x, y, and z velocities in meters p rules.infoDisplay.weather=Shows the weather in your current dimension. rules.infoDisplay.weather.display=Weather: %s rules.infoDisplay.worldDay=Shows the count of Minecraft days since the world began. -rules.infoDisplay.worldDay.display=Day: %s \ No newline at end of file +rules.infoDisplay.worldDay.display=Day: %s + +## simplayer +simplayer.notonline=§cSimplayer '%s' is not online. +simplayer.alreadyonline=§cSimplayer '%s' is already online. +simplayer.leave.broadcast=§e%s left the game +simplayer.swapheld.error=§cError while swapping items: %s \ No newline at end of file diff --git a/__tests__/BP/scripts/lib/canopy/rules/AbilityRule.test.js b/__tests__/BP/scripts/lib/canopy/rules/AbilityRule.test.js index 56f15b86..9f04e52f 100644 --- a/__tests__/BP/scripts/lib/canopy/rules/AbilityRule.test.js +++ b/__tests__/BP/scripts/lib/canopy/rules/AbilityRule.test.js @@ -67,8 +67,8 @@ describe('AbilityRule', () => { }); it('should use a custom action item if provided', () => { - const arrowAbilityTestRuleData = { ...testRuleData, identifier: 'arrowAbilityTestRule' }; - const customArrowAbility = new AbilityRule(arrowAbilityTestRuleData, { slotNumber: 1, actionItem: 'minecraft:other_item' }); + const customArrowRuleData = { ...testRuleData, identifier: 'customArrowRule' }; + const customArrowAbility = new AbilityRule(customArrowRuleData, { slotNumber: 1, actionItem: 'minecraft:other_item' }); expect(customArrowAbility.getActionItemId()).toBe('minecraft:other_item'); }); diff --git a/__tests__/BP/scripts/src/classes/simplayer/Actions.test.js b/__tests__/BP/scripts/src/classes/simplayer/Actions.test.js new file mode 100644 index 00000000..8dd69b0f --- /dev/null +++ b/__tests__/BP/scripts/src/classes/simplayer/Actions.test.js @@ -0,0 +1,167 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { scheduler } from '@forestoflight/minecraft-vitest-mocks'; +import { Actions } from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Actions'; +import { RepeatableAction, REPEATABLE_ACTIONS } from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/RepeatableAction'; + +describe('Actions', () => { + let mockUnderstudy; + let actions; + let performSpy; + let repeatableActionOnTickSpy; + + beforeEach(() => { + mockUnderstudy = { name: 'TestBot' }; + actions = new Actions(mockUnderstudy); + performSpy = vi.spyOn(RepeatableAction.prototype, 'perform').mockImplementation(() => {}); + repeatableActionOnTickSpy = vi.spyOn(RepeatableAction.prototype, 'onTick').mockImplementation(() => {}); + }); + + describe('constructor', () => { + it('stores the understudy', () => { + expect(actions.understudy).toBe(mockUnderstudy); + }); + + it('starts with no actions queued', () => { + expect(actions.isEmpty()).toBe(true); + }); + }); + + describe('once', () => { + it('queues a single action', () => { + actions.once(REPEATABLE_ACTIONS.ATTACK); + expect(actions.isEmpty()).toBe(false); + }); + + it('performs the action on the next onTick call', () => { + actions.once(REPEATABLE_ACTIONS.ATTACK); + actions.onTick(); + expect(performSpy).toHaveBeenCalledOnce(); + }); + + it('performs the action only once', () => { + actions.once(REPEATABLE_ACTIONS.ATTACK); + actions.onTick(); + actions.onTick(); + expect(performSpy).toHaveBeenCalledOnce(); + }); + + it('does not perform the action before the tick delay elapses', () => { + actions.once(REPEATABLE_ACTIONS.ATTACK, 5); + actions.onTick(); + expect(performSpy).not.toHaveBeenCalled(); + }); + + it('performs the action after the tick delay elapses', () => { + actions.once(REPEATABLE_ACTIONS.ATTACK, 5); + scheduler.advanceTicks(5); + actions.onTick(); + expect(performSpy).toHaveBeenCalledOnce(); + }); + }); + + describe('repeat', () => { + it('adds a repeating action', () => { + actions.repeat(REPEATABLE_ACTIONS.ATTACK); + expect(actions.has(REPEATABLE_ACTIONS.ATTACK)).toBe(true); + }); + + it('replaces an existing action of the same type', () => { + actions.repeat(REPEATABLE_ACTIONS.ATTACK, 5); + actions.repeat(REPEATABLE_ACTIONS.ATTACK, 10); + expect(actions.get(REPEATABLE_ACTIONS.ATTACK).intervalTicks).toBe(10); + }); + + it('does not affect other action types when replacing', () => { + actions.repeat(REPEATABLE_ACTIONS.ATTACK); + actions.repeat(REPEATABLE_ACTIONS.JUMP); + actions.repeat(REPEATABLE_ACTIONS.ATTACK, 5); + expect(actions.has(REPEATABLE_ACTIONS.JUMP)).toBe(true); + }); + }); + + describe('get', () => { + it('returns the repeating action with the given type', () => { + actions.repeat(REPEATABLE_ACTIONS.ATTACK); + const action = actions.get(REPEATABLE_ACTIONS.ATTACK); + expect(action).toBeInstanceOf(RepeatableAction); + expect(action.type).toBe(REPEATABLE_ACTIONS.ATTACK); + }); + + it('returns undefined when no action of that type exists', () => { + expect(actions.get(REPEATABLE_ACTIONS.ATTACK)).toBeUndefined(); + }); + }); + + describe('has', () => { + it('returns true when a repeating action with the given type exists', () => { + actions.repeat(REPEATABLE_ACTIONS.ATTACK); + expect(actions.has(REPEATABLE_ACTIONS.ATTACK)).toBe(true); + }); + + it('returns false when no repeating action with the given type exists', () => { + expect(actions.has(REPEATABLE_ACTIONS.ATTACK)).toBe(false); + }); + }); + + describe('isEmpty', () => { + it('returns true when no actions are queued', () => { + expect(actions.isEmpty()).toBe(true); + }); + + it('returns false when a single action is queued', () => { + actions.once(REPEATABLE_ACTIONS.ATTACK); + expect(actions.isEmpty()).toBe(false); + }); + + it('returns false when a repeating action is queued', () => { + actions.repeat(REPEATABLE_ACTIONS.ATTACK); + expect(actions.isEmpty()).toBe(false); + }); + }); + + describe('remove', () => { + it('removes the repeating action with the given type', () => { + actions.repeat(REPEATABLE_ACTIONS.ATTACK); + actions.remove(REPEATABLE_ACTIONS.ATTACK); + expect(actions.has(REPEATABLE_ACTIONS.ATTACK)).toBe(false); + }); + + it('does not remove other action types', () => { + actions.repeat(REPEATABLE_ACTIONS.ATTACK); + actions.repeat(REPEATABLE_ACTIONS.JUMP); + actions.remove(REPEATABLE_ACTIONS.ATTACK); + expect(actions.has(REPEATABLE_ACTIONS.JUMP)).toBe(true); + }); + }); + + describe('clear', () => { + it('removes all repeating and single actions', () => { + actions.once(REPEATABLE_ACTIONS.ATTACK); + actions.repeat(REPEATABLE_ACTIONS.JUMP); + actions.clear(); + expect(actions.isEmpty()).toBe(true); + }); + }); + + describe('onTick', () => { + it('calls perform on each pending single action', () => { + actions.once(REPEATABLE_ACTIONS.ATTACK); + actions.once(REPEATABLE_ACTIONS.JUMP); + actions.onTick(); + expect(performSpy).toHaveBeenCalledTimes(2); + }); + + it('clears single actions after performing them', () => { + actions.once(REPEATABLE_ACTIONS.ATTACK); + actions.onTick(); + expect(actions.isEmpty()).toBe(true); + }); + + it('calls onTick on each repeating action', () => { + actions.repeat(REPEATABLE_ACTIONS.ATTACK); + actions.repeat(REPEATABLE_ACTIONS.JUMP); + actions.onTick(); + expect(repeatableActionOnTickSpy).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/__tests__/BP/scripts/src/classes/simplayer/PlayerInfoSaver.test.js b/__tests__/BP/scripts/src/classes/simplayer/PlayerInfoSaver.test.js new file mode 100644 index 00000000..8b00494f --- /dev/null +++ b/__tests__/BP/scripts/src/classes/simplayer/PlayerInfoSaver.test.js @@ -0,0 +1,180 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { world, system, EntityComponentTypes, TicksPerSecond } from '@minecraft/server'; +import { worldDynamicPropertyStore } from '@forestoflight/minecraft-vitest-mocks'; +import { PlayerInfoSaver } from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/PlayerInfoSaver'; +import Understudy from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudy'; +import { simplayerSaving } from '../../../../../../Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving'; +import { UnderstudySaveInfoError } from '../../../../../../Canopy[BP]/scripts/src/classes/errors/UnderstudySaveInfoError'; +import { UnderstudyNotConnectedError } from '../../../../../../Canopy[BP]/scripts/src/classes/errors/UnderstudyNotConnectedError'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving', () => ({ + simplayerSaving: { getNativeValue: vi.fn(() => true), getID: vi.fn(() => 'simplayerSaving') } +})); +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { onConnect: vi.fn() } +})); + +describe('PlayerInfoSaver', () => { + let understudy; + let infoSaver; + + beforeEach(() => { + vi.clearAllMocks(); + system.currentTick = 0; + understudy = new Understudy('TestBot'); + understudy.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension('minecraft:overworld') }); + infoSaver = new PlayerInfoSaver(understudy); + worldDynamicPropertyStore.set('simplayerSaving', true); + }); + + describe('get', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws when simplayerSaving is disabled', () => { + vi.mocked(simplayerSaving.getNativeValue).mockReturnValue(false); + expect(() => infoSaver.get()).toThrow(UnderstudySaveInfoError); + }); + + it('throws when no player info has been saved', () => { + worldDynamicPropertyStore.set('TestBot:playerinfo', undefined); + expect(() => infoSaver.get()).toThrow(UnderstudySaveInfoError); + }); + + it('throws when player info is corrupted', () => { + worldDynamicPropertyStore.set('TestBot:playerinfo', 'this is not valid json'); + expect(() => infoSaver.get()).toThrow(UnderstudySaveInfoError); + }); + + it('returns parsed player info when data exists', () => { + const playerInfo = { + location: { x: 0, y: 64, z: 0 }, rotation: { x: 0, y: 0 }, + dimensionId: 'minecraft:overworld', gameMode: 'Survival', projectileIds: [] + }; + worldDynamicPropertyStore.set('TestBot:playerinfo', JSON.stringify(playerInfo)); + expect(infoSaver.get()).toEqual(playerInfo); + }); + + it('throws an error when something undefined happens', () => { + const original = world.getDynamicProperty; + vi.spyOn(world, 'getDynamicProperty').mockImplementation((key, value) => { + if (key === 'TestBot:playerinfo') + throw new Error('Unexpected error'); + else + return original.call(world, key, value); + }); + expect(() => infoSaver.get()).toThrow(Error); + }); + }); + + describe('save', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws an error when the understudy is not connected', () => { + understudy.leave(); + expect(() => infoSaver.save()).toThrow(UnderstudyNotConnectedError); + }); + + it('does not save when simplayerSaving is disabled', () => { + vi.mocked(simplayerSaving.getNativeValue).mockReturnValue(false); + infoSaver.save(); + expect(world.setDynamicProperty).not.toHaveBeenCalledWith('TestBot:playerinfo', expect.any(String)); + }); + + it('writes player info to the dynamic property when connected', () => { + infoSaver.save(); + expect(world.setDynamicProperty).toHaveBeenCalledWith('TestBot:playerinfo', expect.any(String)); + }); + + it('saves player info data', () => { + infoSaver.save(); + const call = world.setDynamicProperty.mock.calls.find(c => c[0] === 'TestBot:playerinfo'); + const saved = JSON.parse(call[1]); + expect(saved).toBeDefined(); + }); + + it('saves projectile ids owned by the understudy', () => { + const projectile = { id: 'proj1', getComponent: vi.fn().mockReturnValue({ owner: understudy.simulatedPlayer }) }; + world.getDimension.mockReturnValueOnce({ + getEntities: vi.fn(() => [projectile]) + }); + infoSaver.save(); + const call = world.setDynamicProperty.mock.calls.find(c => c[0] === 'TestBot:playerinfo'); + const saved = JSON.parse(call[1]); + expect(saved.projectileIds).toContain('proj1'); + }); + }); + + describe('loadInventoryAndProjectileOwnership', () => { + it('throws when simplayerSaving is disabled', () => { + vi.mocked(simplayerSaving.getNativeValue).mockReturnValue(false); + expect(() => infoSaver.loadInventoryAndProjectileOwnership()).toThrow(UnderstudySaveInfoError); + }); + + it('loads player inventory when data exists', () => { + const playerInfo = { + location: { x: 1, y: 64, z: 2 }, rotation: { x: 0, y: 90 }, + dimensionId: 'minecraft:overworld', gameMode: 'Creative', projectileIds: [] + }; + worldDynamicPropertyStore.set('TestBot:playerinfo', JSON.stringify(playerInfo)); + worldDynamicPropertyStore.set('bot_TestBot_inventory', JSON.stringify({ 0: { typeId: 'minecraft:stone', amount: 1 } })); + infoSaver.loadInventoryAndProjectileOwnership(); + expect(understudy.getInventory().setItem).toHaveBeenCalled(); + }); + + it('loads claimed projectiles when data exists', () => { + const projectile = { id: 'proj1', getComponent: vi.fn().mockReturnValue({ owner: void 0 }) }; + world.getEntity.mockImplementation(id => id === 'proj1' ? projectile : void 0); + const playerInfo = { + location: { x: 1, y: 64, z: 2 }, rotation: { x: 0, y: 90 }, + dimensionId: 'minecraft:overworld', gameMode: 'Creative', projectileIds: ['proj1'] + }; + worldDynamicPropertyStore.set('TestBot:playerinfo', JSON.stringify(playerInfo)); + infoSaver.loadInventoryAndProjectileOwnership(); + expect(projectile.getComponent(EntityComponentTypes.Projectile).owner).toBe(understudy.simulatedPlayer); + }); + }); + + describe('onConnectedTick', () => { + beforeEach(() => { + vi.clearAllMocks(); + system.currentTick = 0; + }); + + it('does nothing when simplayerSaving is disabled', () => { + vi.mocked(simplayerSaving.getNativeValue).mockReturnValue(false); + infoSaver.onConnectedTick(); + expect(world.setDynamicProperty).not.toHaveBeenCalledWith('TestBot:playerinfo', expect.any(String)); + }); + + it('saves when elapsed ticks is a multiple of saveInterval', () => { + system.currentTick = infoSaver.saveInterval; + infoSaver.onConnectedTick(); + expect(world.setDynamicProperty).toHaveBeenCalledWith('TestBot:playerinfo', expect.any(String)); + }); + + it('does not save playerinfo when elapsed ticks is not a multiple of saveInterval', () => { + system.currentTick = infoSaver.saveInterval - 1; + infoSaver.onConnectedTick(); + expect(world.setDynamicProperty).not.toHaveBeenCalledWith('TestBot:playerinfo', expect.anything()); + }); + + it('saves inventory with NBT every 5 seconds when the player has repeating actions', () => { + system.currentTick = TicksPerSecond * 5; + understudy.actions.repeat('attack'); + const spy = vi.spyOn(infoSaver, 'save'); + infoSaver.onConnectedTick(); + expect(spy).toHaveBeenCalled(); + }); + + it('saves inventory without NBT on off-ticks when player has repeating actions', () => { + system.currentTick = TicksPerSecond * 5 - 1; + understudy.actions.repeat('attack'); + infoSaver.onConnectedTick(); + expect(world.setDynamicProperty).toHaveBeenCalledWith('bot_TestBot_inventory', expect.any(String)); + }); + }); +}); diff --git a/__tests__/BP/scripts/src/classes/simplayer/RepeatableAction.test.js b/__tests__/BP/scripts/src/classes/simplayer/RepeatableAction.test.js new file mode 100644 index 00000000..d9d3f3a8 --- /dev/null +++ b/__tests__/BP/scripts/src/classes/simplayer/RepeatableAction.test.js @@ -0,0 +1,175 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system, world } from '@minecraft/server'; +import { RepeatableAction, REPEATABLE_ACTIONS } from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/RepeatableAction'; +import Understudy from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudy'; +import { UnknownRepeatingActionError } from '../../../../../../Canopy[BP]/scripts/src/classes/errors/UnknownRepeatingActionError'; +import { UnderstudyNotConnectedError } from '../../../../../../Canopy[BP]/scripts/src/classes/errors/UnderstudyNotConnectedError'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving', () => ({ + simplayerSaving: { getNativeValue: vi.fn(() => true), getID: vi.fn(() => 'simplayerSaving') } +})); +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { onConnect: vi.fn() } +})); + +describe('RepeatableAction', () => { + let understudy; + + beforeEach(() => { + vi.clearAllMocks(); + system.currentTick = 0; + understudy = new Understudy('TestBot'); + }); + + describe('constructor', () => { + it('stores understudy, type, intervalTicks, and startTick', () => { + system.currentTick = 10; + const action = new RepeatableAction(understudy, 'attack', 5); + expect(action.understudy).toBe(understudy); + expect(action.type).toBe('attack'); + expect(action.intervalTicks).toBe(5); + expect(action.startTick).toBe(10); + }); + + it('defaults intervalTicks to 0', () => { + const action = new RepeatableAction(understudy, 'attack'); + expect(action.intervalTicks).toBe(0); + }); + }); + + describe('setInterval', () => { + it('updates intervalTicks', () => { + const action = new RepeatableAction(understudy, 'attack', 5); + action.setInterval(20); + expect(action.intervalTicks).toBe(20); + }); + }); + + describe('isActionTick', () => { + it('always returns true when intervalTicks is 0', () => { + const action = new RepeatableAction(understudy, 'attack', 0); + system.currentTick = 7; + expect(action.isActionTick()).toBe(true); + }); + + it('returns true when elapsed ticks is a multiple of interval', () => { + system.currentTick = 0; + const action = new RepeatableAction(understudy, 'attack', 5); + system.currentTick = 5; + expect(action.isActionTick()).toBe(true); + }); + + it('returns false when elapsed ticks is not a multiple of interval', () => { + system.currentTick = 0; + const action = new RepeatableAction(understudy, 'attack', 5); + system.currentTick = 3; + expect(action.isActionTick()).toBe(false); + }); + }); + + describe('while connected', () => { + beforeEach(() => { + understudy.join({ location: { x: 0, y: 0, z: 0 }, dimension: world.getDimension('overworld') }); + }); + + describe('onTick', () => { + it('calls perform when isActionTick returns true', () => { + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.ATTACK, 0); + action.onTick(); + expect(action.understudy.simulatedPlayer.attack).toHaveBeenCalled(); + }); + + it('does not call perform when isActionTick returns false', () => { + system.currentTick = 0; + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.ATTACK, 5); + system.currentTick = 3; + action.onTick(); + expect(action.understudy.simulatedPlayer.attack).not.toHaveBeenCalled(); + }); + }); + + describe('perform', () => { + it('throws when understudy is not connected', () => { + const offlineUnderstudy = new Understudy('OfflineBot'); + const action = new RepeatableAction(offlineUnderstudy, REPEATABLE_ACTIONS.ATTACK); + expect(() => action.perform()).toThrow(UnderstudyNotConnectedError); + }); + + it('calls simulatedPlayer.attack() for ATTACK', () => { + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.ATTACK); + action.perform(); + expect(action.understudy.simulatedPlayer.attack).toHaveBeenCalled(); + }); + + it('calls simulatedPlayer.interact() for INTERACT', () => { + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.INTERACT); + action.perform(); + expect(action.understudy.simulatedPlayer.interact).toHaveBeenCalled(); + }); + + it('calls simulatedPlayer.useItemInSlot() for USE', () => { + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.USE); + action.perform(); + expect(action.understudy.simulatedPlayer.useItemInSlot).toHaveBeenCalled(); + }); + + it('makes the simulated player build for BUILD', () => { + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.BUILD); + action.perform(); + expect(action.understudy.simulatedPlayer.startBuild).toHaveBeenCalled(); + }); + + it('makes the simulated player break when looking at a block for BREAK', () => { + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.BREAK); + understudy.simulatedPlayer.getBlockFromViewDirection.mockReturnValue({ block: { location: { x: 1, y: 64, z: 1 } } }); + action.perform(); + expect(action.understudy.simulatedPlayer.breakBlock).toHaveBeenCalled(); + }); + + it('does not attempt to break when not looking at a block for BREAK', () => { + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.BREAK); + understudy.simulatedPlayer.getBlockFromViewDirection.mockReturnValue(undefined); + action.perform(); + expect(action.understudy.simulatedPlayer.breakBlock).not.toHaveBeenCalled(); + }); + + it('makes the simulated player drop a single item for DROP', () => { + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.DROP); + understudy.getInventory().setItem(0, { typeId: 'minecraft:stone', amount: 1 }); + action.perform(); + expect(action.understudy.simulatedPlayer.dropSelectedItem).toHaveBeenCalled(); + }); + + it('does not attempt to drop when not holding an item for DROP', () => { + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.DROP); + understudy.getInventory().setItem(0, undefined); + action.perform(); + expect(action.understudy.simulatedPlayer.dropSelectedItem).not.toHaveBeenCalled(); + }); + + it('calls simulatedPlayer.dropSelectedItem() for DROP_STACK', () => { + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.DROP_STACK); + action.perform(); + expect(action.understudy.simulatedPlayer.dropSelectedItem).toHaveBeenCalled(); + }); + + it('makes the simulated player drop all items for DROP_ALL', () => { + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.DROP_ALL); + action.perform(); + const inventory = understudy.getInventory(); + expect(inventory.setItem).toHaveBeenCalledWith(0, undefined); + }); + + it('calls simulatedPlayer.jump() for JUMP', () => { + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.JUMP); + action.perform(); + expect(action.understudy.simulatedPlayer.jump).toHaveBeenCalled(); + }); + + it('throws an error for an unknown action type', () => { + const action = new RepeatableAction(understudy, 'unknownType'); + expect(() => action.perform()).toThrow(UnknownRepeatingActionError); + }); + }); + }); +}); diff --git a/__tests__/BP/scripts/src/classes/simplayer/Understudies.test.js b/__tests__/BP/scripts/src/classes/simplayer/Understudies.test.js new file mode 100644 index 00000000..fe19f6c5 --- /dev/null +++ b/__tests__/BP/scripts/src/classes/simplayer/Understudies.test.js @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { system, world } from '@minecraft/server'; +import { scheduler } from '@forestoflight/minecraft-vitest-mocks'; + +vi.mock('@minecraft/server', async () => await import('@forestoflight/minecraft-vitest-mocks/server')); +vi.mock('@minecraft/server-gametest', async () => await import('@forestoflight/minecraft-vitest-mocks/server-gametest')); +vi.mock('../../../../../../Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving', () => ({ + simplayerSaving: { getNativeValue: vi.fn(() => true), getID: vi.fn(() => 'simplayerSaving') } +})); + +let Understudies; + +beforeEach(async () => { + vi.resetModules(); + system.runInterval.mockImplementation((cb, interval) => scheduler.scheduleInterval(cb, interval ?? 1)); + system.clearRun.mockImplementation(id => scheduler.delete(id)); + system.run.mockImplementation(cb => scheduler.scheduleDelay(cb, 1)); + system.runTimeout.mockImplementation((cb, d) => scheduler.scheduleDelay(cb, d)); + ({ default: Understudies } = await import('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies')); +}); + +afterEach(() => { + scheduler.reset(); +}); + +describe('isUnderstudy', () => { + it('returns false when no understudies exist', () => { + expect(Understudies.isUnderstudy({ name: 'Bob' })).toBe(false); + }); + + it('returns false for a player whose name matches a disconnected understudy', () => { + Understudies.create('Bob'); + expect(Understudies.isUnderstudy({ name: 'Bob' })).toBe(false); + }); + + it('returns false for null', () => { + expect(Understudies.isUnderstudy(null)).toBe(false); + }); +}); + +describe('lazy interval management', () => { + it('does not start the interval before any understudy connects', () => { + expect(scheduler.scheduled.size).toBe(0); + }); + + it('starts the interval when onConnect is called for the first time', () => { + Understudies.create('Alice'); + Understudies.onConnect(); + expect(scheduler.scheduled.size).toBeGreaterThan(0); + }); + + it('does not start a second interval when onConnect is called again', () => { + Understudies.create('Alice'); + Understudies.onConnect(); + const countAfterFirst = scheduler.scheduled.size; + Understudies.onConnect(); + expect(scheduler.scheduled.size).toBe(countAfterFirst); + }); +}); + +describe('create and get', () => { + it('creates and retrieves an understudy by name', () => { + const u = Understudies.create('Charlie'); + expect(Understudies.get('Charlie')).toBe(u); + }); + + it('throws when creating a duplicate name that is already online', () => { + Understudies.create('Dave'); + expect(() => Understudies.create('Dave')).toThrow(); + }); +}); + +describe('onEntityDie', () => { + it('does nothing when the dead entity is not a player', () => { + const u = Understudies.create('Alice'); + Understudies.onConnect(); + u.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + Understudies.onEntityDie({ deadEntity: { typeId: 'minecraft:zombie', name: 'Alice' } }); + expect(u.isConnected()).toBe(true); + }); + + it('disconnects and removes an understudy when their player entity dies', () => { + const u = Understudies.create('Alice'); + Understudies.onConnect(); + u.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + Understudies.onEntityDie({ deadEntity: { typeId: 'minecraft:player', name: 'Alice' } }); + expect(u.isConnected()).toBe(false); + }); +}); + +describe('onPlayerGameModeChange', () => { + it('saves player info when an understudy changes game mode', () => { + const u = Understudies.create('Alice'); + Understudies.onConnect(); + u.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + const saveSpy = vi.spyOn(u, 'savePlayerInfo'); + Understudies.onPlayerGameModeChange({ player: { name: 'Alice' } }); + expect(saveSpy).toHaveBeenCalled(); + }); +}); + +describe('remove', () => { + it('disconnects the understudy immediately', () => { + const u = Understudies.create('Alice'); + Understudies.onConnect(); + u.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + Understudies.remove(u); + expect(u.isConnected()).toBe(false); + }); + + it('removes the understudy from the list after the disconnect is processed', () => { + const u = Understudies.create('Alice'); + Understudies.onConnect(); + u.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + scheduler.advanceTicks(1); // drain join's system.run before disconnecting + Understudies.remove(u); + scheduler.advanceTicks(1); // let remove's runInterval fire + expect(Understudies.length()).toBe(0); + }); +}); + +describe('removeAll', () => { + it('removes all online understudies', () => { + const a = Understudies.create('Alice'); + const b = Understudies.create('Bob'); + Understudies.onConnect(); + a.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + b.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + scheduler.advanceTicks(1); // drain join callbacks before disconnecting + Understudies.removeAll(); + scheduler.advanceTicks(1); // let remove intervals fire + expect(Understudies.length()).toBe(0); + }); +}); + +describe('setNametagPrefix', () => { + let u; + + beforeEach(() => { + u = Understudies.create('Alice'); + Understudies.onConnect(); + u.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + }); + + it('sets nameTag to [prefix] name format when prefix is non-empty', () => { + Understudies.setNametagPrefix('Bot'); + expect(u.simulatedPlayer.nameTag).toBe('§r[Bot§r] Alice'); + }); + + it('resets nameTag to just the name when prefix is empty string', () => { + Understudies.setNametagPrefix('Bot'); + Understudies.setNametagPrefix(''); + expect(u.simulatedPlayer.nameTag).toBe('Alice'); + }); + + it('stores the prefix in world dynamic property', () => { + Understudies.setNametagPrefix('Bot'); + expect(world.setDynamicProperty).toHaveBeenCalledWith('nametagPrefix', 'Bot'); + }); +}); + +describe('addNametagPrefix', () => { + let u; + + beforeEach(() => { + u = Understudies.create('Alice'); + Understudies.onConnect(); + u.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + }); + + it('sets nameTag when a prefix is stored in world properties', () => { + world.getDynamicProperty.mockReturnValueOnce('Bot'); + Understudies.addNametagPrefix(u); + expect(u.simulatedPlayer.nameTag).toBe('§r[Bot§r] Alice'); + }); + + it('does not change nameTag when no prefix is stored', () => { + world.getDynamicProperty.mockReturnValueOnce(undefined); + const before = u.simulatedPlayer.nameTag; + Understudies.addNametagPrefix(u); + expect(u.simulatedPlayer.nameTag).toBe(before); + }); +}); + +describe('message helpers', () => { + it('returns the correct not-online message', () => { + expect(Understudies.getNotOnlineMessage('TestBot')).toEqual({ translate: 'simplayer.notonline', with: ['TestBot'] }); + }); + + it('returns the correct already-online message', () => { + expect(Understudies.getAlreadyOnlineMessage('TestBot')).toEqual({ translate: 'simplayer.alreadyonline', with: ['TestBot'] }); + }); +}); diff --git a/__tests__/BP/scripts/src/classes/simplayer/Understudy.test.js b/__tests__/BP/scripts/src/classes/simplayer/Understudy.test.js new file mode 100644 index 00000000..adeb6ddf --- /dev/null +++ b/__tests__/BP/scripts/src/classes/simplayer/Understudy.test.js @@ -0,0 +1,668 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { world, system, Block, Entity, Player } from '@minecraft/server'; +import { scheduler, worldDynamicPropertyStore } from '@forestoflight/minecraft-vitest-mocks'; +import Understudy from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudy'; +import { MOVE_OPTIONS } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playermove'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving', () => ({ + simplayerSaving: { getNativeValue: vi.fn(() => true), getID: vi.fn(() => 'simplayerSaving') } +})); +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { onConnect: vi.fn() } +})); + +describe('Understudy', () => { + let understudy; + + beforeEach(() => { + vi.clearAllMocks(); + system.currentTick = 0; + understudy = new Understudy('TestBot'); + }); + + describe('constructor', () => { + it('stores the given name', () => { + expect(understudy.name).toBe('TestBot'); + }); + + it('captures system.currentTick as createdTick', () => { + system.currentTick = 42; + expect(new Understudy('Alice').createdTick).toBe(42); + }); + + it('starts disconnected', () => { + expect(understudy.isConnected()).toBe(false); + }); + }); + + describe('isConnected', () => { + it('returns false before join is called', () => { + expect(understudy.isConnected()).toBe(false); + }); + + it('returns true after join is called', () => { + understudy.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + expect(understudy.isConnected()).toBe(true); + }); + + it('returns false after leave is called', () => { + understudy.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + understudy.leave(); + expect(understudy.isConnected()).toBe(false); + }); + }); + + describe('createdTick', () => { + it('returns the tick when the Understudy was created', () => { + system.currentTick = 100; + const u = new Understudy('Alice'); + expect(u.createdTick).toBe(100); + }); + + it('cannot be set', () => { + expect(() => { understudy.createdTick = 50; }).toThrow(); + }); + }); + + describe('join', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('creates a new simulated player', () => { + understudy.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + expect(understudy.simulatedPlayer).toBeDefined(); + }); + + it('sets isConnected to true', () => { + understudy.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + expect(understudy.isConnected()).toBe(true); + }); + + it('throws if already connected', () => { + understudy.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + expect(() => understudy.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() })).toThrow(); + }); + + it('warns if loading player info hits a known error', () => { + system.run.mockImplementation(cb => { scheduler.scheduleDelay(cb, 1); }); + vi.spyOn(world, 'getDynamicProperty').mockImplementation((key) => { + if (key === 'TestBot:playerinfo') + return 'invalid json'; + return void 0; + }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + understudy.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + scheduler.advanceTicks(1); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('throws if loading player info hits an unknown error', () => { + system.run.mockImplementation(cb => { cb(); }); + const original = world.getDynamicProperty; + vi.spyOn(world, 'getDynamicProperty').mockImplementation((key, value) => { + if (key === 'TestBot:playerinfo') + throw new Error('Unexpected error'); + else + return original.call(world, key, value); + }); + expect(() => { + understudy.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + }).toThrow(); + }); + }); + + describe('rejoin', () => { + it('calls join with saved player info', () => { + const savedInfo = { location: { x: 0, y: 64, z: 0 }, dimensionId: 'minecraft:overworld', rotation: { x: 0, y: 0 }, gameMode: 'Survival' }; + worldDynamicPropertyStore.set('TestBot:playerinfo', JSON.stringify(savedInfo)); + vi.spyOn(understudy, 'join'); + understudy.rejoin(); + expect(understudy.join).toHaveBeenCalledWith( + expect.objectContaining({ location: savedInfo.location, rotation: savedInfo.rotation, gameMode: savedInfo.gameMode }) + ); + }); + + it('throws if already connected', () => { + understudy.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + expect(() => understudy.rejoin()).toThrow(); + }); + }); + + describe('while connected', () => { + beforeEach(() => { + understudy.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + }); + + describe('onConnectedTick', () => { + it('updates the player info saver', () => { + understudy.onConnectedTick(); + expect(world.setDynamicProperty).toHaveBeenCalledWith( + expect.stringContaining('TestBot:playerinfo'), expect.any(String) + ); + }); + + it('runs the actions', () => { + understudy.actions.once('attack'); + understudy.onConnectedTick(); + expect(understudy.actions.isEmpty()).toBe(true); + }); + + it('clears the look target if it is no longer valid', () => { + const target = new Entity(); + target.isValid = true; + target.location = { x: 0, y: 64, z: 0 }; + understudy.look(target); + target.isValid = false; + understudy.onConnectedTick(); + expect(understudy.lookTarget).toBeUndefined(); + }); + + it('keeps the look target if it is still valid', () => { + const target = new Entity(); + target.isValid = true; + target.location = { x: 0, y: 64, z: 0 }; + understudy.look(target); + understudy.onConnectedTick(); + expect(understudy.lookTarget).toBe(target); + }); + + it('refreshes the held item of the simulated player', () => { + const spy = vi.spyOn(understudy, 'refreshHeldItem'); + understudy.onConnectedTick(); + expect(spy).toHaveBeenCalled(); + }); + }); + + describe('simulatedPlayer', () => { + it('returns the simulated player object', () => { + expect(understudy.simulatedPlayer).toBeDefined(); + }); + + it('cannot be set directly', () => { + expect(() => { understudy.simulatedPlayer = {}; }).toThrow(); + }); + + it('throws when accessed while not connected', () => { + understudy.leave(); + expect(() => understudy.simulatedPlayer).toThrow(); + }); + }); + + describe('actions', () => { + it('returns the actions object', () => { + expect(understudy.actions).toBeDefined(); + }); + + it('cannot be set directly', () => { + expect(() => { understudy.actions = {}; }).toThrow(); + }); + + it('throws when accessed while not connected', () => { + understudy.leave(); + expect(() => understudy.actions).toThrow(); + }); + }); + + describe('lookTarget / clearLookTarget', () => { + it('lookTarget returns undefined initially', () => { + expect(understudy.lookTarget).toBeUndefined(); + }); + + it('returns a target after instructed to look', () => { + const mockBlock = new Block(); + mockBlock.location = { x: 0, y: 64, z: 0 }; + understudy.look(mockBlock); + expect(understudy.lookTarget).toBeDefined(); + }); + + it('clearLookTarget sets lookTarget to undefined', () => { + understudy.clearLookTarget(); + expect(understudy.lookTarget).toBeUndefined(); + }); + + it('cannot be set directly', () => { + expect(() => { understudy.lookTarget = {}; }).toThrow(); + }); + + it('throws an error if the understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.lookTarget).toThrow(); + }); + }); + + describe('headRotation', () => { + it('returns simulatedPlayer.headRotation when there is no look target', () => { + understudy.simulatedPlayer.headRotation = { x: 10, y: 20 }; + expect(understudy.headRotation).toEqual({ x: 10, y: 20 }); + }); + + it('returns simulatedPlayer.headRotation and clears target when target.isValid is false', () => { + const invalidEntity = new Entity(); + invalidEntity.isValid = false; + invalidEntity.location = { x: 1, y: 64, z: 1 }; + understudy.look(invalidEntity); + expect(understudy.headRotation).toEqual({ x: 0, y: 0 }); + expect(understudy.lookTarget).toBeUndefined(); + }); + + it('returns a computed rotation toward a non-Entity target with isValid true', () => { + const blockTarget = new Block(); + blockTarget.isValid = true; + blockTarget.location = { x: 10, y: 64, z: 0 }; + understudy.look(blockTarget); + const result = understudy.headRotation; + expect(result).not.toEqual({ x: 0, y: 0 }); + }); + + it('returns a computed rotation toward an Entity target', () => { + const entityTarget = new Entity(); + entityTarget.isValid = true; + entityTarget.getHeadLocation = vi.fn(() => ({ x: 10, y: 65, z: 0 })); + understudy.look(entityTarget); + const result = understudy.headRotation; + expect(result).not.toEqual({ x: 0, y: 0 }); + }); + + it('returns headRotation when Entity.getHeadLocation throws', () => { + const entityTarget = new Entity(); + entityTarget.isValid = true; + entityTarget.getHeadLocation = vi.fn(() => { throw new Error('invalid'); }); + understudy.look(entityTarget); + expect(understudy.headRotation).toEqual({ x: 0, y: 0 }); + }); + + it('cannot be set directly', () => { + expect(() => { understudy.headRotation = {}; }).toThrow(); + }); + + it('throws an error if the understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.headRotation).toThrow(); + }); + }); + + describe('savePlayerInfo', () => { + it('delegates to playerInfoSaver.save()', () => { + understudy.savePlayerInfo(); + expect(world.setDynamicProperty).toHaveBeenCalled(); + }); + + it('throws an error if the understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.savePlayerInfo()).toThrow(); + }); + }); + + describe('leave', () => { + it('removes the simulated player', () => { + understudy.leave(); + expect(() => understudy.simulatedPlayer).toThrow(); + }); + + it('sets isConnected to false', () => { + understudy.leave(); + expect(understudy.isConnected()).toBe(false); + }); + + it('broadcasts a leave message', () => { + understudy.leave(); + expect(world.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.leave.broadcast', with: ['TestBot'] }); + }); + + it('throws when understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.leave()).toThrow(); + }); + }); + + describe('teleport', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('teleports the simulatedPlayer to the given location', () => { + const teleportOptions = { location: { x: 5, y: 70, z: 5 }, dimension: world.getDimension(), rotation: { x: 0, y: 0 } }; + understudy.teleport(teleportOptions); + expect(understudy.simulatedPlayer.teleport).toHaveBeenCalledWith( + teleportOptions.location, expect.any(Object) + ); + }); + + it('passes rotation and dimension in teleport options', () => { + const teleportOptions = { location: { x: 5, y: 70, z: 5 }, dimension: world.getDimension(), rotation: { x: 10, y: 45 } }; + understudy.teleport(teleportOptions); + const options = understudy.simulatedPlayer.teleport.mock.calls[0][1]; + expect(options.dimension).toBeDefined(); + expect(options.rotation).toEqual(teleportOptions.rotation); + }); + + it('uses 0, 0 as default rotation', () => { + const teleportOptions = { location: { x: 5, y: 70, z: 5 }, dimension: world.getDimension() }; + understudy.teleport(teleportOptions); + const options = understudy.simulatedPlayer.teleport.mock.calls[0][1]; + expect(options.rotation).toEqual({ x: 0, y: 0 }); + }); + + it('throws when understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.teleport({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() })).toThrow(); + }); + }); + + describe('look', () => { + it('calls 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({ translate: 'simplayer.claimprojectiles.success', with: ['TestBot', String(1)] }); + }); + + it('sends a message when no projectiles are found', () => { + understudy.simulatedPlayer.dimension = { getEntities: vi.fn(() => []) }; + understudy.simulatedPlayer.name = 'TestBot'; + understudy.claimProjectiles(10); + expect(world.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.claimprojectiles.none', with: ['TestBot', String(10)] }); + }); + + it('ignores invalid projectile components', () => { + const mockComponent = { isValid: false }; + const mockEntity = { getComponent: vi.fn(() => mockComponent) }; + const mockDimension = { getEntities: vi.fn(() => [mockEntity]) }; + understudy.simulatedPlayer.dimension = mockDimension; + understudy.simulatedPlayer.name = 'TestBot'; + understudy.claimProjectiles(10); + expect(world.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.claimprojectiles.none', with: ['TestBot', String(10)] }); + }); + + it('throws when understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.claimProjectiles(10)).toThrow(); + }); + }); + + describe('stopAll', () => { + it('clears all actions', () => { + understudy.actions.once('attack'); + understudy.stopAll(); + expect(understudy.actions.isEmpty()).toBe(true); + }); + + it('calls all stop methods on simulatedPlayer', () => { + understudy.stopAll(); + const simulatedPlayer = understudy.simulatedPlayer; + expect(simulatedPlayer.stopMoving).toHaveBeenCalled(); + expect(simulatedPlayer.stopBuild).toHaveBeenCalled(); + expect(simulatedPlayer.stopInteracting).toHaveBeenCalled(); + expect(simulatedPlayer.stopBreakingBlock).toHaveBeenCalled(); + expect(simulatedPlayer.stopUsingItem).toHaveBeenCalled(); + expect(simulatedPlayer.stopSwimming).toHaveBeenCalled(); + expect(simulatedPlayer.stopGliding).toHaveBeenCalled(); + }); + + it('resets sprint and sneak', () => { + understudy.sprint(true); + understudy.sneak(true); + understudy.stopAll(); + expect(understudy.simulatedPlayer.isSprinting).toBe(false); + expect(understudy.simulatedPlayer.isSneaking).toBe(false); + }); + + it('clears the look target', () => { + const target = new Entity(); + target.location = { x: 0, y: 64, z: 0 }; + understudy.look(target); + understudy.stopAll(); + expect(understudy.lookTarget).toBeUndefined(); + }); + + it('throws when understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.stopAll()).toThrow(); + }); + }); + + describe('getInventory', () => { + it('returns the inventory container from simulatedPlayer', () => { + expect(understudy.getInventory()).toBeDefined(); + }); + + it('returns undefined when simulatedPlayer has no inventory component', () => { + understudy.simulatedPlayer.getComponent.mockReturnValue(undefined); + expect(understudy.getInventory()).toBeUndefined(); + }); + + it('throws when understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.getInventory()).toThrow(); + }); + }); + + describe('swapHeldItemWithPlayer', () => { + let targetContainer; + let targetPlayer; + + beforeEach(() => { + targetContainer = { swapItems: vi.fn() }; + targetPlayer = { + getComponent: vi.fn(() => ({ container: targetContainer })), + selectedSlotIndex: 1, + sendMessage: vi.fn() + }; + }); + + it('swaps items between the understudy and the target player', () => { + understudy.swapHeldItemWithPlayer(targetPlayer); + expect(understudy.getInventory().swapItems).toHaveBeenCalledWith(0, 1, targetContainer); + }); + + it('sends an error message when swapItems throws', () => { + vi.spyOn(understudy.getInventory(), 'swapItems').mockImplementation(() => { throw new Error('swap failed'); }); + understudy.swapHeldItemWithPlayer(targetPlayer); + expect(targetPlayer.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.swapheld.error', with: ['Error'] }); + }); + + it('throws when understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.swapHeldItemWithPlayer(targetPlayer)).toThrow(); + }); + }); + + describe('refreshHeldItem', () => { + it('re-assigns the selected slot index to visually refresh the held item', () => { + understudy.refreshHeldItem(); + expect(understudy.simulatedPlayer.selectedSlotIndex).toBe(0); + }); + + it('throws when understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.refreshHeldItem()).toThrow(); + }); + }); + }); +}); diff --git a/__tests__/BP/scripts/src/classes/simplayer/UnderstudyInventorySaver.test.js b/__tests__/BP/scripts/src/classes/simplayer/UnderstudyInventorySaver.test.js new file mode 100644 index 00000000..451410ff --- /dev/null +++ b/__tests__/BP/scripts/src/classes/simplayer/UnderstudyInventorySaver.test.js @@ -0,0 +1,183 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { world, Container, EquipmentSlot, EntityComponentTypes } from '@minecraft/server'; +import { makeEquippable } from '@minecraft/server-gametest'; +import { UnderstudyInventorySaver } from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/UnderstudyInventorySaver'; +import Understudy from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudy'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving', () => ({ + simplayerSaving: { getNativeValue: vi.fn(() => true), getID: vi.fn(() => 'simplayerSaving') } +})); +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { onConnect: vi.fn() } +})); + +describe('UnderstudyInventorySaver', () => { + let understudy; + let inventorySaver; + + beforeEach(() => { + vi.clearAllMocks(); + understudy = new Understudy('TestBot'); + inventorySaver = new UnderstudyInventorySaver(understudy); + understudy.join({ location: { x: 0, y: 0, z: 0 }, dimension: world.getDimension('overworld') }); + }); + + describe('constructor', () => { + it('sets inventory dynamic property key based on player name', () => { + expect(inventorySaver.inventoryDP).toBe('bot_TestBot_inventory'); + }); + + it('sets equippable dynamic property key based on player name', () => { + expect(inventorySaver.equippableDP).toBe('bot_TestBot_equippable'); + }); + + it('truncates player name to 8 characters in the table name', () => { + const inv = new UnderstudyInventorySaver(new Understudy('LongNamedPlayer')); + expect(inv.inventoryDP).toBe('bot_LongName_inventory'); + }); + }); + + describe('save', () => { + it('writes inventory items to world dynamic property', () => { + inventorySaver.save(); + expect(world.setDynamicProperty).toHaveBeenCalledWith('bot_TestBot_inventory', expect.any(String)); + }); + + it('writes equippable items to world dynamic property', () => { + inventorySaver.save(); + expect(world.setDynamicProperty).toHaveBeenCalledWith('bot_TestBot_equippable', expect.any(String)); + }); + + it('includes equipped items in the serialized equippable output', () => { + const sword = { typeId: 'minecraft:iron_sword', amount: 1 }; + const equippable = makeEquippable({ [EquipmentSlot.Head]: sword }); + const container = understudy.getInventory(); + understudy.simulatedPlayer.getComponent.mockImplementation(type => { + if (type === EntityComponentTypes.Equippable) return equippable; + if (type === EntityComponentTypes.Inventory) return { container }; + }); + inventorySaver.save(); + expect(world.setDynamicProperty).toHaveBeenCalledWith( + 'bot_TestBot_equippable', + expect.stringContaining('"Head":{"typeId":"minecraft:iron_sword","amount":1}') + ); + }); + + it('excludes undefined items from the serialized output', () => { + const inventory = understudy.getInventory(); + const itemStack = { typeId: 'minecraft:stone', amount: 1 }; + inventory.setItem(0, itemStack); + inventorySaver.save(); + expect(world.setDynamicProperty).toHaveBeenCalledWith( + 'bot_TestBot_inventory', JSON.stringify({ 0: itemStack }) + ); + }); + + it('saves items to the item database', () => { + const spy = vi.spyOn(inventorySaver.itemDatabase, 'setItems'); + inventorySaver.save(); + expect(spy).toHaveBeenCalled(); + }); + }); + + describe('saveWithoutNBT', () => { + it('writes inventory items to world dynamic property', () => { + inventorySaver.saveWithoutNBT(); + expect(world.setDynamicProperty).toHaveBeenCalledWith('bot_TestBot_inventory', expect.any(String)); + }); + + it('writes equippable items to world dynamic property', () => { + inventorySaver.saveWithoutNBT(); + expect(world.setDynamicProperty).toHaveBeenCalledWith('bot_TestBot_equippable', expect.any(String)); + }); + + it('does not save items to the item database', () => { + const spy = vi.spyOn(inventorySaver.itemDatabase, 'setItems'); + inventorySaver.saveWithoutNBT(); + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('load', () => { + describe('inventory', () => { + it('returns early when inventory component is absent', () => { + understudy.simulatedPlayer.getComponent.mockImplementation( + component => component === EntityComponentTypes.Equippable ? makeEquippable() : undefined + ); + inventorySaver.load(); + understudy.simulatedPlayer.getComponent.mockRestore(); + expect(understudy.getInventory().setItem).not.toHaveBeenCalled(); + }); + + it('returns early when no saved data exists', () => { + inventorySaver.load(); + expect(understudy.getInventory().setItem).not.toHaveBeenCalled(); + }); + + it('sets items in the inventory container from saved data', () => { + world.getDynamicProperty.mockImplementation(key => + key === 'bot_TestBot_inventory' + ? JSON.stringify({ 0: { typeId: 'minecraft:stone', amount: 1 } }) + : undefined + ); + vi.spyOn(inventorySaver.itemDatabase, 'getItems').mockReturnValue([{ typeId: 'minecraft:stone', amount: 1 }]); + inventorySaver.load(); + expect(understudy.getInventory().setItem).toHaveBeenCalled(); + }); + + it('falls back to non-NBT item data when absent from the NBT database', () => { + world.getDynamicProperty.mockImplementation(key => + key === 'bot_TestBot_inventory' + ? JSON.stringify({ 0: { typeId: 'minecraft:stone', amount: 1 } }) + : undefined + ); + vi.spyOn(inventorySaver.itemDatabase, 'getItems').mockReturnValue([]); + inventorySaver.load(); + expect(understudy.getInventory().setItem).toHaveBeenCalledWith( + 0, + expect.objectContaining({ typeId: 'minecraft:stone', amount: 1 }) + ); + }); + + it('sets undefined for slots with no saved item data', () => { + world.getDynamicProperty.mockImplementation(key => + key === 'bot_TestBot_inventory' + ? JSON.stringify({ 0: { typeId: 'minecraft:stone', amount: 1 } }) + : undefined + ); + vi.spyOn(inventorySaver.itemDatabase, 'getItems').mockReturnValue([]); + inventorySaver.load(); + expect(understudy.getInventory().setItem).toHaveBeenCalledWith(1, undefined); + }); + }); + + describe('equippable', () => { + it('returns early when equippable component is absent', () => { + understudy.simulatedPlayer.getComponent.mockImplementation( + component => component === EntityComponentTypes.Inventory ? new Container() : undefined + ); + inventorySaver.load(); + understudy.simulatedPlayer.getComponent.mockRestore(); + const equippable = understudy.simulatedPlayer.getComponent(EntityComponentTypes.Equippable); + expect(equippable.setEquipment).not.toHaveBeenCalled(); + }); + + it('returns early when no saved data exists', () => { + inventorySaver.load(); + const equippable = understudy.simulatedPlayer.getComponent(EntityComponentTypes.Equippable); + expect(equippable.setEquipment).not.toHaveBeenCalled(); + }); + + it('calls setEquipment for each slot from saved data', () => { + const savedData = Object.fromEntries(Object.keys(EquipmentSlot).map(slot => [slot, null])); + world.getDynamicProperty.mockImplementation(key => + key === 'bot_TestBot_equippable' ? JSON.stringify(savedData) : undefined + ); + vi.spyOn(inventorySaver.itemDatabase, 'getItems').mockReturnValue([]); + inventorySaver.load(); + const equippable = understudy.simulatedPlayer.getComponent(EntityComponentTypes.Equippable); + expect(equippable.setEquipment).toHaveBeenCalledTimes(Object.keys(EquipmentSlot).length); + }); + }); + }); +}); diff --git a/__tests__/BP/scripts/src/classes/simplayer/utils.test.js b/__tests__/BP/scripts/src/classes/simplayer/utils.test.js new file mode 100644 index 00000000..10fa75fa --- /dev/null +++ b/__tests__/BP/scripts/src/classes/simplayer/utils.test.js @@ -0,0 +1,233 @@ +import { beforeEach, describe, it, expect, vi } from 'vitest'; +import { Block, Entity, Player, world } from '@minecraft/server'; +import { getLookAtLocation, getLookAtRotation, swapSlots, portOldGameModeToNewUpdate, getLocationInfoFromSource } from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/utils.js'; + +vi.mock('@minecraft/server', async () => await import('@forestoflight/minecraft-vitest-mocks/server')); + +const PLAYER_EYE_HEIGHT = 1.62001002; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('getLookAtLocation', () => { + it('returns a location offset from base using rotation', () => { + const base = { x: 0, y: 0, z: 0 }; + const rotation = { x: 0, y: 0 }; + const result = getLookAtLocation(base, rotation); + expect(result).toHaveProperty('x'); + expect(result).toHaveProperty('y'); + expect(result).toHaveProperty('z'); + }); + + it('adds PLAYER_EYE_HEIGHT to y when pitch is 0', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtLocation(base, { x: 0, y: 0 }); + expect(result.y).toBeCloseTo(PLAYER_EYE_HEIGHT); + }); + + it('looks south (positive z) when yaw is 0', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtLocation(base, { x: 0, y: 0 }); + expect(result.z).toBeCloseTo(1000); + expect(result.x).toBeCloseTo(0); + }); + + it('looks west (negative x) when yaw is 90', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtLocation(base, { x: 0, y: 90 }); + expect(result.x).toBeCloseTo(-1000); + expect(result.z).toBeCloseTo(0); + }); + + it('looks north (negative z) when yaw is 180', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtLocation(base, { x: 0, y: 180 }); + expect(result.z).toBeCloseTo(-1000); + expect(result.x).toBeCloseTo(0); + }); + + it('looks east (positive x) when yaw is -90', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtLocation(base, { x: 0, y: -90 }); + expect(result.x).toBeCloseTo(1000); + expect(result.z).toBeCloseTo(0); + }); + + it('points straight up when pitch is -90', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtLocation(base, { x: -90, y: 0 }); + expect(result.y).toBeCloseTo(1000 + PLAYER_EYE_HEIGHT); + expect(result.x).toBeCloseTo(0); + expect(result.z).toBeCloseTo(0); + }); + + it('points straight down when pitch is 90', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtLocation(base, { x: 90, y: 0 }); + expect(result.y).toBeCloseTo(-1000 + PLAYER_EYE_HEIGHT); + }); + + it('offsets from base location', () => { + const base = { x: 10, y: 5, z: 20 }; + const result = getLookAtLocation(base, { x: 0, y: 0 }); + expect(result.x).toBeCloseTo(10); + expect(result.y).toBeCloseTo(5 + PLAYER_EYE_HEIGHT); + expect(result.z).toBeCloseTo(1020); + }); +}); + +describe('getLookAtRotation', () => { + it('returns pitch and yaw from base to target', () => { + const base = { x: 0, y: 0, z: 0 }; + const target = { x: 0, y: PLAYER_EYE_HEIGHT, z: 1 }; + const result = getLookAtRotation(base, target); + expect(result).toHaveProperty('x'); + expect(result).toHaveProperty('y'); + expect(typeof result.x).toBe('number'); + expect(typeof result.y).toBe('number'); + }); + + it('returns pitch ~0 when target is at eye height directly south', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtRotation(base, { x: 0, y: PLAYER_EYE_HEIGHT, z: 1 }); + expect(result.x).toBeCloseTo(0); + }); + + it('returns pitch ~0 when target is at eye height directly north', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtRotation(base, { x: 0, y: PLAYER_EYE_HEIGHT, z: -1 }); + expect(result.x).toBeCloseTo(0); + }); + + it('returns pitch ~0 when target is at eye height directly east', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtRotation(base, { x: 1, y: PLAYER_EYE_HEIGHT, z: 0 }); + expect(result.x).toBeCloseTo(0); + }); + + it('returns pitch ~0 when target is at eye height directly west', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtRotation(base, { x: -1, y: PLAYER_EYE_HEIGHT, z: 0 }); + expect(result.x).toBeCloseTo(0); + }); + + it('returns pitch -90 when looking straight up', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtRotation(base, { x: 0, y: PLAYER_EYE_HEIGHT + 1000, z: 0 }); + expect(result.x).toBeCloseTo(-90); + }); + + it('returns pitch 90 when looking straight down', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtRotation(base, { x: 0, y: PLAYER_EYE_HEIGHT - 1000, z: 0 }); + expect(result.x).toBeCloseTo(90); + }); +}); + +describe('swapSlots', () => { + it('swaps items between two slots', () => { + const item0 = { typeId: 'minecraft:apple' }; + const item1 = { typeId: 'minecraft:stone' }; + const container = { + getItem: vi.fn(i => i === 0 ? item0 : item1), + setItem: vi.fn() + }; + swapSlots(container, 0, 1); + expect(container.setItem).toHaveBeenCalledWith(0, item1); + expect(container.setItem).toHaveBeenCalledWith(1, item0); + }); + + it('swaps when one slot is empty', () => { + const item0 = { typeId: 'minecraft:apple' }; + const container = { + getItem: vi.fn(i => i === 0 ? item0 : undefined), + setItem: vi.fn() + }; + swapSlots(container, 0, 1); + expect(container.setItem).toHaveBeenCalledWith(0, undefined); + expect(container.setItem).toHaveBeenCalledWith(1, item0); + }); + + it('throws when container is null', () => { + expect(() => swapSlots(null, 0, 1)).toThrow(); + }); + + it('throws when container is undefined', () => { + expect(() => swapSlots(undefined, 0, 1)).toThrow(); + }); +}); + +describe('portOldGameModeToNewUpdate', () => { + it('converts string game modes to GameMode enum values', () => { + expect(portOldGameModeToNewUpdate('survival')).toBe('Survival'); + expect(portOldGameModeToNewUpdate('creative')).toBe('Creative'); + expect(portOldGameModeToNewUpdate('adventure')).toBe('Adventure'); + expect(portOldGameModeToNewUpdate('spectator')).toBe('Spectator'); + }); + + it('handles uppercase game mode strings', () => { + expect(portOldGameModeToNewUpdate('Survival')).toBe('Survival'); + expect(portOldGameModeToNewUpdate('Creative')).toBe('Creative'); + expect(portOldGameModeToNewUpdate('Adventure')).toBe('Adventure'); + expect(portOldGameModeToNewUpdate('Spectator')).toBe('Spectator'); + }); + + it('throws on unknown game mode string', () => { + expect(() => portOldGameModeToNewUpdate('unknown')).toThrow(); + }); + + it('throws when gameMode is not a string', () => { + expect(() => portOldGameModeToNewUpdate(0)).toThrow(); + }); + + it('throws when gameMode is null', () => { + expect(() => portOldGameModeToNewUpdate(null)).toThrow(); + }); +}); + +describe('getLocationInfoFromSource', () => { + it('throws for invalid source', () => { + expect(() => getLocationInfoFromSource({})).toThrow(); + }); + + it('throws for null source', () => { + expect(() => getLocationInfoFromSource(null)).toThrow(); + }); + + it('returns location, dimension, rotation, and gameMode for a Player source', () => { + const player = new Player(); + player.location = { x: 1, y: 64, z: 1 }; + player.dimension = world.getDimension(); + player.getRotation.mockReturnValue({ x: 0, y: 90 }); + player.getGameMode.mockReturnValue('Survival'); + const result = getLocationInfoFromSource(player); + expect(result.location).toEqual(player.location); + expect(result.dimension).toBe(player.dimension); + expect(result.rotation).toEqual({ x: 0, y: 90 }); + expect(result.gameMode).toBe('Survival'); + }); + + it('returns location, dimension, and rotation for an Entity source', () => { + const entity = new Entity(); + entity.location = { x: 5, y: 70, z: 5 }; + entity.dimension = world.getDimension(); + entity.getRotation.mockReturnValue({ x: 10, y: 45 }); + const result = getLocationInfoFromSource(entity); + expect(result.location).toEqual(entity.location); + expect(result.dimension).toBe(entity.dimension); + expect(result.rotation).toEqual({ x: 10, y: 45 }); + expect(result.gameMode).toBeUndefined(); + }); + + it('returns offset location and dimension for a Block source', () => { + const block = new Block(); + block.x = 5; + block.y = 63; + block.z = 5; + block.dimension = world.getDimension(); + const result = getLocationInfoFromSource(block); + expect(result.location).toEqual({ x: 5.5, y: 64, z: 5.5 }); + expect(result.dimension).toBe(block.dimension); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playeraction.test.js b/__tests__/BP/scripts/src/commands/simplayer/playeraction.test.js new file mode 100644 index 00000000..3ae615c8 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playeraction.test.js @@ -0,0 +1,103 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { CustomCommandStatus } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playeractionCommand } 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 => ({ translate: 'simplayer.notonline', with: [name] })), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playeractionCommand', () => { + let mockActions; + let mockUnderstudy; + let mockOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockActions = { + once: vi.fn(), + repeat: vi.fn(), + remove: vi.fn(), + }; + mockUnderstudy = { name: 'TestBot', actions: mockActions }; + mockOrigin = { sendMessage: vi.fn() }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playeractionCommand.playeractionCommand(mockOrigin, 'TestBot', REPEATABLE_ACTIONS.ATTACK); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); + }); + + it('queues a once action with ONCE timing (default)', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playeractionCommand.playeractionCommand(mockOrigin, 'TestBot', REPEATABLE_ACTIONS.ATTACK, TIMING_OPTIONS.ONCE); + expect(mockActions.once).toHaveBeenCalledWith(REPEATABLE_ACTIONS.ATTACK); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns failure for AFTER timing without ticks', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playeractionCommand.playeractionCommand(mockOrigin, 'TestBot', REPEATABLE_ACTIONS.ATTACK, TIMING_OPTIONS.AFTER, undefined); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playeraction.invalidticks', with: [TIMING_OPTIONS.AFTER, 'undefined'] }); + }); + + it('queues a delayed once action with AFTER timing', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playeractionCommand.playeractionCommand(mockOrigin, 'TestBot', REPEATABLE_ACTIONS.ATTACK, TIMING_OPTIONS.AFTER, 10); + expect(mockActions.once).toHaveBeenCalledWith(REPEATABLE_ACTIONS.ATTACK, 10); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('queues a repeating action with CONTINUOUS timing', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playeractionCommand.playeractionCommand(mockOrigin, 'TestBot', REPEATABLE_ACTIONS.ATTACK, TIMING_OPTIONS.CONTINUOUS); + expect(mockActions.repeat).toHaveBeenCalledWith(REPEATABLE_ACTIONS.ATTACK); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns failure for INTERVAL timing without ticks', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playeractionCommand.playeractionCommand(mockOrigin, 'TestBot', REPEATABLE_ACTIONS.ATTACK, TIMING_OPTIONS.INTERVAL, undefined); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playeraction.invalidticks', with: [TIMING_OPTIONS.INTERVAL, 'undefined'] }); + }); + + it('queues an interval repeating action with INTERVAL timing', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playeractionCommand.playeractionCommand(mockOrigin, 'TestBot', REPEATABLE_ACTIONS.ATTACK, TIMING_OPTIONS.INTERVAL, 20); + expect(mockActions.repeat).toHaveBeenCalledWith(REPEATABLE_ACTIONS.ATTACK, 20); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('removes a repeating action with STOP timing', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playeractionCommand.playeractionCommand(mockOrigin, 'TestBot', REPEATABLE_ACTIONS.ATTACK, TIMING_OPTIONS.STOP); + expect(mockActions.remove).toHaveBeenCalledWith(REPEATABLE_ACTIONS.ATTACK); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns failure for invalid timing option', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playeractionCommand.playeractionCommand(mockOrigin, 'TestBot', REPEATABLE_ACTIONS.ATTACK, 'invalid'); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playeraction.invalidtiming', with: [REPEATABLE_ACTIONS.ATTACK, 'invalid'] }); + }); + + it('defaults to ONCE timing when no timing option is provided', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + playeractionCommand.playeractionCommand(mockOrigin, 'TestBot', REPEATABLE_ACTIONS.ATTACK); + expect(mockActions.once).toHaveBeenCalledWith(REPEATABLE_ACTIONS.ATTACK); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerinventory.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerinventory.test.js new file mode 100644 index 00000000..8d80c2b2 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerinventory.test.js @@ -0,0 +1,87 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { CustomCommandStatus } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playerinventoryCommand } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playerinventory'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playerinventoryCommand', () => { + let mockUnderstudy; + let mockOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { name: 'TestBot', getInventory: vi.fn() }; + mockOrigin = { sendMessage: vi.fn() }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playerinventoryCommand.playerinventoryCommand(mockOrigin, 'TestBot'); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); + }); + + it('returns success with no-inventory message when inventory is absent', () => { + mockUnderstudy.getInventory.mockReturnValue(undefined); + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerinventoryCommand.playerinventoryCommand(mockOrigin, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(result.message).toBe('commands.playerinventory.noinventory'); + }); + + it('returns success with empty message when all slots are empty', () => { + mockUnderstudy.getInventory.mockReturnValue({ size: 36, emptySlotsCount: 36, getItem: vi.fn(() => undefined) }); + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerinventoryCommand.playerinventoryCommand(mockOrigin, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playerinventory.empty', with: ['TestBot'] }); + }); + + it('lists items when inventory has contents', () => { + const mockInventory = { + size: 36, + emptySlotsCount: 35, + getItem: vi.fn(i => i === 0 ? { typeId: 'minecraft:stone', amount: 64 } : undefined) + }; + mockUnderstudy.getInventory.mockReturnValue(mockInventory); + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerinventoryCommand.playerinventoryCommand(mockOrigin, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ + rawtext: [ + { translate: 'commands.playerinventory.header', with: ['TestBot'] }, + { text: '\n' }, + { translate: 'commands.playerinventory.item', with: ['§a', '0', 'minecraft:stone', '64'] } + ] + }); + }); + + it('uses hotbar color code for slots 0-9', () => { + const mockInventory = { + size: 36, + emptySlotsCount: 35, + getItem: vi.fn(i => i === 0 ? { typeId: 'minecraft:stone', amount: 1 } : undefined) + }; + mockUnderstudy.getInventory.mockReturnValue(mockInventory); + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + playerinventoryCommand.playerinventoryCommand(mockOrigin, 'TestBot'); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ + rawtext: [ + { translate: 'commands.playerinventory.header', with: ['TestBot'] }, + { text: '\n' }, + { translate: 'commands.playerinventory.item', with: ['§a', '0', 'minecraft:stone', '1'] } + ] + }); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerjoin.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerjoin.test.js new file mode 100644 index 00000000..754f1cc6 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerjoin.test.js @@ -0,0 +1,51 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playerjoinCommand } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playerjoin'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + isOnline: vi.fn(() => false), + create: vi.fn(), + remove: vi.fn(), + addNametagPrefix: vi.fn(), + getNotOnlineMessage: vi.fn(name => `§cSimplayer '${name}' is not online.`), + getAlreadyOnlineMessage: vi.fn(name => ({ translate: 'simplayer.alreadyonline', with: [name] })), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playerjoinCommand', () => { + let mockUnderstudy; + let mockOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { join: vi.fn(), name: 'TestBot' }; + vi.mocked(Understudies.create).mockReturnValue(mockUnderstudy); + vi.mocked(Understudies.isOnline).mockReturnValue(false); + mockOrigin = { getSource: vi.fn(() => ({ location: { x: 0, y: 64, z: 0 }, dimension: {}, getRotation: vi.fn(() => ({ x: 0, y: 0 })), getGameMode: vi.fn(() => 'Survival') })), sendMessage: vi.fn() }; + }); + + it('returns failure when the simplayer is already online', () => { + vi.mocked(Understudies.isOnline).mockReturnValue(true); + const result = playerjoinCommand.playerjoinCommand(mockOrigin, 'TestBot'); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.alreadyonline', with: ['TestBot'] }); + }); + + it('queues a system.run when the simplayer is not online', () => { + playerjoinCommand.playerjoinCommand(mockOrigin, 'TestBot'); + expect(system.run).toHaveBeenCalled(); + }); + + it('returns undefined (no explicit return) when the simplayer is not online', () => { + const result = playerjoinCommand.playerjoinCommand(mockOrigin, 'TestBot'); + expect(result).toBeUndefined(); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerleave.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerleave.test.js new file mode 100644 index 00000000..c92d46fd --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerleave.test.js @@ -0,0 +1,49 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playerleaveCommand } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playerleave'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + isOnline: vi.fn(() => false), + remove: vi.fn(), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), + getAlreadyOnlineMessage: vi.fn(name => `§cSimplayer '${name}' is already online.`), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playerleaveCommand', () => { + let mockUnderstudy; + let mockOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { leave: vi.fn(), name: 'TestBot' }; + mockOrigin = { sendMessage: vi.fn() }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playerleaveCommand.playerleaveCommand(mockOrigin, 'TestBot'); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); + }); + + it('queues a system.run when the simplayer is online', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + playerleaveCommand.playerleaveCommand(mockOrigin, 'TestBot'); + expect(system.run).toHaveBeenCalled(); + }); + + it('returns undefined (no explicit return) when the simplayer is online', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerleaveCommand.playerleaveCommand(mockOrigin, 'TestBot'); + expect(result).toBeUndefined(); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerlook.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerlook.test.js new file mode 100644 index 00000000..2e6cd68d --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerlook.test.js @@ -0,0 +1,139 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system, Entity, CustomCommandStatus } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playerlookCommand, LOOK_OPTIONS } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playerlook'; +import { ServerCommandOrigin } from '../../../../../../Canopy[BP]/scripts/lib/canopy/commands/ServerCommandOrigin'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playerlookCommand', () => { + let mockUnderstudy; + let mockEntityOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { look: vi.fn(), stopLooking: vi.fn(), name: 'TestBot' }; + const mockEntity = new Entity(); + mockEntity.getBlockFromViewDirection = vi.fn(() => ({ block: { location: { x: 0, y: 64, z: 0 } } })); + mockEntity.getEntitiesFromViewDirection = vi.fn(() => [{ entity: new Entity() }]); + mockEntityOrigin = { getSource: vi.fn(() => mockEntity), sendMessage: vi.fn() }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.UP); + expect(result).toBeUndefined(); + expect(mockEntityOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); + }); + + it.each([LOOK_OPTIONS.UP, LOOK_OPTIONS.DOWN, LOOK_OPTIONS.NORTH, LOOK_OPTIONS.SOUTH, LOOK_OPTIONS.EAST, LOOK_OPTIONS.WEST])( + 'returns success for cardinal direction: %s', + (direction) => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', direction); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(system.run).toHaveBeenCalled(); + } + ); + + it('returns failure for BLOCK option from a non-entity source', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const serverOrigin = new ServerCommandOrigin({ sourceType: 'Server' }); + const result = playerlookCommand.playerlookCommand(serverOrigin, 'TestBot', LOOK_OPTIONS.BLOCK); + expect(result.status).toBe(CustomCommandStatus.Failure); + expect(result.message).toBe('commands.playerlook.block.entityonly'); + }); + + it('returns failure for BLOCK option when no block is in view', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + mockEntityOrigin.getSource().getBlockFromViewDirection.mockReturnValue(undefined); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.BLOCK); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns success for BLOCK option when a block is in view', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.BLOCK); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns failure for ENTITY option from a non-entity source', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const serverOrigin = new ServerCommandOrigin({ sourceType: 'Server' }); + const result = playerlookCommand.playerlookCommand(serverOrigin, 'TestBot', LOOK_OPTIONS.ENTITY); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns failure for ENTITY option when no entity is in view', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + mockEntityOrigin.getSource().getEntitiesFromViewDirection.mockReturnValue([]); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.ENTITY); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns success for ENTITY option when an entity is in view', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.ENTITY); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns failure for ME option from server origin', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const serverOrigin = new ServerCommandOrigin({ sourceType: 'Server' }); + const result = playerlookCommand.playerlookCommand(serverOrigin, 'TestBot', LOOK_OPTIONS.ME); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns success for ME option from entity origin', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.ME); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns success for AT option', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.AT, 0, 64, 0); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns failure for AT option when no position is provided', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.AT); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns success for ROTATION option', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.ROTATION, 0, 0); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns failure for ROTATION option when no rotation is provided', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.ROTATION); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns success for STOP option', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.STOP); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns failure for invalid look option', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', 'invalid'); + expect(result).toBeUndefined(); + expect(mockEntityOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playerlook.invalidoption', with: ['invalid'] }); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playermove.test.js b/__tests__/BP/scripts/src/commands/simplayer/playermove.test.js new file mode 100644 index 00000000..0050727b --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playermove.test.js @@ -0,0 +1,120 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system, Entity, CustomCommandStatus } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playermoveCommand, MOVE_OPTIONS } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playermove'; +import { ServerCommandOrigin } from '../../../../../../Canopy[BP]/scripts/lib/canopy/commands/ServerCommandOrigin'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playermoveCommand', () => { + let mockUnderstudy; + let mockEntityOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { moveRelative: vi.fn(), moveLocation: vi.fn(), stopMoving: vi.fn(), name: 'TestBot' }; + const mockEntity = new Entity(); + mockEntity.getBlockFromViewDirection = vi.fn(() => ({ block: { location: { x: 0, y: 64, z: 0 } } })); + mockEntity.getEntitiesFromViewDirection = vi.fn(() => [{ entity: new Entity() }]); + mockEntityOrigin = { getSource: vi.fn(() => mockEntity), sendMessage: vi.fn() }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playermoveCommand.playermoveCommand(mockEntityOrigin, 'TestBot', MOVE_OPTIONS.FORWARD); + expect(result).toBeUndefined(); + expect(mockEntityOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); + }); + + it.each([MOVE_OPTIONS.FORWARD, MOVE_OPTIONS.BACKWARD, MOVE_OPTIONS.LEFT, MOVE_OPTIONS.RIGHT])( + 'returns success for relative direction: %s', + (direction) => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playermoveCommand.playermoveCommand(mockEntityOrigin, 'TestBot', direction); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(system.run).toHaveBeenCalled(); + } + ); + + it('returns failure for BLOCK option from a non-entity source', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const serverOrigin = new ServerCommandOrigin({ sourceType: 'Server' }); + const result = playermoveCommand.playermoveCommand(serverOrigin, 'TestBot', MOVE_OPTIONS.BLOCK); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns failure for BLOCK option when no block is in view', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + mockEntityOrigin.getSource().getBlockFromViewDirection.mockReturnValue(undefined); + const result = playermoveCommand.playermoveCommand(mockEntityOrigin, 'TestBot', MOVE_OPTIONS.BLOCK); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns success for BLOCK option when a block is in view', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playermoveCommand.playermoveCommand(mockEntityOrigin, 'TestBot', MOVE_OPTIONS.BLOCK); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns failure for ENTITY option from a non-entity source', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const serverOrigin = new ServerCommandOrigin({ sourceType: 'Server' }); + const result = playermoveCommand.playermoveCommand(serverOrigin, 'TestBot', MOVE_OPTIONS.ENTITY); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns failure for ENTITY option when no entity is in view', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + mockEntityOrigin.getSource().getEntitiesFromViewDirection.mockReturnValue([]); + const result = playermoveCommand.playermoveCommand(mockEntityOrigin, 'TestBot', MOVE_OPTIONS.ENTITY); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns success for ENTITY option when an entity is in view', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playermoveCommand.playermoveCommand(mockEntityOrigin, 'TestBot', MOVE_OPTIONS.ENTITY); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns failure for ME option from server origin', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const serverOrigin = new ServerCommandOrigin({ sourceType: 'Server' }); + const result = playermoveCommand.playermoveCommand(serverOrigin, 'TestBot', MOVE_OPTIONS.ME); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns success for ME option from entity origin', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playermoveCommand.playermoveCommand(mockEntityOrigin, 'TestBot', MOVE_OPTIONS.ME); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns success for TO option', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playermoveCommand.playermoveCommand(mockEntityOrigin, 'TestBot', MOVE_OPTIONS.TO, { x: 0, y: 64, z: 0 }); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns success for STOP option', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playermoveCommand.playermoveCommand(mockEntityOrigin, 'TestBot', MOVE_OPTIONS.STOP); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns failure for invalid move option', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playermoveCommand.playermoveCommand(mockEntityOrigin, 'TestBot', 'invalid'); + expect(result).toBeUndefined(); + expect(mockEntityOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playermove.invalidoption', with: ['invalid'] }); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerprefix.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerprefix.test.js new file mode 100644 index 00000000..828cb2f8 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerprefix.test.js @@ -0,0 +1,37 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system, CustomCommandStatus } from '@minecraft/server'; +import { playerprefixCommand } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playerprefix'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + setNametagPrefix: vi.fn(), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playerprefixCommand', () => { + let mockOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockOrigin = { sendMessage: vi.fn() }; + }); + + it('clears the prefix and returns success message when "-none" is passed', () => { + const result = playerprefixCommand.playerprefixCommand(mockOrigin, '-none'); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(result.message).toBe('commands.playerprefix.removed'); + expect(system.run).toHaveBeenCalled(); + }); + + it('sets the prefix and returns success message with the new prefix', () => { + const result = playerprefixCommand.playerprefixCommand(mockOrigin, 'Bot'); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playerprefix.set', with: ['Bot'] }); + expect(system.run).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerrejoin.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerrejoin.test.js new file mode 100644 index 00000000..a259a69c --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerrejoin.test.js @@ -0,0 +1,54 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system, CustomCommandStatus } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playerrejoinCommand } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playerrejoin'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + isOnline: vi.fn(() => false), + create: vi.fn(), + addNametagPrefix: vi.fn(), + getNotOnlineMessage: vi.fn(name => `§cSimplayer '${name}' is not online.`), + getAlreadyOnlineMessage: vi.fn(name => ({ translate: 'simplayer.alreadyonline', with: [name] })), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playerrejoinCommand', () => { + let mockUnderstudy; + let mockOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { rejoin: vi.fn(), join: vi.fn(), name: 'TestBot' }; + vi.mocked(Understudies.create).mockReturnValue(mockUnderstudy); + vi.mocked(Understudies.isOnline).mockReturnValue(false); + mockOrigin = { + getSource: vi.fn(() => ({ + location: { x: 0, y: 64, z: 0 }, + dimension: {}, + getRotation: vi.fn(() => ({ x: 0, y: 0 })), + getGameMode: vi.fn(() => 'Survival') + })), + sendMessage: vi.fn() + }; + }); + + it('returns failure when the simplayer is already online', () => { + vi.mocked(Understudies.isOnline).mockReturnValue(true); + const result = playerrejoinCommand.playerrejoinCommand(mockOrigin, 'TestBot'); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.alreadyonline', with: ['TestBot'] }); + }); + + it('returns success and queues rejoin when the simplayer is offline', () => { + const result = playerrejoinCommand.playerrejoinCommand(mockOrigin, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(system.run).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerselect.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerselect.test.js new file mode 100644 index 00000000..a8d81218 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerselect.test.js @@ -0,0 +1,62 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system, CustomCommandStatus } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playerselectCommand } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playerselect'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playerselectCommand', () => { + let mockUnderstudy; + let mockOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { selectSlot: vi.fn(), name: 'TestBot' }; + mockOrigin = { sendMessage: vi.fn() }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playerselectCommand.playerselectCommand(mockOrigin, 'TestBot', 0); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); + }); + + it('returns failure when slot number is less than 0', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerselectCommand.playerselectCommand(mockOrigin, 'TestBot', -1); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playerselect.invalidslot', with: ['-1'] }); + }); + + it('returns failure when slot number is greater than 8', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerselectCommand.playerselectCommand(mockOrigin, 'TestBot', 9); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playerselect.invalidslot', with: ['9'] }); + }); + + it('returns success and queues selectSlot for valid slot 0', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerselectCommand.playerselectCommand(mockOrigin, 'TestBot', 0); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(system.run).toHaveBeenCalled(); + }); + + it('returns success and queues selectSlot for valid slot 8', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerselectCommand.playerselectCommand(mockOrigin, 'TestBot', 8); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(system.run).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playersneak.test.js b/__tests__/BP/scripts/src/commands/simplayer/playersneak.test.js new file mode 100644 index 00000000..bda3d8c9 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playersneak.test.js @@ -0,0 +1,48 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system, CustomCommandStatus } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playersneakCommand } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playersneak'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playersneakCommand', () => { + let mockUnderstudy; + let mockOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { sneak: vi.fn(), name: 'TestBot' }; + mockOrigin = { sendMessage: vi.fn() }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playersneakCommand.playersneakCommand(mockOrigin, 'TestBot', true); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); + }); + + it('returns success and queues sneak(true) when online', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playersneakCommand.playersneakCommand(undefined, 'TestBot', true); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(system.run).toHaveBeenCalled(); + }); + + it('returns success and queues sneak(false) when online', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playersneakCommand.playersneakCommand(undefined, 'TestBot', false); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(system.run).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playersprint.test.js b/__tests__/BP/scripts/src/commands/simplayer/playersprint.test.js new file mode 100644 index 00000000..c1b30611 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playersprint.test.js @@ -0,0 +1,48 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system, CustomCommandStatus } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playersprintCommand } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playersprint'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playersprintCommand', () => { + let mockUnderstudy; + let mockOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { sprint: vi.fn(), name: 'TestBot' }; + mockOrigin = { sendMessage: vi.fn() }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playersprintCommand.playersprintCommand(mockOrigin, 'TestBot', true); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); + }); + + it('returns success and queues sprint(true) when online', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playersprintCommand.playersprintCommand(undefined, 'TestBot', true); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(system.run).toHaveBeenCalled(); + }); + + it('returns success and queues sprint(false) when online', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playersprintCommand.playersprintCommand(undefined, 'TestBot', false); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(system.run).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerstop.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerstop.test.js new file mode 100644 index 00000000..a18b30c0 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerstop.test.js @@ -0,0 +1,41 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system, CustomCommandStatus } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playerstopCommand } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playerstop'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playerstopCommand', () => { + let mockUnderstudy; + let mockOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { stopAll: vi.fn(), name: 'TestBot' }; + mockOrigin = { sendMessage: vi.fn() }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playerstopCommand.playerstopCommand(mockOrigin, 'TestBot'); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); + }); + + it('returns success and queues stopAll when online', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerstopCommand.playerstopCommand(undefined, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(system.run).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerswapheld.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerswapheld.test.js new file mode 100644 index 00000000..9df76ce3 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerswapheld.test.js @@ -0,0 +1,41 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system, CustomCommandStatus } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playerswapheldCommand } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playerswapheld'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playerswapheldCommand', () => { + let mockUnderstudy; + let mockOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { swapHeldItemWithPlayer: vi.fn(), name: 'TestBot' }; + mockOrigin = { getSource: vi.fn(() => ({ name: 'Player1', selectedSlotIndex: 0 })), sendMessage: vi.fn() }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playerswapheldCommand.playerswapheldCommand(mockOrigin, 'TestBot'); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); + }); + + it('returns success and queues swap when online', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerswapheldCommand.playerswapheldCommand(mockOrigin, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(system.run).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playertp.test.js b/__tests__/BP/scripts/src/commands/simplayer/playertp.test.js new file mode 100644 index 00000000..12af4c8c --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playertp.test.js @@ -0,0 +1,46 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system, CustomCommandStatus } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playertpCommand } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playertp'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playertpCommand', () => { + let mockUnderstudy; + let mockOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { teleport: vi.fn(), name: 'TestBot' }; + mockOrigin = { getSource: vi.fn(() => ({ location: { x: 0, y: 64, z: 0 }, dimension: {}, getRotation: vi.fn(() => ({ x: 0, y: 0 })), getGameMode: vi.fn(() => 'Survival') })), sendMessage: vi.fn() }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playertpCommand.playertpCommand(mockOrigin, 'TestBot'); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); + }); + + it('returns success when the simplayer is online', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playertpCommand.playertpCommand(mockOrigin, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('queues a system.run for the teleport', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + playertpCommand.playertpCommand(mockOrigin, 'TestBot'); + expect(system.run).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/BP/scripts/src/rules/simplayer/noSimplayerSaving.test.js b/__tests__/BP/scripts/src/rules/simplayer/noSimplayerSaving.test.js new file mode 100644 index 00000000..34007054 --- /dev/null +++ b/__tests__/BP/scripts/src/rules/simplayer/noSimplayerSaving.test.js @@ -0,0 +1,32 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { worldDynamicPropertyStore } from '@forestoflight/minecraft-vitest-mocks'; +import { simplayerSaving } from '../../../../../../Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving'; + +describe('simplayerSaving', () => { + beforeEach(() => { + vi.clearAllMocks(); + worldDynamicPropertyStore.set('simplayerSaving', void 0); + }); + + describe('getID', () => { + it('returns the correct identifier', () => { + expect(simplayerSaving.getID()).toBe('simplayerSaving'); + }); + }); + + describe('getNativeValue', () => { + it('returns true by default when no value is stored', () => { + expect(simplayerSaving.getNativeValue()).toBe(true); + }); + + it('returns true when the rule is enabled', () => { + worldDynamicPropertyStore.set('simplayerSaving', true); + expect(simplayerSaving.getNativeValue()).toBe(true); + }); + + it('returns false when the rule is explicitly disabled', () => { + worldDynamicPropertyStore.set('simplayerSaving', false); + expect(simplayerSaving.getNativeValue()).toBe(false); + }); + }); +}); diff --git a/__tests__/BP/scripts/src/rules/simplayer/simplayerRejoining.test.js b/__tests__/BP/scripts/src/rules/simplayer/simplayerRejoining.test.js new file mode 100644 index 00000000..36aab6a7 --- /dev/null +++ b/__tests__/BP/scripts/src/rules/simplayer/simplayerRejoining.test.js @@ -0,0 +1,125 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { system, world } from '@minecraft/server'; +import { worldDynamicPropertyStore } from '@forestoflight/minecraft-vitest-mocks'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + create: vi.fn(), + addNametagPrefix: vi.fn(), + understudies: [] + } +})); + +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { simplayerRejoining } from '../../../../../../Canopy[BP]/scripts/src/rules/simplayer/simplayerRejoining'; + +describe('simplayerRejoining', () => { + beforeEach(() => { + vi.clearAllMocks(); + worldDynamicPropertyStore.set('simplayerRejoining', undefined); + worldDynamicPropertyStore.set('simplayersToRejoin', undefined); + Understudies.understudies = []; + }); + + describe('getID', () => { + it('returns the correct identifier', () => { + expect(simplayerRejoining.getID()).toBe('simplayerRejoining'); + }); + }); + + describe('getNativeValue', () => { + it('returns false by default', () => { + expect(simplayerRejoining.getNativeValue()).toBe(false); + }); + + it('returns true when the rule is enabled', () => { + worldDynamicPropertyStore.set('simplayerRejoining', true); + expect(simplayerRejoining.getNativeValue()).toBe(true); + }); + }); + + describe('subscribeToEvent', () => { + it('subscribes to the shutdown event', () => { + simplayerRejoining.subscribeToEvent(); + expect(system.beforeEvents.shutdown.subscribe).toHaveBeenCalledWith(simplayerRejoining.onShutdownBound); + }); + }); + + describe('unsubscribeFromEvent', () => { + it('unsubscribes from the shutdown event', () => { + simplayerRejoining.unsubscribeFromEvent(); + expect(system.beforeEvents.shutdown.unsubscribe).toHaveBeenCalledWith(simplayerRejoining.onShutdownBound); + }); + }); + + describe('onShutdown', () => { + it('saves the names of online simplayers when the rule is enabled', () => { + worldDynamicPropertyStore.set('simplayerRejoining', true); + Understudies.understudies = [{ name: 'Alice' }, { name: 'Bob' }]; + simplayerRejoining.onShutdown(); + expect(world.setDynamicProperty).toHaveBeenCalledWith('simplayersToRejoin', JSON.stringify(['Alice', 'Bob'])); + }); + + it('saves an empty array when no simplayers are online', () => { + worldDynamicPropertyStore.set('simplayerRejoining', true); + Understudies.understudies = []; + simplayerRejoining.onShutdown(); + expect(world.setDynamicProperty).toHaveBeenCalledWith('simplayersToRejoin', JSON.stringify([])); + }); + + it('saves an empty array when the rule is disabled', () => { + worldDynamicPropertyStore.set('simplayerRejoining', false); + Understudies.understudies = [{ name: 'Alice' }]; + simplayerRejoining.onShutdown(); + expect(world.setDynamicProperty).toHaveBeenCalledWith('simplayersToRejoin', JSON.stringify([])); + }); + }); + + describe('onStartup', () => { + it('does nothing when the rule is disabled', () => { + worldDynamicPropertyStore.set('simplayerRejoining', false); + simplayerRejoining.onStartup(); + expect(Understudies.create).not.toHaveBeenCalled(); + }); + + it('does nothing when no player list is stored', () => { + worldDynamicPropertyStore.set('simplayerRejoining', true); + simplayerRejoining.onStartup(); + expect(Understudies.create).not.toHaveBeenCalled(); + }); + + it('does nothing when stored player list is invalid JSON', () => { + worldDynamicPropertyStore.set('simplayerRejoining', true); + worldDynamicPropertyStore.set('simplayersToRejoin', 'not valid json'); + const warnSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + simplayerRejoining.onStartup(); + expect(Understudies.create).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('creates and rejoins simplayers listed in the stored data', () => { + worldDynamicPropertyStore.set('simplayerRejoining', true); + worldDynamicPropertyStore.set('simplayersToRejoin', JSON.stringify(['Alice', 'Bob'])); + const mockPlayer = { rejoin: vi.fn() }; + vi.mocked(Understudies.create).mockReturnValue(mockPlayer); + simplayerRejoining.onStartup(); + expect(Understudies.create).toHaveBeenCalledWith('Alice'); + expect(Understudies.create).toHaveBeenCalledWith('Bob'); + expect(mockPlayer.rejoin).toHaveBeenCalledTimes(2); + }); + + it('logs an error and continues when a player fails to rejoin', () => { + worldDynamicPropertyStore.set('simplayerRejoining', true); + worldDynamicPropertyStore.set('simplayersToRejoin', JSON.stringify(['Alice', 'Bob'])); + const alicePlayer = { rejoin: vi.fn(() => { throw new Error('rejoin failed'); }) }; + const bobPlayer = { rejoin: vi.fn() }; + vi.mocked(Understudies.create) + .mockReturnValueOnce(alicePlayer) + .mockReturnValueOnce(bobPlayer); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + simplayerRejoining.onStartup(); + expect(bobPlayer.rejoin).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + }); +}); diff --git a/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: {