import { getBlockId, getItemId } from "../../utils/mcdata.js"; import { actionsList } from './actions.js'; import { queryList } from './queries.js'; let suppressNoDomainWarning = false; const commandList = queryList.concat(actionsList); const commandMap = {}; for (let command of commandList) { commandMap[command.name] = command; } export function getCommand(name) { return commandMap[name]; } const commandRegex = /!(\w+)(?:\(([\s\S]*)\))?/ const argRegex = /(?:"[^"]*"|'[^']*'|[^,])+/g; export function containsCommand(message) { const commandMatch = message.match(commandRegex); if (commandMatch) return "!" + commandMatch[1]; return null; } export function commandExists(commandName) { if (!commandName.startsWith("!")) commandName = "!" + commandName; return commandMap[commandName] !== undefined; } /** * Converts a string into a boolean. * @param {string} input * @returns {boolean | null} the boolean or `null` if it could not be parsed. * */ function parseBoolean(input) { switch(input.toLowerCase()) { case 'false': //These are interpreted as flase; case 'f': case '0': case 'off': return false; case 'true': //These are interpreted as true; case 't': case '1': case 'on': return true; default: return null; } } /** * @param {number} value - the value to check * @param {number} lowerBound * @param {number} upperBound * @param {string} endpointType - The type of the endpoints represented as a two character string. `'[)'` `'()'` */ function checkInInterval(number, lowerBound, upperBound, endpointType) { switch (endpointType) { case '[)': return lowerBound <= number && number < upperBound; case '()': return lowerBound < number && number < upperBound; case '(]': return lowerBound < number && number <= upperBound; case '[]': return lowerBound <= number && number <= upperBound; default: throw new Error('Unknown endpoint type:', endpointType) } } // todo: handle arrays? /** * Returns an object containing the command, the command name, and the comand parameters. * If parsing unsuccessful, returns an error message as a string. * @param {string} message - A message from a player or language model containing a command. * @returns {string | Object} */ function parseCommandMessage(message) { const commandMatch = message.match(commandRegex); if (!commandMatch) return `Command is incorrectly formatted`; const commandName = "!"+commandMatch[1]; let args; if (commandMatch[2]) args = commandMatch[2].match(argRegex); else args = []; const command = getCommand(commandName); if(!command) return `${commandName} is not a command.` const params = commandParams(command); const paramNames = commandParamNames(command); if (args.length !== params.length) return `Command ${command.name} was given ${args.length} args, but requires ${params.length} args.`; for (let i = 0; i < args.length; i++) { const param = params[i]; //Remove any extra characters let arg = args[i].trim(); if ((arg.startsWith('"') && arg.endsWith('"')) || (arg.startsWith("'") && arg.endsWith("'"))) { arg = arg.substring(1, arg.length-1); } if (arg.includes('=')) { // this sanitizes syntaxes like "x=2" and ignores the param name let split = arg.split('='); args[i] = split[1]; } //Convert to the correct type switch(param.type) { case 'int': arg = Number.parseInt(arg); break; case 'float': arg = Number.parseFloat(arg); break; case 'boolean': arg = parseBoolean(arg); break; case 'BlockName': case 'ItemName': if (arg.endsWith('plank')) arg += 's'; // catches common mistakes like "oak_plank" instead of "oak_planks" case 'string': break; default: throw new Error(`Command '${commandName}' parameter '${paramNames[i]}' has an unknown type: ${param.type}`); } if(arg === null || Number.isNaN(arg)) return `Error: Param '${paramNames[i]}' must be of type ${param.type}.` if(typeof arg === 'number') { //Check the domain of numbers const domain = param.domain; if(domain) { /** * Javascript has a built in object for sets but not intervals. * Currently the interval (lowerbound,upperbound] is represented as an Array: `[lowerbound, upperbound, '(]']` */ if (!domain[2]) domain[2] = '[)'; //By default, lower bound is included. Upper is not. if(!checkInInterval(arg, ...domain)) { return `Error: Param '${paramNames[i]}' must be an element of ${domain[2][0]}${domain[0]}, ${domain[1]}${domain[2][1]}.`; //Alternatively arg could be set to the nearest value in the domain. } } else if (!suppressNoDomainWarning) { console.warn(`Command '${commandName}' parameter '${paramNames[i]}' has no domain set. Expect any value [-Infinity, Infinity].`) suppressNoDomainWarning = true; //Don't spam console. Only give the warning once. } } else if(param.type === 'BlockName') { //Check that there is a block with this name if(getBlockId(arg) == null) return `Invalid block type: ${arg}.` } else if(param.type === 'ItemName') { //Check that there is an item with this name if(getItemId(arg) == null) return `Invalid item type: ${arg}.` } args[i] = arg; } return { commandName, args }; } export function truncCommandMessage(message) { const commandMatch = message.match(commandRegex); if (commandMatch) { return message.substring(0, commandMatch.index + commandMatch[0].length); } return message; } export function isAction(name) { return actionsList.find(action => action.name === name) !== undefined; } /** * @param {Object} command * @returns {Object[]} The command's parameters. */ function commandParams(command) { if (!command.params) return []; return Object.values(command.params); } /** * @param {Object} command * @returns {string[]} The names of the command's parameters. */ function commandParamNames(command) { if (!command.params) return []; return Object.keys(command.params); } function numParams(command) { return commandParams(command).length; } export async function executeCommand(agent, message) { let parsed = parseCommandMessage(message); if (typeof parsed === 'string') return parsed; //The command was incorrectly formatted or an invalid input was given. else { console.log('parsed command:', parsed); const command = getCommand(parsed.commandName); let numArgs = 0; if (parsed.args) { numArgs = parsed.args.length; } if (numArgs !== numParams(command)) return `Command ${command.name} was given ${numArgs} args, but requires ${numParams(command)} args.`; else { const result = await command.perform(agent, ...parsed.args); return result; } } } export function getCommandDocs() { const typeTranslations = { //This was added to keep the prompt the same as before type checks were implemented. //If the language model is giving invalid inputs changing this might help. 'float': 'number', 'int': 'number', 'BlockName': 'string', 'ItemName': 'string', 'boolean': 'bool' } let docs = `\n*COMMAND DOCS\n You can use the following commands to perform actions and get information about the world. Use the commands with the syntax: !commandName or !commandName("arg1", 1.2, ...) if the command takes arguments.\n Do not use codeblocks. Only use one command in each response, trailing commands and comments will be ignored.\n`; for (let command of commandList) { docs += command.name + ': ' + command.description + '\n'; if (command.params) { docs += 'Params:\n'; for (let param in command.params) { docs += `${param}: (${typeTranslations[command.params[param].type]??command.params[param].type}) ${command.params[param].description}\n`; } } } return docs + '*\n'; }