This commit is contained in:
Bertie690 2025-08-18 22:26:39 -04:00 committed by GitHub
commit 5fb24dc757
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 700 additions and 223 deletions

View File

@ -14,6 +14,7 @@
"test:watch": "vitest watch --coverage --no-isolate",
"test:silent": "vitest run --silent='passed-only' --no-isolate",
"test:create": "node scripts/create-test/create-test.js",
"scrape-trainers": "node scripts/scrape-trainer-names/main.js",
"typecheck": "tsc --noEmit",
"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",

179
scripts/helpers/strings.js Normal file
View 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("_");
}

View 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;
}
}

View 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]+(?: &amp; [a-z]+)?)<\/a>/i.exec(content);
if (!nameMatch) {
continue;
}
(female ? femaleTrainerNames : trainerNames).add(nameMatch[1].replace("&amp;", "&"));
}
}
}

View 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.
`);
}

View 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();

View File

@ -0,0 +1,9 @@
/**
* @typedef {Object}
* parsedNames
* A parsed object containing the desired names.
* @property {string[]} male
* @property {string[]} female
*/
export {};

View File

@ -1758,7 +1758,7 @@ export class PokemonTypeChangeAbAttr extends PreAttackAbAttr {
* Parameters for abilities that modify the hit count and damage of a move
*/
export interface AddSecondStrikeAbAttrParams extends Omit<AugmentMoveInteractionAbAttrParams, "opponent"> {
/** Holder for the number of hits. May be modified by ability application */
/** Holder for the number of hits. May be modified by ability application */
hitCount?: NumberHolder;
/** Holder for the damage multiplier _of the current hit_ */
multiplier?: NumberHolder;
@ -5814,7 +5814,7 @@ export class NoFusionAbilityAbAttr extends AbAttr {
export interface IgnoreTypeImmunityAbAttrParams extends AbAttrBaseParams {
/** The type of the move being used */
readonly moveType: PokemonType;
/** The type being checked for */
/** The type being checked for */
readonly defenderType: PokemonType;
/** Holds whether the type immunity should be bypassed */
cancelled: BooleanHolder;
@ -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() {
allAbilities.push(
new Ability(AbilityId.NONE, 3),
@ -7867,3 +7867,4 @@ export function initAbilities() {
.attr(ConfusionOnStatusEffectAbAttr, StatusEffect.POISON, StatusEffect.TOXIC)
);
}
// biome-ignore-end format: prevent biome from removing the newlines (e.g. prevent `new Ability(...).attr(...)`)

View File

@ -8483,8 +8483,6 @@ const MoveAttrs = Object.freeze({
/** Map of of move attribute names to their constructors */
export type MoveAttrConstructorMap = typeof MoveAttrs;
export const selfStatLowerMoves: MoveId[] = [];
export function initMoves() {
allMoves.push(
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)
.attr(StatusEffectAttr, StatusEffect.TOXIC)
);
allMoves.map(m => {
if (m.getAttrs("StatStageChangeAttr").some(a => a.selfTarget && a.stages < 0)) {
selfStatLowerMoves.push(m.id);
}
});
}

View File

@ -44,7 +44,10 @@ import { PokemonData } from "#system/pokemon-data";
import { MusicPreference } from "#system/settings";
import type { OptionSelectItem } from "#ui/abstract-option-select-ui-handler";
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 { toCamelCase } from "#utils/strings";
import i18next from "i18next";
/** the i18n namespace for the encounter */
@ -984,14 +987,17 @@ function doTradeReceivedSequence(
}
function generateRandomTraderName() {
const length = TrainerType.YOUNGSTER - TrainerType.ACE_TRAINER + 1;
// +1 avoids TrainerType.UNKNOWN
const classKey = `trainersCommon:${TrainerType[randInt(length) + 1]}`;
// Some trainers have 2 gendered pools, some do not
const genderKey = i18next.exists(`${classKey}.MALE`) ? (randInt(2) === 0 ? ".MALE" : ".FEMALE") : "";
const trainerNameKey = randSeedItem(Object.keys(i18next.t(`${classKey}${genderKey}`, { returnObjects: true })));
const trainerNameString = i18next.t(`${classKey}${genderKey}.${trainerNameKey}`);
// Some names have an '&' symbol and need to be trimmed to a single name instead of a double name
const trainerNames = trainerNameString.split(" & ");
return trainerNames[randInt(trainerNames.length)];
const allTrainerNames = getEnumKeys(TrainerType);
// Exclude TrainerType.UNKNOWN and everything after Ace Trainers (grunts and unique trainers)
const eligibleNames = allTrainerNames.slice(
1,
allTrainerNames.indexOf(TrainerType[TrainerType.YOUNGSTER] as keyof typeof TrainerType),
);
const randomTrainer = toCamelCase(randSeedItem(eligibleNames));
const classKey = `trainersCommon:${randomTrainer}`;
// Pick a random gender for ones with gendered pools, or access the raw object for ones without.
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(" & "));
}

View File

@ -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]+(?: &amp; [a-z]+)?)<\/a>/i.exec(content);
if (nameMatch) {
(female || forceFemale ? femaleTrainerNames : trainerNames).add(nameMatch[1].replace("&amp;", "&"));
}
}
}
}
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);
});
}*/

View File

@ -16,14 +16,11 @@ import type { PersistentModifier } from "#modifiers/modifier";
import { getIsInitialized, initI18n } from "#plugins/i18n";
import type { TrainerConfig } from "#trainers/trainer-config";
import { trainerConfigs } from "#trainers/trainer-config";
import {
TrainerPartyCompoundTemplate,
type TrainerPartyTemplate,
trainerPartyTemplates,
} from "#trainers/trainer-party-template";
import { TrainerPartyCompoundTemplate, type TrainerPartyTemplate } from "#trainers/trainer-party-template";
import { randSeedInt, randSeedItem, randSeedWeightedItem } from "#utils/common";
import { getRandomLocaleKey } from "#utils/i18n";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import { toSnakeCase } from "#utils/strings";
import { toCamelCase, toSnakeCase } from "#utils/strings";
import i18next from "i18next";
export class Trainer extends Phaser.GameObjects.Container {
@ -35,6 +32,18 @@ export class Trainer extends Phaser.GameObjects.Container {
public partnerNameKey: string | undefined;
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(
trainerType: TrainerType,
variant: TrainerVariant,
@ -44,13 +53,11 @@ export class Trainer extends Phaser.GameObjects.Container {
trainerConfigOverride?: TrainerConfig,
) {
super(globalScene, -72, 80);
this.config = trainerConfigs.hasOwnProperty(trainerType)
? trainerConfigs[trainerType]
: trainerConfigs[TrainerType.ACE_TRAINER];
if (trainerConfigOverride) {
this.config = trainerConfigOverride;
}
this.config =
trainerConfigOverride ??
(trainerConfigs.hasOwnProperty(trainerType)
? trainerConfigs[trainerType]
: trainerConfigs[TrainerType.ACE_TRAINER]);
this.variant = variant;
this.partyTemplateIndex = Math.min(
@ -59,20 +66,21 @@ export class Trainer extends Phaser.GameObjects.Container {
: randSeedWeightedItem(this.config.partyTemplates.map((_, i) => i)),
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 (nameKey) {
this.nameKey = nameKey;
this.name = i18next.t(nameKey);
} else {
const genderKey = i18next.exists(`${classKey}.MALE`)
const genderKey = i18next.exists(`${classKey}.male`)
? variant === TrainerVariant.FEMALE
? ".FEMALE"
: ".MALE"
? ".female"
: ".male"
: "";
const trainerKey = randSeedItem(Object.keys(i18next.t(`${classKey}${genderKey}`, { returnObjects: true })));
this.nameKey = `${classKey}${genderKey}.${trainerKey}`;
[this.nameKey, this.name] = getRandomLocaleKey(`${classKey}${genderKey}`);
}
this.name = i18next.t(this.nameKey);
if (variant === TrainerVariant.DOUBLE) {
if (this.config.doubleOnly) {
if (partnerNameKey) {
@ -82,16 +90,8 @@ export class Trainer extends Phaser.GameObjects.Container {
[this.name, this.partnerName] = this.name.split(" & ");
}
} else {
const partnerGenderKey = i18next.exists(`${classKey}.FEMALE`) ? ".FEMALE" : "";
const partnerTrainerKey = randSeedItem(
Object.keys(
i18next.t(`${classKey}${partnerGenderKey}`, {
returnObjects: true,
}),
),
);
this.partnerNameKey = `${classKey}${partnerGenderKey}.${partnerTrainerKey}`;
this.partnerName = i18next.t(this.partnerNameKey);
const partnerGenderKey = i18next.exists(`${classKey}.fenale`) ? ".fenale" : "";
[this.partnerNameKey, this.partnerName] = getRandomLocaleKey(`${classKey}${partnerGenderKey}`);
}
}
}
@ -109,10 +109,6 @@ export class Trainer extends Phaser.GameObjects.Container {
break;
}
console.log(
Object.keys(trainerPartyTemplates)[Object.values(trainerPartyTemplates).indexOf(this.getPartyTemplate())],
);
const getSprite = (hasShadow?: boolean, forceFemale?: boolean) => {
const ret = globalScene.addFieldSprite(
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.
* @param {TrainerSlot} trainerSlot - The slot to determine which name to use. Defaults to TrainerSlot.NONE.
* @param {boolean} includeTitle - Whether to include the title in the returned name. Defaults to false.
* @returns {string} - The formatted name of the trainer.
* @param rainerSlot - The slot to determine which name to use; default `TrainerSlot.NONE`
* @param includeTitle - Whether to include the title in the returned name; default `false`
* @returns - The formatted name of the trainer
*/
getName(trainerSlot: TrainerSlot = TrainerSlot.NONE, includeTitle = false): string {
// Get the base title based on the trainer slot and variant.

17
src/utils/i18n.ts Normal file
View 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)];
}

View File

@ -1,7 +1,7 @@
{
"entryPoints": ["./src"],
"entryPointStrategy": "expand",
"exclude": ["**/*+.test.ts"],
"exclude": ["**/*+.test.ts", "**/src/data/trainer-names.ts"],
"out": "typedoc",
"highlightLanguages": ["javascript", "json", "jsonc", "json5", "tsx", "typescript", "markdown"]
}