mirror of
https://github.com/pagefaultgames/pokerogue.git
synced 2025-08-19 22:09:27 +02:00
Merge 6b98afa34f
into dd03887d05
This commit is contained in:
commit
5fb24dc757
@ -14,6 +14,7 @@
|
|||||||
"test:watch": "vitest watch --coverage --no-isolate",
|
"test:watch": "vitest watch --coverage --no-isolate",
|
||||||
"test:silent": "vitest run --silent='passed-only' --no-isolate",
|
"test:silent": "vitest run --silent='passed-only' --no-isolate",
|
||||||
"test:create": "node scripts/create-test/create-test.js",
|
"test:create": "node scripts/create-test/create-test.js",
|
||||||
|
"scrape-trainers": "node scripts/scrape-trainer-names/main.js",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"biome": "biome check --write --changed --no-errors-on-unmatched --diagnostic-level=error",
|
"biome": "biome check --write --changed --no-errors-on-unmatched --diagnostic-level=error",
|
||||||
"biome-ci": "biome ci --diagnostic-level=error --reporter=github --no-errors-on-unmatched",
|
"biome-ci": "biome ci --diagnostic-level=error --reporter=github --no-errors-on-unmatched",
|
||||||
|
179
scripts/helpers/strings.js
Normal file
179
scripts/helpers/strings.js
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
// #region Split string code
|
||||||
|
// Regexps involved with splitting words in various case formats.
|
||||||
|
// Sourced from https://www.npmjs.com/package/change-case (with slight tweaking here and there)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regex to split at word boundaries.
|
||||||
|
* @type {RegExp}
|
||||||
|
*/
|
||||||
|
const SPLIT_LOWER_UPPER_RE = /([\p{Ll}\d])(\p{Lu})/gu;
|
||||||
|
/**
|
||||||
|
* Regex to split around single-letter uppercase words.
|
||||||
|
* @type {RegExp}
|
||||||
|
*/
|
||||||
|
const SPLIT_UPPER_UPPER_RE = /(\p{Lu})([\p{Lu}][\p{Ll}])/gu;
|
||||||
|
/**
|
||||||
|
* Regexp involved with stripping non-word delimiters from the result.
|
||||||
|
* @type {RegExp}
|
||||||
|
*/
|
||||||
|
const DELIM_STRIP_REGEXP = /[-_ ]+/giu;
|
||||||
|
// The replacement value for splits.
|
||||||
|
const SPLIT_REPLACE_VALUE = "$1\0$2";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split any cased string into an array of its constituent words.
|
||||||
|
* @param {string} value
|
||||||
|
* @returns {string[]} The new string, delimited at each instance of one or more spaces, underscores, hyphens
|
||||||
|
* or lower-to-upper boundaries.
|
||||||
|
*/
|
||||||
|
function splitWords(value) {
|
||||||
|
let result = value.trim();
|
||||||
|
result = result.replace(SPLIT_LOWER_UPPER_RE, SPLIT_REPLACE_VALUE).replace(SPLIT_UPPER_UPPER_RE, SPLIT_REPLACE_VALUE);
|
||||||
|
result = result.replace(DELIM_STRIP_REGEXP, "\0");
|
||||||
|
// Trim the delimiter from around the output string
|
||||||
|
return trimFromStartAndEnd(result, "\0").split(/\0/g);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to remove one or more sequences of characters from either end of a string.
|
||||||
|
* @param {string} str - The string to replace
|
||||||
|
* @param {string} charToTrim - The string to remove
|
||||||
|
* @returns {string} The string having been trimmed
|
||||||
|
*/
|
||||||
|
function trimFromStartAndEnd(str, charToTrim) {
|
||||||
|
let start = 0;
|
||||||
|
let end = str.length;
|
||||||
|
const blockLength = charToTrim.length;
|
||||||
|
while (str.startsWith(charToTrim, start)) {
|
||||||
|
start += blockLength;
|
||||||
|
}
|
||||||
|
if (start - end === blockLength) {
|
||||||
|
// Occurs if the ENTIRE string is made up of charToTrim (at which point we return nothing)
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
while (str.endsWith(charToTrim, end)) {
|
||||||
|
end -= blockLength;
|
||||||
|
}
|
||||||
|
return str.slice(start, end);
|
||||||
|
}
|
||||||
|
// #endregion Split String code
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capitalize the first letter of a string.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* console.log(capitalizeFirstLetter("consectetur adipiscing elit")); // returns "Consectetur adipiscing elit"
|
||||||
|
* ```
|
||||||
|
* @param {string} str - The string whose first letter is to be capitalized
|
||||||
|
* @return {string} The original string with its first letter capitalized.
|
||||||
|
*/
|
||||||
|
export function capitalizeFirstLetter(str) {
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to convert a string into `Title Case` (such as one used for console logs).
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* console.log(toTitleCase("lorem ipsum dolor sit amet")); // returns "Lorem Ipsum Dolor Sit Amet"
|
||||||
|
* ```
|
||||||
|
* @param {string} str - The string being converted
|
||||||
|
* @returns {string} The result of converting `str` into title case.
|
||||||
|
*/
|
||||||
|
export function toTitleCase(str) {
|
||||||
|
return splitWords(str)
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to convert a string into `camelCase` (such as one used for i18n keys).
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* console.log(toCamelCase("BIG_ANGRY_TRAINER")); // returns "bigAngryTrainer"
|
||||||
|
* ```
|
||||||
|
* @param {string} str - The string being converted
|
||||||
|
* @returns {string} The result of converting `str` into camel case.
|
||||||
|
*/
|
||||||
|
export function toCamelCase(str) {
|
||||||
|
return splitWords(str)
|
||||||
|
.map((word, index) =>
|
||||||
|
index === 0 ? word.toLowerCase() : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to convert a string into `PascalCase`.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* console.log(toPascalCase("hi how was your day")); // returns "HiHowWasYourDay"
|
||||||
|
* ```
|
||||||
|
* @param {string} str - The string being converted
|
||||||
|
* @returns {string} The result of converting `str` into pascal case.
|
||||||
|
*/
|
||||||
|
export function toPascalCase(str) {
|
||||||
|
return splitWords(str)
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to convert a string into `kebab-case` (such as one used for filenames).
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* console.log(toKebabCase("not_kebab-caSe String")); // returns "not-kebab-case-string"
|
||||||
|
* ```
|
||||||
|
* @param {string} str - The string being converted
|
||||||
|
* @returns {string} The result of converting `str` into kebab case.
|
||||||
|
*/
|
||||||
|
export function toKebabCase(str) {
|
||||||
|
return splitWords(str)
|
||||||
|
.map(word => word.toLowerCase())
|
||||||
|
.join("-");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to convert a string into `snake_case` (such as one used for filenames).
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* console.log(toSnakeCase("not-in snake_CaSe")); // returns "not_in_snake_case"
|
||||||
|
* ```
|
||||||
|
* @param {string} str - The string being converted
|
||||||
|
* @returns {string} The result of converting `str` into snake case.
|
||||||
|
*/
|
||||||
|
export function toSnakeCase(str) {
|
||||||
|
return splitWords(str)
|
||||||
|
.map(word => word.toLowerCase())
|
||||||
|
.join("_");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to convert a string into `UPPER_SNAKE_CASE`.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* console.log(toUpperSnakeCase("apples bananas_oranGes-PearS")); // returns "APPLES_BANANAS_ORANGES_PEARS"
|
||||||
|
* ```
|
||||||
|
* @param {string} str - The string being converted
|
||||||
|
* @returns {string} The result of converting `str` into upper snake case.
|
||||||
|
*/
|
||||||
|
export function toUpperSnakeCase(str) {
|
||||||
|
return splitWords(str)
|
||||||
|
.map(word => word.toUpperCase())
|
||||||
|
.join("_");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to convert a string into `Pascal_Snake_Case`.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* console.log(toPascalSnakeCase("apples-bananas_oranGes Pears")); // returns "Apples_Bananas_Oranges_Pears"
|
||||||
|
* ```
|
||||||
|
* @param {string} str - The string being converted
|
||||||
|
* @returns {string} The result of converting `str` into pascal snake case.
|
||||||
|
*/
|
||||||
|
export function toPascalSnakeCase(str) {
|
||||||
|
return splitWords(str)
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||||
|
.join("_");
|
||||||
|
}
|
53
scripts/scrape-trainer-names/check-gender.js
Normal file
53
scripts/scrape-trainer-names/check-gender.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Check if the given trainer class is female.
|
||||||
|
* @param {Document} document - The HTML document to scrape
|
||||||
|
* @returns {[gender: boolean, counterpartURLs: string[]]} A 2-length tuple containing:
|
||||||
|
* 1. The trainer class' gender (female or not)
|
||||||
|
* 2. A list of all the current class' opposite-gender counterparts (if the trainer has any).
|
||||||
|
*/
|
||||||
|
export function checkGenderAndType(document) {
|
||||||
|
const infoBox = document.getElementsByClassName("infobox")[0];
|
||||||
|
if (!infoBox) {
|
||||||
|
return [false, []];
|
||||||
|
}
|
||||||
|
// Find the row of the table containing the specified gender
|
||||||
|
const children = [...infoBox.getElementsByTagName("tr")];
|
||||||
|
const genderCell = children.find(node => [...node.childNodes].some(c => c.textContent?.includes("Gender")));
|
||||||
|
const tableBox = genderCell?.querySelector("td");
|
||||||
|
if (!tableBox) {
|
||||||
|
return [false, []];
|
||||||
|
}
|
||||||
|
|
||||||
|
const gender = getGender(tableBox);
|
||||||
|
|
||||||
|
// CHeck the cell's inner HTML for any `href`s to gender counterparts and scrape them too
|
||||||
|
const hrefExtractRegex = /href="\/wiki\/(.*?)_\(Trainer_class\)"/g;
|
||||||
|
const counterpartCell = children.find(node => [...node.childNodes].some(c => c.textContent?.includes("Counterpart")));
|
||||||
|
|
||||||
|
const counterpartURLs = [];
|
||||||
|
for (const url of counterpartCell?.innerHTML?.matchAll(hrefExtractRegex) ?? []) {
|
||||||
|
counterpartURLs.push(url[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [gender, counterpartURLs];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the gender from the given node text.
|
||||||
|
* @param {HTMLTableCellElement} genderCell - The cell to check
|
||||||
|
* @returns {boolean} The gender type
|
||||||
|
* @todo Handle trainers whose gender type has changed across different gens (Artists, etc.)
|
||||||
|
*/
|
||||||
|
function getGender(genderCell) {
|
||||||
|
const gender = genderCell.textContent?.trim().toLowerCase() ?? "";
|
||||||
|
|
||||||
|
switch (gender) {
|
||||||
|
case "female only":
|
||||||
|
return true;
|
||||||
|
case "male only":
|
||||||
|
case "both":
|
||||||
|
case undefined:
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
76
scripts/scrape-trainer-names/fetch-names.js
Normal file
76
scripts/scrape-trainer-names/fetch-names.js
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* @import { parsedNames } from "./types.js";
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An error code for a bad URL.
|
||||||
|
*/
|
||||||
|
export const INVALID_URL = /** @type {const} */ ("bad_url_code");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a given trainer's names from the given HTML document.
|
||||||
|
* @param {HTMLElement | null | undefined} trainerListHeader - The header containing the trainer lists
|
||||||
|
* @param {boolean} [knownFemale=false] - Whether the class is known to be female; default `false`
|
||||||
|
* @returns {parsedNames | INVALID_URL}
|
||||||
|
* An object containing the parsed names. \
|
||||||
|
* Will instead return with {@linkcode INVALID_URL} if the data is invalid.
|
||||||
|
*/
|
||||||
|
export function fetchNames(trainerListHeader, knownFemale = false) {
|
||||||
|
const trainerNames = /** @type {Set<string>} */ (new Set());
|
||||||
|
const femaleTrainerNames = /** @type {Set<string>} */ (new Set());
|
||||||
|
if (!trainerListHeader?.parentElement?.childNodes) {
|
||||||
|
// Return early if no child nodes (ie tables) can be found
|
||||||
|
return INVALID_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elements = [...trainerListHeader.parentElement.childNodes];
|
||||||
|
|
||||||
|
// Find all elements within the "Trainer Names" header and selectively filter to find the name tables.
|
||||||
|
const startChildIndex = elements.indexOf(trainerListHeader);
|
||||||
|
const endChildIndex = elements.findIndex(h => h.nodeName === "H2" && elements.indexOf(h) > startChildIndex);
|
||||||
|
|
||||||
|
// Grab all the trainer name tables sorted by generation
|
||||||
|
const tables = elements.slice(startChildIndex, endChildIndex).filter(
|
||||||
|
/** @type {(t: ChildNode) => t is Element} */
|
||||||
|
(
|
||||||
|
t =>
|
||||||
|
// Only grab expandable tables within the header block
|
||||||
|
t.nodeName === "TABLE" && t["className"] === "expandable"
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
parseTable(tables, knownFemale, trainerNames, femaleTrainerNames);
|
||||||
|
return {
|
||||||
|
male: Array.from(trainerNames),
|
||||||
|
female: Array.from(femaleTrainerNames),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the table in question.
|
||||||
|
* @param {Element[]} tables - The array of Elements forming the current table
|
||||||
|
* @param {boolean} isFemale - Whether the trainer is known to be female or not
|
||||||
|
* @param {Set<string>} trainerNames A Set containing the male trainer names
|
||||||
|
* @param {Set<string>} femaleTrainerNames - A Set containing the female trainer names
|
||||||
|
*/
|
||||||
|
function parseTable(tables, isFemale, trainerNames, femaleTrainerNames) {
|
||||||
|
for (const table of tables) {
|
||||||
|
// Grab all rows past the first header with exactly 9 children in them (Name, Battle, Winnings, 6 party slots)
|
||||||
|
const trainerRows = [...table.querySelectorAll("tr:not(:first-child)")].filter(r => r.children.length === 9);
|
||||||
|
for (const row of trainerRows) {
|
||||||
|
const content = row.firstElementChild?.innerHTML;
|
||||||
|
// Skip empty elements & ones without anchors
|
||||||
|
if (!content || content?.indexOf(" <a ") === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
/** Whether the name is female */
|
||||||
|
const female = isFemale || content.includes("♀");
|
||||||
|
// Grab the plaintext name part with an optional ampersand
|
||||||
|
const nameMatch = />([a-z]+(?: & [a-z]+)?)<\/a>/i.exec(content);
|
||||||
|
if (!nameMatch) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
(female ? femaleTrainerNames : trainerNames).add(nameMatch[1].replace("&", "&"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
16
scripts/scrape-trainer-names/help-message.js
Normal file
16
scripts/scrape-trainer-names/help-message.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import chalk from "chalk";
|
||||||
|
|
||||||
|
/** Show help/usage text for the `scrape-trainers` CLI. */
|
||||||
|
export function showHelpText() {
|
||||||
|
console.log(`
|
||||||
|
Usage: ${chalk.cyan("pnpm scrape-trainers [options] <names>")}
|
||||||
|
Note that all option names are ${chalk.bold("case insensitive")}.
|
||||||
|
|
||||||
|
${chalk.hex("#8a2be2")("Arguments:")}
|
||||||
|
${chalk.hex("#7fff00")("names")} The name of one or more trainer classes to parse.
|
||||||
|
|
||||||
|
${chalk.hex("#ffa500")("Options:")}
|
||||||
|
${chalk.blue("-h, --help")} Show this help message.
|
||||||
|
${chalk.blue("-o, --out, --outfile")} The path to a file to save the output. If not provided, will send directly to stdout.
|
||||||
|
`);
|
||||||
|
}
|
295
scripts/scrape-trainer-names/main.js
Normal file
295
scripts/scrape-trainer-names/main.js
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
import { existsSync, writeFileSync } from "node:fs";
|
||||||
|
import { format, inspect } from "node:util";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import inquirer from "inquirer";
|
||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
import { toCamelCase, toPascalSnakeCase, toTitleCase } from "../helpers/strings.js";
|
||||||
|
import { checkGenderAndType } from "./check-gender.js";
|
||||||
|
import { fetchNames, INVALID_URL } from "./fetch-names.js";
|
||||||
|
import { showHelpText } from "./help-message.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @packageDocumentation
|
||||||
|
* This script will scrape Bulbapedia for the English names of a given trainer class,
|
||||||
|
* outputting them as JSON.
|
||||||
|
* Usage: `pnpm scrape-trainers`
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @import { parsedNames } from "./types.js"
|
||||||
|
*/
|
||||||
|
|
||||||
|
const version = "1.0.0";
|
||||||
|
const OUTFILE_ALIASES = /** @type {const} */ (["-o", "--outfile", "--outFile"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A large object mapping each "base" trainer name to a list of replacements.
|
||||||
|
* Used to allow for trainer classes with different `TrainerType`s than in mainline.
|
||||||
|
* @type {Record<string, string[]>}
|
||||||
|
*/
|
||||||
|
const trainerNamesMap = {
|
||||||
|
pokemonBreeder: ["breeder"],
|
||||||
|
worker: ["worker", "snowWorker"],
|
||||||
|
richBoy: ["richKid"],
|
||||||
|
gentleman: ["rich"],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(chalk.hex("#FF7F50")(`🍳 Trainer Name Scraper v${version}`));
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const out = getOutfile(args);
|
||||||
|
// Break out if no args remain
|
||||||
|
if (args.length === 0) {
|
||||||
|
console.error(
|
||||||
|
chalk.red.bold(
|
||||||
|
`✗ Error: No trainer classes provided!\nArgs: ${chalk.hex("#7310fdff")(process.argv.slice(2).join(", "))}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
showHelpText();
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = await scrapeTrainerNames(args);
|
||||||
|
await tryWriteFile(out, output);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the outfile location from the args array.
|
||||||
|
* @param {string[]} args - The command line arguments
|
||||||
|
* @returns {string | undefined} The outfile location, or `undefined` if none is provided
|
||||||
|
* @remarks
|
||||||
|
* This will mutate the `args` array by removing the outfile from the list of arguments.
|
||||||
|
*/
|
||||||
|
function getOutfile(args) {
|
||||||
|
let /** @type {string} */ outFile;
|
||||||
|
// Extract the outfile as either the form "-o=y" or "-o y".
|
||||||
|
const hasEquals = /^.*=(.+)$/g.exec(args[0]);
|
||||||
|
if (hasEquals) {
|
||||||
|
outFile = hasEquals[1];
|
||||||
|
args.splice(0, 1);
|
||||||
|
} else if (/** @type {readonly string[]} */ (OUTFILE_ALIASES).includes(args[0])) {
|
||||||
|
outFile = args[1];
|
||||||
|
args.splice(0, 2);
|
||||||
|
} else {
|
||||||
|
console.log(chalk.hex("#ffa500")("No outfile detected, logging to stdout..."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(chalk.hex("#ffa500")(`Using outfile: ${chalk.blue(outFile)}`));
|
||||||
|
return outFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrape the requested trainer names and format the resultant output.
|
||||||
|
* @param {string[]} classes The names of the trainer classes to retrieve
|
||||||
|
* @returns {Promise<string>} A Promise that resolves with the finished text.
|
||||||
|
*/
|
||||||
|
async function scrapeTrainerNames(classes) {
|
||||||
|
classes = [...new Set(classes)];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Set containing all trainer URLs that have been seen.
|
||||||
|
* @type {Set<string>}
|
||||||
|
*/
|
||||||
|
const seenClasses = new Set();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A large array of tuples matching each class to their corresponding list of trainer names. \
|
||||||
|
* Trainer classes with only 1 gender will only contain the single array for that gender.
|
||||||
|
* @type {[keyName: string, names: string[] | parsedNames][]}
|
||||||
|
*/
|
||||||
|
const namesTuples = await Promise.all(
|
||||||
|
classes.map(async trainerClass => {
|
||||||
|
try {
|
||||||
|
const [trainerName, names] = await doFetch(trainerClass, seenClasses);
|
||||||
|
const namesObj = names.female.length === 0 ? names.male : names;
|
||||||
|
return /** @type {const} */ ([trainerName, namesObj]);
|
||||||
|
} catch (e) {
|
||||||
|
if (!(e instanceof Error)) {
|
||||||
|
throw new Error(chalk.red.bold("Unrecognized error detected:", inspect(e)));
|
||||||
|
}
|
||||||
|
// If the error contains an HTTP status, attempt to parse the code to give a more friendly
|
||||||
|
// response than JSDOM's "Resource was not loaded"gi
|
||||||
|
const errCode = /Status: (\d*)/g.exec(e.message)?.[1];
|
||||||
|
if (!errCode) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
/** @type {string} */
|
||||||
|
let reason;
|
||||||
|
switch (+errCode) {
|
||||||
|
case 404:
|
||||||
|
reason = "Page not found";
|
||||||
|
break;
|
||||||
|
case 403:
|
||||||
|
reason = "Access is forbidden";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
reason = `Server produced error code of ${+errCode}`;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
chalk.red.bold(`Failed to parse URL for ${chalk.hex("#7fff00")(`\"${trainerClass}\"`)}!\nReason: ${reason}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Grab all keys inside the name replacement map and change them accordingly.
|
||||||
|
const mappedNames = namesTuples.filter(tuple => tuple[0] in trainerNamesMap);
|
||||||
|
for (const mappedName of mappedNames) {
|
||||||
|
const namesMapping = trainerNamesMap[mappedName[0]];
|
||||||
|
namesTuples.splice(
|
||||||
|
namesTuples.indexOf(mappedName),
|
||||||
|
1,
|
||||||
|
...namesMapping.map(
|
||||||
|
name => /** @type {[keyName: string, names: parsedNames | string[]]} */ ([name, mappedName[1]]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
namesTuples.sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
|
||||||
|
/** @type {Record<string, string[] | parsedNames>} */
|
||||||
|
const namesRecord = Object.fromEntries(namesTuples);
|
||||||
|
|
||||||
|
// Convert all arrays into objects indexed by numbers
|
||||||
|
return JSON.stringify(
|
||||||
|
namesRecord,
|
||||||
|
(_, v) => {
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
return v.reduce((ret, curr, i) => {
|
||||||
|
ret[i + 1] = curr; // 1 indexed
|
||||||
|
return ret;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively scrape names from a given Trainer class and its gender counterparts.
|
||||||
|
* @param {string} trainerClass - The URL to parse
|
||||||
|
* @param {Set<string>} seenClasses - A Set containing all seen class URLs, used for record keeping.
|
||||||
|
* @returns {Promise<[string, parsedNames]>}
|
||||||
|
* A Promise that resolves with:
|
||||||
|
* 1. The name to use for the key.
|
||||||
|
* 2. All fetched names for this trainer class and its gender variants.
|
||||||
|
*/
|
||||||
|
async function doFetch(trainerClass, seenClasses) {
|
||||||
|
let keyName = toCamelCase(trainerClass);
|
||||||
|
// Bulba URLs are in Pascal_Snake_Case (Pokemon_Breeder)
|
||||||
|
const classURL = toPascalSnakeCase(trainerClass);
|
||||||
|
seenClasses.add(classURL);
|
||||||
|
|
||||||
|
// Bulbapedia has redirects mapping basically all variant spellings of each trainer name to the corresponding main page.
|
||||||
|
// We thus rely on it
|
||||||
|
const { document } = (await JSDOM.fromURL(`https://bulbapedia.bulbagarden.net/wiki/${classURL}`)).window;
|
||||||
|
const trainerListHeader = document.querySelector("#Trainer_list")?.parentElement;
|
||||||
|
const [female, counterpartURLs] = checkGenderAndType(document);
|
||||||
|
const names = fetchNames(trainerListHeader, female);
|
||||||
|
if (names === INVALID_URL) {
|
||||||
|
return Promise.reject(
|
||||||
|
new Error(chalk.red.bold(`URL \"${classURL}\" did not correspond to a valid trainer class!`)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse into all unseen gender counterparts' URLs, using the first male name we find
|
||||||
|
const counterpartNames = await Promise.all(
|
||||||
|
counterpartURLs
|
||||||
|
.filter(url => !seenClasses.has(url))
|
||||||
|
.map(counterpartURL => {
|
||||||
|
console.log(chalk.green(`Accessing gender counterpart URL: ${toTitleCase(counterpartURL)}`));
|
||||||
|
return doFetch(counterpartURL, seenClasses);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let overrodeName = false;
|
||||||
|
for (const [cKeyName, cNameObj] of counterpartNames) {
|
||||||
|
if (!overrodeName && female) {
|
||||||
|
overrodeName = true;
|
||||||
|
console.log(chalk.green(`Using "${cKeyName}" as the name of the JSON key object...`));
|
||||||
|
keyName = cKeyName;
|
||||||
|
}
|
||||||
|
names.male = [...new Set(names.male.concat(cNameObj.male))];
|
||||||
|
names.female = [...new Set(names.female.concat(cNameObj.female))];
|
||||||
|
}
|
||||||
|
return [normalizeDiacritics(keyName), names];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert all diacritical marks within a string into their normalized variants.
|
||||||
|
* @param {string} str - The string to parse
|
||||||
|
* @returns {string} The string with normalized diacritics
|
||||||
|
*/
|
||||||
|
function normalizeDiacritics(str) {
|
||||||
|
// Normalizing to NFKD splits all diacritics into the base letter + grapheme (à -> a + `),
|
||||||
|
// which are conveniently all in their own little Unicode block for easy removal
|
||||||
|
return str.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to write the output to a file (or log it to stdout, as the case may be).
|
||||||
|
* @param {string | undefined} outFile - The outfile
|
||||||
|
* @param {string} output - The scraped output to produce
|
||||||
|
*/
|
||||||
|
async function tryWriteFile(outFile, output) {
|
||||||
|
if (!outFile) {
|
||||||
|
console.log(output);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(outFile) && !(await promptExisting(outFile))) {
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeFileSync(outFile, output);
|
||||||
|
console.log(chalk.green.bold(`✔ Output written to ${chalk.blue(outFile)} successfully!`));
|
||||||
|
} catch (e) {
|
||||||
|
let /** @type {string} */ errStr;
|
||||||
|
if (!(e instanceof Error)) {
|
||||||
|
errStr = format("Unknown error occurred: ", e);
|
||||||
|
} else {
|
||||||
|
// @ts-expect-error - Node.JS file errors always have codes
|
||||||
|
switch (e.code) {
|
||||||
|
case "ENOENT":
|
||||||
|
errStr = `File not found: ${outFile}`;
|
||||||
|
break;
|
||||||
|
case "EACCES":
|
||||||
|
errStr = `Could not write ${outFile}: Permission denied`;
|
||||||
|
break;
|
||||||
|
case "EISDIR":
|
||||||
|
errStr = `Unable to write to ${outFile} as it is a directory`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
errStr = `Error writing file: ${e.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error(chalk.red.bold(errStr));
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm overwriting an already-existing file.
|
||||||
|
* @param {string} outFile - The outfile
|
||||||
|
* @returns {Promise<boolean>} Whether "Yes" or "No" was selected.
|
||||||
|
*/
|
||||||
|
async function promptExisting(outFile) {
|
||||||
|
return (
|
||||||
|
await inquirer.prompt([
|
||||||
|
{
|
||||||
|
type: "confirm",
|
||||||
|
name: "continue",
|
||||||
|
message: `File ${chalk.blue(outFile)} already exists!` + "\nDo you want to replace it?",
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
).continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
9
scripts/scrape-trainer-names/types.js
Normal file
9
scripts/scrape-trainer-names/types.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {Object}
|
||||||
|
* parsedNames
|
||||||
|
* A parsed object containing the desired names.
|
||||||
|
* @property {string[]} male
|
||||||
|
* @property {string[]} female
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {};
|
@ -6755,7 +6755,7 @@ function getPokemonWithWeatherBasedForms() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// biome-ignore format: prevent biome from removing the newlines (e.g. prevent `new Ability(...).attr(...)`)
|
// biome-ignore-start format: prevent biome from removing the newlines (e.g. prevent `new Ability(...).attr(...)`)
|
||||||
export function initAbilities() {
|
export function initAbilities() {
|
||||||
allAbilities.push(
|
allAbilities.push(
|
||||||
new Ability(AbilityId.NONE, 3),
|
new Ability(AbilityId.NONE, 3),
|
||||||
@ -7867,3 +7867,4 @@ export function initAbilities() {
|
|||||||
.attr(ConfusionOnStatusEffectAbAttr, StatusEffect.POISON, StatusEffect.TOXIC)
|
.attr(ConfusionOnStatusEffectAbAttr, StatusEffect.POISON, StatusEffect.TOXIC)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// biome-ignore-end format: prevent biome from removing the newlines (e.g. prevent `new Ability(...).attr(...)`)
|
||||||
|
@ -8483,8 +8483,6 @@ const MoveAttrs = Object.freeze({
|
|||||||
/** Map of of move attribute names to their constructors */
|
/** Map of of move attribute names to their constructors */
|
||||||
export type MoveAttrConstructorMap = typeof MoveAttrs;
|
export type MoveAttrConstructorMap = typeof MoveAttrs;
|
||||||
|
|
||||||
export const selfStatLowerMoves: MoveId[] = [];
|
|
||||||
|
|
||||||
export function initMoves() {
|
export function initMoves() {
|
||||||
allMoves.push(
|
allMoves.push(
|
||||||
new SelfStatusMove(MoveId.NONE, PokemonType.NORMAL, MoveCategory.STATUS, -1, -1, 0, 1),
|
new SelfStatusMove(MoveId.NONE, PokemonType.NORMAL, MoveCategory.STATUS, -1, -1, 0, 1),
|
||||||
@ -11540,9 +11538,4 @@ export function initMoves() {
|
|||||||
new AttackMove(MoveId.MALIGNANT_CHAIN, PokemonType.POISON, MoveCategory.SPECIAL, 100, 100, 5, 50, 0, 9)
|
new AttackMove(MoveId.MALIGNANT_CHAIN, PokemonType.POISON, MoveCategory.SPECIAL, 100, 100, 5, 50, 0, 9)
|
||||||
.attr(StatusEffectAttr, StatusEffect.TOXIC)
|
.attr(StatusEffectAttr, StatusEffect.TOXIC)
|
||||||
);
|
);
|
||||||
allMoves.map(m => {
|
|
||||||
if (m.getAttrs("StatStageChangeAttr").some(a => a.selfTarget && a.stages < 0)) {
|
|
||||||
selfStatLowerMoves.push(m.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
@ -44,7 +44,10 @@ import { PokemonData } from "#system/pokemon-data";
|
|||||||
import { MusicPreference } from "#system/settings";
|
import { MusicPreference } from "#system/settings";
|
||||||
import type { OptionSelectItem } from "#ui/abstract-option-select-ui-handler";
|
import type { OptionSelectItem } from "#ui/abstract-option-select-ui-handler";
|
||||||
import { isNullOrUndefined, NumberHolder, randInt, randSeedInt, randSeedItem, randSeedShuffle } from "#utils/common";
|
import { isNullOrUndefined, NumberHolder, randInt, randSeedInt, randSeedItem, randSeedShuffle } from "#utils/common";
|
||||||
|
import { getEnumKeys } from "#utils/enums";
|
||||||
|
import { getRandomLocaleKey } from "#utils/i18n";
|
||||||
import { getPokemonSpecies } from "#utils/pokemon-utils";
|
import { getPokemonSpecies } from "#utils/pokemon-utils";
|
||||||
|
import { toCamelCase } from "#utils/strings";
|
||||||
import i18next from "i18next";
|
import i18next from "i18next";
|
||||||
|
|
||||||
/** the i18n namespace for the encounter */
|
/** the i18n namespace for the encounter */
|
||||||
@ -984,14 +987,17 @@ function doTradeReceivedSequence(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function generateRandomTraderName() {
|
function generateRandomTraderName() {
|
||||||
const length = TrainerType.YOUNGSTER - TrainerType.ACE_TRAINER + 1;
|
const allTrainerNames = getEnumKeys(TrainerType);
|
||||||
// +1 avoids TrainerType.UNKNOWN
|
// Exclude TrainerType.UNKNOWN and everything after Ace Trainers (grunts and unique trainers)
|
||||||
const classKey = `trainersCommon:${TrainerType[randInt(length) + 1]}`;
|
const eligibleNames = allTrainerNames.slice(
|
||||||
// Some trainers have 2 gendered pools, some do not
|
1,
|
||||||
const genderKey = i18next.exists(`${classKey}.MALE`) ? (randInt(2) === 0 ? ".MALE" : ".FEMALE") : "";
|
allTrainerNames.indexOf(TrainerType[TrainerType.YOUNGSTER] as keyof typeof TrainerType),
|
||||||
const trainerNameKey = randSeedItem(Object.keys(i18next.t(`${classKey}${genderKey}`, { returnObjects: true })));
|
);
|
||||||
const trainerNameString = i18next.t(`${classKey}${genderKey}.${trainerNameKey}`);
|
const randomTrainer = toCamelCase(randSeedItem(eligibleNames));
|
||||||
// Some names have an '&' symbol and need to be trimmed to a single name instead of a double name
|
const classKey = `trainersCommon:${randomTrainer}`;
|
||||||
const trainerNames = trainerNameString.split(" & ");
|
// Pick a random gender for ones with gendered pools, or access the raw object for ones without.
|
||||||
return trainerNames[randInt(trainerNames.length)];
|
const genderKey = i18next.exists(`${classKey}.male`) ? randSeedItem([".male", ".female"]) : "";
|
||||||
|
const trainerNameString = getRandomLocaleKey(`${classKey}${genderKey}`)[1];
|
||||||
|
// Split the string by &s (for duo trainers)
|
||||||
|
return randSeedItem(trainerNameString.split(" & "));
|
||||||
}
|
}
|
||||||
|
@ -1,165 +0,0 @@
|
|||||||
import { TrainerType } from "#enums/trainer-type";
|
|
||||||
import { toPascalSnakeCase } from "#utils/strings";
|
|
||||||
|
|
||||||
class TrainerNameConfig {
|
|
||||||
public urls: string[];
|
|
||||||
public femaleUrls: string[] | null;
|
|
||||||
|
|
||||||
constructor(type: TrainerType, ...urls: string[]) {
|
|
||||||
this.urls = urls.length ? urls : [toPascalSnakeCase(TrainerType[type])];
|
|
||||||
}
|
|
||||||
|
|
||||||
hasGenderVariant(...femaleUrls: string[]): TrainerNameConfig {
|
|
||||||
this.femaleUrls = femaleUrls.length ? femaleUrls : null;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TrainerNameConfigs {
|
|
||||||
[key: number]: TrainerNameConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
// used in a commented code
|
|
||||||
// biome-ignore lint/correctness/noUnusedVariables: Used by commented code
|
|
||||||
const trainerNameConfigs: TrainerNameConfigs = {
|
|
||||||
[TrainerType.ACE_TRAINER]: new TrainerNameConfig(TrainerType.ACE_TRAINER),
|
|
||||||
[TrainerType.ARTIST]: new TrainerNameConfig(TrainerType.ARTIST),
|
|
||||||
[TrainerType.BACKERS]: new TrainerNameConfig(TrainerType.BACKERS),
|
|
||||||
[TrainerType.BACKPACKER]: new TrainerNameConfig(TrainerType.BACKPACKER),
|
|
||||||
[TrainerType.BAKER]: new TrainerNameConfig(TrainerType.BAKER),
|
|
||||||
[TrainerType.BEAUTY]: new TrainerNameConfig(TrainerType.BEAUTY),
|
|
||||||
[TrainerType.BIKER]: new TrainerNameConfig(TrainerType.BIKER),
|
|
||||||
[TrainerType.BLACK_BELT]: new TrainerNameConfig(TrainerType.BLACK_BELT).hasGenderVariant("Battle_Girl"),
|
|
||||||
[TrainerType.BREEDER]: new TrainerNameConfig(TrainerType.BREEDER, "Pokémon_Breeder"),
|
|
||||||
[TrainerType.CLERK]: new TrainerNameConfig(TrainerType.CLERK),
|
|
||||||
[TrainerType.CYCLIST]: new TrainerNameConfig(TrainerType.CYCLIST),
|
|
||||||
[TrainerType.DANCER]: new TrainerNameConfig(TrainerType.DANCER),
|
|
||||||
[TrainerType.DEPOT_AGENT]: new TrainerNameConfig(TrainerType.DEPOT_AGENT),
|
|
||||||
[TrainerType.DOCTOR]: new TrainerNameConfig(TrainerType.DOCTOR).hasGenderVariant("Nurse"),
|
|
||||||
[TrainerType.FIREBREATHER]: new TrainerNameConfig(TrainerType.FIREBREATHER),
|
|
||||||
[TrainerType.FISHERMAN]: new TrainerNameConfig(TrainerType.FISHERMAN),
|
|
||||||
[TrainerType.GUITARIST]: new TrainerNameConfig(TrainerType.GUITARIST),
|
|
||||||
[TrainerType.HARLEQUIN]: new TrainerNameConfig(TrainerType.HARLEQUIN),
|
|
||||||
[TrainerType.HIKER]: new TrainerNameConfig(TrainerType.HIKER),
|
|
||||||
[TrainerType.HOOLIGANS]: new TrainerNameConfig(TrainerType.HOOLIGANS),
|
|
||||||
[TrainerType.HOOPSTER]: new TrainerNameConfig(TrainerType.HOOPSTER),
|
|
||||||
[TrainerType.INFIELDER]: new TrainerNameConfig(TrainerType.INFIELDER),
|
|
||||||
[TrainerType.JANITOR]: new TrainerNameConfig(TrainerType.JANITOR),
|
|
||||||
[TrainerType.LINEBACKER]: new TrainerNameConfig(TrainerType.LINEBACKER),
|
|
||||||
[TrainerType.MAID]: new TrainerNameConfig(TrainerType.MAID),
|
|
||||||
[TrainerType.MUSICIAN]: new TrainerNameConfig(TrainerType.MUSICIAN),
|
|
||||||
[TrainerType.HEX_MANIAC]: new TrainerNameConfig(TrainerType.HEX_MANIAC),
|
|
||||||
[TrainerType.NURSERY_AIDE]: new TrainerNameConfig(TrainerType.NURSERY_AIDE),
|
|
||||||
[TrainerType.OFFICER]: new TrainerNameConfig(TrainerType.OFFICER),
|
|
||||||
[TrainerType.PARASOL_LADY]: new TrainerNameConfig(TrainerType.PARASOL_LADY),
|
|
||||||
[TrainerType.PILOT]: new TrainerNameConfig(TrainerType.PILOT),
|
|
||||||
[TrainerType.POKEFAN]: new TrainerNameConfig(TrainerType.POKEFAN, "Poké_Fan"),
|
|
||||||
[TrainerType.PRESCHOOLER]: new TrainerNameConfig(TrainerType.PRESCHOOLER),
|
|
||||||
[TrainerType.PSYCHIC]: new TrainerNameConfig(TrainerType.PSYCHIC),
|
|
||||||
[TrainerType.RANGER]: new TrainerNameConfig(TrainerType.RANGER),
|
|
||||||
[TrainerType.RICH]: new TrainerNameConfig(TrainerType.RICH, "Gentleman").hasGenderVariant("Madame"),
|
|
||||||
[TrainerType.RICH_KID]: new TrainerNameConfig(TrainerType.RICH_KID, "Rich_Boy").hasGenderVariant("Lady"),
|
|
||||||
[TrainerType.ROUGHNECK]: new TrainerNameConfig(TrainerType.ROUGHNECK),
|
|
||||||
[TrainerType.SAILOR]: new TrainerNameConfig(TrainerType.SAILOR),
|
|
||||||
[TrainerType.SCIENTIST]: new TrainerNameConfig(TrainerType.SCIENTIST),
|
|
||||||
[TrainerType.SMASHER]: new TrainerNameConfig(TrainerType.SMASHER),
|
|
||||||
[TrainerType.SNOW_WORKER]: new TrainerNameConfig(TrainerType.SNOW_WORKER, "Worker"),
|
|
||||||
[TrainerType.STRIKER]: new TrainerNameConfig(TrainerType.STRIKER),
|
|
||||||
[TrainerType.SCHOOL_KID]: new TrainerNameConfig(TrainerType.SCHOOL_KID, "School_Kid"),
|
|
||||||
[TrainerType.SWIMMER]: new TrainerNameConfig(TrainerType.SWIMMER),
|
|
||||||
[TrainerType.TWINS]: new TrainerNameConfig(TrainerType.TWINS),
|
|
||||||
[TrainerType.VETERAN]: new TrainerNameConfig(TrainerType.VETERAN),
|
|
||||||
[TrainerType.WAITER]: new TrainerNameConfig(TrainerType.WAITER).hasGenderVariant("Waitress"),
|
|
||||||
[TrainerType.WORKER]: new TrainerNameConfig(TrainerType.WORKER),
|
|
||||||
[TrainerType.YOUNGSTER]: new TrainerNameConfig(TrainerType.YOUNGSTER).hasGenderVariant("Lass"),
|
|
||||||
};
|
|
||||||
|
|
||||||
// function used in a commented code
|
|
||||||
// biome-ignore lint/correctness/noUnusedVariables: TODO make this into a script instead of having it be in src/data...
|
|
||||||
function fetchAndPopulateTrainerNames(
|
|
||||||
url: string,
|
|
||||||
parser: DOMParser,
|
|
||||||
trainerNames: Set<string>,
|
|
||||||
femaleTrainerNames: Set<string>,
|
|
||||||
forceFemale = false,
|
|
||||||
) {
|
|
||||||
return new Promise<void>(resolve => {
|
|
||||||
fetch(`https://bulbapedia.bulbagarden.net/wiki/${url}_(Trainer_class)`)
|
|
||||||
.then(response => response.text())
|
|
||||||
.then(html => {
|
|
||||||
console.log(url);
|
|
||||||
const htmlDoc = parser.parseFromString(html, "text/html");
|
|
||||||
const trainerListHeader = htmlDoc.querySelector("#Trainer_list")?.parentElement;
|
|
||||||
if (!trainerListHeader) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const elements = [...(trainerListHeader?.parentElement?.childNodes ?? [])];
|
|
||||||
const startChildIndex = elements.indexOf(trainerListHeader);
|
|
||||||
const endChildIndex = elements.findIndex(h => h.nodeName === "H2" && elements.indexOf(h) > startChildIndex);
|
|
||||||
const tables = elements
|
|
||||||
.filter(t => {
|
|
||||||
if (t.nodeName !== "TABLE" || t["className"] !== "expandable") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const childIndex = elements.indexOf(t);
|
|
||||||
return childIndex > startChildIndex && childIndex < endChildIndex;
|
|
||||||
})
|
|
||||||
.map(t => t as Element);
|
|
||||||
console.log(url, tables);
|
|
||||||
for (const table of tables) {
|
|
||||||
const trainerRows = [...table.querySelectorAll("tr:not(:first-child)")].filter(r => r.children.length === 9);
|
|
||||||
for (const row of trainerRows) {
|
|
||||||
const nameCell = row.firstElementChild;
|
|
||||||
if (!nameCell) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const content = nameCell.innerHTML;
|
|
||||||
if (content.indexOf(" <a ") > -1) {
|
|
||||||
const female = /♀/.test(content);
|
|
||||||
if (url === "Twins") {
|
|
||||||
console.log(content);
|
|
||||||
}
|
|
||||||
const nameMatch = />([a-z]+(?: & [a-z]+)?)<\/a>/i.exec(content);
|
|
||||||
if (nameMatch) {
|
|
||||||
(female || forceFemale ? femaleTrainerNames : trainerNames).add(nameMatch[1].replace("&", "&"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/*export function scrapeTrainerNames() {
|
|
||||||
const parser = new DOMParser();
|
|
||||||
const trainerTypeNames = {};
|
|
||||||
const populateTrainerNamePromises: Promise<void>[] = [];
|
|
||||||
for (let t of Object.keys(trainerNameConfigs)) {
|
|
||||||
populateTrainerNamePromises.push(new Promise<void>(resolve => {
|
|
||||||
const trainerType = t;
|
|
||||||
trainerTypeNames[trainerType] = [];
|
|
||||||
|
|
||||||
const config = trainerNameConfigs[t] as TrainerNameConfig;
|
|
||||||
const trainerNames = new Set<string>();
|
|
||||||
const femaleTrainerNames = new Set<string>();
|
|
||||||
console.log(config.urls, config.femaleUrls)
|
|
||||||
const trainerClassRequests = config.urls.map(u => fetchAndPopulateTrainerNames(u, parser, trainerNames, femaleTrainerNames));
|
|
||||||
if (config.femaleUrls)
|
|
||||||
trainerClassRequests.push(...config.femaleUrls.map(u => fetchAndPopulateTrainerNames(u, parser, null, femaleTrainerNames, true)));
|
|
||||||
Promise.all(trainerClassRequests).then(() => {
|
|
||||||
console.log(trainerNames, femaleTrainerNames)
|
|
||||||
trainerTypeNames[trainerType] = !femaleTrainerNames.size ? Array.from(trainerNames) : [ Array.from(trainerNames), Array.from(femaleTrainerNames) ];
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
Promise.all(populateTrainerNamePromises).then(() => {
|
|
||||||
let output = 'export const trainerNamePools = {';
|
|
||||||
Object.keys(trainerTypeNames).forEach(t => {
|
|
||||||
output += `\n\t[TrainerType.${TrainerType[t]}]: ${JSON.stringify(trainerTypeNames[t])},`;
|
|
||||||
});
|
|
||||||
output += `\n};`;
|
|
||||||
console.log(output);
|
|
||||||
});
|
|
||||||
}*/
|
|
@ -16,14 +16,11 @@ import type { PersistentModifier } from "#modifiers/modifier";
|
|||||||
import { getIsInitialized, initI18n } from "#plugins/i18n";
|
import { getIsInitialized, initI18n } from "#plugins/i18n";
|
||||||
import type { TrainerConfig } from "#trainers/trainer-config";
|
import type { TrainerConfig } from "#trainers/trainer-config";
|
||||||
import { trainerConfigs } from "#trainers/trainer-config";
|
import { trainerConfigs } from "#trainers/trainer-config";
|
||||||
import {
|
import { TrainerPartyCompoundTemplate, type TrainerPartyTemplate } from "#trainers/trainer-party-template";
|
||||||
TrainerPartyCompoundTemplate,
|
|
||||||
type TrainerPartyTemplate,
|
|
||||||
trainerPartyTemplates,
|
|
||||||
} from "#trainers/trainer-party-template";
|
|
||||||
import { randSeedInt, randSeedItem, randSeedWeightedItem } from "#utils/common";
|
import { randSeedInt, randSeedItem, randSeedWeightedItem } from "#utils/common";
|
||||||
|
import { getRandomLocaleKey } from "#utils/i18n";
|
||||||
import { getPokemonSpecies } from "#utils/pokemon-utils";
|
import { getPokemonSpecies } from "#utils/pokemon-utils";
|
||||||
import { toSnakeCase } from "#utils/strings";
|
import { toCamelCase, toSnakeCase } from "#utils/strings";
|
||||||
import i18next from "i18next";
|
import i18next from "i18next";
|
||||||
|
|
||||||
export class Trainer extends Phaser.GameObjects.Container {
|
export class Trainer extends Phaser.GameObjects.Container {
|
||||||
@ -35,6 +32,18 @@ export class Trainer extends Phaser.GameObjects.Container {
|
|||||||
public partnerNameKey: string | undefined;
|
public partnerNameKey: string | undefined;
|
||||||
public originalIndexes: { [key: number]: number } = {};
|
public originalIndexes: { [key: number]: number } = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new Trainer.
|
||||||
|
* @param trainerType - The {@linkcode TrainerType} for this trainer, used to determine
|
||||||
|
* name, sprite, party contents and other details.
|
||||||
|
* @param variant - The {@linkcode TrainerVariant} for this trainer (if any are available)
|
||||||
|
* @param partyTemplateIndex - If provided, will override the trainer's party template with the given
|
||||||
|
* version.
|
||||||
|
* @param nameKey - If provided, will override the name key of the trainer
|
||||||
|
* @param partnerNameKey - If provided, will override the
|
||||||
|
* @param trainerConfigOverride - If provided, will override the trainer config for the given trainer type
|
||||||
|
* @todo Review how many of these parameters we actually need
|
||||||
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
trainerType: TrainerType,
|
trainerType: TrainerType,
|
||||||
variant: TrainerVariant,
|
variant: TrainerVariant,
|
||||||
@ -44,13 +53,11 @@ export class Trainer extends Phaser.GameObjects.Container {
|
|||||||
trainerConfigOverride?: TrainerConfig,
|
trainerConfigOverride?: TrainerConfig,
|
||||||
) {
|
) {
|
||||||
super(globalScene, -72, 80);
|
super(globalScene, -72, 80);
|
||||||
this.config = trainerConfigs.hasOwnProperty(trainerType)
|
this.config =
|
||||||
|
trainerConfigOverride ??
|
||||||
|
(trainerConfigs.hasOwnProperty(trainerType)
|
||||||
? trainerConfigs[trainerType]
|
? trainerConfigs[trainerType]
|
||||||
: trainerConfigs[TrainerType.ACE_TRAINER];
|
: trainerConfigs[TrainerType.ACE_TRAINER]);
|
||||||
|
|
||||||
if (trainerConfigOverride) {
|
|
||||||
this.config = trainerConfigOverride;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.variant = variant;
|
this.variant = variant;
|
||||||
this.partyTemplateIndex = Math.min(
|
this.partyTemplateIndex = Math.min(
|
||||||
@ -59,20 +66,21 @@ export class Trainer extends Phaser.GameObjects.Container {
|
|||||||
: randSeedWeightedItem(this.config.partyTemplates.map((_, i) => i)),
|
: randSeedWeightedItem(this.config.partyTemplates.map((_, i) => i)),
|
||||||
this.config.partyTemplates.length - 1,
|
this.config.partyTemplates.length - 1,
|
||||||
);
|
);
|
||||||
const classKey = `trainersCommon:${TrainerType[trainerType]}`;
|
// TODO: Rework this and add actual error handling for missing names
|
||||||
|
const classKey = `trainersCommon:${toCamelCase(TrainerType[trainerType])}`;
|
||||||
if (i18next.exists(classKey, { returnObjects: true })) {
|
if (i18next.exists(classKey, { returnObjects: true })) {
|
||||||
if (nameKey) {
|
if (nameKey) {
|
||||||
this.nameKey = nameKey;
|
this.nameKey = nameKey;
|
||||||
|
this.name = i18next.t(nameKey);
|
||||||
} else {
|
} else {
|
||||||
const genderKey = i18next.exists(`${classKey}.MALE`)
|
const genderKey = i18next.exists(`${classKey}.male`)
|
||||||
? variant === TrainerVariant.FEMALE
|
? variant === TrainerVariant.FEMALE
|
||||||
? ".FEMALE"
|
? ".female"
|
||||||
: ".MALE"
|
: ".male"
|
||||||
: "";
|
: "";
|
||||||
const trainerKey = randSeedItem(Object.keys(i18next.t(`${classKey}${genderKey}`, { returnObjects: true })));
|
[this.nameKey, this.name] = getRandomLocaleKey(`${classKey}${genderKey}`);
|
||||||
this.nameKey = `${classKey}${genderKey}.${trainerKey}`;
|
|
||||||
}
|
}
|
||||||
this.name = i18next.t(this.nameKey);
|
|
||||||
if (variant === TrainerVariant.DOUBLE) {
|
if (variant === TrainerVariant.DOUBLE) {
|
||||||
if (this.config.doubleOnly) {
|
if (this.config.doubleOnly) {
|
||||||
if (partnerNameKey) {
|
if (partnerNameKey) {
|
||||||
@ -82,16 +90,8 @@ export class Trainer extends Phaser.GameObjects.Container {
|
|||||||
[this.name, this.partnerName] = this.name.split(" & ");
|
[this.name, this.partnerName] = this.name.split(" & ");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const partnerGenderKey = i18next.exists(`${classKey}.FEMALE`) ? ".FEMALE" : "";
|
const partnerGenderKey = i18next.exists(`${classKey}.fenale`) ? ".fenale" : "";
|
||||||
const partnerTrainerKey = randSeedItem(
|
[this.partnerNameKey, this.partnerName] = getRandomLocaleKey(`${classKey}${partnerGenderKey}`);
|
||||||
Object.keys(
|
|
||||||
i18next.t(`${classKey}${partnerGenderKey}`, {
|
|
||||||
returnObjects: true,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
this.partnerNameKey = `${classKey}${partnerGenderKey}.${partnerTrainerKey}`;
|
|
||||||
this.partnerName = i18next.t(this.partnerNameKey);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -109,10 +109,6 @@ export class Trainer extends Phaser.GameObjects.Container {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
|
||||||
Object.keys(trainerPartyTemplates)[Object.values(trainerPartyTemplates).indexOf(this.getPartyTemplate())],
|
|
||||||
);
|
|
||||||
|
|
||||||
const getSprite = (hasShadow?: boolean, forceFemale?: boolean) => {
|
const getSprite = (hasShadow?: boolean, forceFemale?: boolean) => {
|
||||||
const ret = globalScene.addFieldSprite(
|
const ret = globalScene.addFieldSprite(
|
||||||
0,
|
0,
|
||||||
@ -157,9 +153,9 @@ export class Trainer extends Phaser.GameObjects.Container {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the name of the trainer based on the provided trainer slot and the option to include a title.
|
* Returns the name of the trainer based on the provided trainer slot and the option to include a title.
|
||||||
* @param {TrainerSlot} trainerSlot - The slot to determine which name to use. Defaults to TrainerSlot.NONE.
|
* @param rainerSlot - The slot to determine which name to use; default `TrainerSlot.NONE`
|
||||||
* @param {boolean} includeTitle - Whether to include the title in the returned name. Defaults to false.
|
* @param includeTitle - Whether to include the title in the returned name; default `false`
|
||||||
* @returns {string} - The formatted name of the trainer.
|
* @returns - The formatted name of the trainer
|
||||||
*/
|
*/
|
||||||
getName(trainerSlot: TrainerSlot = TrainerSlot.NONE, includeTitle = false): string {
|
getName(trainerSlot: TrainerSlot = TrainerSlot.NONE, includeTitle = false): string {
|
||||||
// Get the base title based on the trainer slot and variant.
|
// Get the base title based on the trainer slot and variant.
|
||||||
|
17
src/utils/i18n.ts
Normal file
17
src/utils/i18n.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { randSeedItem } from "#utils/common";
|
||||||
|
import i18next from "i18next";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a random i18n key from all nested keys in the given object.
|
||||||
|
* @param key - The i18n key to retrieve a random value of.
|
||||||
|
* The key's value should be an object containing numerical keys (starting from 1).
|
||||||
|
* @returns A typle containing the key and value pair.
|
||||||
|
* @privateRemarks
|
||||||
|
* The reason such "array-like" keys are not stored as actual arrays is due to the
|
||||||
|
* translation software used by the Translation Team (Mozilla Pontoon)
|
||||||
|
* not supporting arrays in any capacity.
|
||||||
|
*/
|
||||||
|
export function getRandomLocaleKey(key: string): [key: string, value: string] {
|
||||||
|
const keyName = `${key}.${randSeedItem(Object.keys(i18next.t("key", { returnObjects: true })))}`;
|
||||||
|
return [keyName, i18next.t(keyName)];
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"entryPoints": ["./src"],
|
"entryPoints": ["./src"],
|
||||||
"entryPointStrategy": "expand",
|
"entryPointStrategy": "expand",
|
||||||
"exclude": ["**/*+.test.ts"],
|
"exclude": ["**/*+.test.ts", "**/src/data/trainer-names.ts"],
|
||||||
"out": "typedoc",
|
"out": "typedoc",
|
||||||
"highlightLanguages": ["javascript", "json", "jsonc", "json5", "tsx", "typescript", "markdown"]
|
"highlightLanguages": ["javascript", "json", "jsonc", "json5", "tsx", "typescript", "markdown"]
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user