diff --git a/biome.jsonc b/biome.jsonc index ed5db201824..ecabdbfb940 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -177,9 +177,10 @@ } }, - // Overrides to prevent unused import removal inside `overrides.ts` and enums files (for TSDoc linkcodes) + // Overrides to prevent unused import removal inside `overrides.ts` and enums files (for TSDoc linkcodes), + // as well as in all TS files in `scripts/` (which are assumed to be boilerplate templates). { - "includes": ["**/src/overrides.ts", "**/src/enums/**/*"], + "includes": ["**/src/overrides.ts", "**/src/enums/**/*", "**/scripts/**/*.ts"], "linter": { "rules": { "correctness": { @@ -189,7 +190,7 @@ } }, { - "includes": ["**/src/overrides.ts"], + "includes": ["**/src/overrides.ts", "**/scripts/**/*.ts"], "linter": { "rules": { "style": { diff --git a/package.json b/package.json index 64f2f9786db..b559fcbca29 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "test:watch": "vitest watch --coverage --no-isolate", "test:silent": "vitest run --silent --no-isolate", "test:create": "node scripts/create-test/create-test.js", + "eggMove:parse": "node scripts/parse-egg-moves/main.js", "typecheck": "tsc --noEmit", "eslint": "eslint --fix .", "eslint-ci": "eslint .", diff --git a/scripts/parse-egg-moves/egg-move-template.ts b/scripts/parse-egg-moves/egg-move-template.ts new file mode 100644 index 00000000000..bfac05f4bde --- /dev/null +++ b/scripts/parse-egg-moves/egg-move-template.ts @@ -0,0 +1,10 @@ +//! DO NOT EDIT THIS FILE - CREATED BY THE `eggMoves:parse` script automatically +import { MoveId } from "#enums/move-id"; +import { SpeciesId } from "#enums/species-id"; + +/** + * An object mapping all base form {@linkcode SpeciesId}s to an array of {@linkcode MoveId}s corresponding + * to their current egg moves. + * Generated by the `eggMoves:parse` script using a CSV sourced from the current Balance Team spreadsheet. + */ +export const speciesEggMoves = "{{table}}"; diff --git a/scripts/parse-egg-moves/help-message.js b/scripts/parse-egg-moves/help-message.js new file mode 100644 index 00000000000..57d28c36132 --- /dev/null +++ b/scripts/parse-egg-moves/help-message.js @@ -0,0 +1,16 @@ +import chalk from "chalk"; + +export function showHelpText() { + console.log(` +Usage: ${chalk.cyan("pnpm eggMove:parse [options]")} +If given no options, assumes ${chalk.blue("\`--interactive\`")}. +If given only a file path, assumes ${chalk.blue("\`--file\`")}. + +${chalk.hex("#ffa500")("Options:")} + ${chalk.blue("-h, --help")} Show this help message. + ${chalk.blue("-f, --file[=PATH]")} Specify a path to a CSV file to read, or provide one from stdin. + ${chalk.blue("-t, --text[=TEXT]")} + ${chalk.blue("-c, --console[=TEXT]")} Specify CSV text to read, or provide it from stdin. + ${chalk.blue("-i, --interactive")} Run in interactive mode (default) +`); +} diff --git a/scripts/parse-egg-moves/interactive.js b/scripts/parse-egg-moves/interactive.js new file mode 100644 index 00000000000..46150137f74 --- /dev/null +++ b/scripts/parse-egg-moves/interactive.js @@ -0,0 +1,104 @@ +import fs from "fs"; +import chalk from "chalk"; +import inquirer from "inquirer"; +import { showHelpText } from "./help-message.js"; + +/** + * Prompt the user to interactively select an option (console/file) to retrieve the egg move CSV. + * @returns {Promise<{type: "Console" | "File", value: string} | {type: "Exit"}>} The selected option with value + */ +export async function runInteractive() { + /** @type {"Console" | "File" | "HELP MEEEEE" | "Exit"} */ + const answer = await inquirer + .prompt([ + { + type: "list", + name: "type", + message: "Select the method to obtain egg moves.", + choices: ["Console", "File", "HELP MEEEEE", "Exit"], + }, + ]) + .then(a => a.type); + + if (answer === "Exit") { + console.log("Exiting..."); + process.exitCode = 1; + return { type: "Exit" }; + } + + if (answer === "HELP MEEEEE") { + showHelpText(); + return { type: "Exit" }; + } + + if (!["Console", "File"].includes(answer)) { + console.error(chalk.red("Please provide a valid type!")); + return await runInteractive(); + } + + return { type: answer, value: await promptForValue(answer) }; +} + +/** + * Prompt the user to give a value (either the direct CSV or the file path). + * @param {"Console" | "File"} type - The input method + * @returns {Promise} A Promise resolving with the CSV/file path. + */ +function promptForValue(type) { + switch (type) { + case "Console": + return doPromptConsole(); + case "File": + return getFilePath(); + } +} + +/** + * Prompt the user to enter a file path from the console. + * @returns {Promise} The file path inputted by the user. + */ +async function getFilePath() { + return await inquirer + .prompt([ + { + type: "input", + name: "path", + message: "Please enter the path to the egg move CSV file.", + validate: input => { + if (input.trim() === "") { + return "File path cannot be empty!"; + } + if (!fs.existsSync(input)) { + return "File does not exist!"; + } + return true; + }, + }, + ]) + .then(answer => answer.path); +} + +/** + * Prompt the user for CSV input from the console. + * @returns {Promise} The CSV input from the user. + */ +async function doPromptConsole() { + return await inquirer + .prompt([ + { + type: "input", + name: "csv", + message: "Please enter the egg move CSV text.", + validate: input => { + if (input.trim() === "") { + return "CSV text cannot be empty!"; + } + if (!input.match(/^[^,]+(,[^,]+){4}$/gm)) { + return "CSV text malformed - should contain 5 consecutive comma-separated values per line!"; + } + return true; + }, + }, + ]) + .then(answer => answer.csv); +} diff --git a/scripts/parse-egg-moves/main.js b/scripts/parse-egg-moves/main.js new file mode 100644 index 00000000000..1d0009a6fe9 --- /dev/null +++ b/scripts/parse-egg-moves/main.js @@ -0,0 +1,163 @@ +/* + * This script accepts a CSV value or file path as input, parses the egg moves, + * and writes the output to a TypeScript file. + * It can be run interactively or with command line arguments. + * Usage: `pnpm eggMove:parse` + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import chalk from "chalk"; +import { showHelpText } from "./help-message.js"; +import { runInteractive } from "./interactive.js"; +import { parseEggMoves } from "./parse.js"; + +const version = "0.0.0"; // Replace with actual version if needed + +// Get the directory name of the current module file +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.join(__dirname, "..", ".."); +const templatePath = path.join(__dirname, "egg-move-template.ts"); +// TODO: Do we want this to be configurable? +const eggMoveTargetPath = path.join(projectRoot, "src/data/balance/egg-moves.ts"); + +/** + * Runs the interactive eggMove:parse CLI. + * @returns {Promise} + */ +async function start() { + console.log(chalk.yellow(`🄚 Egg Move Parser - v${version}`)); + + if (process.argv.length > 4) { + console.error( + chalk.redBright.bold( + `āœ— Error: Too many arguments provided!\nArgs: ${chalk.hex("#7310fdff")(process.argv.slice(2).join(" "))}`, + ), + ); + showHelpText(); + process.exitCode = 1; + return; + } + + /** @type {string} */ + let csv = ""; + const inputType = await parseArguments(); + if (process.exitCode) { + // If exit code is non-zero, return to allow it to propagate up the chain. + return; + } + switch (inputType.type) { + case "Console": + csv = inputType.value; + break; + case "File": + csv = await fs.promises.readFile(inputType.value, "utf-8"); + break; + case "Exit": + // Help screen triggered; break out + return; + } + + await writeToFile(parseEggMoves(csv)); +} + +/** + * Handle the arguments passed to the script and obtain the CSV input type. + * @returns {Promise<{type: "Console" | "File", value: string} | {type: "Exit"}>} The input method selected by the user + */ +async function parseArguments() { + const args = process.argv.slice(2); // first 2 args are node and script name (irrelevant) + + /** @type {string | undefined} */ + const arg = args[0].split("=")[0]; // Yoink everything up to the first "=" to get the raw command + switch (arg) { + case "-f": + case "--file": + return { type: "File", value: getArgValue() }; + case "-t": + case "--text": + case "-c": + case "--console": + return { type: "Console", value: getArgValue() }; + case "-h": + case "--help": + showHelpText(); + process.exitCode = 0; + return { type: "Exit" }; + case "--interactive": + case "-i": + case undefined: + return await runInteractive(); + default: + // If no arguments are found, check if it's a file path + if (fs.existsSync(arg)) { + console.log(chalk.green(`Using file path: ${chalk.blue(arg)}`)); + return { type: "File", value: arg }; + } + badArgs(); + return { type: "Exit" }; + } +} + +/** + * Get the value of the argument provided. + * @returns {string} The CSV or file path from the arguments + * @throws {Error} If arguments are malformed + */ +function getArgValue() { + // If the user provided a value as argument 2, take that as the argument. + // Otherwise, check the 1st argument to see if it contains an `=` and extract everything afterwards. + /** @type {string | undefined} */ + let filePath = process.argv[3]; + const equalsIndex = process.argv[2].indexOf("="); + if (equalsIndex > -1) { + // If arg 3 was aleady existing and someone used `=` notation to assign a property, throw an error. + filePath = filePath ? undefined : process.argv[2].slice(equalsIndex + 1); + } + + if (!filePath?.trim()) { + badArgs(); + return ""; + } + // NB: It doesn't really matter that this can be `undefined` - we'll always break out by lieu of setting the exit code + return filePath; +} + +/** + * Write out the parsed CSV to a file. + * @param {string} moves - The parsed CSV + * @returns {Promise} + */ +export async function writeToFile(moves) { + try { + // Read the template file, replacing the placeholder with the move table. + const content = fs.readFileSync(templatePath, "utf8").replace(`"{{table}}"`, moves); + + if (fs.existsSync(eggMoveTargetPath)) { + console.warn(chalk.hex("#ffa500")("\nEgg moves file already exists, overwriting...\n")); + } + + // Write the template content to the file + fs.writeFileSync(eggMoveTargetPath, content, "utf8"); + + console.log(chalk.green.bold(`\nāœ” Egg Moves written to ${eggMoveTargetPath}`)); + console.groupEnd(); + } catch (err) { + console.error(chalk.red("āœ— Error while writing egg moves!", err.message)); + process.exitCode = 1; + } +} + +/** + * Do logging for incorrect or malformed CLI arguments. + * @returns {void} + */ +function badArgs() { + chalk.red.bold(`āœ— Error: Malformed arguments!\nArgs: ${chalk.hex("#7310fdff")(process.argv.slice(2).join(" "))}`); + showHelpText(); + process.exitCode = 1; +} + +start(); diff --git a/scripts/parse-egg-moves/parse.js b/scripts/parse-egg-moves/parse.js new file mode 100644 index 00000000000..b6d41965bdc --- /dev/null +++ b/scripts/parse-egg-moves/parse.js @@ -0,0 +1,60 @@ +import chalk from "chalk"; + +/** + * Given a CSV string, parse it and return a structured table ready to be inputted into code. + * @param {string} csv - The formatted CSV string. + * @returns {string} The fully formatted table. + */ +export function parseEggMoves(csv) { + console.log(chalk.grey("āš™ļø Parsing egg moves...")); + let output = "{\n"; + + const lines = csv.split(/\n/g); + + for (const line of lines) { + /** + * The individual CSV column for this species. + */ + const cols = + /** @type {[speciesName: string, move1: string, move2: string, move3: string, move4: string]} */ + (line.split(",").slice(0, 5)); + const speciesName = toUpperSnakeCase(cols[0]); + + /** @type {string[]} */ + const eggMoves = []; + + for (let m = 1; m < 5; m++) { + const moveName = cols[m].trim(); + if (moveName === "N/A") { + console.warn(`Species ${speciesName} missing ${m}th egg move!`); + eggMoves.push("MoveId.NONE"); + continue; + } + + // Remove (N) and (P) from the ends of move names before UPPER_SNAKE_CASE-ing them + const moveNameTitle = toUpperSnakeCase(moveName.replace(/ \([A-Z]\)$/, "")); + eggMoves.push("MoveId." + moveNameTitle); + } + + if (eggMoves.every(move => move === "MoveId.NONE")) { + console.warn(`Species ${speciesName} could not be parsed, excluding from output...`); + output += ` // [SpeciesId.${speciesName}]: [ MoveId.NONE, MoveId.NONE, MoveId.NONE, MoveId.NONE ],\n`; + } else { + output += ` [SpeciesId.${speciesName}]: [ ${eggMoves.join(", ")} ],\n`; + } + } + + return output + "} satisfies Partial>;"; +} + +/** + * Helper method to convert a string into `UPPER_SNAKE_CASE`. + * @param {string} str - The string being converted + * @returns {string} The result of converting `str` into upper snake case. + */ +function toUpperSnakeCase(str) { + return str + .split(/[_ -]+/g) + .map(word => word.toUpperCase()) + .join("_"); +} diff --git a/src/data/balance/egg-moves.ts b/src/data/balance/egg-moves.ts index f5026abe2ef..08ee74098fb 100644 --- a/src/data/balance/egg-moves.ts +++ b/src/data/balance/egg-moves.ts @@ -1,9 +1,12 @@ -import { allMoves } from "#data/data-lists"; +//! DO NOT EDIT THIS FILE - CREATED BY THE `eggMoves:parse` script automatically import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; -import { toReadableString } from "#utils/common"; -import { getEnumKeys, getEnumValues } from "#utils/enums"; +/** + * An object mapping all base form {@linkcode SpeciesId}s to an array of {@linkcode MoveId}s corresponding + * to their current egg moves. + * Generated by the `eggMoves:parse` script using a CSV sourced from the current Balance Team spreadsheet. + */ export const speciesEggMoves = { [SpeciesId.BULBASAUR]: [ MoveId.SAPPY_SEED, MoveId.MALIGNANT_CHAIN, MoveId.EARTH_POWER, MoveId.MATCHA_GOTCHA ], [SpeciesId.CHARMANDER]: [ MoveId.DRAGON_DANCE, MoveId.BITTER_BLADE, MoveId.EARTH_POWER, MoveId.OBLIVION_WING ], @@ -582,55 +585,4 @@ export const speciesEggMoves = { [SpeciesId.PALDEA_TAUROS]: [ MoveId.NO_RETREAT, MoveId.BLAZING_TORQUE, MoveId.AQUA_STEP, MoveId.THUNDEROUS_KICK ], [SpeciesId.PALDEA_WOOPER]: [ MoveId.STONE_AXE, MoveId.RECOVER, MoveId.BANEFUL_BUNKER, MoveId.BARB_BARRAGE ], [SpeciesId.BLOODMOON_URSALUNA]: [ MoveId.NASTY_PLOT, MoveId.ROCK_POLISH, MoveId.SANDSEAR_STORM, MoveId.BOOMBURST ] -}; - -/** - * Parse a CSV-separated list of Egg Moves (such as one sourced from a Google Sheets) - * into code able to form the `speciesEggMoves` const object as above. - * @param content - The CSV-formatted string to convert into code. - */ -// TODO: Move this into the scripts folder and stop running it on initialization -function parseEggMoves(content: string): void { - let output = ""; - - const speciesNames = getEnumKeys(SpeciesId); - const speciesValues = getEnumValues(SpeciesId); - const moveNames = allMoves.map(m => m.name.replace(/ \([A-Z]\)$/, "").toLowerCase()); - const lines = content.split(/\n/g); - - for (const line of lines) { - const cols = line.split(",").slice(0, 5); - const enumSpeciesName = cols[0].toUpperCase().replace(/[ -]/g, "_") as keyof typeof SpeciesId; - // TODO: This should use reverse mapping instead of `indexOf` - const species = speciesValues[speciesNames.indexOf(enumSpeciesName)]; - - const eggMoves: MoveId[] = []; - - for (let m = 0; m < 4; m++) { - const moveName = cols[m + 1].trim(); - const moveIndex = moveName !== "N/A" ? moveNames.indexOf(moveName.toLowerCase()) : -1; - if (moveIndex === -1) { - console.warn(moveName, "could not be parsed"); - } - - eggMoves.push(moveIndex > -1 ? moveIndex as MoveId : MoveId.NONE); - } - - if (eggMoves.every(m => m === MoveId.NONE)) { - console.warn(`Species ${toReadableString(SpeciesId[species])} could not be parsed, excluding from output...`) - } else { - output += `[SpeciesId.${SpeciesId[species]}]: [ ${eggMoves.map(m => `MoveId.${MoveId[m]}`).join(", ")} ],\n`; - } - } - - console.log(output); -} - -export function initEggMoves() { - const eggMovesStr = ""; - if (eggMovesStr) { - setTimeout(() => { - parseEggMoves(eggMovesStr); - }, 1000); - } -} +} satisfies Partial>; \ No newline at end of file diff --git a/src/loading-scene.ts b/src/loading-scene.ts index eb6883e0c68..686958aa0de 100644 --- a/src/loading-scene.ts +++ b/src/loading-scene.ts @@ -3,7 +3,6 @@ import { timedEventManager } from "#app/global-event-manager"; import { SceneBase } from "#app/scene-base"; import { isMobile } from "#app/touch-controls"; import { initBiomes } from "#balance/biomes"; -import { initEggMoves } from "#balance/egg-moves"; import { initPokemonPrevolutions, initPokemonStarters } from "#balance/pokemon-evolutions"; import { initChallenges } from "#data/challenge"; import { initTrainerTypeDialogue } from "#data/dialogue"; @@ -375,7 +374,6 @@ export class LoadingScene extends SceneBase { initPokemonPrevolutions(); initPokemonStarters(); initBiomes(); - initEggMoves(); initPokemonForms(); initTrainerTypeDialogue(); initSpecies(); diff --git a/test/testUtils/testFileInitialization.ts b/test/testUtils/testFileInitialization.ts index 98b49159d98..c1b7bb4fe37 100644 --- a/test/testUtils/testFileInitialization.ts +++ b/test/testUtils/testFileInitialization.ts @@ -2,7 +2,6 @@ import { initAbilities } from "#abilities/ability"; import { initLoggedInUser } from "#app/account"; import { SESSION_ID_COOKIE_NAME } from "#app/constants"; import { initBiomes } from "#balance/biomes"; -import { initEggMoves } from "#balance/egg-moves"; import { initPokemonPrevolutions, initPokemonStarters } from "#balance/pokemon-evolutions"; import { initPokemonForms } from "#data/pokemon-forms"; import { initSpecies } from "#data/pokemon-species"; @@ -97,7 +96,6 @@ export function initTestFile() { initStatsKeys(); initPokemonPrevolutions(); initBiomes(); - initEggMoves(); initPokemonForms(); initSpecies(); initMoves();