Merge branch 'beta' into ChampionAdjustments

This commit is contained in:
Blitzy 2025-07-31 09:13:13 -05:00 committed by GitHub
commit 81cde974fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
94 changed files with 3754 additions and 2130 deletions

View File

@ -11,6 +11,7 @@ on:
- beta
merge_group:
types: [checks_requested]
workflow_dispatch:
jobs:
check-path-change-filter:

View File

@ -30,19 +30,19 @@
"@biomejs/biome": "2.0.0",
"@ls-lint/ls-lint": "2.3.1",
"@types/jsdom": "^21.1.7",
"@types/node": "^22.16.3",
"@types/node": "^22.16.5",
"@vitest/coverage-istanbul": "^3.2.4",
"@vitest/expect": "^3.2.4",
"chalk": "^5.4.1",
"dependency-cruiser": "^16.10.4",
"inquirer": "^12.7.0",
"inquirer": "^12.8.2",
"jsdom": "^26.1.0",
"lefthook": "^1.12.2",
"msw": "^2.10.4",
"phaser3spectorjs": "^0.0.8",
"typedoc": "^0.28.7",
"typedoc": "^0.28.8",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vite": "^7.0.6",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4",
"vitest-canvas-mock": "^0.3.3"

File diff suppressed because it is too large Load Diff

View File

@ -17,15 +17,20 @@ const version = "2.0.1";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.join(__dirname, "..", "..");
const boilerplateFilePath = path.join(__dirname, "test-boilerplate.ts");
const choices = [
{ label: "Move", dir: "moves" },
{ label: "Ability", dir: "abilities" },
{ label: "Item", dir: "items" },
{ label: "Mystery Encounter", dir: "mystery-encounter/encounters" },
{ label: "Utils", dir: "utils" },
{ label: "UI", dir: "ui" },
];
const choices = /** @type {const} */ (["Move", "Ability", "Item", "Reward", "Mystery Encounter", "Utils", "UI"]);
/** @typedef {choices[number]} choiceType */
/** @satisfies {{[k in choiceType]: string}} */
const choicesToDirs = /** @type {const} */ ({
Move: "moves",
Ability: "abilities",
Item: "items",
Reward: "rewards",
"Mystery Encounter": "mystery-encounter/encounters",
Utils: "utils",
UI: "ui",
});
//#endregion
//#region Functions
@ -41,48 +46,47 @@ function getTestFolderPath(...folders) {
/**
* Prompts the user to select a type via list.
* @returns {Promise<{selectedOption: {label: string, dir: string}}>} the selected type
* @returns {Promise<choiceType>} the selected type
*/
async function promptTestType() {
const typeAnswer = await inquirer
/** @type {choiceType | "EXIT"} */
const choice = await inquirer
.prompt([
{
type: "list",
name: "selectedOption",
message: "What type of test would you like to create?",
choices: [...choices.map(choice => ({ name: choice.label, value: choice })), { name: "EXIT", value: "N/A" }],
choices: [...choices, "EXIT"],
},
])
.then(ans => ans.selectedOption);
.then(ta => ta.selectedOption);
if (typeAnswer.name === "EXIT") {
if (choice === "EXIT") {
console.log("Exiting...");
return process.exit(0);
}
if (!choices.some(choice => choice.dir === typeAnswer.dir)) {
console.error(`Please provide a valid type: (${choices.map(choice => choice.label).join(", ")})!`);
return await promptTestType();
}
return typeAnswer;
return choice;
}
/**
* Prompts the user to provide a file name.
* @param {string} selectedType
* @returns {Promise<{userInput: string}>} the selected file name
* @param {choiceType} selectedType The chosen string (used to display console logs)
* @returns {Promise<string>} the selected file name
*/
async function promptFileName(selectedType) {
/** @type {{userInput: string}} */
const fileNameAnswer = await inquirer.prompt([
{
type: "input",
name: "userInput",
message: `Please provide the name of the ${selectedType}:`,
},
]);
/** @type {string} */
const fileNameAnswer = await inquirer
.prompt([
{
type: "input",
name: "userInput",
message: `Please provide the name of the ${selectedType}.`,
},
])
.then(fa => fa.userInput);
if (!fileNameAnswer.userInput || fileNameAnswer.userInput.trim().length === 0) {
if (fileNameAnswer.trim().length === 0) {
console.error("Please provide a valid file name!");
return await promptFileName(selectedType);
}
@ -90,51 +94,66 @@ async function promptFileName(selectedType) {
return fileNameAnswer;
}
/**
* Obtain the path to the boilerplate file based on the current option.
* @param {choiceType} choiceType The choice selected
* @returns {string} The path to the boilerplate file
*/
function getBoilerplatePath(choiceType) {
switch (choiceType) {
// case "Reward":
// return path.join(__dirname, "boilerplates/reward.ts");
default:
return path.join(__dirname, "boilerplates/default.ts");
}
}
/**
* Runs the interactive test:create "CLI"
* @returns {Promise<void>}
*/
async function runInteractive() {
console.group(chalk.grey(`Create Test - v${version}\n`));
console.group(chalk.grey(`🧪 Create Test - v${version}\n`));
try {
const typeAnswer = await promptTestType();
const fileNameAnswer = await promptFileName(typeAnswer.selectedOption.label);
const choice = await promptTestType();
const fileNameAnswer = await promptFileName(choice);
const type = typeAnswer.selectedOption;
// Convert fileName from snake_case or camelCase to kebab-case
const fileName = fileNameAnswer.userInput
const fileName = fileNameAnswer
.replace(/_+/g, "-") // Convert snake_case (underscore) to kebab-case (dashes)
.replace(/([a-z])([A-Z])/g, "$1-$2") // Convert camelCase to kebab-case
.replace(/\s+/g, "-") // Replace spaces with dashes
.toLowerCase(); // Ensure all lowercase
// Format the description for the test case
// Format the description for the test case in Title Case
const formattedName = fileName.replace(/-/g, " ").replace(/\b\w/g, char => char.toUpperCase());
const description = `${choice} - ${formattedName}`;
// Determine the directory based on the type
const dir = getTestFolderPath(type.dir);
const description = `${type.label} - ${formattedName}`;
const localDir = choicesToDirs[choice];
const absoluteDir = getTestFolderPath(localDir);
// Define the content template
const content = fs.readFileSync(boilerplateFilePath, "utf8").replace("{{description}}", description);
const content = fs.readFileSync(getBoilerplatePath(choice), "utf8").replace("{{description}}", description);
// Ensure the directory exists
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
if (!fs.existsSync(absoluteDir)) {
fs.mkdirSync(absoluteDir, { recursive: true });
}
// Create the file with the given name
const filePath = path.join(dir, `${fileName}.test.ts`);
const filePath = path.join(absoluteDir, `${fileName}.test.ts`);
if (fs.existsSync(filePath)) {
console.error(chalk.red.bold(`\n✗ File "${fileName}.test.ts" already exists!\n`));
console.error(chalk.red.bold(`✗ File "${fileName}.test.ts" already exists!\n`));
process.exit(1);
}
// Write the template content to the file
fs.writeFileSync(filePath, content, "utf8");
console.log(chalk.green.bold(`\n✔ File created at: test/${type.dir}/${fileName}.test.ts\n`));
console.log(chalk.green.bold(`✔ File created at: test/${localDir}/${fileName}.test.ts\n`));
console.groupEnd();
} catch (err) {
console.error(chalk.red("✗ Error: ", err.message));

View File

@ -1,6 +1,5 @@
import type { ArenaTagTypeMap } from "#data/arena-tag";
import type { ArenaTagType } from "#enums/arena-tag-type";
import type { NonFunctionProperties } from "./type-helpers";
/** Subset of {@linkcode ArenaTagType}s that apply some negative effect to pokemon that switch in ({@link https://bulbapedia.bulbagarden.net/wiki/List_of_moves_that_cause_entry_hazards#List_of_traps | entry hazards} and Imprison. */
export type ArenaTrapTagType =
@ -10,9 +9,6 @@ export type ArenaTrapTagType =
| ArenaTagType.STEALTH_ROCK
| ArenaTagType.IMPRISON;
/** Subset of {@linkcode ArenaTagType}s that are considered delayed attacks */
export type ArenaDelayedAttackTagType = ArenaTagType.FUTURE_SIGHT | ArenaTagType.DOOM_DESIRE;
/** Subset of {@linkcode ArenaTagType}s that create {@link https://bulbapedia.bulbagarden.net/wiki/Category:Screen-creating_moves | screens}. */
export type ArenaScreenTagType = ArenaTagType.REFLECT | ArenaTagType.LIGHT_SCREEN | ArenaTagType.AURORA_VEIL;
@ -30,13 +26,13 @@ export type NonSerializableArenaTagType = ArenaTagType.NONE | TurnProtectArenaTa
export type SerializableArenaTagType = Exclude<ArenaTagType, NonSerializableArenaTagType>;
/**
* Type-safe representation of the serializable data of an ArenaTag
* Type-safe representation of an arbitrary, serialized Arena Tag
*/
export type ArenaTagTypeData = NonFunctionProperties<
export type ArenaTagTypeData = Parameters<
ArenaTagTypeMap[keyof {
[K in keyof ArenaTagTypeMap as K extends SerializableArenaTagType ? K : never]: ArenaTagTypeMap[K];
}]
>;
}]["loadTag"]
>[0];
/** Dummy, typescript-only declaration to ensure that
* {@linkcode ArenaTagTypeMap} has a map for all ArenaTagTypes.

128
src/@types/battler-tags.ts Normal file
View File

@ -0,0 +1,128 @@
// biome-ignore-start lint/correctness/noUnusedImports: Used in a TSDoc comment
import type { AbilityBattlerTag, BattlerTagTypeMap, SerializableBattlerTag, TypeBoostTag } from "#data/battler-tags";
import type { AbilityId } from "#enums/ability-id";
// biome-ignore-end lint/correctness/noUnusedImports: end
import type { BattlerTagType } from "#enums/battler-tag-type";
/**
* Subset of {@linkcode BattlerTagType}s that restrict the use of moves.
*/
export type MoveRestrictionBattlerTagType =
| BattlerTagType.THROAT_CHOPPED
| BattlerTagType.TORMENT
| BattlerTagType.TAUNT
| BattlerTagType.IMPRISON
| BattlerTagType.HEAL_BLOCK
| BattlerTagType.ENCORE
| BattlerTagType.DISABLED
| BattlerTagType.GORILLA_TACTICS;
/**
* Subset of {@linkcode BattlerTagType}s that block damage from moves.
*/
export type FormBlockDamageBattlerTagType = BattlerTagType.ICE_FACE | BattlerTagType.DISGUISE;
/**
* Subset of {@linkcode BattlerTagType}s that are related to trapping effects.
*/
export type TrappingBattlerTagType =
| BattlerTagType.BIND
| BattlerTagType.WRAP
| BattlerTagType.FIRE_SPIN
| BattlerTagType.WHIRLPOOL
| BattlerTagType.CLAMP
| BattlerTagType.SAND_TOMB
| BattlerTagType.MAGMA_STORM
| BattlerTagType.SNAP_TRAP
| BattlerTagType.THUNDER_CAGE
| BattlerTagType.INFESTATION
| BattlerTagType.INGRAIN
| BattlerTagType.OCTOLOCK
| BattlerTagType.NO_RETREAT;
/**
* Subset of {@linkcode BattlerTagType}s that are related to protection effects.
*/
export type ProtectionBattlerTagType = BattlerTagType.PROTECTED | BattlerTagType.SPIKY_SHIELD | DamageProtectedTagType;
/**
* Subset of {@linkcode BattlerTagType}s related to protection effects that block damage but not status moves.
*/
export type DamageProtectedTagType = ContactSetStatusProtectedTagType | ContactStatStageChangeProtectedTagType;
/**
* Subset of {@linkcode BattlerTagType}s related to protection effects that set a status effect on the attacker.
*/
export type ContactSetStatusProtectedTagType = BattlerTagType.BANEFUL_BUNKER | BattlerTagType.BURNING_BULWARK;
/**
* Subset of {@linkcode BattlerTagType}s related to protection effects that change stat stages of the attacker.
*/
export type ContactStatStageChangeProtectedTagType =
| BattlerTagType.KINGS_SHIELD
| BattlerTagType.SILK_TRAP
| BattlerTagType.OBSTRUCT;
/** Subset of {@linkcode BattlerTagType}s that provide the Endure effect */
export type EndureTagType = BattlerTagType.ENDURE_TOKEN | BattlerTagType.ENDURING;
/**
* Subset of {@linkcode BattlerTagType}s that are related to semi-invulnerable states.
*/
export type SemiInvulnerableTagType =
| BattlerTagType.FLYING
| BattlerTagType.UNDERGROUND
| BattlerTagType.UNDERWATER
| BattlerTagType.HIDDEN;
/**
* Subset of {@linkcode BattlerTagType}s corresponding to {@linkcode AbilityBattlerTag}s
*
* @remarks
* {@linkcode AbilityId.FLASH_FIRE | Flash Fire}'s {@linkcode BattlerTagType.FIRE_BOOST} is not included as it
* subclasses {@linkcode TypeBoostTag} and not `AbilityBattlerTag`.
*/
export type AbilityBattlerTagType =
| BattlerTagType.PROTOSYNTHESIS
| BattlerTagType.QUARK_DRIVE
| BattlerTagType.UNBURDEN
| BattlerTagType.SLOW_START
| BattlerTagType.TRUANT;
/**
* Subset of {@linkcode BattlerTagType}s related to abilities that boost the highest stat.
*/
export type HighestStatBoostTagType =
| BattlerTagType.QUARK_DRIVE // formatting
| BattlerTagType.PROTOSYNTHESIS;
/**
* Subset of {@linkcode BattlerTagType}s that are able to persist between turns and should therefore be serialized
*/
export type SerializableBattlerTagType = keyof {
[K in keyof BattlerTagTypeMap as BattlerTagTypeMap[K] extends SerializableBattlerTag
? K
: never]: BattlerTagTypeMap[K];
};
/**
* Subset of {@linkcode BattlerTagType}s that are not able to persist across waves and should therefore not be serialized
*/
export type NonSerializableBattlerTagType = Exclude<BattlerTagType, SerializableBattlerTagType>;
/**
* Type-safe representation of an arbitrary, serialized Battler Tag
*/
export type BattlerTagTypeData = Parameters<
BattlerTagTypeMap[keyof {
[K in keyof BattlerTagTypeMap as K extends SerializableBattlerTagType ? K : never]: BattlerTagTypeMap[K];
}]["loadTag"]
>[0];
/**
* Dummy, typescript-only declaration to ensure that
* {@linkcode BattlerTagTypeMap} has an entry for all `BattlerTagType`s.
*
* If a battler tag is missing from the map, Typescript will throw an error on this statement.
*
* Does not actually exist at runtime, so it must not be used!
*/
declare const EnsureAllBattlerTagTypesAreMapped: BattlerTagTypeMap[BattlerTagType] & never;

View File

@ -1,18 +1,14 @@
import type { ObjectValues } from "#types/type-helpers";
/** Union type accepting any TS Enum or `const object`, with or without reverse mapping. */
export type EnumOrObject = Record<string | number, string | number>;
/**
* Utility type to extract the enum values from a `const object`,
* or convert an `enum` interface produced by `typeof Enum` into the union type representing its values.
*/
export type EnumValues<E> = E[keyof E];
/**
* Generic type constraint representing a TS numeric enum with reverse mappings.
* @example
* TSNumericEnum<typeof WeatherType>
*/
export type TSNumericEnum<T extends EnumOrObject> = number extends EnumValues<T> ? T : never;
export type TSNumericEnum<T extends EnumOrObject> = number extends ObjectValues<T> ? T : never;
/** Generic type constraint representing a non reverse-mapped TS enum or `const object`. */
export type NormalEnum<T extends EnumOrObject> = Exclude<T, TSNumericEnum<T>>;

View File

@ -6,8 +6,6 @@
import type { AbAttr } from "#abilities/ability";
// biome-ignore-end lint/correctness/noUnusedImports: Used in a tsdoc comment
import type { EnumValues } from "#types/enum-types";
/**
* Exactly matches the type of the argument, preventing adding additional properties.
*
@ -37,16 +35,25 @@ export type Mutable<T> = {
};
/**
* Type helper to obtain the keys associated with a given value inside a `const object`.
* Type helper to obtain the keys associated with a given value inside an object.
* @typeParam O - The type of the object
* @typeParam V - The type of one of O's values
*/
export type InferKeys<O extends Record<keyof any, unknown>, V extends EnumValues<O>> = {
export type InferKeys<O extends object, V extends ObjectValues<O>> = {
[K in keyof O]: O[K] extends V ? K : never;
}[keyof O];
/**
* Type helper that matches any `Function` type. Equivalent to `Function`, but will not raise a warning from Biome.
* Utility type to obtain the values of a given object. \
* Functions similar to `keyof E`, except producing the values instead of the keys.
* @remarks
* This can be used to convert an `enum` interface produced by `typeof Enum` into the union type representing its members.
*/
export type ObjectValues<E extends object> = E[keyof E];
/**
* Type helper that matches any `Function` type.
* Equivalent to `Function`, but will not raise a warning from Biome.
*/
export type AnyFn = (...args: any[]) => any;
@ -65,6 +72,7 @@ export type NonFunctionProperties<T> = {
/**
* Type helper to extract out non-function properties from a type, recursively applying to nested properties.
* This can be used to mimic the effects of JSON serialization and de-serialization on a given type.
*/
export type NonFunctionPropertiesRecursive<Class> = {
[K in keyof Class as Class[K] extends AnyFn ? never : K]: Class[K] extends Array<infer U>
@ -75,3 +83,14 @@ export type NonFunctionPropertiesRecursive<Class> = {
};
export type AbstractConstructor<T> = abstract new (...args: any[]) => T;
/**
* Type helper that iterates through the fields of the type and coerces any `null` properties to `undefined` (including in union types).
*
* @remarks
* This is primarily useful when an object with nullable properties wants to be serialized and have its `null`
* properties coerced to `undefined`.
*/
export type CoerceNullPropertiesToUndefined<T extends object> = {
[K in keyof T]: null extends T[K] ? Exclude<T[K], null> | undefined : T[K];
};

View File

@ -11,7 +11,7 @@ export interface IllusionData {
/** The name of pokemon featured in the illusion */
name: string;
/** The nickname of the pokemon featured in the illusion */
nickname: string;
nickname?: string;
/** Whether the pokemon featured in the illusion is shiny or not */
shiny: boolean;
/** The variant of the pokemon featured in the illusion */

View File

@ -3,6 +3,7 @@
import type { Pokemon } from "#field/pokemon";
import type { ModifierConstructorMap } from "#modifiers/modifier";
import type { ModifierType, WeightedModifierType } from "#modifiers/modifier-type";
import type { ObjectValues } from "#types/type-helpers";
export type ModifierTypeFunc = () => ModifierType;
export type WeightedModifierTypeWeightFunc = (party: Pokemon[], rerollCount?: number) => number;
@ -19,7 +20,7 @@ export type ModifierInstanceMap = {
/**
* Union type of all modifier constructors.
*/
export type ModifierClass = ModifierConstructorMap[keyof ModifierConstructorMap];
export type ModifierClass = ObjectValues<ModifierConstructorMap>;
/**
* Union type of all modifier names as strings.

View File

@ -1,4 +1,5 @@
import type { PhaseConstructorMap } from "#app/phase-manager";
import type { ObjectValues } from "#types/type-helpers";
// Intentionally export the types of everything in phase-manager, as this file is meant to be
// the centralized place for type definitions for the phase system.
@ -17,7 +18,7 @@ export type PhaseMap = {
/**
* Union type of all phase constructors.
*/
export type PhaseClass = PhaseConstructorMap[keyof PhaseConstructorMap];
export type PhaseClass = ObjectValues<PhaseConstructorMap>;
/**
* Union type of all phase names as strings.

View File

@ -657,9 +657,7 @@ export class BattleScene extends SceneBase {
).then(() => loadMoveAnimAssets(defaultMoves, true)),
this.initStarterColors(),
]).then(() => {
this.phaseManager.pushNew("LoginPhase");
this.phaseManager.pushNew("TitlePhase");
this.phaseManager.toTitleScreen(true);
this.phaseManager.shiftPhase();
});
}
@ -1269,13 +1267,12 @@ export class BattleScene extends SceneBase {
duration: 250,
ease: "Sine.easeInOut",
onComplete: () => {
this.phaseManager.clearPhaseQueue();
this.ui.freeUIData();
this.uiContainer.remove(this.ui, true);
this.uiContainer.destroy();
this.children.removeAll(true);
this.game.domContainer.innerHTML = "";
// TODO: `launchBattle` calls `reset(false, false, true)`
this.launchBattle();
},
});

View File

@ -1,3 +1,7 @@
/** biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports */
import type { BattlerTag } from "#app/data/battler-tags";
/** biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports */
import { applyAbAttrs, applyOnGainAbAttrs, applyOnLoseAbAttrs } from "#abilities/apply-ab-attrs";
import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages";
@ -6,58 +10,75 @@ import { allMoves } from "#data/data-lists";
import { AbilityId } from "#enums/ability-id";
import { ArenaTagSide } from "#enums/arena-tag-side";
import { ArenaTagType } from "#enums/arena-tag-type";
import type { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { HitResult } from "#enums/hit-result";
import { CommonAnim } from "#enums/move-anims-common";
import { MoveCategory } from "#enums/move-category";
import { MoveId } from "#enums/move-id";
import { MoveTarget } from "#enums/move-target";
import { MoveUseMode } from "#enums/move-use-mode";
import { PokemonType } from "#enums/pokemon-type";
import { Stat } from "#enums/stat";
import { StatusEffect } from "#enums/status-effect";
import type { Arena } from "#field/arena";
import type { Pokemon } from "#field/pokemon";
import type {
ArenaDelayedAttackTagType,
ArenaScreenTagType,
ArenaTagTypeData,
ArenaTrapTagType,
SerializableArenaTagType,
} from "#types/arena-tags";
import type { Mutable, NonFunctionProperties } from "#types/type-helpers";
import { BooleanHolder, isNullOrUndefined, NumberHolder, toDmgValue } from "#utils/common";
import type { Mutable } from "#types/type-helpers";
import { BooleanHolder, NumberHolder, toDmgValue } from "#utils/common";
import i18next from "i18next";
/*
ArenaTags are are meant for effects that are tied to the arena (as opposed to a specific pokemon).
Examples include (but are not limited to)
- Cross-turn effects that persist even if the user/target switches out, such as Wish, Future Sight, and Happy Hour
- Effects that are applied to a specific side of the field, such as Crafty Shield, Reflect, and Spikes
- Field-Effects, like Gravity and Trick Room
Any arena tag that persists across turns *must* extend from `SerializableArenaTag` in the class definition signature.
Serializable ArenaTags have strict rules for their fields.
These rules ensure that only the data necessary to reconstruct the tag is serialized, and that the
session loader is able to deserialize saved tags correctly.
If the data is static (i.e. it is always the same for all instances of the class, such as the
type that is weakened by Mud Sport/Water Sport), then it must not be defined as a field, and must
instead be defined as a getter.
A static property is also acceptable, though static properties are less ergonomic with inheritance.
If the data is mutable (i.e. it can change over the course of the tag's lifetime), then it *must*
be defined as a field, and it must be set in the `loadTag` method.
Such fields cannot be marked as `private/protected`, as if they were, typescript would omit them from
types that are based off of the class, namely, `ArenaTagTypeData`. It is preferrable to trade the
type-safety of private/protected fields for the type safety when deserializing arena tags from save data.
For data that is mutable only within a turn (e.g. SuppressAbilitiesTag's beingRemoved field),
where it does not make sense to be serialized, the field should use ES2020's private field syntax (a `#` prepended to the field name).
If the field should be accessible outside of the class, then a public getter should be used.
*/
/**
* @module
* ArenaTags are are meant for effects that are tied to the arena (as opposed to a specific pokemon).
* Examples include (but are not limited to)
* - Cross-turn effects that persist even if the user/target switches out, such as Happy Hour
* - Effects that are applied to a specific side of the field, such as Crafty Shield, Reflect, and Spikes
* - Field-Effects, like Gravity and Trick Room
*
* Any arena tag that persists across turns *must* extend from `SerializableArenaTag` in the class definition signature.
*
* Serializable ArenaTags have strict rules for their fields.
* These rules ensure that only the data necessary to reconstruct the tag is serialized, and that the
* session loader is able to deserialize saved tags correctly.
*
* If the data is static (i.e. it is always the same for all instances of the class, such as the
* type that is weakened by Mud Sport/Water Sport), then it must not be defined as a field, and must
* instead be defined as a getter.
* A static property is also acceptable, though static properties are less ergonomic with inheritance.
*
* If the data is mutable (i.e. it can change over the course of the tag's lifetime), then it *must*
* be defined as a field, and it must be set in the `loadTag` method.
* Such fields cannot be marked as `private`/`protected`; if they were, Typescript would omit them from
* types that are based off of the class, namely, `ArenaTagTypeData`. It is preferrable to trade the
* type-safety of private/protected fields for the type safety when deserializing arena tags from save data.
*
* For data that is mutable only within a turn (e.g. SuppressAbilitiesTag's beingRemoved field),
* where it does not make sense to be serialized, the field should use ES2020's
* [private field syntax](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_elements#private_fields).
* If the field should be accessible outside of the class, then a public getter should be used.
*
* If any new serializable fields *are* added, then the class *must* override the
* `loadTag` method to set the new fields. Its signature *must* match the example below,
* ```
* class ExampleTag extends SerializableArenaTag {
* // Example, if we add 2 new fields that should be serialized:
* public a: string;
* public b: number;
* // Then we must also define a loadTag method with one of the following signatures
* public override loadTag(source: BaseArenaTag & Pick<ExampleTag, "tagType" | "a" | "b"): void;
* public override loadTag<const T extends this>(source: BaseArenaTag & Pick<T, "tagType" | "a" | "b">): void;
* public override loadTag(source: NonFunctionProperties<ExampleTag>): void;
* }
* ```
* Notes
* - If the class has any subclasses, then the second form of `loadTag` *must* be used.
* - The third form *must not* be used if the class has any getters, as typescript would expect such fields to be
* present in `source`.
*/
/** Interface containing the serializable fields of ArenaTagData. */
interface BaseArenaTag {
@ -141,9 +162,9 @@ export abstract class ArenaTag implements BaseArenaTag {
/**
* When given a arena tag or json representing one, load the data for it.
* This is meant to be inherited from by any arena tag with custom attributes
* @param source - The {@linkcode BaseArenaTag} being loaded
* @param source - The arena tag being loaded
*/
loadTag(source: BaseArenaTag): void {
loadTag<const T extends this>(source: BaseArenaTag & Pick<T, "tagType">): void {
this.turnCount = source.turnCount;
this.sourceMove = source.sourceMove;
this.sourceId = source.sourceId;
@ -604,56 +625,6 @@ export class NoCritTag extends SerializableArenaTag {
}
}
/**
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Wish_(move) | Wish}.
* Heals the Pokémon in the user's position the turn after Wish is used.
*/
class WishTag extends SerializableArenaTag {
// The following fields are meant to be inwardly mutable, but outwardly immutable.
readonly battlerIndex: BattlerIndex;
readonly healHp: number;
readonly sourceName: string;
// End inwardly mutable fields
public readonly tagType = ArenaTagType.WISH;
constructor(turnCount: number, sourceId: number | undefined, side: ArenaTagSide) {
super(turnCount, MoveId.WISH, sourceId, side);
}
onAdd(_arena: Arena): void {
const source = this.getSourcePokemon();
if (!source) {
console.warn(`Failed to get source Pokemon for WishTag on add message; id: ${this.sourceId}`);
return;
}
(this as Mutable<this>).sourceName = getPokemonNameWithAffix(source);
(this as Mutable<this>).healHp = toDmgValue(source.getMaxHp() / 2);
(this as Mutable<this>).battlerIndex = source.getBattlerIndex();
}
onRemove(_arena: Arena): void {
const target = globalScene.getField()[this.battlerIndex];
if (target?.isActive(true)) {
globalScene.phaseManager.queueMessage(
// TODO: Rename key as it triggers on activation
i18next.t("arenaTag:wishTagOnAdd", {
pokemonNameWithAffix: this.sourceName,
}),
);
globalScene.phaseManager.unshiftNew("PokemonHealPhase", target.getBattlerIndex(), this.healHp, null, true, false);
}
}
override loadTag(source: NonFunctionProperties<WishTag>): void {
super.loadTag(source);
(this as Mutable<this>).battlerIndex = source.battlerIndex;
(this as Mutable<this>).healHp = source.healHp;
(this as Mutable<this>).sourceName = source.sourceName;
}
}
/**
* Abstract class to implement weakened moves of a specific type.
*/
@ -813,7 +784,7 @@ export abstract class ArenaTrapTag extends SerializableArenaTag {
: Phaser.Math.Linear(0, 1 / Math.pow(2, this.layers), Math.min(pokemon.getHpRatio(), 0.5) * 2);
}
loadTag(source: NonFunctionProperties<ArenaTrapTag>): void {
public loadTag<T extends this>(source: BaseArenaTag & Pick<T, "tagType" | "layers" | "maxLayers">): void {
super.loadTag(source);
this.layers = source.layers;
this.maxLayers = source.maxLayers;
@ -1126,48 +1097,6 @@ class StickyWebTag extends ArenaTrapTag {
}
}
/**
* Arena Tag class for delayed attacks, such as {@linkcode MoveId.FUTURE_SIGHT} or {@linkcode MoveId.DOOM_DESIRE}.
* Delays the attack's effect by a set amount of turns, usually 3 (including the turn the move is used),
* and deals damage after the turn count is reached.
*/
export class DelayedAttackTag extends SerializableArenaTag {
public targetIndex: BattlerIndex;
public readonly tagType: ArenaDelayedAttackTagType;
constructor(
tagType: ArenaTagType.DOOM_DESIRE | ArenaTagType.FUTURE_SIGHT,
sourceMove: MoveId | undefined,
sourceId: number | undefined,
targetIndex: BattlerIndex,
side: ArenaTagSide = ArenaTagSide.BOTH,
) {
super(3, sourceMove, sourceId, side);
this.tagType = tagType;
this.targetIndex = targetIndex;
this.side = side;
}
lapse(arena: Arena): boolean {
const ret = super.lapse(arena);
if (!ret) {
// TODO: This should not add to move history (for Spite)
globalScene.phaseManager.unshiftNew(
"MoveEffectPhase",
this.sourceId!,
[this.targetIndex],
allMoves[this.sourceMove!],
MoveUseMode.FOLLOW_UP,
); // TODO: are those bangs correct?
}
return ret;
}
onRemove(_arena: Arena): void {}
}
/**
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Trick_Room_(move) Trick Room}.
* Reverses the Speed stats for all Pokémon on the field as long as this arena tag is up,
@ -1581,7 +1510,7 @@ export class SuppressAbilitiesTag extends SerializableArenaTag {
this.#beingRemoved = false;
}
public override loadTag(source: NonFunctionProperties<SuppressAbilitiesTag>): void {
public override loadTag(source: BaseArenaTag & Pick<SuppressAbilitiesTag, "tagType" | "sourceCount">): void {
super.loadTag(source);
(this as Mutable<this>).sourceCount = source.sourceCount;
}
@ -1663,7 +1592,6 @@ export function getArenaTag(
turnCount: number,
sourceMove: MoveId | undefined,
sourceId: number | undefined,
targetIndex?: BattlerIndex,
side: ArenaTagSide = ArenaTagSide.BOTH,
): ArenaTag | null {
switch (tagType) {
@ -1689,14 +1617,6 @@ export function getArenaTag(
return new SpikesTag(sourceId, side);
case ArenaTagType.TOXIC_SPIKES:
return new ToxicSpikesTag(sourceId, side);
case ArenaTagType.FUTURE_SIGHT:
case ArenaTagType.DOOM_DESIRE:
if (isNullOrUndefined(targetIndex)) {
return null; // If missing target index, no tag is created
}
return new DelayedAttackTag(tagType, sourceMove, sourceId, targetIndex, side);
case ArenaTagType.WISH:
return new WishTag(turnCount, sourceId, side);
case ArenaTagType.STEALTH_ROCK:
return new StealthRockTag(sourceId, side);
case ArenaTagType.STICKY_WEB:
@ -1739,16 +1659,9 @@ export function getArenaTag(
* @param source - An arena tag
* @returns The valid arena tag
*/
export function loadArenaTag(source: (ArenaTag | ArenaTagTypeData) & { targetIndex?: BattlerIndex }): ArenaTag {
export function loadArenaTag(source: ArenaTag | ArenaTagTypeData): ArenaTag {
const tag =
getArenaTag(
source.tagType,
source.turnCount,
source.sourceMove,
source.sourceId,
source.targetIndex,
source.side,
) ?? new NoneTag();
getArenaTag(source.tagType, source.turnCount, source.sourceMove, source.sourceId, source.side) ?? new NoneTag();
tag.loadTag(source);
return tag;
}
@ -1765,9 +1678,6 @@ export type ArenaTagTypeMap = {
[ArenaTagType.CRAFTY_SHIELD]: CraftyShieldTag;
[ArenaTagType.NO_CRIT]: NoCritTag;
[ArenaTagType.TOXIC_SPIKES]: ToxicSpikesTag;
[ArenaTagType.FUTURE_SIGHT]: DelayedAttackTag;
[ArenaTagType.DOOM_DESIRE]: DelayedAttackTag;
[ArenaTagType.WISH]: WishTag;
[ArenaTagType.STEALTH_ROCK]: StealthRockTag;
[ArenaTagType.STICKY_WEB]: StickyWebTag;
[ArenaTagType.TRICK_ROOM]: TrickRoomTag;

View File

@ -1,8 +1,8 @@
import { allMoves } from "#data/data-lists";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { toReadableString } from "#utils/common";
import { getEnumKeys, getEnumValues } from "#utils/enums";
import { toTitleCase } from "#utils/strings";
export const speciesEggMoves = {
[SpeciesId.BULBASAUR]: [ MoveId.SAPPY_SEED, MoveId.MALIGNANT_CHAIN, MoveId.EARTH_POWER, MoveId.MATCHA_GOTCHA ],
@ -617,7 +617,7 @@ function parseEggMoves(content: string): void {
}
if (eggMoves.every(m => m === MoveId.NONE)) {
console.warn(`Species ${toReadableString(SpeciesId[species])} could not be parsed, excluding from output...`)
console.warn(`Species ${toTitleCase(SpeciesId[species])} could not be parsed, excluding from output...`)
} else {
output += `[SpeciesId.${SpeciesId[species]}]: [ ${eggMoves.map(m => `MoveId.${MoveId[m]}`).join(", ")} ],\n`;
}

View File

@ -7,8 +7,9 @@ import { AnimBlendType, AnimFocus, AnimFrameTarget, ChargeAnim, CommonAnim } fro
import { MoveFlags } from "#enums/move-flags";
import { MoveId } from "#enums/move-id";
import type { Pokemon } from "#field/pokemon";
import { animationFileName, coerceArray, getFrameMs, isNullOrUndefined, type nil } from "#utils/common";
import { coerceArray, getFrameMs, isNullOrUndefined, type nil } from "#utils/common";
import { getEnumKeys, getEnumValues } from "#utils/enums";
import { toKebabCase } from "#utils/strings";
import Phaser from "phaser";
export class AnimConfig {
@ -412,7 +413,7 @@ export function initCommonAnims(): Promise<void> {
const commonAnimId = commonAnimIds[ca];
commonAnimFetches.push(
globalScene
.cachedFetch(`./battle-anims/common-${commonAnimNames[ca].toLowerCase().replace(/_/g, "-")}.json`)
.cachedFetch(`./battle-anims/common-${toKebabCase(commonAnimNames[ca])}.json`)
.then(response => response.json())
.then(cas => commonAnims.set(commonAnimId, new AnimConfig(cas))),
);
@ -450,7 +451,7 @@ export function initMoveAnim(move: MoveId): Promise<void> {
const fetchAnimAndResolve = (move: MoveId) => {
globalScene
.cachedFetch(`./battle-anims/${animationFileName(move)}.json`)
.cachedFetch(`./battle-anims/${toKebabCase(MoveId[move])}.json`)
.then(response => {
const contentType = response.headers.get("content-type");
if (!response.ok || contentType?.indexOf("application/json") === -1) {
@ -506,7 +507,7 @@ function useDefaultAnim(move: MoveId, defaultMoveAnim: MoveId) {
* @remarks use {@linkcode useDefaultAnim} to use a default animation
*/
function logMissingMoveAnim(move: MoveId, ...optionalParams: any[]) {
const moveName = animationFileName(move);
const moveName = toKebabCase(MoveId[move]);
console.warn(`Could not load animation file for move '${moveName}'`, ...optionalParams);
}
@ -524,7 +525,7 @@ export async function initEncounterAnims(encounterAnim: EncounterAnim | Encounte
}
encounterAnimFetches.push(
globalScene
.cachedFetch(`./battle-anims/encounter-${encounterAnimNames[anim].toLowerCase().replace(/_/g, "-")}.json`)
.cachedFetch(`./battle-anims/encounter-${toKebabCase(encounterAnimNames[anim])}.json`)
.then(response => response.json())
.then(cas => encounterAnims.set(anim, new AnimConfig(cas))),
);
@ -548,7 +549,7 @@ export function initMoveChargeAnim(chargeAnim: ChargeAnim): Promise<void> {
} else {
chargeAnims.set(chargeAnim, null);
globalScene
.cachedFetch(`./battle-anims/${ChargeAnim[chargeAnim].toLowerCase().replace(/_/g, "-")}.json`)
.cachedFetch(`./battle-anims/${toKebabCase(ChargeAnim[chargeAnim])}.json`)
.then(response => response.json())
.then(ca => {
if (Array.isArray(ca)) {
@ -1405,7 +1406,9 @@ export async function populateAnims() {
const chargeAnimIds = getEnumValues(ChargeAnim);
const commonNamePattern = /name: (?:Common:)?(Opp )?(.*)/;
const moveNameToId = {};
// Exclude MoveId.NONE;
for (const move of getEnumValues(MoveId).slice(1)) {
// KARATE_CHOP => KARATECHOP
const moveName = MoveId[move].toUpperCase().replace(/_/g, "");
moveNameToId[moveName] = move;
}

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@ import { defaultStarterSpecies } from "#app/constants";
import { globalScene } from "#app/global-scene";
import { pokemonEvolutions } from "#balance/pokemon-evolutions";
import { speciesStarterCosts } from "#balance/starters";
import { getEggTierForSpecies } from "#data/egg";
import { pokemonFormChanges } from "#data/pokemon-forms";
import type { PokemonSpecies } from "#data/pokemon-species";
import { getPokemonSpeciesForm } from "#data/pokemon-species";
@ -11,6 +12,7 @@ import { BattleType } from "#enums/battle-type";
import { ChallengeType } from "#enums/challenge-type";
import { Challenges } from "#enums/challenges";
import { TypeColor, TypeShadow } from "#enums/color";
import { EggTier } from "#enums/egg-type";
import { ClassicFixedBossWaves } from "#enums/fixed-boss-waves";
import { ModifierTier } from "#enums/modifier-tier";
import type { MoveId } from "#enums/move-id";
@ -27,6 +29,7 @@ import type { DexAttrProps, GameData } from "#system/game-data";
import { BooleanHolder, type NumberHolder, randSeedItem } from "#utils/common";
import { deepCopy } from "#utils/data";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import { toCamelCase, toSnakeCase } from "#utils/strings";
import i18next from "i18next";
/** A constant for the default max cost of the starting party before a run */
@ -67,14 +70,11 @@ export abstract class Challenge {
}
/**
* Gets the localisation key for the challenge
* @returns {@link string} The i18n key for this challenge
* Gets the localization key for the challenge
* @returns The i18n key for this challenge as camel case.
*/
geti18nKey(): string {
return Challenges[this.id]
.split("_")
.map((f, i) => (i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase()))
.join("");
return toCamelCase(Challenges[this.id]);
}
/**
@ -105,23 +105,22 @@ export abstract class Challenge {
}
/**
* Returns the textual representation of a challenge's current value.
* @param overrideValue {@link number} The value to check for. If undefined, gets the current value.
* @returns {@link string} The localised name for the current value.
* Return the textual representation of a challenge's current value.
* @param overrideValue - The value to check for; default {@linkcode this.value}
* @returns The localised text for the current value.
*/
getValue(overrideValue?: number): string {
const value = overrideValue ?? this.value;
return i18next.t(`challenges:${this.geti18nKey()}.value.${value}`);
getValue(overrideValue: number = this.value): string {
return i18next.t(`challenges:${this.geti18nKey()}.value.${overrideValue}`);
}
/**
* Returns the description of a challenge's current value.
* @param overrideValue {@link number} The value to check for. If undefined, gets the current value.
* @returns {@link string} The localised description for the current value.
* Return the description of a challenge's current value.
* @param overrideValue - The value to check for; default {@linkcode this.value}
* @returns The localised description for the current value.
*/
getDescription(overrideValue?: number): string {
const value = overrideValue ?? this.value;
return `${i18next.t([`challenges:${this.geti18nKey()}.desc.${value}`, `challenges:${this.geti18nKey()}.desc`])}`;
// TODO: Do we need an override value here? it's currently unused
getDescription(overrideValue: number = this.value): string {
return `${i18next.t([`challenges:${this.geti18nKey()}.desc.${overrideValue}`, `challenges:${this.geti18nKey()}.desc`])}`;
}
/**
@ -579,31 +578,19 @@ export class SingleGenerationChallenge extends Challenge {
return this.value > 0 ? 1 : 0;
}
/**
* Returns the textual representation of a challenge's current value.
* @param {value} overrideValue The value to check for. If undefined, gets the current value.
* @returns {string} The localised name for the current value.
*/
getValue(overrideValue?: number): string {
const value = overrideValue ?? this.value;
if (value === 0) {
getValue(overrideValue: number = this.value): string {
if (overrideValue === 0) {
return i18next.t("settings:off");
}
return i18next.t(`starterSelectUiHandler:gen${value}`);
return i18next.t(`starterSelectUiHandler:gen${overrideValue}`);
}
/**
* Returns the description of a challenge's current value.
* @param {value} overrideValue The value to check for. If undefined, gets the current value.
* @returns {string} The localised description for the current value.
*/
getDescription(overrideValue?: number): string {
const value = overrideValue ?? this.value;
if (value === 0) {
getDescription(overrideValue: number = this.value): string {
if (overrideValue === 0) {
return i18next.t("challenges:singleGeneration.desc_default");
}
return i18next.t("challenges:singleGeneration.desc", {
gen: i18next.t(`challenges:singleGeneration.gen_${value}`),
gen: i18next.t(`challenges:singleGeneration.gen_${overrideValue}`),
});
}
@ -671,29 +658,13 @@ export class SingleTypeChallenge extends Challenge {
return this.value > 0 ? 1 : 0;
}
/**
* Returns the textual representation of a challenge's current value.
* @param {value} overrideValue The value to check for. If undefined, gets the current value.
* @returns {string} The localised name for the current value.
*/
getValue(overrideValue?: number): string {
if (overrideValue === undefined) {
overrideValue = this.value;
}
return PokemonType[this.value - 1].toLowerCase();
getValue(overrideValue: number = this.value): string {
return toSnakeCase(PokemonType[overrideValue - 1]);
}
/**
* Returns the description of a challenge's current value.
* @param {value} overrideValue The value to check for. If undefined, gets the current value.
* @returns {string} The localised description for the current value.
*/
getDescription(overrideValue?: number): string {
if (overrideValue === undefined) {
overrideValue = this.value;
}
const type = i18next.t(`pokemonInfo:Type.${PokemonType[this.value - 1]}`);
const typeColor = `[color=${TypeColor[PokemonType[this.value - 1]]}][shadow=${TypeShadow[PokemonType[this.value - 1]]}]${type}[/shadow][/color]`;
getDescription(overrideValue: number = this.value): string {
const type = i18next.t(`pokemonInfo:Type.${PokemonType[overrideValue - 1]}`);
const typeColor = `[color=${TypeColor[PokemonType[overrideValue - 1]]}][shadow=${TypeShadow[PokemonType[this.value - 1]]}]${type}[/shadow][/color]`;
const defaultDesc = i18next.t("challenges:singleType.desc_default");
const typeDesc = i18next.t("challenges:singleType.desc", {
type: typeColor,
@ -714,11 +685,14 @@ export class SingleTypeChallenge extends Challenge {
*/
export class FreshStartChallenge extends Challenge {
constructor() {
super(Challenges.FRESH_START, 1);
super(Challenges.FRESH_START, 3);
}
applyStarterChoice(pokemon: PokemonSpecies, valid: BooleanHolder): boolean {
if (!defaultStarterSpecies.includes(pokemon.speciesId)) {
if (
(this.value === 1 && !defaultStarterSpecies.includes(pokemon.speciesId)) ||
(this.value === 2 && getEggTierForSpecies(pokemon) >= EggTier.EPIC)
) {
valid.value = false;
return true;
}
@ -726,15 +700,12 @@ export class FreshStartChallenge extends Challenge {
}
applyStarterCost(species: SpeciesId, cost: NumberHolder): boolean {
if (defaultStarterSpecies.includes(species)) {
cost.value = speciesStarterCosts[species];
return true;
}
return false;
cost.value = speciesStarterCosts[species];
return true;
}
applyStarterModify(pokemon: Pokemon): boolean {
pokemon.abilityIndex = 0; // Always base ability, not hidden ability
pokemon.abilityIndex = pokemon.abilityIndex % 2; // Always base ability, if you set it to hidden it wraps to first ability
pokemon.passive = false; // Passive isn't unlocked
pokemon.nature = Nature.HARDY; // Neutral nature
pokemon.moveset = pokemon.species
@ -746,7 +717,22 @@ export class FreshStartChallenge extends Challenge {
pokemon.luck = 0; // No luck
pokemon.shiny = false; // Not shiny
pokemon.variant = 0; // Not shiny
pokemon.formIndex = 0; // Froakie should be base form
if (pokemon.species.speciesId === SpeciesId.ZYGARDE && pokemon.formIndex >= 2) {
pokemon.formIndex -= 2; // Sets 10%-PC to 10%-AB and 50%-PC to 50%-AB
} else if (
pokemon.formIndex > 0 &&
[
SpeciesId.PIKACHU,
SpeciesId.EEVEE,
SpeciesId.PICHU,
SpeciesId.ROTOM,
SpeciesId.MELOETTA,
SpeciesId.FROAKIE,
SpeciesId.ROCKRUFF,
].includes(pokemon.species.speciesId)
) {
pokemon.formIndex = 0; // These mons are set to form 0 because they're meant to be unlocks or mid-run form changes
}
pokemon.ivs = [15, 15, 15, 15, 15, 15]; // Default IVs of 15 for all stats (Updated to 15 from 10 in 1.2.0)
pokemon.teraType = pokemon.species.type1; // Always primary tera type
return true;
@ -832,13 +818,7 @@ export class LowerStarterMaxCostChallenge extends Challenge {
super(Challenges.LOWER_MAX_STARTER_COST, 9);
}
/**
* @override
*/
getValue(overrideValue?: number): string {
if (overrideValue === undefined) {
overrideValue = this.value;
}
getValue(overrideValue: number = this.value): string {
return (DEFAULT_PARTY_MAX_COST - overrideValue).toString();
}
@ -866,13 +846,7 @@ export class LowerStarterPointsChallenge extends Challenge {
super(Challenges.LOWER_STARTER_POINTS, 9);
}
/**
* @override
*/
getValue(overrideValue?: number): string {
if (overrideValue === undefined) {
overrideValue = this.value;
}
getValue(overrideValue: number = this.value): string {
return (DEFAULT_PARTY_MAX_COST - overrideValue).toString();
}

View File

@ -1,6 +1,7 @@
import { BattleSpec } from "#enums/battle-spec";
import { TrainerType } from "#enums/trainer-type";
import { trainerConfigs } from "#trainers/trainer-config";
import { capitalizeFirstLetter } from "#utils/strings";
export interface TrainerTypeMessages {
encounter?: string | string[];
@ -1755,8 +1756,7 @@ export function initTrainerTypeDialogue(): void {
trainerConfigs[trainerType][`${messageType}Messages`] = messages[0][messageType];
}
if (messages.length > 1) {
trainerConfigs[trainerType][`female${messageType.slice(0, 1).toUpperCase()}${messageType.slice(1)}Messages`] =
messages[1][messageType];
trainerConfigs[trainerType][`female${capitalizeFirstLetter(messageType)}Messages`] = messages[1][messageType];
}
} else {
trainerConfigs[trainerType][`${messageType}Messages`] = messages[messageType];

View File

@ -25,6 +25,7 @@ import { getBerryEffectFunc } from "#data/berry";
import { applyChallenges } from "#data/challenge";
import { allAbilities, allMoves } from "#data/data-lists";
import { SpeciesFormChangeRevertWeatherFormTrigger } from "#data/form-change-triggers";
import { DelayedAttackTag } from "#data/positional-tags/positional-tag";
import {
getNonVolatileStatusEffects,
getStatusEffectHealText,
@ -54,6 +55,7 @@ import { MoveFlags } from "#enums/move-flags";
import { MoveTarget } from "#enums/move-target";
import { MultiHitType } from "#enums/multi-hit-type";
import { PokemonType } from "#enums/pokemon-type";
import { PositionalTagType } from "#enums/positional-tag-type";
import { SpeciesId } from "#enums/species-id";
import {
BATTLE_STATS,
@ -87,8 +89,9 @@ import type { AttackMoveResult } from "#types/attack-move-result";
import type { Localizable } from "#types/locales";
import type { ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindString } from "#types/move-types";
import type { TurnMove } from "#types/turn-move";
import { BooleanHolder, type Constructor, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue, toReadableString } from "#utils/common";
import { BooleanHolder, type Constructor, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue } from "#utils/common";
import { getEnumValues } from "#utils/enums";
import { toTitleCase } from "#utils/strings";
import i18next from "i18next";
/**
@ -422,9 +425,8 @@ export abstract class Move implements Localizable {
/**
* Sets the {@linkcode MoveFlags.MAKES_CONTACT} flag for the calling Move
* @param setFlag Default `true`, set to `false` if the move doesn't make contact
* @see {@linkcode AbilityId.STATIC}
* @returns The {@linkcode Move} that called this function
* @param setFlag - Whether the move should make contact; default `true`
* @returns `this`
*/
makesContact(setFlag: boolean = true): this {
this.setFlag(MoveFlags.MAKES_CONTACT, setFlag);
@ -3122,54 +3124,110 @@ export class OverrideMoveEffectAttr extends MoveAttr {
* Its sole purpose is to ensure that typescript is able to properly narrow when the `is` method is called.
*/
declare private _: never;
override apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
/**
* Apply the move attribute to override other effects of this move.
* @param user - The {@linkcode Pokemon} using the move
* @param target - The {@linkcode Pokemon} targeted by the move
* @param move - The {@linkcode Move} being used
* @param args -
* `[0]`: A {@linkcode BooleanHolder} containing whether move effects were successfully overridden; should be set to `true` on success \
* `[1]`: The {@linkcode MoveUseMode} dictating how this move was used.
* @returns `true` if the move effect was successfully overridden.
*/
public override apply(_user: Pokemon, _target: Pokemon, _move: Move, _args: [overridden: BooleanHolder, useMode: MoveUseMode]): boolean {
return true;
}
}
/** Abstract class for moves that add {@linkcode PositionalTag}s to the field. */
abstract class AddPositionalTagAttr extends OverrideMoveEffectAttr {
protected abstract readonly tagType: PositionalTagType;
public override getCondition(): MoveConditionFunc {
// Check the arena if another similar positional tag is active and affecting the same slot
return (_user, target, move) => globalScene.arena.positionalTagManager.canAddTag(this.tagType, target.getBattlerIndex())
}
}
/**
* Attack Move that doesn't hit the turn it is played and doesn't allow for multiple
* uses on the same target. Examples are Future Sight or Doom Desire.
* @extends OverrideMoveEffectAttr
* @param tagType The {@linkcode ArenaTagType} that will be placed on the field when the move is used
* @param chargeAnim The {@linkcode ChargeAnim | Charging Animation} used for the move
* @param chargeText The text to display when the move is used
* Attribute to implement delayed attacks, such as {@linkcode MoveId.FUTURE_SIGHT} or {@linkcode MoveId.DOOM_DESIRE}.
* Delays the attack's effect with a {@linkcode DelayedAttackTag},
* activating against the given slot after the given turn count has elapsed.
*/
export class DelayedAttackAttr extends OverrideMoveEffectAttr {
public tagType: ArenaTagType;
public chargeAnim: ChargeAnim;
private chargeText: string;
constructor(tagType: ArenaTagType, chargeAnim: ChargeAnim, chargeText: string) {
/**
* @param chargeAnim - The {@linkcode ChargeAnim | charging animation} used for the move's charging phase.
* @param chargeKey - The `i18next` locales **key** to show when the delayed attack is used.
* In the displayed text, `{{pokemonName}}` will be populated with the user's name.
*/
constructor(chargeAnim: ChargeAnim, chargeKey: string) {
super();
this.tagType = tagType;
this.chargeAnim = chargeAnim;
this.chargeText = chargeText;
this.chargeText = chargeKey;
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
// Edge case for the move applied on a pokemon that has fainted
if (!target) {
return true;
public override apply(user: Pokemon, target: Pokemon, move: Move, args: [overridden: BooleanHolder, useMode: MoveUseMode]): boolean {
const useMode = args[1];
if (useMode === MoveUseMode.DELAYED_ATTACK) {
// don't trigger if already queueing an indirect attack
return false;
}
const overridden = args[0] as BooleanHolder;
const virtual = args[1] as boolean;
const overridden = args[0];
overridden.value = true;
if (!virtual) {
overridden.value = true;
globalScene.phaseManager.unshiftNew("MoveAnimPhase", new MoveChargeAnim(this.chargeAnim, move.id, user));
globalScene.phaseManager.queueMessage(this.chargeText.replace("{TARGET}", getPokemonNameWithAffix(target)).replace("{USER}", getPokemonNameWithAffix(user)));
user.pushMoveHistory({ move: move.id, targets: [ target.getBattlerIndex() ], result: MoveResult.OTHER, useMode: MoveUseMode.NORMAL });
const side = target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY;
globalScene.arena.addTag(this.tagType, 3, move.id, user.id, side, false, target.getBattlerIndex());
} else {
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:tookMoveAttack", { pokemonName: getPokemonNameWithAffix(globalScene.getPokemonById(target.id) ?? undefined), moveName: move.name }));
}
// Display the move animation to foresee an attack
globalScene.phaseManager.unshiftNew("MoveAnimPhase", new MoveChargeAnim(this.chargeAnim, move.id, user));
globalScene.phaseManager.queueMessage(
i18next.t(
this.chargeText,
{ pokemonName: getPokemonNameWithAffix(user) }
)
)
user.pushMoveHistory({move: move.id, targets: [target.getBattlerIndex()], result: MoveResult.OTHER, useMode, turn: globalScene.currentBattle.turn})
user.pushMoveHistory({move: move.id, targets: [target.getBattlerIndex()], result: MoveResult.OTHER, useMode, turn: globalScene.currentBattle.turn})
// Queue up an attack on the given slot.
globalScene.arena.positionalTagManager.addTag<PositionalTagType.DELAYED_ATTACK>({
tagType: PositionalTagType.DELAYED_ATTACK,
sourceId: user.id,
targetIndex: target.getBattlerIndex(),
sourceMove: move.id,
turnCount: 3
})
return true;
}
public override getCondition(): MoveConditionFunc {
// Check the arena if another similar attack is active and affecting the same slot
return (_user, target) => globalScene.arena.positionalTagManager.canAddTag(PositionalTagType.DELAYED_ATTACK, target.getBattlerIndex())
}
}
/**
* Attribute to queue a {@linkcode WishTag} to activate in 2 turns.
* The tag whill heal
*/
export class WishAttr extends MoveEffectAttr {
public override apply(user: Pokemon, target: Pokemon, _move: Move): boolean {
globalScene.arena.positionalTagManager.addTag<PositionalTagType.WISH>({
tagType: PositionalTagType.WISH,
healHp: toDmgValue(user.getMaxHp() / 2),
targetIndex: target.getBattlerIndex(),
turnCount: 2,
pokemonName: getPokemonNameWithAffix(user),
});
return true;
}
public override getCondition(): MoveConditionFunc {
// Check the arena if another wish is active and affecting the same slot
return (_user, target) => globalScene.arena.positionalTagManager.canAddTag(PositionalTagType.WISH, target.getBattlerIndex())
}
}
/**
@ -3187,8 +3245,8 @@ export class AwaitCombinedPledgeAttr extends OverrideMoveEffectAttr {
* @param user the {@linkcode Pokemon} using this move
* @param target n/a
* @param move the {@linkcode Move} being used
* @param args
* - [0] a {@linkcode BooleanHolder} indicating whether the move's base
* @param args -
* `[0]`: A {@linkcode BooleanHolder} indicating whether the move's base
* effects should be overridden this turn.
* @returns `true` if base move effects were overridden; `false` otherwise
*/
@ -3575,8 +3633,7 @@ export class CutHpStatStageBoostAttr extends StatStageChangeAttr {
/**
* Attribute implementing the stat boosting effect of {@link https://bulbapedia.bulbagarden.net/wiki/Order_Up_(move) | Order Up}.
* If the user has a Pokemon with {@link https://bulbapedia.bulbagarden.net/wiki/Commander_(Ability) | Commander} in their mouth,
* one of the user's stats are increased by 1 stage, depending on the "commanding" Pokemon's form. This effect does not respect
* effect chance, but Order Up itself may be boosted by Sheer Force.
* one of the user's stats are increased by 1 stage, depending on the "commanding" Pokemon's form.
*/
export class OrderUpStatBoostAttr extends MoveEffectAttr {
constructor() {
@ -8137,7 +8194,7 @@ export class ResistLastMoveTypeAttr extends MoveEffectAttr {
}
const type = validTypes[user.randBattleSeedInt(validTypes.length)];
user.summonData.types = [ type ];
globalScene.phaseManager.queueMessage(i18next.t("battle:transformedIntoType", { pokemonName: getPokemonNameWithAffix(user), type: toReadableString(PokemonType[type]) }));
globalScene.phaseManager.queueMessage(i18next.t("battle:transformedIntoType", { pokemonName: getPokemonNameWithAffix(user), type: toTitleCase(PokemonType[type]) }));
user.updateInfo();
return true;
@ -9204,9 +9261,12 @@ export function initMoves() {
.attr(StatStageChangeAttr, [ Stat.SPDEF ], -1)
.ballBombMove(),
new AttackMove(MoveId.FUTURE_SIGHT, PokemonType.PSYCHIC, MoveCategory.SPECIAL, 120, 100, 10, -1, 0, 2)
.partial() // cannot be used on multiple Pokemon on the same side in a double battle, hits immediately when called by Metronome/etc, should not apply abilities or held items if user is off the field
.attr(DelayedAttackAttr, ChargeAnim.FUTURE_SIGHT_CHARGING, "moveTriggers:foresawAnAttack")
.ignoresProtect()
.attr(DelayedAttackAttr, ArenaTagType.FUTURE_SIGHT, ChargeAnim.FUTURE_SIGHT_CHARGING, i18next.t("moveTriggers:foresawAnAttack", { pokemonName: "{USER}" })),
/*
* Should not apply abilities or held items if user is off the field
*/
.edgeCase(),
new AttackMove(MoveId.ROCK_SMASH, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 40, 100, 15, 50, 0, 2)
.attr(StatStageChangeAttr, [ Stat.DEF ], -1),
new AttackMove(MoveId.WHIRLPOOL, PokemonType.WATER, MoveCategory.SPECIAL, 35, 85, 15, -1, 0, 2)
@ -9227,7 +9287,7 @@ export function initMoves() {
new SelfStatusMove(MoveId.STOCKPILE, PokemonType.NORMAL, -1, 20, -1, 0, 3)
.condition(user => (user.getTag(StockpilingTag)?.stockpiledCount ?? 0) < 3)
.attr(AddBattlerTagAttr, BattlerTagType.STOCKPILING, true),
new AttackMove(MoveId.SPIT_UP, PokemonType.NORMAL, MoveCategory.SPECIAL, -1, -1, 10, -1, 0, 3)
new AttackMove(MoveId.SPIT_UP, PokemonType.NORMAL, MoveCategory.SPECIAL, -1, 100, 10, -1, 0, 3)
.condition(hasStockpileStacksCondition)
.attr(SpitUpPowerAttr, 100)
.attr(RemoveBattlerTagAttr, [ BattlerTagType.STOCKPILING ], true),
@ -9292,8 +9352,8 @@ export function initMoves() {
.ignoresSubstitute()
.attr(AbilityCopyAttr),
new SelfStatusMove(MoveId.WISH, PokemonType.NORMAL, -1, 10, -1, 0, 3)
.triageMove()
.attr(AddArenaTagAttr, ArenaTagType.WISH, 2, true),
.attr(WishAttr)
.triageMove(),
new SelfStatusMove(MoveId.ASSIST, PokemonType.NORMAL, -1, 20, -1, 0, 3)
.attr(RandomMovesetMoveAttr, invalidAssistMoves, true),
new SelfStatusMove(MoveId.INGRAIN, PokemonType.GRASS, -1, 20, -1, 0, 3)
@ -9470,7 +9530,7 @@ export function initMoves() {
new AttackMove(MoveId.SAND_TOMB, PokemonType.GROUND, MoveCategory.PHYSICAL, 35, 85, 15, -1, 0, 3)
.attr(TrapAttr, BattlerTagType.SAND_TOMB)
.makesContact(false),
new AttackMove(MoveId.SHEER_COLD, PokemonType.ICE, MoveCategory.SPECIAL, 200, 20, 5, -1, 0, 3)
new AttackMove(MoveId.SHEER_COLD, PokemonType.ICE, MoveCategory.SPECIAL, 200, 30, 5, -1, 0, 3)
.attr(IceNoEffectTypeAttr)
.attr(OneHitKOAttr)
.attr(SheerColdAccuracyAttr),
@ -9542,9 +9602,12 @@ export function initMoves() {
.attr(ConfuseAttr)
.pulseMove(),
new AttackMove(MoveId.DOOM_DESIRE, PokemonType.STEEL, MoveCategory.SPECIAL, 140, 100, 5, -1, 0, 3)
.partial() // cannot be used on multiple Pokemon on the same side in a double battle, hits immediately when called by Metronome/etc, should not apply abilities or held items if user is off the field
.attr(DelayedAttackAttr, ChargeAnim.DOOM_DESIRE_CHARGING, "moveTriggers:choseDoomDesireAsDestiny")
.ignoresProtect()
.attr(DelayedAttackAttr, ArenaTagType.DOOM_DESIRE, ChargeAnim.DOOM_DESIRE_CHARGING, i18next.t("moveTriggers:choseDoomDesireAsDestiny", { pokemonName: "{USER}" })),
/*
* Should not apply abilities or held items if user is off the field
*/
.edgeCase(),
new AttackMove(MoveId.PSYCHO_BOOST, PokemonType.PSYCHIC, MoveCategory.SPECIAL, 140, 90, 5, -1, 0, 3)
.attr(StatStageChangeAttr, [ Stat.SPATK ], -2, true),
new SelfStatusMove(MoveId.ROOST, PokemonType.FLYING, -1, 5, -1, 0, 4)
@ -10392,7 +10455,7 @@ export function initMoves() {
.attr(RemoveBattlerTagAttr, [ BattlerTagType.FLYING, BattlerTagType.FLOATING, BattlerTagType.TELEKINESIS ])
.makesContact(false)
.target(MoveTarget.ALL_NEAR_ENEMIES),
new AttackMove(MoveId.THOUSAND_WAVES, PokemonType.GROUND, MoveCategory.PHYSICAL, 90, 100, 10, -1, 0, 6)
new AttackMove(MoveId.THOUSAND_WAVES, PokemonType.GROUND, MoveCategory.PHYSICAL, 90, 100, 10, 100, 0, 6)
.attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, false, 1, 1, true)
.makesContact(false)
.target(MoveTarget.ALL_NEAR_ENEMIES),
@ -10799,7 +10862,7 @@ export function initMoves() {
new SelfStatusMove(MoveId.NO_RETREAT, PokemonType.FIGHTING, -1, 5, -1, 0, 8)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.DEF, Stat.SPATK, Stat.SPDEF, Stat.SPD ], 1, true)
.attr(AddBattlerTagAttr, BattlerTagType.NO_RETREAT, true, false)
.condition((user, target, move) => user.getTag(TrappedTag)?.sourceMove !== MoveId.NO_RETREAT), // fails if the user is currently trapped by No Retreat
.condition((user, target, move) => user.getTag(TrappedTag)?.tagType !== BattlerTagType.NO_RETREAT), // fails if the user is currently trapped by No Retreat
new StatusMove(MoveId.TAR_SHOT, PokemonType.ROCK, 100, 15, -1, 0, 8)
.attr(StatStageChangeAttr, [ Stat.SPD ], -1)
.attr(AddBattlerTagAttr, BattlerTagType.TAR_SHOT, false)
@ -10927,7 +10990,8 @@ export function initMoves() {
new StatusMove(MoveId.LIFE_DEW, PokemonType.WATER, -1, 10, -1, 0, 8)
.attr(HealAttr, 0.25, true, false)
.target(MoveTarget.USER_AND_ALLIES)
.ignoresProtect(),
.ignoresProtect()
.triageMove(),
new SelfStatusMove(MoveId.OBSTRUCT, PokemonType.DARK, 100, 10, -1, 4, 8)
.attr(ProtectAttr, BattlerTagType.OBSTRUCT)
.condition(failIfLastCondition),
@ -11005,7 +11069,8 @@ export function initMoves() {
new StatusMove(MoveId.JUNGLE_HEALING, PokemonType.GRASS, -1, 10, -1, 0, 8)
.attr(HealAttr, 0.25, true, false)
.attr(HealStatusEffectAttr, false, getNonVolatileStatusEffects())
.target(MoveTarget.USER_AND_ALLIES),
.target(MoveTarget.USER_AND_ALLIES)
.triageMove(),
new AttackMove(MoveId.WICKED_BLOW, PokemonType.DARK, MoveCategory.PHYSICAL, 75, 100, 5, -1, 0, 8)
.attr(CritOnlyAttr)
.punchingMove(),
@ -11233,7 +11298,7 @@ export function initMoves() {
.makesContact(false),
new AttackMove(MoveId.LUMINA_CRASH, PokemonType.PSYCHIC, MoveCategory.SPECIAL, 80, 100, 10, 100, 0, 9)
.attr(StatStageChangeAttr, [ Stat.SPDEF ], -2),
new AttackMove(MoveId.ORDER_UP, PokemonType.DRAGON, MoveCategory.PHYSICAL, 80, 100, 10, 100, 0, 9)
new AttackMove(MoveId.ORDER_UP, PokemonType.DRAGON, MoveCategory.PHYSICAL, 80, 100, 10, -1, 0, 9)
.attr(OrderUpStatBoostAttr)
.makesContact(false),
new AttackMove(MoveId.JET_PUNCH, PokemonType.WATER, MoveCategory.PHYSICAL, 60, 100, 15, -1, 1, 9)
@ -11416,7 +11481,7 @@ export function initMoves() {
.attr(IvyCudgelTypeAttr)
.attr(HighCritAttr)
.makesContact(false),
new ChargingAttackMove(MoveId.ELECTRO_SHOT, PokemonType.ELECTRIC, MoveCategory.SPECIAL, 130, 100, 10, 100, 0, 9)
new ChargingAttackMove(MoveId.ELECTRO_SHOT, PokemonType.ELECTRIC, MoveCategory.SPECIAL, 130, 100, 10, -1, 0, 9)
.chargeText(i18next.t("moveTriggers:absorbedElectricity", { pokemonName: "{USER}" }))
.chargeAttr(StatStageChangeAttr, [ Stat.SPATK ], 1, true)
.chargeAttr(WeatherInstantChargeAttr, [ WeatherType.RAIN, WeatherType.HEAVY_RAIN ]),

View File

@ -39,6 +39,7 @@ import { addPokemonDataToDexAndValidateAchievements } from "#mystery-encounters/
import type { MysteryEncounter } from "#mystery-encounters/mystery-encounter";
import { MysteryEncounterBuilder } from "#mystery-encounters/mystery-encounter";
import { MysteryEncounterOptionBuilder } from "#mystery-encounters/mystery-encounter-option";
import { PartySizeRequirement } from "#mystery-encounters/mystery-encounter-requirements";
import { PokemonData } from "#system/pokemon-data";
import { MusicPreference } from "#system/settings";
import type { OptionSelectItem } from "#ui/abstact-option-select-ui-handler";
@ -151,7 +152,8 @@ export const GlobalTradeSystemEncounter: MysteryEncounter = MysteryEncounterBuil
return true;
})
.withOption(
MysteryEncounterOptionBuilder.newOptionWithMode(MysteryEncounterOptionMode.DEFAULT)
MysteryEncounterOptionBuilder.newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT)
.withSceneRequirement(new PartySizeRequirement([2, 6], true)) // Requires 2 valid party members
.withHasDexProgress(true)
.withDialogue({
buttonLabel: `${namespace}:option.1.label`,
@ -257,7 +259,8 @@ export const GlobalTradeSystemEncounter: MysteryEncounter = MysteryEncounterBuil
.build(),
)
.withOption(
MysteryEncounterOptionBuilder.newOptionWithMode(MysteryEncounterOptionMode.DEFAULT)
MysteryEncounterOptionBuilder.newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT)
.withSceneRequirement(new PartySizeRequirement([2, 6], true)) // Requires 2 valid party members
.withHasDexProgress(true)
.withDialogue({
buttonLabel: `${namespace}:option.2.label`,

View File

@ -25,7 +25,8 @@ import {
StatusEffectRequirement,
WaveRangeRequirement,
} from "#mystery-encounters/mystery-encounter-requirements";
import { capitalizeFirstLetter, coerceArray, isNullOrUndefined, randSeedInt } from "#utils/common";
import { coerceArray, isNullOrUndefined, randSeedInt } from "#utils/common";
import { capitalizeFirstLetter } from "#utils/strings";
export interface EncounterStartOfBattleEffect {
sourcePokemon?: Pokemon;

View File

@ -3,7 +3,7 @@ import { EFFECTIVE_STATS, getShortenedStatKey, Stat } from "#enums/stat";
import { TextStyle } from "#enums/text-style";
import { UiTheme } from "#enums/ui-theme";
import { getBBCodeFrag } from "#ui/text";
import { toReadableString } from "#utils/common";
import { toTitleCase } from "#utils/strings";
import i18next from "i18next";
export function getNatureName(
@ -13,7 +13,7 @@ export function getNatureName(
ignoreBBCode = false,
uiTheme: UiTheme = UiTheme.DEFAULT,
): string {
let ret = toReadableString(Nature[nature]);
let ret = toTitleCase(Nature[nature]);
//Translating nature
if (i18next.exists(`nature:${ret}`)) {
ret = i18next.t(`nature:${ret}` as any);

View File

@ -29,15 +29,9 @@ import type { Variant, VariantSet } from "#sprites/variant";
import { populateVariantColorCache, variantColorCache, variantData } from "#sprites/variant";
import type { StarterMoveset } from "#system/game-data";
import type { Localizable } from "#types/locales";
import {
capitalizeString,
isNullOrUndefined,
randSeedFloat,
randSeedGauss,
randSeedInt,
randSeedItem,
} from "#utils/common";
import { isNullOrUndefined, randSeedFloat, randSeedGauss, randSeedInt, randSeedItem } from "#utils/common";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import { toCamelCase, toPascalCase } from "#utils/strings";
import { argbFromRgba, QuantizerCelebi, rgbaFromArgb } from "@material/material-color-utilities";
import i18next from "i18next";
@ -91,6 +85,7 @@ export function getPokemonSpeciesForm(species: SpeciesId, formIndex: number): Po
return retSpecies;
}
// TODO: Clean this up and seriously review alternate means of fusion naming
export function getFusedSpeciesName(speciesAName: string, speciesBName: string): string {
const fragAPattern = /([a-z]{2}.*?[aeiou(?:y$)\-']+)(.*?)$/i;
const fragBPattern = /([a-z]{2}.*?[aeiou(?:y$)\-'])(.*?)$/i;
@ -904,14 +899,14 @@ export class PokemonSpecies extends PokemonSpeciesForm implements Localizable {
* @returns the pokemon-form locale key for the single form name ("Alolan Form", "Eternal Flower" etc)
*/
getFormNameToDisplay(formIndex = 0, append = false): string {
const formKey = this.forms?.[formIndex!]?.formKey;
const formText = capitalizeString(formKey, "-", false, false) || "";
const speciesName = capitalizeString(SpeciesId[this.speciesId], "_", true, false);
const formKey = this.forms[formIndex]?.formKey ?? "";
const formText = toPascalCase(formKey);
const speciesName = toCamelCase(SpeciesId[this.speciesId]);
let ret = "";
const region = this.getRegion();
if (this.speciesId === SpeciesId.ARCEUS) {
ret = i18next.t(`pokemonInfo:Type.${formText?.toUpperCase()}`);
ret = i18next.t(`pokemonInfo:Type.${formText.toUpperCase()}`);
} else if (
[
SpeciesFormKey.MEGA,
@ -937,7 +932,7 @@ export class PokemonSpecies extends PokemonSpeciesForm implements Localizable {
if (i18next.exists(i18key)) {
ret = i18next.t(i18key);
} else {
const rootSpeciesName = capitalizeString(SpeciesId[this.getRootSpeciesId()], "_", true, false);
const rootSpeciesName = toCamelCase(SpeciesId[this.getRootSpeciesId()]);
const i18RootKey = `pokemonForm:${rootSpeciesName}${formText}`;
ret = i18next.exists(i18RootKey) ? i18next.t(i18RootKey) : formText;
}

View File

@ -1,18 +1,30 @@
import { type BattlerTag, loadBattlerTag } from "#data/battler-tags";
import type { BattlerTag } from "#data/battler-tags";
import { loadBattlerTag, SerializableBattlerTag } from "#data/battler-tags";
import { allSpecies } from "#data/data-lists";
import type { Gender } from "#data/gender";
import { PokemonMove } from "#data/moves/pokemon-move";
import type { PokemonSpeciesForm } from "#data/pokemon-species";
import { getPokemonSpeciesForm, type PokemonSpeciesForm } from "#data/pokemon-species";
import type { TypeDamageMultiplier } from "#data/type";
import type { AbilityId } from "#enums/ability-id";
import type { BerryType } from "#enums/berry-type";
import type { MoveId } from "#enums/move-id";
import type { Nature } from "#enums/nature";
import type { PokemonType } from "#enums/pokemon-type";
import type { SpeciesId } from "#enums/species-id";
import type { AttackMoveResult } from "#types/attack-move-result";
import type { IllusionData } from "#types/illusion-data";
import type { TurnMove } from "#types/turn-move";
import type { CoerceNullPropertiesToUndefined } from "#types/type-helpers";
import { isNullOrUndefined } from "#utils/common";
/**
* The type that {@linkcode PokemonSpeciesForm} is converted to when an object containing it serializes it.
*/
type SerializedSpeciesForm = {
id: SpeciesId;
formIdx: number;
};
/**
* Permanent data that can customize a Pokemon in non-standard ways from its Species.
* Includes abilities, nature, changed types, etc.
@ -41,9 +53,59 @@ export class CustomPokemonData {
}
}
/**
* Deserialize a pokemon species form from an object containing `id` and `formIdx` properties.
* @param value - The value to deserialize
* @returns The `PokemonSpeciesForm` or `null` if the fields could not be properly discerned
*/
function deserializePokemonSpeciesForm(value: SerializedSpeciesForm | PokemonSpeciesForm): PokemonSpeciesForm | null {
// @ts-expect-error: We may be deserializing a PokemonSpeciesForm, but we catch later on
let { id, formIdx } = value;
if (isNullOrUndefined(id) || isNullOrUndefined(formIdx)) {
// @ts-expect-error: Typescript doesn't know that in block, `value` must be a PokemonSpeciesForm
id = value.speciesId;
// @ts-expect-error: Same as above (plus we are accessing a protected property)
formIdx = value._formIndex;
}
// If for some reason either of these fields are null/undefined, we cannot reconstruct the species form
if (isNullOrUndefined(id) || isNullOrUndefined(formIdx)) {
return null;
}
return getPokemonSpeciesForm(id, formIdx);
}
interface SerializedIllusionData extends Omit<IllusionData, "fusionSpecies"> {
/** The id of the illusioned fusion species, or `undefined` if not a fusion */
fusionSpecies?: SpeciesId;
}
interface SerializedPokemonSummonData {
statStages: number[];
moveQueue: TurnMove[];
tags: BattlerTag[];
abilitySuppressed: boolean;
speciesForm?: SerializedSpeciesForm;
fusionSpeciesForm?: SerializedSpeciesForm;
ability?: AbilityId;
passiveAbility?: AbilityId;
gender?: Gender;
fusionGender?: Gender;
stats: number[];
moveset?: PokemonMove[];
types: PokemonType[];
addedType?: PokemonType;
illusion?: SerializedIllusionData;
illusionBroken: boolean;
berriesEatenLast: BerryType[];
moveHistory: TurnMove[];
}
/**
* Persistent in-battle data for a {@linkcode Pokemon}.
* Resets on switch or new battle.
*
* @sealed
*/
export class PokemonSummonData {
/** [Atk, Def, SpAtk, SpDef, Spd, Acc, Eva] */
@ -86,7 +148,7 @@ export class PokemonSummonData {
*/
public moveHistory: TurnMove[] = [];
constructor(source?: PokemonSummonData | Partial<PokemonSummonData>) {
constructor(source?: PokemonSummonData | SerializedPokemonSummonData) {
if (isNullOrUndefined(source)) {
return;
}
@ -97,19 +159,88 @@ export class PokemonSummonData {
continue;
}
if (key === "speciesForm" || key === "fusionSpeciesForm") {
this[key] = deserializePokemonSpeciesForm(value);
}
if (key === "illusion" && typeof value === "object") {
// Make a copy so as not to mutate provided value
const illusionData = {
...value,
};
if (!isNullOrUndefined(illusionData.fusionSpecies)) {
switch (typeof illusionData.fusionSpecies) {
case "object":
illusionData.fusionSpecies = allSpecies[illusionData.fusionSpecies.speciesId];
break;
case "number":
illusionData.fusionSpecies = allSpecies[illusionData.fusionSpecies];
break;
default:
illusionData.fusionSpecies = undefined;
}
}
this[key] = illusionData as IllusionData;
}
if (key === "moveset") {
this.moveset = value?.map((m: any) => PokemonMove.loadMove(m));
continue;
}
if (key === "tags") {
// load battler tags
this.tags = value.map((t: BattlerTag) => loadBattlerTag(t));
if (key === "tags" && Array.isArray(value)) {
// load battler tags, discarding any that are not serializable
this.tags = value
.map((t: SerializableBattlerTag) => loadBattlerTag(t))
.filter((t): t is SerializableBattlerTag => t instanceof SerializableBattlerTag);
continue;
}
this[key] = value;
}
}
/**
* Serialize this PokemonSummonData to JSON, converting {@linkcode PokemonSpeciesForm} and {@linkcode IllusionData.fusionSpecies}
* into simpler types instead of serializing all of their fields.
*
* @remarks
* - `IllusionData.fusionSpecies` is serialized as just the species ID
* - `PokemonSpeciesForm` and `PokemonSpeciesForm.fusionSpeciesForm` are converted into {@linkcode SerializedSpeciesForm} objects
*/
public toJSON(): SerializedPokemonSummonData {
// Pokemon species forms are never saved, only the species ID.
const illusion = this.illusion;
const speciesForm = this.speciesForm;
const fusionSpeciesForm = this.fusionSpeciesForm;
const illusionSpeciesForm = illusion?.fusionSpecies;
const t = {
// the "as omit" is required to avoid TS resolving the overwritten properties to "never"
// We coerce null to undefined in the type, as the for loop below replaces `null` with `undefined`
...(this as Omit<
CoerceNullPropertiesToUndefined<PokemonSummonData>,
"speciesForm" | "fusionSpeciesForm" | "illusion"
>),
speciesForm: isNullOrUndefined(speciesForm)
? undefined
: { id: speciesForm.speciesId, formIdx: speciesForm.formIndex },
fusionSpeciesForm: isNullOrUndefined(fusionSpeciesForm)
? undefined
: { id: fusionSpeciesForm.speciesId, formIdx: fusionSpeciesForm.formIndex },
illusion: isNullOrUndefined(illusion)
? undefined
: {
...(this.illusion as Omit<typeof illusion, "fusionSpecies">),
fusionSpecies: illusionSpeciesForm?.speciesId,
},
};
// Replace `null` with `undefined`, as `undefined` never gets serialized
for (const [key, value] of Object.entries(t)) {
if (value === null) {
t[key] = undefined;
}
}
return t;
}
}
// TODO: Merge this inside `summmonData` but exclude from save if/when a save data serializer is added

View File

@ -0,0 +1,70 @@
import { DelayedAttackTag, type PositionalTag, WishTag } from "#data/positional-tags/positional-tag";
import { PositionalTagType } from "#enums/positional-tag-type";
import type { ObjectValues } from "#types/type-helpers";
import type { Constructor } from "#utils/common";
/**
* Load the attributes of a {@linkcode PositionalTag}.
* @param tagType - The {@linkcode PositionalTagType} to create
* @param args - The arguments needed to instantize the given tag
* @returns The newly created tag.
* @remarks
* This function does not perform any checking if the added tag is valid.
*/
export function loadPositionalTag<T extends PositionalTagType>({
tagType,
...args
}: serializedPosTagMap[T]): posTagInstanceMap[T];
/**
* Load the attributes of a {@linkcode PositionalTag}.
* @param tag - The {@linkcode SerializedPositionalTag} to instantiate
* @returns The newly created tag.
* @remarks
* This function does not perform any checking if the added tag is valid.
*/
export function loadPositionalTag(tag: SerializedPositionalTag): PositionalTag;
export function loadPositionalTag<T extends PositionalTagType>({
tagType,
...rest
}: serializedPosTagMap[T]): posTagInstanceMap[T] {
// Note: We need 2 type assertions here:
// 1 because TS doesn't narrow the type of TagClass correctly based on `T`.
// It converts it into `new (DelayedAttackTag | WishTag) => DelayedAttackTag & WishTag`
const tagClass = posTagConstructorMap[tagType] as new (args: posTagParamMap[T]) => posTagInstanceMap[T];
// 2 because TS doesn't narrow the type of `rest` correctly
// (from `Omit<serializedPosTagParamMap[T], "tagType"> into `posTagParamMap[T]`)
return new tagClass(rest as unknown as posTagParamMap[T]);
}
/** Const object mapping tag types to their constructors. */
const posTagConstructorMap = Object.freeze({
[PositionalTagType.DELAYED_ATTACK]: DelayedAttackTag,
[PositionalTagType.WISH]: WishTag,
}) satisfies {
// NB: This `satisfies` block ensures that all tag types have corresponding entries in the map.
[k in PositionalTagType]: Constructor<PositionalTag & { tagType: k }>;
};
/** Type mapping positional tag types to their constructors. */
type posTagMap = typeof posTagConstructorMap;
/** Type mapping all positional tag types to their instances. */
type posTagInstanceMap = {
[k in PositionalTagType]: InstanceType<posTagMap[k]>;
};
/** Type mapping all positional tag types to their constructors' parameters. */
type posTagParamMap = {
[k in PositionalTagType]: ConstructorParameters<posTagMap[k]>[0];
};
/**
* Type mapping all positional tag types to their constructors' parameters, alongside the `tagType` selector.
* Equivalent to their serialized representations.
*/
export type serializedPosTagMap = {
[k in PositionalTagType]: posTagParamMap[k] & { tagType: k };
};
/** Union type containing all serialized {@linkcode PositionalTag}s. */
export type SerializedPositionalTag = ObjectValues<serializedPosTagMap>;

View File

@ -0,0 +1,55 @@
import { loadPositionalTag } from "#data/positional-tags/load-positional-tag";
import type { PositionalTag } from "#data/positional-tags/positional-tag";
import type { BattlerIndex } from "#enums/battler-index";
import type { PositionalTagType } from "#enums/positional-tag-type";
/** A manager for the {@linkcode PositionalTag}s in the arena. */
export class PositionalTagManager {
/**
* Array containing all pending unactivated {@linkcode PositionalTag}s,
* sorted by order of creation (oldest first).
*/
public tags: PositionalTag[] = [];
/**
* Add a new {@linkcode PositionalTag} to the arena.
* @remarks
* This function does not perform any checking if the added tag is valid.
*/
public addTag<T extends PositionalTagType = never>(tag: Parameters<typeof loadPositionalTag<T>>[0]): void {
this.tags.push(loadPositionalTag(tag));
}
/**
* Check whether a new {@linkcode PositionalTag} can be added to the battlefield.
* @param tagType - The {@linkcode PositionalTagType} being created
* @param targetIndex - The {@linkcode BattlerIndex} being targeted
* @returns Whether the tag can be added.
*/
public canAddTag(tagType: PositionalTagType, targetIndex: BattlerIndex): boolean {
return !this.tags.some(t => t.tagType === tagType && t.targetIndex === targetIndex);
}
/**
* Decrement turn counts of and trigger all pending {@linkcode PositionalTag}s on field.
* @remarks
* If multiple tags trigger simultaneously, they will activate in order of **initial creation**, regardless of current speed order.
* (Source: [Smogon](<https://www.smogon.com/forums/threads/sword-shield-battle-mechanics-research.3655528/page-64#post-9244179>))
*/
public activateAllTags(): void {
const leftoverTags: PositionalTag[] = [];
for (const tag of this.tags) {
// Check for silent removal, immediately removing invalid tags.
if (--tag.turnCount > 0) {
// tag still cooking
leftoverTags.push(tag);
continue;
}
if (tag.shouldTrigger()) {
tag.trigger();
}
}
this.tags = leftoverTags;
}
}

View File

@ -0,0 +1,174 @@
import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages";
// biome-ignore-start lint/correctness/noUnusedImports: TSDoc
import type { ArenaTag } from "#data/arena-tag";
// biome-ignore-end lint/correctness/noUnusedImports: TSDoc
import { allMoves } from "#data/data-lists";
import type { BattlerIndex } from "#enums/battler-index";
import type { MoveId } from "#enums/move-id";
import { MoveUseMode } from "#enums/move-use-mode";
import { PositionalTagType } from "#enums/positional-tag-type";
import type { Pokemon } from "#field/pokemon";
import i18next from "i18next";
/**
* Baseline arguments used to construct all {@linkcode PositionalTag}s,
* the contents of which are serialized and used to construct new tags. \
* Does not contain the `tagType` parameter (which is used to select the proper class constructor during tag loading).
* @privateRemarks
* All {@linkcode PositionalTag}s are intended to implement a sub-interface of this containing their respective parameters,
* and should refrain from adding extra serializable fields not contained in said interface.
* This ensures that all tags truly "become" their respective interfaces when converted to and from JSON.
*/
export interface PositionalTagBaseArgs {
/**
* The number of turns remaining until this tag's activation. \
* Decremented by 1 at the end of each turn until reaching 0, at which point it will
* {@linkcode PositionalTag.trigger | trigger} the tag's effects and be removed.
*/
turnCount: number;
/**
* The {@linkcode BattlerIndex} targeted by this effect.
*/
targetIndex: BattlerIndex;
}
/**
* A {@linkcode PositionalTag} is a variant of an {@linkcode ArenaTag} that targets a single *slot* of the battlefield.
* Each tag can last one or more turns, triggering various effects on removal.
* Multiple tags of the same kind can stack with one another, provided they are affecting different targets.
*/
export abstract class PositionalTag implements PositionalTagBaseArgs {
/** This tag's {@linkcode PositionalTagType | type} */
public abstract readonly tagType: PositionalTagType;
// These arguments have to be public to implement the interface, but are functionally private
// outside this and the tag manager.
// Left undocumented to inherit doc comments from the interface
public turnCount: number;
public readonly targetIndex: BattlerIndex;
constructor({ turnCount, targetIndex }: PositionalTagBaseArgs) {
this.turnCount = turnCount;
this.targetIndex = targetIndex;
}
/** Trigger this tag's effects prior to removal. */
public abstract trigger(): void;
/**
* Check whether this tag should be allowed to {@linkcode trigger} and activate its effects
* upon its duration elapsing.
* @returns Whether this tag should be allowed to trigger prior to being removed.
*/
public abstract shouldTrigger(): boolean;
/**
* Get the {@linkcode Pokemon} currently targeted by this tag.
* @returns The {@linkcode Pokemon} located in this tag's target position, or `undefined` if none exist in it.
*/
protected getTarget(): Pokemon | undefined {
return globalScene.getField()[this.targetIndex];
}
}
/** Interface containing additional properties used to construct a {@linkcode DelayedAttackTag}. */
interface DelayedAttackArgs extends PositionalTagBaseArgs {
/**
* The {@linkcode Pokemon.id | PID} of the {@linkcode Pokemon} having created this effect.
*/
sourceId: number;
/** The {@linkcode MoveId} that created this attack. */
sourceMove: MoveId;
}
/**
* Tag to manage execution of delayed attacks, such as {@linkcode MoveId.FUTURE_SIGHT} or {@linkcode MoveId.DOOM_DESIRE}. \
* Delayed attacks do nothing for the first several turns after use (including the turn the move is used),
* triggering against a certain slot after the turn count has elapsed.
*/
export class DelayedAttackTag extends PositionalTag implements DelayedAttackArgs {
public override readonly tagType = PositionalTagType.DELAYED_ATTACK;
public readonly sourceMove: MoveId;
public readonly sourceId: number;
constructor({ sourceId, turnCount, targetIndex, sourceMove }: DelayedAttackArgs) {
super({ turnCount, targetIndex });
this.sourceId = sourceId;
this.sourceMove = sourceMove;
}
public override trigger(): void {
// Bangs are justified as the `shouldTrigger` method will queue the tag for removal
// if the source or target no longer exist
const source = globalScene.getPokemonById(this.sourceId)!;
const target = this.getTarget()!;
source.turnData.extraTurns++;
globalScene.phaseManager.queueMessage(
i18next.t("moveTriggers:tookMoveAttack", {
pokemonName: getPokemonNameWithAffix(target),
moveName: allMoves[this.sourceMove].name,
}),
);
globalScene.phaseManager.unshiftNew(
"MoveEffectPhase",
this.sourceId, // TODO: Find an alternate method of passing the source pokemon without a source ID
[this.targetIndex],
allMoves[this.sourceMove],
MoveUseMode.DELAYED_ATTACK,
);
}
public override shouldTrigger(): boolean {
const source = globalScene.getPokemonById(this.sourceId);
const target = this.getTarget();
// Silently disappear if either source or target are missing or happen to be the same pokemon
// (i.e. targeting oneself)
// We also need to check for fainted targets as they don't technically leave the field until _after_ the turn ends
return !!source && !!target && source !== target && !target.isFainted();
}
}
/** Interface containing arguments used to construct a {@linkcode WishTag}. */
interface WishArgs extends PositionalTagBaseArgs {
/** The amount of {@linkcode Stat.HP | HP} to heal; set to 50% of the user's max HP during move usage. */
healHp: number;
/** The name of the {@linkcode Pokemon} having created the tag. */
pokemonName: string;
}
/**
* Tag to implement {@linkcode MoveId.WISH | Wish}.
*/
export class WishTag extends PositionalTag implements WishArgs {
public override readonly tagType = PositionalTagType.WISH;
public readonly pokemonName: string;
public readonly healHp: number;
constructor({ turnCount, targetIndex, healHp, pokemonName }: WishArgs) {
super({ turnCount, targetIndex });
this.healHp = healHp;
this.pokemonName = pokemonName;
}
public override trigger(): void {
// TODO: Rename this locales key - wish shows a message on REMOVAL, not addition
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:wishTagOnAdd", {
pokemonNameWithAffix: this.pokemonName,
}),
);
globalScene.phaseManager.unshiftNew("PokemonHealPhase", this.targetIndex, this.healHp, null, true, false);
}
public override shouldTrigger(): boolean {
// Disappear if no target or target is fainted.
// The source need not exist at the time of activation (since all we need is a simple message)
// TODO: Verify whether Wish shows a message if the Pokemon it would affect is KO'd on the turn of activation
const target = this.getTarget();
return !!target && !target.isFainted();
}
}

View File

@ -1,12 +1,12 @@
import { TrainerType } from "#enums/trainer-type";
import { toReadableString } from "#utils/common";
import { toPascalSnakeCase } from "#utils/strings";
class TrainerNameConfig {
public urls: string[];
public femaleUrls: string[] | null;
constructor(type: TrainerType, ...urls: string[]) {
this.urls = urls.length ? urls : [toReadableString(TrainerType[type]).replace(/ /g, "_")];
this.urls = urls.length ? urls : [toPascalSnakeCase(TrainerType[type])];
}
hasGenderVariant(...femaleUrls: string[]): TrainerNameConfig {

View File

@ -41,15 +41,9 @@ import type {
TrainerConfigs,
TrainerTierPools,
} from "#types/trainer-funcs";
import {
coerceArray,
isNullOrUndefined,
randSeedInt,
randSeedIntRange,
randSeedItem,
toReadableString,
} from "#utils/common";
import { coerceArray, isNullOrUndefined, randSeedInt, randSeedIntRange, randSeedItem } from "#utils/common";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import { toSnakeCase, toTitleCase } from "#utils/strings";
import i18next from "i18next";
/** Minimum BST for Pokemon generated onto the Elite Four's teams */
@ -140,7 +134,7 @@ export class TrainerConfig {
constructor(trainerType: TrainerType, allowLegendaries?: boolean) {
this.trainerType = trainerType;
this.trainerAI = new TrainerAI();
this.name = toReadableString(TrainerType[this.getDerivedType()]);
this.name = toTitleCase(TrainerType[this.getDerivedType()]);
this.battleBgm = "battle_trainer";
this.mixedBattleBgm = "battle_trainer";
this.victoryBgm = "victory_trainer";
@ -734,7 +728,7 @@ export class TrainerConfig {
}
// Localize the trainer's name by converting it to lowercase and replacing spaces with underscores.
const nameForCall = this.name.toLowerCase().replace(/\s/g, "_");
const nameForCall = toSnakeCase(this.name);
this.name = i18next.t(`trainerNames:${nameForCall}`);
// Set the title to "elite_four". (this is the key in the i18n file)

View File

@ -1,3 +1,5 @@
import type { ObjectValues } from "#types/type-helpers";
/**
* Not to be confused with an Ability Attribute.
* This is an object literal storing the slot that an ability can occupy.
@ -8,4 +10,4 @@ export const AbilityAttr = Object.freeze({
ABILITY_HIDDEN: 4,
});
export type AbilityAttr = typeof AbilityAttr[keyof typeof AbilityAttr];
export type AbilityAttr = ObjectValues<typeof AbilityAttr>;

View File

@ -15,9 +15,6 @@ export enum ArenaTagType {
SPIKES = "SPIKES",
TOXIC_SPIKES = "TOXIC_SPIKES",
MIST = "MIST",
FUTURE_SIGHT = "FUTURE_SIGHT",
DOOM_DESIRE = "DOOM_DESIRE",
WISH = "WISH",
STEALTH_ROCK = "STEALTH_ROCK",
STICKY_WEB = "STICKY_WEB",
TRICK_ROOM = "TRICK_ROOM",

View File

@ -1,5 +1,4 @@
export enum BattlerTagType {
NONE = "NONE",
RECHARGING = "RECHARGING",
FLINCHED = "FLINCHED",
INTERRUPTED = "INTERRUPTED",

View File

@ -1,3 +1,5 @@
import type { ObjectValues } from "#types/type-helpers";
export const DexAttr = Object.freeze({
NON_SHINY: 1n,
SHINY: 2n,
@ -8,4 +10,4 @@ export const DexAttr = Object.freeze({
VARIANT_3: 64n,
DEFAULT_FORM: 128n,
});
export type DexAttr = typeof DexAttr[keyof typeof DexAttr];
export type DexAttr = ObjectValues<typeof DexAttr>;

View File

@ -1,6 +1,7 @@
/**
* Enum representation of the phase types held by implementations of {@linkcode PhasePriorityQueue}
* Enum representation of the phase types held by implementations of {@linkcode PhasePriorityQueue}.
*/
// TODO: We currently assume these are in order
export enum DynamicPhaseType {
POST_SUMMON
}

View File

@ -1,7 +1,9 @@
import type { ObjectValues } from "#types/type-helpers";
export const GachaType = Object.freeze({
MOVE: 0,
LEGENDARY: 1,
SHINY: 2
});
export type GachaType = typeof GachaType[keyof typeof GachaType];
export type GachaType = ObjectValues<typeof GachaType>;

View File

@ -1,3 +1,5 @@
import type { ObjectValues } from "#types/type-helpers";
/** The result of a hit check calculation */
export const HitCheckResult = {
/** Hit checks haven't been evaluated yet in this pass */
@ -20,4 +22,4 @@ export const HitCheckResult = {
ERROR: 8,
} as const;
export type HitCheckResult = typeof HitCheckResult[keyof typeof HitCheckResult];
export type HitCheckResult = ObjectValues<typeof HitCheckResult>;

View File

@ -4,15 +4,19 @@
*/
export enum MoveFlags {
NONE = 0,
/**
* Whether the move makes contact.
* Set by default on all contact moves, and unset by default on all special moves.
*/
MAKES_CONTACT = 1 << 0,
IGNORE_PROTECT = 1 << 1,
/**
* Sound-based moves have the following effects:
* - Pokemon with the {@linkcode AbilityId.SOUNDPROOF Soundproof Ability} are unaffected by other Pokemon's sound-based moves.
* - Pokemon affected by {@linkcode MoveId.THROAT_CHOP Throat Chop} cannot use sound-based moves for two turns.
* - Sound-based moves used by a Pokemon with {@linkcode AbilityId.LIQUID_VOICE Liquid Voice} become Water-type moves.
* - Sound-based moves used by a Pokemon with {@linkcode AbilityId.PUNK_ROCK Punk Rock} are boosted by 30%. Pokemon with Punk Rock also take half damage from sound-based moves.
* - All sound-based moves (except Howl) can hit Pokemon behind an active {@linkcode MoveId.SUBSTITUTE Substitute}.
* - Pokemon with the {@linkcode AbilityId.SOUNDPROOF | Soundproof} Ability are unaffected by other Pokemon's sound-based moves.
* - Pokemon affected by {@linkcode MoveId.THROAT_CHOP | Throat Chop} cannot use sound-based moves for two turns.
* - Sound-based moves used by a Pokemon with {@linkcode AbilityId.LIQUID_VOICE | Liquid Voice} become Water-type moves.
* - Sound-based moves used by a Pokemon with {@linkcode AbilityId.PUNK_ROCK | Punk Rock} are boosted by 30%. Pokemon with Punk Rock also take half damage from sound-based moves.
* - All sound-based moves (except Howl) can hit Pokemon behind an active {@linkcode MoveId.SUBSTITUTE | Substitute}.
*
* cf https://bulbapedia.bulbagarden.net/wiki/Sound-based_move
*/

View File

@ -1,5 +1,7 @@
import type { PostDancingMoveAbAttr } from "#abilities/ability";
import type { DelayedAttackAttr } from "#app/@types/move-types";
import type { BattlerTagLapseType } from "#enums/battler-tag-lapse-type";
import type { ObjectValues } from "#types/type-helpers";
/**
* Enum representing all the possible means through which a given move can be executed.
@ -59,11 +61,20 @@ export const MoveUseMode = {
* and retain the same copy prevention as {@linkcode MoveUseMode.FOLLOW_UP}, but additionally
* **cannot be reflected by other reflecting effects**.
*/
REFLECTED: 5
// TODO: Add use type TRANSPARENT for Future Sight and Doom Desire to prevent move history pushing
REFLECTED: 5,
/**
* This "move" was created by a transparent effect that **does not count as using a move**,
* such as {@linkcode DelayedAttackAttr | Future Sight/Doom Desire}.
*
* In addition to inheriting the cancellation ignores and copy prevention from {@linkcode MoveUseMode.REFLECTED},
* transparent moves are ignored by **all forms of move usage checks** due to **not pushing to move history**.
* @todo Consider other means of implementing FS/DD than this - we currently only use it
* to prevent pushing to move history and avoid re-delaying the attack portion
*/
DELAYED_ATTACK: 6
} as const;
export type MoveUseMode = (typeof MoveUseMode)[keyof typeof MoveUseMode];
export type MoveUseMode = ObjectValues<typeof MoveUseMode>;
// # HELPER FUNCTIONS
// Please update the markdown tables if any new `MoveUseMode`s get added.
@ -75,13 +86,14 @@ export type MoveUseMode = (typeof MoveUseMode)[keyof typeof MoveUseMode];
* @remarks
* This function is equivalent to the following truth table:
*
* | Use Type | Returns |
* |------------------------------------|---------|
* | {@linkcode MoveUseMode.NORMAL} | `false` |
* | {@linkcode MoveUseMode.IGNORE_PP} | `false` |
* | {@linkcode MoveUseMode.INDIRECT} | `true` |
* | {@linkcode MoveUseMode.FOLLOW_UP} | `true` |
* | {@linkcode MoveUseMode.REFLECTED} | `true` |
* | Use Type | Returns |
* |----------------------------------------|---------|
* | {@linkcode MoveUseMode.NORMAL} | `false` |
* | {@linkcode MoveUseMode.IGNORE_PP} | `false` |
* | {@linkcode MoveUseMode.INDIRECT} | `true` |
* | {@linkcode MoveUseMode.FOLLOW_UP} | `true` |
* | {@linkcode MoveUseMode.REFLECTED} | `true` |
* | {@linkcode MoveUseMode.DELAYED_ATTACK} | `true` |
*/
export function isVirtual(useMode: MoveUseMode): boolean {
return useMode >= MoveUseMode.INDIRECT
@ -95,13 +107,14 @@ export function isVirtual(useMode: MoveUseMode): boolean {
* @remarks
* This function is equivalent to the following truth table:
*
* | Use Type | Returns |
* |------------------------------------|---------|
* | {@linkcode MoveUseMode.NORMAL} | `false` |
* | {@linkcode MoveUseMode.IGNORE_PP} | `false` |
* | {@linkcode MoveUseMode.INDIRECT} | `false` |
* | {@linkcode MoveUseMode.FOLLOW_UP} | `true` |
* | {@linkcode MoveUseMode.REFLECTED} | `true` |
* | Use Type | Returns |
* |----------------------------------------|---------|
* | {@linkcode MoveUseMode.NORMAL} | `false` |
* | {@linkcode MoveUseMode.IGNORE_PP} | `false` |
* | {@linkcode MoveUseMode.INDIRECT} | `false` |
* | {@linkcode MoveUseMode.FOLLOW_UP} | `true` |
* | {@linkcode MoveUseMode.REFLECTED} | `true` |
* | {@linkcode MoveUseMode.DELAYED_ATTACK} | `true` |
*/
export function isIgnoreStatus(useMode: MoveUseMode): boolean {
return useMode >= MoveUseMode.FOLLOW_UP;
@ -115,13 +128,14 @@ export function isIgnoreStatus(useMode: MoveUseMode): boolean {
* @remarks
* This function is equivalent to the following truth table:
*
* | Use Type | Returns |
* |------------------------------------|---------|
* | {@linkcode MoveUseMode.NORMAL} | `false` |
* | {@linkcode MoveUseMode.IGNORE_PP} | `true` |
* | {@linkcode MoveUseMode.INDIRECT} | `true` |
* | {@linkcode MoveUseMode.FOLLOW_UP} | `true` |
* | {@linkcode MoveUseMode.REFLECTED} | `true` |
* | Use Type | Returns |
* |----------------------------------------|---------|
* | {@linkcode MoveUseMode.NORMAL} | `false` |
* | {@linkcode MoveUseMode.IGNORE_PP} | `true` |
* | {@linkcode MoveUseMode.INDIRECT} | `true` |
* | {@linkcode MoveUseMode.FOLLOW_UP} | `true` |
* | {@linkcode MoveUseMode.REFLECTED} | `true` |
* | {@linkcode MoveUseMode.DELAYED_ATTACK} | `true` |
*/
export function isIgnorePP(useMode: MoveUseMode): boolean {
return useMode >= MoveUseMode.IGNORE_PP;
@ -136,14 +150,15 @@ export function isIgnorePP(useMode: MoveUseMode): boolean {
* @remarks
* This function is equivalent to the following truth table:
*
* | Use Type | Returns |
* |------------------------------------|---------|
* | {@linkcode MoveUseMode.NORMAL} | `false` |
* | {@linkcode MoveUseMode.IGNORE_PP} | `false` |
* | {@linkcode MoveUseMode.INDIRECT} | `false` |
* | {@linkcode MoveUseMode.FOLLOW_UP} | `false` |
* | {@linkcode MoveUseMode.REFLECTED} | `true` |
* | Use Type | Returns |
* |----------------------------------------|---------|
* | {@linkcode MoveUseMode.NORMAL} | `false` |
* | {@linkcode MoveUseMode.IGNORE_PP} | `false` |
* | {@linkcode MoveUseMode.INDIRECT} | `false` |
* | {@linkcode MoveUseMode.FOLLOW_UP} | `false` |
* | {@linkcode MoveUseMode.REFLECTED} | `true` |
* | {@linkcode MoveUseMode.DELAYED_ATTACK} | `false` |
*/
export function isReflected(useMode: MoveUseMode): boolean {
return useMode === MoveUseMode.REFLECTED;
}
}

View File

@ -0,0 +1,10 @@
/**
* Enum representing all positional tag types.
* @privateRemarks
* When adding new tag types, please update `positionalTagConstructorMap` in `src/data/positionalTags`
* with the new tag type.
*/
export enum PositionalTagType {
DELAYED_ATTACK = "DELAYED_ATTACK",
WISH = "WISH",
}

View File

@ -1,3 +1,7 @@
// biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports
import type { PositionalTag } from "#data/positional-tags/positional-tag";
// biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports
import { applyAbAttrs } from "#abilities/apply-ab-attrs";
import { globalScene } from "#app/global-scene";
import Overrides from "#app/overrides";
@ -7,6 +11,7 @@ import type { ArenaTag } from "#data/arena-tag";
import { ArenaTrapTag, getArenaTag } from "#data/arena-tag";
import { SpeciesFormChangeRevertWeatherFormTrigger, SpeciesFormChangeWeatherTrigger } from "#data/form-change-triggers";
import type { PokemonSpecies } from "#data/pokemon-species";
import { PositionalTagManager } from "#data/positional-tags/positional-tag-manager";
import { getTerrainClearMessage, getTerrainStartMessage, Terrain, TerrainType } from "#data/terrain";
import {
getLegendaryWeatherContinuesMessage,
@ -38,7 +43,14 @@ export class Arena {
public biomeType: BiomeId;
public weather: Weather | null;
public terrain: Terrain | null;
public tags: ArenaTag[];
/** All currently-active {@linkcode ArenaTag}s on both sides of the field. */
public tags: ArenaTag[] = [];
/**
* All currently-active {@linkcode PositionalTag}s on both sides of the field,
* sorted by tag type.
*/
public positionalTagManager: PositionalTagManager = new PositionalTagManager();
public bgm: string;
public ignoreAbilities: boolean;
public ignoringEffectSource: BattlerIndex | null;
@ -58,7 +70,6 @@ export class Arena {
constructor(biome: BiomeId, bgm: string, playerFaints = 0) {
this.biomeType = biome;
this.tags = [];
this.bgm = bgm;
this.trainerPool = biomeTrainerPools[biome];
this.updatePoolsForTimeOfDay();
@ -676,15 +687,15 @@ export class Arena {
}
/**
* Adds a new tag to the arena
* @param tagType {@linkcode ArenaTagType} the tag being added
* @param turnCount How many turns the tag lasts
* @param sourceMove {@linkcode MoveId} the move the tag came from, or `undefined` if not from a move
* @param sourceId The ID of the pokemon in play the tag came from (see {@linkcode BattleScene.getPokemonById})
* @param side {@linkcode ArenaTagSide} which side(s) the tag applies to
* @param quiet If a message should be queued on screen to announce the tag being added
* @param targetIndex The {@linkcode BattlerIndex} of the target pokemon
* @returns `false` if there already exists a tag of this type in the Arena
* Add a new {@linkcode ArenaTag} to the arena, triggering overlap effects on existing tags as applicable.
* @param tagType - The {@linkcode ArenaTagType} of the tag to add.
* @param turnCount - The number of turns the newly-added tag should last.
* @param sourceId - The {@linkcode Pokemon.id | PID} of the Pokemon creating the tag.
* @param sourceMove - The {@linkcode MoveId} of the move creating the tag, or `undefined` if not from a move.
* @param side - The {@linkcode ArenaTagSide}(s) to which the tag should apply; default `ArenaTagSide.BOTH`.
* @param quiet - Whether to suppress messages produced by tag addition; default `false`.
* @returns `true` if the tag was successfully added without overlapping.
// TODO: Do we need the return value here? literally nothing uses it
*/
addTag(
tagType: ArenaTagType,
@ -693,7 +704,6 @@ export class Arena {
sourceId: number,
side: ArenaTagSide = ArenaTagSide.BOTH,
quiet = false,
targetIndex?: BattlerIndex,
): boolean {
const existingTag = this.getTagOnSide(tagType, side);
if (existingTag) {
@ -708,7 +718,7 @@ export class Arena {
}
// creates a new tag object
const newTag = getArenaTag(tagType, turnCount || 0, sourceMove, sourceId, targetIndex, side);
const newTag = getArenaTag(tagType, turnCount || 0, sourceMove, sourceId, side);
if (newTag) {
newTag.onAdd(this, quiet);
this.tags.push(newTag);
@ -724,10 +734,19 @@ export class Arena {
}
/**
* Attempts to get a tag from the Arena via {@linkcode getTagOnSide} that applies to both sides
* @param tagType The {@linkcode ArenaTagType} or {@linkcode ArenaTag} to get
* @returns either the {@linkcode ArenaTag}, or `undefined` if it isn't there
* Attempt to get a tag from the Arena via {@linkcode getTagOnSide} that applies to both sides
* @param tagType - The {@linkcode ArenaTagType} to retrieve
* @returns The existing {@linkcode ArenaTag}, or `undefined` if not present.
* @overload
*/
getTag(tagType: ArenaTagType): ArenaTag | undefined;
/**
* Attempt to get a tag from the Arena via {@linkcode getTagOnSide} that applies to both sides
* @param tagType - The constructor of the {@linkcode ArenaTag} to retrieve
* @returns The existing {@linkcode ArenaTag}, or `undefined` if not present.
* @overload
*/
getTag<T extends ArenaTag>(tagType: Constructor<T> | AbstractConstructor<T>): T | undefined;
getTag(tagType: ArenaTagType | Constructor<ArenaTag> | AbstractConstructor<ArenaTag>): ArenaTag | undefined {
return this.getTagOnSide(tagType, ArenaTagSide.BOTH);
}

View File

@ -213,8 +213,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
* TODO: Stop treating this like a unique ID and stop treating 0 as no pokemon
*/
public id: number;
public name: string;
public nickname: string;
/**
* The Pokemon's current nickname, or `undefined` if it currently lacks one.
* If omitted, references to this should refer to the default name for this Pokemon's species.
*/
public nickname?: string;
public species: PokemonSpecies;
public formIndex: number;
public abilityIndex: number;
@ -444,7 +447,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
getNameToRender(useIllusion = true) {
const illusion = this.summonData.illusion;
const name = useIllusion ? (illusion?.name ?? this.name) : this.name;
const nickname: string = useIllusion ? (illusion?.nickname ?? this.nickname) : this.nickname;
const nickname: string | undefined = useIllusion ? illusion?.nickname : this.nickname;
try {
if (nickname) {
return decodeURIComponent(escape(atob(nickname))); // TODO: Remove `atob` and `escape`... eventually...
@ -5664,7 +5667,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
}
export class PlayerPokemon extends Pokemon {
protected battleInfo: PlayerBattleInfo;
protected declare battleInfo: PlayerBattleInfo;
public compatibleTms: MoveId[];
constructor(
@ -6193,7 +6196,7 @@ export class PlayerPokemon extends Pokemon {
}
export class EnemyPokemon extends Pokemon {
protected battleInfo: EnemyBattleInfo;
protected declare battleInfo: EnemyBattleInfo;
public trainerSlot: TrainerSlot;
public aiType: AiType;
public bossSegments: number;

View File

@ -23,13 +23,13 @@ import {
} from "#trainers/trainer-party-template";
import { randSeedInt, randSeedItem, randSeedWeightedItem } from "#utils/common";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import { toSnakeCase } from "#utils/strings";
import i18next from "i18next";
export class Trainer extends Phaser.GameObjects.Container {
public config: TrainerConfig;
public variant: TrainerVariant;
public partyTemplateIndex: number;
public name: string;
public partnerName: string;
public nameKey: string;
public partnerNameKey: string | undefined;
@ -170,7 +170,7 @@ export class Trainer extends Phaser.GameObjects.Container {
const evilTeamTitles = ["grunt"];
if (this.name === "" && evilTeamTitles.some(t => name.toLocaleLowerCase().includes(t))) {
// This is a evil team grunt so we localize it by only using the "name" as the title
title = i18next.t(`trainerClasses:${name.toLowerCase().replace(/\s/g, "_")}`);
title = i18next.t(`trainerClasses:${toSnakeCase(name)}`);
console.log("Localized grunt name: " + title);
// Since grunts are not named we can just return the title
return title;
@ -187,7 +187,7 @@ export class Trainer extends Phaser.GameObjects.Container {
}
// Get the localized trainer class name from the i18n file and set it as the title.
// This is used for trainer class names, not titles like "Elite Four, Champion, etc."
title = i18next.t(`trainerClasses:${name.toLowerCase().replace(/\s/g, "_")}`);
title = i18next.t(`trainerClasses:${toSnakeCase(name)}`);
}
// If no specific trainer slot is set.
@ -208,7 +208,7 @@ export class Trainer extends Phaser.GameObjects.Container {
if (this.config.titleDouble && this.variant === TrainerVariant.DOUBLE && !this.config.doubleOnly) {
title = this.config.titleDouble;
name = i18next.t(`trainerNames:${this.config.nameDouble.toLowerCase().replace(/\s/g, "_")}`);
name = i18next.t(`trainerNames:${toSnakeCase(this.config.nameDouble)}`);
}
console.log(title ? `${title} ${name}` : name);

View File

@ -462,7 +462,7 @@ export abstract class LapsingPersistentModifier extends PersistentModifier {
* @see {@linkcode apply}
*/
export class DoubleBattleChanceBoosterModifier extends LapsingPersistentModifier {
public override type: DoubleBattleChanceBoosterModifierType;
public declare type: DoubleBattleChanceBoosterModifierType;
match(modifier: Modifier): boolean {
return modifier instanceof DoubleBattleChanceBoosterModifier && modifier.getMaxBattles() === this.getMaxBattles();
@ -936,7 +936,7 @@ export class EvoTrackerModifier extends PokemonHeldItemModifier {
* Currently used by Shuckle Juice item
*/
export class PokemonBaseStatTotalModifier extends PokemonHeldItemModifier {
public override type: PokemonBaseStatTotalModifierType;
public declare type: PokemonBaseStatTotalModifierType;
public isTransferable = false;
public statModifier: 10 | -15;
@ -2074,7 +2074,7 @@ export abstract class ConsumablePokemonModifier extends ConsumableModifier {
}
export class TerrastalizeModifier extends ConsumablePokemonModifier {
public override type: TerastallizeModifierType;
public declare type: TerastallizeModifierType;
public teraType: PokemonType;
constructor(type: TerastallizeModifierType, pokemonId: number, teraType: PokemonType) {
@ -2318,7 +2318,7 @@ export class PokemonLevelIncrementModifier extends ConsumablePokemonModifier {
}
export class TmModifier extends ConsumablePokemonModifier {
public override type: TmModifierType;
public declare type: TmModifierType;
/**
* Applies {@linkcode TmModifier}
@ -2365,7 +2365,7 @@ export class RememberMoveModifier extends ConsumablePokemonModifier {
}
export class EvolutionItemModifier extends ConsumablePokemonModifier {
public override type: EvolutionItemModifierType;
public declare type: EvolutionItemModifierType;
/**
* Applies {@linkcode EvolutionItemModifier}
* @param playerPokemon The {@linkcode PlayerPokemon} that should evolve via item
@ -2530,7 +2530,7 @@ export class ExpBoosterModifier extends PersistentModifier {
}
export class PokemonExpBoosterModifier extends PokemonHeldItemModifier {
public override type: PokemonExpBoosterModifierType;
public declare type: PokemonExpBoosterModifierType;
private boostMultiplier: number;
@ -2627,7 +2627,7 @@ export class ExpBalanceModifier extends PersistentModifier {
}
export class PokemonFriendshipBoosterModifier extends PokemonHeldItemModifier {
public override type: PokemonFriendshipBoosterModifierType;
public declare type: PokemonFriendshipBoosterModifierType;
matchType(modifier: Modifier): boolean {
return modifier instanceof PokemonFriendshipBoosterModifier;
@ -2684,7 +2684,7 @@ export class PokemonNatureWeightModifier extends PokemonHeldItemModifier {
}
export class PokemonMoveAccuracyBoosterModifier extends PokemonHeldItemModifier {
public override type: PokemonMoveAccuracyBoosterModifierType;
public declare type: PokemonMoveAccuracyBoosterModifierType;
private accuracyAmount: number;
constructor(type: PokemonMoveAccuracyBoosterModifierType, pokemonId: number, accuracy: number, stackCount?: number) {
@ -2736,7 +2736,7 @@ export class PokemonMoveAccuracyBoosterModifier extends PokemonHeldItemModifier
}
export class PokemonMultiHitModifier extends PokemonHeldItemModifier {
public override type: PokemonMultiHitModifierType;
public declare type: PokemonMultiHitModifierType;
matchType(modifier: Modifier): boolean {
return modifier instanceof PokemonMultiHitModifier;
@ -2817,7 +2817,7 @@ export class PokemonMultiHitModifier extends PokemonHeldItemModifier {
}
export class PokemonFormChangeItemModifier extends PokemonHeldItemModifier {
public override type: FormChangeItemModifierType;
public declare type: FormChangeItemModifierType;
public formChangeItem: FormChangeItem;
public active: boolean;
public isTransferable = false;

View File

@ -9,6 +9,7 @@ import { AttemptCapturePhase } from "#phases/attempt-capture-phase";
import { AttemptRunPhase } from "#phases/attempt-run-phase";
import { BattleEndPhase } from "#phases/battle-end-phase";
import { BerryPhase } from "#phases/berry-phase";
import { CheckInterludePhase } from "#phases/check-interlude-phase";
import { CheckStatusEffectPhase } from "#phases/check-status-effect-phase";
import { CheckSwitchPhase } from "#phases/check-switch-phase";
import { CommandPhase } from "#phases/command-phase";
@ -60,6 +61,7 @@ import { PartyHealPhase } from "#phases/party-heal-phase";
import { PokemonAnimPhase } from "#phases/pokemon-anim-phase";
import { PokemonHealPhase } from "#phases/pokemon-heal-phase";
import { PokemonTransformPhase } from "#phases/pokemon-transform-phase";
import { PositionalTagPhase } from "#phases/positional-tag-phase";
import { PostGameOverPhase } from "#phases/post-game-over-phase";
import { PostSummonPhase } from "#phases/post-summon-phase";
import { PostTurnStatusEffectPhase } from "#phases/post-turn-status-effect-phase";
@ -121,6 +123,7 @@ const PHASES = Object.freeze({
AttemptRunPhase,
BattleEndPhase,
BerryPhase,
CheckInterludePhase,
CheckStatusEffectPhase,
CheckSwitchPhase,
CommandPhase,
@ -170,6 +173,7 @@ const PHASES = Object.freeze({
PokemonAnimPhase,
PokemonHealPhase,
PokemonTransformPhase,
PositionalTagPhase,
PostGameOverPhase,
PostSummonPhase,
PostTurnStatusEffectPhase,
@ -240,6 +244,21 @@ export class PhaseManager {
this.dynamicPhaseTypes = [PostSummonPhase];
}
/**
* Clear all previously set phases, then add a new {@linkcode TitlePhase} to transition to the title screen.
* @param addLogin - Whether to add a new {@linkcode LoginPhase} before the {@linkcode TitlePhase}
* (but reset everything else).
* Default `false`
*/
public toTitleScreen(addLogin = false): void {
this.clearAllPhases();
if (addLogin) {
this.unshiftNew("LoginPhase");
}
this.unshiftNew("TitlePhase");
}
/* Phase Functions */
getCurrentPhase(): Phase | null {
return this.currentPhase;
@ -665,4 +684,15 @@ export class PhaseManager {
): void {
this.startDynamicPhase(this.create(phase, ...args));
}
/** Prevents end of turn effects from triggering when transitioning to a new biome on a X0 wave */
public onInterlude(): void {
const phasesToRemove = ["WeatherEffectPhase", "BerryPhase", "CheckStatusEffectPhase"];
this.phaseQueue = this.phaseQueue.filter(p => !phasesToRemove.includes(p.phaseName));
const turnEndPhase = this.findPhase<TurnEndPhase>(p => p.phaseName === "TurnEndPhase");
if (turnEndPhase) {
turnEndPhase.upcomingInterlude = true;
}
}
}

View File

@ -0,0 +1,18 @@
import { globalScene } from "#app/global-scene";
import { Phase } from "#app/phase";
export class CheckInterludePhase extends Phase {
public override readonly phaseName = "CheckInterludePhase";
public override start(): void {
super.start();
const { phaseManager } = globalScene;
const { waveIndex } = globalScene.currentBattle;
if (waveIndex % 10 === 0 && globalScene.getEnemyParty().every(p => p.isFainted())) {
phaseManager.onInterlude();
}
this.end();
}
}

View File

@ -2,7 +2,6 @@ import type { TurnCommand } from "#app/battle";
import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages";
import { speciesStarterCosts } from "#balance/starters";
import type { EncoreTag } from "#data/battler-tags";
import { TrappedTag } from "#data/battler-tags";
import { AbilityId } from "#enums/ability-id";
import { ArenaTagSide } from "#enums/arena-tag-side";
@ -22,59 +21,77 @@ import type { MoveTargetSet } from "#moves/move";
import { getMoveTargets } from "#moves/move-utils";
import { FieldPhase } from "#phases/field-phase";
import type { TurnMove } from "#types/turn-move";
import { isNullOrUndefined } from "#utils/common";
import i18next from "i18next";
export class CommandPhase extends FieldPhase {
public readonly phaseName = "CommandPhase";
protected fieldIndex: number;
/**
* Whether the command phase is handling a switch command
*/
private isSwitch = false;
constructor(fieldIndex: number) {
super();
this.fieldIndex = fieldIndex;
}
start() {
super.start();
globalScene.updateGameInfo();
/**
* Resets the cursor to the position of {@linkcode Command.FIGHT} if any of the following are true
* - The setting to remember the last action is not enabled
* - This is the first turn of a mystery encounter, trainer battle, or the END biome
* - The cursor is currently on the POKEMON command
*/
private resetCursorIfNeeded(): void {
const commandUiHandler = globalScene.ui.handlers[UiMode.COMMAND];
const { arena, commandCursorMemory, currentBattle } = globalScene;
const { battleType, turn } = currentBattle;
const { biomeType } = arena;
// If one of these conditions is true, we always reset the cursor to Command.FIGHT
const cursorResetEvent =
globalScene.currentBattle.battleType === BattleType.MYSTERY_ENCOUNTER ||
globalScene.currentBattle.battleType === BattleType.TRAINER ||
globalScene.arena.biomeType === BiomeId.END;
battleType === BattleType.MYSTERY_ENCOUNTER || battleType === BattleType.TRAINER || biomeType === BiomeId.END;
if (commandUiHandler) {
if (
(globalScene.currentBattle.turn === 1 && (!globalScene.commandCursorMemory || cursorResetEvent)) ||
commandUiHandler.getCursor() === Command.POKEMON
) {
commandUiHandler.setCursor(Command.FIGHT);
} else {
commandUiHandler.setCursor(commandUiHandler.getCursor());
}
if (!commandUiHandler) {
return;
}
if (
(turn === 1 && (!commandCursorMemory || cursorResetEvent)) ||
commandUiHandler.getCursor() === Command.POKEMON
) {
commandUiHandler.setCursor(Command.FIGHT);
}
}
/**
* Submethod of {@linkcode start} that validates field index logic for nonzero field indices.
* Must only be called if the field index is nonzero.
*/
private handleFieldIndexLogic(): void {
// If we somehow are attempting to check the right pokemon but there's only one pokemon out
// Switch back to the center pokemon. This can happen rarely in double battles with mid turn switching
// TODO: Prevent this from happening in the first place
if (globalScene.getPlayerField().filter(p => p.isActive()).length === 1) {
this.fieldIndex = FieldPosition.CENTER;
return;
}
if (this.fieldIndex) {
// If we somehow are attempting to check the right pokemon but there's only one pokemon out
// Switch back to the center pokemon. This can happen rarely in double battles with mid turn switching
if (globalScene.getPlayerField().filter(p => p.isActive()).length === 1) {
this.fieldIndex = FieldPosition.CENTER;
} else {
const allyCommand = globalScene.currentBattle.turnCommands[this.fieldIndex - 1];
if (allyCommand?.command === Command.BALL || allyCommand?.command === Command.RUN) {
globalScene.currentBattle.turnCommands[this.fieldIndex] = {
command: allyCommand?.command,
skip: true,
};
}
}
const allyCommand = globalScene.currentBattle.turnCommands[this.fieldIndex - 1];
if (allyCommand?.command === Command.BALL || allyCommand?.command === Command.RUN) {
globalScene.currentBattle.turnCommands[this.fieldIndex] = {
command: allyCommand?.command,
skip: true,
};
}
}
/**
* Submethod of {@linkcode start} that sets the turn command to skip if this pokemon
* is commanding its ally via {@linkcode AbilityId.COMMANDER}.
*/
private checkCommander(): void {
// If the Pokemon has applied Commander's effects to its ally, skip this command
if (
globalScene.currentBattle?.double &&
@ -86,377 +103,521 @@ export class CommandPhase extends FieldPhase {
skip: true,
};
}
}
// Checks if the Pokemon is under the effects of Encore. If so, Encore can end early if the encored move has no more PP.
const encoreTag = this.getPokemon().getTag(BattlerTagType.ENCORE) as EncoreTag | undefined;
if (encoreTag) {
this.getPokemon().lapseTag(BattlerTagType.ENCORE);
}
if (globalScene.currentBattle.turnCommands[this.fieldIndex]?.skip) {
return this.end();
}
const playerPokemon = globalScene.getPlayerField()[this.fieldIndex];
/**
* Clear out all unusable moves in front of the currently acting pokemon's move queue.
*/
// TODO: Refactor move queue handling to ensure that this method is not necessary.
private clearUnusuableMoves(): void {
const playerPokemon = this.getPokemon();
const moveQueue = playerPokemon.getMoveQueue();
while (
moveQueue.length &&
moveQueue[0] &&
moveQueue[0].move &&
!isVirtual(moveQueue[0].useMode) &&
(!playerPokemon.getMoveset().find(m => m.moveId === moveQueue[0].move) ||
!playerPokemon
.getMoveset()
[playerPokemon.getMoveset().findIndex(m => m.moveId === moveQueue[0].move)].isUsable(
playerPokemon,
isIgnorePP(moveQueue[0].useMode),
))
) {
moveQueue.shift();
if (moveQueue.length === 0) {
return;
}
// TODO: Refactor this. I did a few simple find/replace matches but this is just ABHORRENTLY structured
if (moveQueue.length > 0) {
const queuedMove = moveQueue[0];
if (!queuedMove.move) {
this.handleCommand(Command.FIGHT, -1, MoveUseMode.NORMAL);
} else {
const moveIndex = playerPokemon.getMoveset().findIndex(m => m.moveId === queuedMove.move);
if (
(moveIndex > -1 &&
playerPokemon.getMoveset()[moveIndex].isUsable(playerPokemon, isIgnorePP(queuedMove.useMode))) ||
isVirtual(queuedMove.useMode)
) {
this.handleCommand(Command.FIGHT, moveIndex, queuedMove.useMode, queuedMove);
} else {
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
}
}
} else {
let entriesToDelete = 0;
const moveset = playerPokemon.getMoveset();
for (const queuedMove of moveQueue) {
const movesetQueuedMove = moveset.find(m => m.moveId === queuedMove.move);
if (
globalScene.currentBattle.isBattleMysteryEncounter() &&
globalScene.currentBattle.mysteryEncounter?.skipToFightInput
queuedMove.move !== MoveId.NONE &&
!isVirtual(queuedMove.useMode) &&
!movesetQueuedMove?.isUsable(playerPokemon, isIgnorePP(queuedMove.useMode))
) {
globalScene.ui.clearText();
globalScene.ui.setMode(UiMode.FIGHT, this.fieldIndex);
entriesToDelete++;
} else {
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
break;
}
}
if (entriesToDelete) {
moveQueue.splice(0, entriesToDelete);
}
}
/**
* TODO: Remove `args` and clean this thing up
* Code will need to be copied over from pkty except replacing the `virtual` and `ignorePP` args with a corresponding `MoveUseMode`.
* Attempt to execute the first usable move in this Pokemon's move queue
* @returns Whether a queued move was successfully set to be executed.
*/
handleCommand(command: Command, cursor: number, ...args: any[]): boolean {
private tryExecuteQueuedMove(): boolean {
this.clearUnusuableMoves();
const playerPokemon = globalScene.getPlayerField()[this.fieldIndex];
const moveQueue = playerPokemon.getMoveQueue();
if (moveQueue.length === 0) {
return false;
}
const queuedMove = moveQueue[0];
if (queuedMove.move === MoveId.NONE) {
this.handleCommand(Command.FIGHT, -1);
return true;
}
const moveIndex = playerPokemon.getMoveset().findIndex(m => m.moveId === queuedMove.move);
if (!isVirtual(queuedMove.useMode) && moveIndex === -1) {
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
} else {
this.handleCommand(Command.FIGHT, moveIndex, queuedMove.useMode, queuedMove);
}
return true;
}
public override start(): void {
super.start();
globalScene.updateGameInfo();
this.resetCursorIfNeeded();
if (this.fieldIndex) {
this.handleFieldIndexLogic();
}
this.checkCommander();
const playerPokemon = this.getPokemon();
// Note: It is OK to call this if the target is not under the effect of encore; it will simply do nothing.
playerPokemon.lapseTag(BattlerTagType.ENCORE);
if (globalScene.currentBattle.turnCommands[this.fieldIndex]?.skip) {
this.end();
return;
}
if (this.tryExecuteQueuedMove()) {
return;
}
if (
globalScene.currentBattle.isBattleMysteryEncounter() &&
globalScene.currentBattle.mysteryEncounter?.skipToFightInput
) {
globalScene.ui.clearText();
globalScene.ui.setMode(UiMode.FIGHT, this.fieldIndex);
} else {
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
}
}
/**
* Submethod of {@linkcode handleFightCommand} responsible for queuing the appropriate
* error message when a move cannot be used.
* @param user - The pokemon using the move
* @param cursor - The index of the move in the moveset
*/
private queueFightErrorMessage(user: PlayerPokemon, cursor: number) {
const move = user.getMoveset()[cursor];
globalScene.ui.setMode(UiMode.MESSAGE);
// Decides between a Disabled, Not Implemented, or No PP translation message
const errorMessage = user.isMoveRestricted(move.moveId, user)
? user.getRestrictingTag(move.moveId, user)!.selectionDeniedText(user, move.moveId)
: move.getName().endsWith(" (N)")
? "battle:moveNotImplemented"
: "battle:moveNoPP";
const moveName = move.getName().replace(" (N)", ""); // Trims off the indicator
globalScene.ui.showText(
i18next.t(errorMessage, { moveName: moveName }),
null,
() => {
globalScene.ui.clearText();
globalScene.ui.setMode(UiMode.FIGHT, this.fieldIndex);
},
null,
true,
);
}
/**
* Helper method for {@linkcode handleFightCommand} that returns the moveID for the phase
* based on the move passed in or the cursor.
*
* Does not check if the move is usable or not, that should be handled by the caller.
*/
private computeMoveId(playerPokemon: PlayerPokemon, cursor: number, move: TurnMove | undefined): MoveId {
return move?.move ?? (cursor > -1 ? playerPokemon.getMoveset()[cursor]?.moveId : MoveId.NONE);
}
/**
* Process the logic for executing a fight-related command
*
* @remarks
* - Validates whether the move can be used, using struggle if not
* - Constructs the turn command and inserts it into the battle's turn commands
*
* @param command - The command to handle (FIGHT or TERA)
* @param cursor - The index that the cursor is placed on, or -1 if no move can be selected.
* @param ignorePP - Whether to ignore PP when checking if the move can be used.
* @param move - The move to force the command to use, if any.
*/
private handleFightCommand(
command: Command.FIGHT | Command.TERA,
cursor: number,
useMode: MoveUseMode = MoveUseMode.NORMAL,
move?: TurnMove,
): boolean {
const playerPokemon = this.getPokemon();
const ignorePP = isIgnorePP(useMode);
let canUse = cursor === -1 || playerPokemon.trySelectMove(cursor, ignorePP);
// Ternary here ensures we don't compute struggle conditions unless necessary
const useStruggle = canUse
? false
: cursor > -1 && !playerPokemon.getMoveset().some(m => m.isUsable(playerPokemon));
canUse ||= useStruggle;
if (!canUse) {
this.queueFightErrorMessage(playerPokemon, cursor);
return false;
}
const moveId = useStruggle ? MoveId.STRUGGLE : this.computeMoveId(playerPokemon, cursor, move);
const turnCommand: TurnCommand = {
command: Command.FIGHT,
cursor,
move: { move: moveId, targets: [], useMode },
args: [useMode, move],
};
const preTurnCommand: TurnCommand = {
command,
targets: [this.fieldIndex],
skip: command === Command.FIGHT,
};
const moveTargets: MoveTargetSet =
move === undefined
? getMoveTargets(playerPokemon, moveId)
: {
targets: move.targets,
multiple: move.targets.length > 1,
};
if (moveId === MoveId.NONE) {
turnCommand.targets = [this.fieldIndex];
}
console.log(
"Move:",
MoveId[moveId],
"Move targets:",
moveTargets,
"\nPlayer Pokemon:",
getPokemonNameWithAffix(playerPokemon),
);
if (moveTargets.targets.length > 1 && moveTargets.multiple) {
globalScene.phaseManager.unshiftNew("SelectTargetPhase", this.fieldIndex);
}
if (turnCommand.move && (moveTargets.targets.length <= 1 || moveTargets.multiple)) {
turnCommand.move.targets = moveTargets.targets;
} else if (
turnCommand.move &&
playerPokemon.getTag(BattlerTagType.CHARGING) &&
playerPokemon.getMoveQueue().length >= 1
) {
turnCommand.move.targets = playerPokemon.getMoveQueue()[0].targets;
} else {
globalScene.phaseManager.unshiftNew("SelectTargetPhase", this.fieldIndex);
}
globalScene.currentBattle.preTurnCommands[this.fieldIndex] = preTurnCommand;
globalScene.currentBattle.turnCommands[this.fieldIndex] = turnCommand;
return true;
}
/**
* Set the mode in preparation to show the text, and then show the text.
* Only works for parameterless i18next keys.
* @param key - The i18next key for the text to show
*/
private queueShowText(key: string): void {
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
globalScene.ui.setMode(UiMode.MESSAGE);
globalScene.ui.showText(
i18next.t(key),
null,
() => {
globalScene.ui.showText("", 0);
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
},
null,
true,
);
}
/**
* Helper method for {@linkcode handleBallCommand} that checks if a pokeball can be thrown
* and displays the appropriate error message.
*
* @remarks
* The pokeball may not be thrown if any of the following are true:
* - It is a trainer battle
* - The player is in the {@linkcode BiomeId.END | End} biome and
* - it is not classic mode; or
* - the fresh start challenge is active; or
* - the player has not caught the target before and the player is still missing more than one starter
* - The player is in a mystery encounter that disallows catching the pokemon
* @returns Whether a pokeball can be thrown
*/
private checkCanUseBall(): boolean {
const { arena, currentBattle, gameData, gameMode } = globalScene;
const { battleType } = currentBattle;
const { biomeType } = arena;
const { isClassic } = gameMode;
const { dexData } = gameData;
const someUncaughtSpeciesOnField = globalScene
.getEnemyField()
.some(p => p.isActive() && !dexData[p.species.speciesId].caughtAttr);
const missingMultipleStarters =
gameData.getStarterCount(d => !!d.caughtAttr) < Object.keys(speciesStarterCosts).length - 1;
if (
biomeType === BiomeId.END &&
(!isClassic || gameMode.isFreshStartChallenge() || (someUncaughtSpeciesOnField && missingMultipleStarters))
) {
this.queueShowText("battle:noPokeballForce");
} else if (battleType === BattleType.TRAINER) {
this.queueShowText("battle:noPokeballTrainer");
} else if (currentBattle.isBattleMysteryEncounter() && !currentBattle.mysteryEncounter!.catchAllowed) {
this.queueShowText("battle:noPokeballMysteryEncounter");
} else {
return true;
}
return false;
}
/**
* Helper method for {@linkcode handleCommand} that handles the logic when the selected command is to use a pokeball.
*
* @param cursor - The index of the pokeball to use
* @returns Whether the command was successfully initiated
*/
private handleBallCommand(cursor: number): boolean {
const targets = globalScene
.getEnemyField()
.filter(p => p.isActive(true))
.map(p => p.getBattlerIndex());
if (targets.length > 1) {
this.queueShowText("battle:noPokeballMulti");
return false;
}
if (!this.checkCanUseBall()) {
return false;
}
const numBallTypes = 5;
if (cursor < numBallTypes) {
const targetPokemon = globalScene.getEnemyPokemon();
if (
targetPokemon?.isBoss() &&
targetPokemon?.bossSegmentIndex >= 1 &&
// TODO: Decouple this hardcoded exception for wonder guard and just check the target...
!targetPokemon?.hasAbility(AbilityId.WONDER_GUARD, false, true) &&
cursor < PokeballType.MASTER_BALL
) {
this.queueShowText("battle:noPokeballStrong");
return false;
}
globalScene.currentBattle.turnCommands[this.fieldIndex] = {
command: Command.BALL,
cursor: cursor,
};
globalScene.currentBattle.turnCommands[this.fieldIndex]!.targets = targets;
if (this.fieldIndex) {
globalScene.currentBattle.turnCommands[this.fieldIndex - 1]!.skip = true;
}
return true;
}
return false;
}
/**
* Submethod of {@linkcode tryLeaveField} to handle the logic for effects that prevent the pokemon from leaving the field
* due to trapping abilities or effects.
*
* This method queues the proper messages in the case of trapping abilities or effects.
*
* @returns Whether the pokemon is currently trapped
*/
private handleTrap(): boolean {
const playerPokemon = this.getPokemon();
const trappedAbMessages: string[] = [];
const isSwitch = this.isSwitch;
if (!playerPokemon.isTrapped(trappedAbMessages)) {
return false;
}
if (trappedAbMessages.length > 0) {
if (isSwitch) {
globalScene.ui.setMode(UiMode.MESSAGE);
}
globalScene.ui.showText(
trappedAbMessages[0],
null,
() => {
globalScene.ui.showText("", 0);
if (isSwitch) {
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
}
},
null,
true,
);
} else {
const trapTag = playerPokemon.getTag(TrappedTag);
const fairyLockTag = globalScene.arena.getTagOnSide(ArenaTagType.FAIRY_LOCK, ArenaTagSide.PLAYER);
if (!isSwitch) {
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
globalScene.ui.setMode(UiMode.MESSAGE);
}
if (trapTag) {
this.showNoEscapeText(trapTag, false);
} else if (fairyLockTag) {
this.showNoEscapeText(fairyLockTag, false);
}
}
return true;
}
/**
* Common helper method that attempts to have the pokemon leave the field.
* Checks for trapping abilities and effects.
*
* @param cursor - The index of the option that the cursor is on
* @returns Whether the pokemon is able to leave the field, indicating the command phase should end
*/
private tryLeaveField(cursor?: number, isBatonSwitch = false): boolean {
const currentBattle = globalScene.currentBattle;
if (isBatonSwitch || !this.handleTrap()) {
currentBattle.turnCommands[this.fieldIndex] = this.isSwitch
? {
command: Command.POKEMON,
cursor,
args: [isBatonSwitch],
}
: {
command: Command.RUN,
};
if (!this.isSwitch && this.fieldIndex) {
currentBattle.turnCommands[this.fieldIndex - 1]!.skip = true;
}
return true;
}
return false;
}
/**
* Helper method for {@linkcode handleCommand} that handles the logic when the selected command is RUN.
*
* @remarks
* Checks if the player is allowed to flee, and if not, queues the appropriate message.
*
* The player cannot flee if:
* - The player is in the {@linkcode BiomeId.END | End} biome
* - The player is in a trainer battle
* - The player is in a mystery encounter that disallows fleeing
* - The player's pokemon is trapped by an ability or effect
* @returns Whether the pokemon is able to leave the field, indicating the command phase should end
*/
private handleRunCommand(): boolean {
const { currentBattle, arena } = globalScene;
const mysteryEncounterFleeAllowed = currentBattle.mysteryEncounter?.fleeAllowed ?? true;
if (arena.biomeType === BiomeId.END || !mysteryEncounterFleeAllowed) {
this.queueShowText("battle:noEscapeForce");
return false;
}
if (
currentBattle.battleType === BattleType.TRAINER ||
currentBattle.mysteryEncounter?.encounterMode === MysteryEncounterMode.TRAINER_BATTLE
) {
this.queueShowText("battle:noEscapeTrainer");
return false;
}
const success = this.tryLeaveField();
return success;
}
/**
* Show a message indicating that the pokemon cannot escape, and then return to the command phase.
*/
private showNoEscapeText(tag: any, isSwitch: boolean): void {
globalScene.ui.showText(
i18next.t("battle:noEscapePokemon", {
pokemonName:
tag.sourceId && globalScene.getPokemonById(tag.sourceId)
? getPokemonNameWithAffix(globalScene.getPokemonById(tag.sourceId)!)
: "",
moveName: tag.getMoveName(),
escapeVerb: i18next.t(isSwitch ? "battle:escapeVerbSwitch" : "battle:escapeVerbFlee"),
}),
null,
() => {
globalScene.ui.showText("", 0);
if (!isSwitch) {
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
}
},
null,
true,
);
}
// Overloads for handleCommand to provide a more specific signature for the different options
/**
* Process the command phase logic based on the selected command
*
* @param command - The kind of command to handle
* @param cursor - The index of option that the cursor is on, or -1 if no option is selected
* @param useMode - The mode to use for the move, if applicable. For switches, a boolean that specifies whether the switch is a Baton switch.
* @param move - For {@linkcode Command.FIGHT}, the move to use
* @returns Whether the command was successful
*/
handleCommand(command: Command.FIGHT | Command.TERA, cursor: number, useMode?: MoveUseMode, move?: TurnMove): boolean;
handleCommand(command: Command.BALL, cursor: number): boolean;
handleCommand(command: Command.POKEMON, cursor: number, useBaton: boolean): boolean;
handleCommand(command: Command.RUN, cursor: number): boolean;
handleCommand(command: Command, cursor: number, useMode?: boolean | MoveUseMode, move?: TurnMove): boolean;
public handleCommand(
command: Command,
cursor: number,
useMode: boolean | MoveUseMode = false,
move?: TurnMove,
): boolean {
let success = false;
switch (command) {
// TODO: We don't need 2 args for this - moveUseMode is carried over from queuedMove
case Command.TERA:
case Command.FIGHT: {
let useStruggle = false;
const turnMove: TurnMove | undefined = args.length === 2 ? (args[1] as TurnMove) : undefined;
if (
cursor === -1 ||
playerPokemon.trySelectMove(cursor, isIgnorePP(args[0] as MoveUseMode)) ||
(useStruggle = cursor > -1 && !playerPokemon.getMoveset().filter(m => m.isUsable(playerPokemon)).length)
) {
let moveId: MoveId;
if (useStruggle) {
moveId = MoveId.STRUGGLE;
} else if (turnMove !== undefined) {
moveId = turnMove.move;
} else if (cursor > -1) {
moveId = playerPokemon.getMoveset()[cursor].moveId;
} else {
moveId = MoveId.NONE;
}
const turnCommand: TurnCommand = {
command: Command.FIGHT,
cursor: cursor,
move: { move: moveId, targets: [], useMode: args[0] },
args: args,
};
const preTurnCommand: TurnCommand = {
command: command,
targets: [this.fieldIndex],
skip: command === Command.FIGHT,
};
const moveTargets: MoveTargetSet =
turnMove === undefined
? getMoveTargets(playerPokemon, moveId)
: {
targets: turnMove.targets,
multiple: turnMove.targets.length > 1,
};
if (!moveId) {
turnCommand.targets = [this.fieldIndex];
}
console.log(moveTargets, getPokemonNameWithAffix(playerPokemon));
if (moveTargets.targets.length > 1 && moveTargets.multiple) {
globalScene.phaseManager.unshiftNew("SelectTargetPhase", this.fieldIndex);
}
if (turnCommand.move && (moveTargets.targets.length <= 1 || moveTargets.multiple)) {
turnCommand.move.targets = moveTargets.targets;
} else if (
turnCommand.move &&
playerPokemon.getTag(BattlerTagType.CHARGING) &&
playerPokemon.getMoveQueue().length >= 1
) {
turnCommand.move.targets = playerPokemon.getMoveQueue()[0].targets;
} else {
globalScene.phaseManager.unshiftNew("SelectTargetPhase", this.fieldIndex);
}
globalScene.currentBattle.preTurnCommands[this.fieldIndex] = preTurnCommand;
globalScene.currentBattle.turnCommands[this.fieldIndex] = turnCommand;
success = true;
} else if (cursor < playerPokemon.getMoveset().length) {
const move = playerPokemon.getMoveset()[cursor];
globalScene.ui.setMode(UiMode.MESSAGE);
// Decides between a Disabled, Not Implemented, or No PP translation message
const errorMessage = playerPokemon.isMoveRestricted(move.moveId, playerPokemon)
? playerPokemon
.getRestrictingTag(move.moveId, playerPokemon)!
.selectionDeniedText(playerPokemon, move.moveId)
: move.getName().endsWith(" (N)")
? "battle:moveNotImplemented"
: "battle:moveNoPP";
const moveName = move.getName().replace(" (N)", ""); // Trims off the indicator
globalScene.ui.showText(
i18next.t(errorMessage, { moveName: moveName }),
null,
() => {
globalScene.ui.clearText();
globalScene.ui.setMode(UiMode.FIGHT, this.fieldIndex);
},
null,
true,
);
}
case Command.FIGHT:
success = this.handleFightCommand(command, cursor, typeof useMode === "boolean" ? undefined : useMode, move);
break;
}
case Command.BALL: {
const notInDex =
globalScene
.getEnemyField()
.filter(p => p.isActive(true))
.some(p => !globalScene.gameData.dexData[p.species.speciesId].caughtAttr) &&
globalScene.gameData.getStarterCount(d => !!d.caughtAttr) < Object.keys(speciesStarterCosts).length - 1;
if (
globalScene.arena.biomeType === BiomeId.END &&
(!globalScene.gameMode.isClassic || globalScene.gameMode.isFreshStartChallenge() || notInDex)
) {
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
globalScene.ui.setMode(UiMode.MESSAGE);
globalScene.ui.showText(
i18next.t("battle:noPokeballForce"),
null,
() => {
globalScene.ui.showText("", 0);
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
},
null,
true,
);
} else if (globalScene.currentBattle.battleType === BattleType.TRAINER) {
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
globalScene.ui.setMode(UiMode.MESSAGE);
globalScene.ui.showText(
i18next.t("battle:noPokeballTrainer"),
null,
() => {
globalScene.ui.showText("", 0);
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
},
null,
true,
);
} else if (
globalScene.currentBattle.isBattleMysteryEncounter() &&
!globalScene.currentBattle.mysteryEncounter!.catchAllowed
) {
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
globalScene.ui.setMode(UiMode.MESSAGE);
globalScene.ui.showText(
i18next.t("battle:noPokeballMysteryEncounter"),
null,
() => {
globalScene.ui.showText("", 0);
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
},
null,
true,
);
} else {
const targets = globalScene
.getEnemyField()
.filter(p => p.isActive(true))
.map(p => p.getBattlerIndex());
if (targets.length > 1) {
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
globalScene.ui.setMode(UiMode.MESSAGE);
globalScene.ui.showText(
i18next.t("battle:noPokeballMulti"),
null,
() => {
globalScene.ui.showText("", 0);
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
},
null,
true,
);
} else if (cursor < 5) {
const targetPokemon = globalScene.getEnemyField().find(p => p.isActive(true));
if (
targetPokemon?.isBoss() &&
targetPokemon?.bossSegmentIndex >= 1 &&
!targetPokemon?.hasAbility(AbilityId.WONDER_GUARD, false, true) &&
cursor < PokeballType.MASTER_BALL
) {
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
globalScene.ui.setMode(UiMode.MESSAGE);
globalScene.ui.showText(
i18next.t("battle:noPokeballStrong"),
null,
() => {
globalScene.ui.showText("", 0);
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
},
null,
true,
);
} else {
globalScene.currentBattle.turnCommands[this.fieldIndex] = {
command: Command.BALL,
cursor: cursor,
};
globalScene.currentBattle.turnCommands[this.fieldIndex]!.targets = targets;
if (this.fieldIndex) {
globalScene.currentBattle.turnCommands[this.fieldIndex - 1]!.skip = true;
}
success = true;
}
}
}
case Command.BALL:
success = this.handleBallCommand(cursor);
break;
}
case Command.POKEMON:
case Command.RUN: {
const isSwitch = command === Command.POKEMON;
const { currentBattle, arena } = globalScene;
const mysteryEncounterFleeAllowed = currentBattle.mysteryEncounter?.fleeAllowed;
if (
!isSwitch &&
(arena.biomeType === BiomeId.END ||
(!isNullOrUndefined(mysteryEncounterFleeAllowed) && !mysteryEncounterFleeAllowed))
) {
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
globalScene.ui.setMode(UiMode.MESSAGE);
globalScene.ui.showText(
i18next.t("battle:noEscapeForce"),
null,
() => {
globalScene.ui.showText("", 0);
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
},
null,
true,
);
} else if (
!isSwitch &&
(currentBattle.battleType === BattleType.TRAINER ||
currentBattle.mysteryEncounter?.encounterMode === MysteryEncounterMode.TRAINER_BATTLE)
) {
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
globalScene.ui.setMode(UiMode.MESSAGE);
globalScene.ui.showText(
i18next.t("battle:noEscapeTrainer"),
null,
() => {
globalScene.ui.showText("", 0);
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
},
null,
true,
);
} else {
const batonPass = isSwitch && (args[0] as boolean);
const trappedAbMessages: string[] = [];
if (batonPass || !playerPokemon.isTrapped(trappedAbMessages)) {
currentBattle.turnCommands[this.fieldIndex] = isSwitch
? { command: Command.POKEMON, cursor: cursor, args: args }
: { command: Command.RUN };
success = true;
if (!isSwitch && this.fieldIndex) {
currentBattle.turnCommands[this.fieldIndex - 1]!.skip = true;
}
} else if (trappedAbMessages.length > 0) {
if (!isSwitch) {
globalScene.ui.setMode(UiMode.MESSAGE);
}
globalScene.ui.showText(
trappedAbMessages[0],
null,
() => {
globalScene.ui.showText("", 0);
if (!isSwitch) {
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
}
},
null,
true,
);
} else {
const trapTag = playerPokemon.getTag(TrappedTag);
const fairyLockTag = globalScene.arena.getTagOnSide(ArenaTagType.FAIRY_LOCK, ArenaTagSide.PLAYER);
if (!trapTag && !fairyLockTag) {
i18next.t(`battle:noEscape${isSwitch ? "Switch" : "Flee"}`);
break;
}
if (!isSwitch) {
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
globalScene.ui.setMode(UiMode.MESSAGE);
}
const showNoEscapeText = (tag: any) => {
globalScene.ui.showText(
i18next.t("battle:noEscapePokemon", {
pokemonName:
tag.sourceId && globalScene.getPokemonById(tag.sourceId)
? getPokemonNameWithAffix(globalScene.getPokemonById(tag.sourceId)!)
: "",
moveName: tag.getMoveName(),
escapeVerb: isSwitch ? i18next.t("battle:escapeVerbSwitch") : i18next.t("battle:escapeVerbFlee"),
}),
null,
() => {
globalScene.ui.showText("", 0);
if (!isSwitch) {
globalScene.ui.setMode(UiMode.COMMAND, this.fieldIndex);
}
},
null,
true,
);
};
if (trapTag) {
showNoEscapeText(trapTag);
} else if (fairyLockTag) {
showNoEscapeText(fairyLockTag);
}
}
}
this.isSwitch = true;
success = this.tryLeaveField(cursor, typeof useMode === "boolean" ? useMode : undefined);
this.isSwitch = false;
break;
}
case Command.RUN:
success = this.handleRunCommand();
}
if (success) {

View File

@ -19,7 +19,7 @@ import { MoveFlags } from "#enums/move-flags";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { MoveTarget } from "#enums/move-target";
import { isReflected, isVirtual, MoveUseMode } from "#enums/move-use-mode";
import { isReflected, MoveUseMode } from "#enums/move-use-mode";
import { PokemonType } from "#enums/pokemon-type";
import type { Pokemon } from "#field/pokemon";
import {
@ -244,43 +244,19 @@ export class MoveEffectPhase extends PokemonPhase {
globalScene.currentBattle.lastPlayerInvolved = this.fieldIndex;
}
const isDelayedAttack = this.move.hasAttr("DelayedAttackAttr");
/** If the user was somehow removed from the field and it's not a delayed attack, end this phase */
if (!user.isOnField()) {
if (!isDelayedAttack) {
super.end();
return;
}
if (!user.scene) {
/*
* This happens if the Pokemon that used the delayed attack gets caught and released
* on the turn the attack would have triggered. Having access to the global scene
* in the future may solve this entirely, so for now we just cancel the hit
*/
super.end();
return;
}
}
const move = this.move;
/**
* Does an effect from this move override other effects on this turn?
* e.g. Charging moves (Fly, etc.) on their first turn of use.
*/
const overridden = new BooleanHolder(false);
const move = this.move;
// Apply effects to override a move effect.
// Assuming single target here works as this is (currently)
// only used for Future Sight, calling and Pledge moves.
// TODO: change if any other move effect overrides are introduced
applyMoveAttrs(
"OverrideMoveEffectAttr",
user,
this.getFirstTarget() ?? null,
move,
overridden,
isVirtual(this.useMode),
);
applyMoveAttrs("OverrideMoveEffectAttr", user, this.getFirstTarget() ?? null, move, overridden, this.useMode);
// If other effects were overriden, stop this phase before they can be applied
if (overridden.value) {
@ -355,7 +331,7 @@ export class MoveEffectPhase extends PokemonPhase {
*/
private postAnimCallback(user: Pokemon, targets: Pokemon[]) {
// Add to the move history entry
if (this.firstHit) {
if (this.firstHit && this.useMode !== MoveUseMode.DELAYED_ATTACK) {
user.pushMoveHistory(this.moveHistoryEntry);
applyAbAttrs("ExecutedMoveAbAttr", { pokemon: user });
}
@ -663,6 +639,7 @@ export class MoveEffectPhase extends PokemonPhase {
/** @returns The {@linkcode Pokemon} using this phase's invoked move */
public getUserPokemon(): Pokemon | null {
// TODO: Make this purely a battler index
if (this.battlerIndex > BattlerIndex.ENEMY_2) {
return globalScene.getPokemonById(this.battlerIndex);
}

View File

@ -2,14 +2,12 @@ import { applyAbAttrs } from "#abilities/apply-ab-attrs";
import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages";
import Overrides from "#app/overrides";
import type { DelayedAttackTag } from "#data/arena-tag";
import { CenterOfAttentionTag } from "#data/battler-tags";
import { SpeciesFormChangePreMoveTrigger } from "#data/form-change-triggers";
import { getStatusEffectActivationText, getStatusEffectHealText } from "#data/status-effect";
import { getTerrainBlockMessage } from "#data/terrain";
import { getWeatherBlockMessage } from "#data/weather";
import { AbilityId } from "#enums/ability-id";
import { ArenaTagType } from "#enums/arena-tag-type";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagLapseType } from "#enums/battler-tag-lapse-type";
import { BattlerTagType } from "#enums/battler-tag-type";
@ -297,21 +295,6 @@ export class MovePhase extends BattlePhase {
// form changes happen even before we know that the move wll execute.
globalScene.triggerPokemonFormChange(this.pokemon, SpeciesFormChangePreMoveTrigger);
// Check the player side arena if another delayed attack is active and hitting the same slot.
if (move.hasAttr("DelayedAttackAttr")) {
const currentTargetIndex = targets[0].getBattlerIndex();
const delayedAttackHittingSameSlot = globalScene.arena.tags.some(
tag =>
(tag.tagType === ArenaTagType.FUTURE_SIGHT || tag.tagType === ArenaTagType.DOOM_DESIRE) &&
(tag as DelayedAttackTag).targetIndex === currentTargetIndex,
);
if (delayedAttackHittingSameSlot) {
this.failMove(true);
return;
}
}
// Check if the move has any attributes that can interrupt its own use **before** displaying text.
// TODO: This should not rely on direct return values
let failed = move.getAttrs("PreUseInterruptAttr").some(attr => attr.apply(this.pokemon, targets[0], move));

View File

@ -0,0 +1,21 @@
// biome-ignore-start lint/correctness/noUnusedImports: TSDocs
import type { PositionalTag } from "#data/positional-tags/positional-tag";
import type { TurnEndPhase } from "#phases/turn-end-phase";
// biome-ignore-end lint/correctness/noUnusedImports: TSDocs
import { globalScene } from "#app/global-scene";
import { Phase } from "#app/phase";
/**
* Phase to trigger all pending post-turn {@linkcode PositionalTag}s.
* Occurs before {@linkcode TurnEndPhase} to allow for proper electrify timing.
*/
export class PositionalTagPhase extends Phase {
public readonly phaseName = "PositionalTagPhase";
public override start(): void {
globalScene.arena.positionalTagManager.activateAllTags();
super.end();
return;
}
}

View File

@ -24,10 +24,11 @@ export class SelectStarterPhase extends Phase {
globalScene.ui.setMode(UiMode.STARTER_SELECT, (starters: Starter[]) => {
globalScene.ui.clearText();
globalScene.ui.setMode(UiMode.SAVE_SLOT, SaveSlotUiMode.SAVE, (slotId: number) => {
// If clicking cancel, back out to title screen
if (slotId === -1) {
globalScene.phaseManager.clearPhaseQueue();
globalScene.phaseManager.pushNew("TitlePhase");
return this.end();
globalScene.phaseManager.toTitleScreen();
this.end();
return;
}
globalScene.sessionSlotId = slotId;
this.initBattle(starters);

View File

@ -114,11 +114,11 @@ export class TitlePhase extends Phase {
});
}
}
// Cancel button = back to title
options.push({
label: i18next.t("menu:cancel"),
handler: () => {
globalScene.phaseManager.clearPhaseQueue();
globalScene.phaseManager.pushNew("TitlePhase");
globalScene.phaseManager.toTitleScreen();
super.end();
return true;
},
@ -191,11 +191,12 @@ export class TitlePhase extends Phase {
initDailyRun(): void {
globalScene.ui.clearText();
globalScene.ui.setMode(UiMode.SAVE_SLOT, SaveSlotUiMode.SAVE, (slotId: number) => {
globalScene.phaseManager.clearPhaseQueue();
if (slotId === -1) {
globalScene.phaseManager.pushNew("TitlePhase");
return super.end();
globalScene.phaseManager.toTitleScreen();
super.end();
return;
}
globalScene.phaseManager.clearPhaseQueue();
globalScene.sessionSlotId = slotId;
const generateDaily = (seed: string) => {

View File

@ -18,6 +18,8 @@ import i18next from "i18next";
export class TurnEndPhase extends FieldPhase {
public readonly phaseName = "TurnEndPhase";
public upcomingInterlude = false;
start() {
super.start();
@ -59,9 +61,11 @@ export class TurnEndPhase extends FieldPhase {
pokemon.tempSummonData.waveTurnCount++;
};
this.executeForAll(handlePokemon);
if (!this.upcomingInterlude) {
this.executeForAll(handlePokemon);
globalScene.arena.lapseTags();
globalScene.arena.lapseTags();
}
if (globalScene.arena.weather && !globalScene.arena.weather.lapse()) {
globalScene.arena.trySetWeather(WeatherType.NONE);

View File

@ -218,6 +218,10 @@ export class TurnStartPhase extends FieldPhase {
break;
}
}
phaseManager.pushNew("CheckInterludePhase");
// TODO: Re-order these phases to be consistent with mainline turn order:
// https://www.smogon.com/forums/threads/sword-shield-battle-mechanics-research.3655528/page-64#post-9244179
phaseManager.pushNew("WeatherEffectPhase");
phaseManager.pushNew("BerryPhase");
@ -225,12 +229,13 @@ export class TurnStartPhase extends FieldPhase {
/** Add a new phase to check who should be taking status damage */
phaseManager.pushNew("CheckStatusEffectPhase", moveOrder);
phaseManager.pushNew("PositionalTagPhase");
phaseManager.pushNew("TurnEndPhase");
/**
* this.end() will call shiftPhase(), which dumps everything from PrependQueue (aka everything that is unshifted()) to the front
* of the queue and dequeues to start the next phase
* this is important since stuff like SwitchSummon, AttemptRun, AttemptCapture Phases break the "flow" and should take precedence
/*
* `this.end()` will call `PhaseManager#shiftPhase()`, which dumps everything from `phaseQueuePrepend`
* (aka everything that is queued via `unshift()`) to the front of the queue and dequeues to start the next phase.
* This is important since stuff like `SwitchSummonPhase`, `AttemptRunPhase`, and `AttemptCapturePhase` break the "flow" and should take precedence
*/
this.end();
}

View File

@ -1,5 +1,5 @@
import pkg from "#package.json";
import { camelCaseToKebabCase } from "#utils/common";
import { toKebabCase } from "#utils/strings";
import i18next from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import HttpBackend from "i18next-http-backend";
@ -194,14 +194,16 @@ export async function initI18n(): Promise<void> {
],
backend: {
loadPath(lng: string, [ns]: string[]) {
// Use namespace maps where required
let fileName: string;
if (namespaceMap[ns]) {
fileName = namespaceMap[ns];
} else if (ns.startsWith("mysteryEncounters/")) {
fileName = camelCaseToKebabCase(ns + "Dialogue");
fileName = toKebabCase(ns + "-dialogue"); // mystery-encounters/a-trainers-test-dialogue
} else {
fileName = camelCaseToKebabCase(ns);
fileName = toKebabCase(ns);
}
// ex: "./locales/en/move-anims"
return `./locales/${lng}/${fileName}.json?v=${pkg.version}`;
},
},

View File

@ -890,7 +890,7 @@ export const achvs = {
100,
c =>
c instanceof FreshStartChallenge &&
c.value > 0 &&
c.value === 1 &&
!globalScene.gameMode.challenges.some(
c => [Challenges.INVERSE_BATTLE, Challenges.FLIP_STAT].includes(c.id) && c.value > 0,
),

View File

@ -1,5 +1,6 @@
import type { ArenaTag } from "#data/arena-tag";
import { loadArenaTag, SerializableArenaTag } from "#data/arena-tag";
import type { SerializedPositionalTag } from "#data/positional-tags/load-positional-tag";
import { Terrain } from "#data/terrain";
import { Weather } from "#data/weather";
import type { BiomeId } from "#enums/biome-id";
@ -12,6 +13,7 @@ export interface SerializedArenaData {
weather: NonFunctionProperties<Weather> | null;
terrain: NonFunctionProperties<Terrain> | null;
tags?: ArenaTagTypeData[];
positionalTags: SerializedPositionalTag[];
playerTerasUsed?: number;
}
@ -20,6 +22,7 @@ export class ArenaData {
public weather: Weather | null;
public terrain: Terrain | null;
public tags: ArenaTag[];
public positionalTags: SerializedPositionalTag[] = [];
public playerTerasUsed: number;
constructor(source: Arena | SerializedArenaData) {
@ -37,11 +40,15 @@ export class ArenaData {
this.biome = source.biomeType;
this.weather = source.weather;
this.terrain = source.terrain;
// The assertion here is ok - we ensure that all tags are inside the `posTagConstructorMap` map,
// and that all `PositionalTags` will become their respective interfaces when serialized and de-serialized.
this.positionalTags = (source.positionalTagManager.tags as unknown as SerializedPositionalTag[]) ?? [];
return;
}
this.biome = source.biome;
this.weather = source.weather ? new Weather(source.weather.weatherType, source.weather.turnsLeft) : null;
this.terrain = source.terrain ? new Terrain(source.terrain.terrainType, source.terrain.turnsLeft) : null;
this.positionalTags = source.positionalTags ?? [];
}
}

View File

@ -16,6 +16,7 @@ import { allMoves, allSpecies } from "#data/data-lists";
import type { Egg } from "#data/egg";
import { pokemonFormChanges } from "#data/pokemon-forms";
import type { PokemonSpecies } from "#data/pokemon-species";
import { loadPositionalTag } from "#data/positional-tags/load-positional-tag";
import { TerrainType } from "#data/terrain";
import { AbilityAttr } from "#enums/ability-attr";
import { BattleType } from "#enums/battle-type";
@ -1096,6 +1097,10 @@ export class GameData {
}
}
globalScene.arena.positionalTagManager.tags = sessionData.arena.positionalTags.map(tag =>
loadPositionalTag(tag),
);
if (globalScene.modifiers.length) {
console.warn("Existing modifiers not cleared on session load, deleting...");
globalScene.modifiers = [];
@ -1454,11 +1459,10 @@ export class GameData {
reader.onload = (_ => {
return e => {
let dataName: string;
let dataName = GameDataType[dataType].toLowerCase();
let dataStr = AES.decrypt(e.target?.result?.toString()!, saveKey).toString(enc.Utf8); // TODO: is this bang correct?
let valid = false;
try {
dataName = GameDataType[dataType].toLowerCase();
switch (dataType) {
case GameDataType.SYSTEM: {
dataStr = this.convertSystemDataStr(dataStr);
@ -1493,7 +1497,6 @@ export class GameData {
const displayError = (error: string) =>
globalScene.ui.showText(error, null, () => globalScene.ui.showText("", 0), fixedInt(1500));
dataName = dataName!; // tell TS compiler that dataName is defined!
if (!valid) {
return globalScene.ui.showText(

View File

@ -6,7 +6,7 @@ import { UiMode } from "#enums/ui-mode";
import type { InputFieldConfig } from "#ui/form-modal-ui-handler";
import { FormModalUiHandler } from "#ui/form-modal-ui-handler";
import type { ModalConfig } from "#ui/modal-ui-handler";
import { formatText } from "#utils/common";
import { toTitleCase } from "#utils/strings";
type AdminUiHandlerService = "discord" | "google";
type AdminUiHandlerServiceMode = "Link" | "Unlink";
@ -21,9 +21,9 @@ export class AdminUiHandler extends FormModalUiHandler {
private readonly httpUserNotFoundErrorCode: number = 404;
private readonly ERR_REQUIRED_FIELD = (field: string) => {
if (field === "username") {
return `${formatText(field)} is required`;
return `${toTitleCase(field)} is required`;
}
return `${formatText(field)} Id is required`;
return `${toTitleCase(field)} Id is required`;
};
// returns a string saying whether a username has been successfully linked/unlinked to discord/google
private readonly SUCCESS_SERVICE_MODE = (service: string, mode: string) => {

View File

@ -18,7 +18,8 @@ import { BattleSceneEventType } from "#events/battle-scene";
import { addTextObject } from "#ui/text";
import { TimeOfDayWidget } from "#ui/time-of-day-widget";
import { addWindow, WindowVariant } from "#ui/ui-theme";
import { fixedInt, formatText, toCamelCaseString } from "#utils/common";
import { fixedInt } from "#utils/common";
import { toCamelCase, toTitleCase } from "#utils/strings";
import type { ParseKeys } from "i18next";
import i18next from "i18next";
@ -49,10 +50,10 @@ export function getFieldEffectText(arenaTagType: string): string {
if (!arenaTagType || arenaTagType === ArenaTagType.NONE) {
return arenaTagType;
}
const effectName = toCamelCaseString(arenaTagType);
const effectName = toCamelCase(arenaTagType);
const i18nKey = `arenaFlyout:${effectName}` as ParseKeys;
const resultName = i18next.t(i18nKey);
return !resultName || resultName === i18nKey ? formatText(arenaTagType) : resultName;
return !resultName || resultName === i18nKey ? toTitleCase(arenaTagType) : resultName;
}
export class ArenaFlyout extends Phaser.GameObjects.Container {

View File

@ -1,7 +1,7 @@
import { globalScene } from "#app/global-scene";
import { TextStyle } from "#enums/text-style";
import { addTextObject } from "#ui/text";
import { formatText } from "#utils/common";
import { toTitleCase } from "#utils/strings";
import i18next from "i18next";
const hiddenX = -150;
@ -101,7 +101,7 @@ export class BgmBar extends Phaser.GameObjects.Container {
getRealBgmName(bgmName: string): string {
return i18next.t([`bgmName:${bgmName}`, "bgmName:missing_entries"], {
name: formatText(bgmName),
name: toTitleCase(bgmName),
});
}
}

View File

@ -382,8 +382,7 @@ export class GameChallengesUiHandler extends UiHandler {
this.cursorObj?.setVisible(true);
this.updateChallengeArrows(this.startCursor.visible);
} else {
globalScene.phaseManager.clearPhaseQueue();
globalScene.phaseManager.pushNew("TitlePhase");
globalScene.phaseManager.toTitleScreen();
globalScene.phaseManager.getCurrentPhase()?.end();
}
success = true;

View File

@ -72,6 +72,10 @@ export abstract class FormModalUiHandler extends ModalUiHandler {
(hasTitle ? 31 : 5) + 20 * (config.length - 1) + 16 + this.getButtonTopMargin(),
"",
TextStyle.TOOLTIP_CONTENT,
{
fontSize: "42px",
wordWrap: { width: 850 },
},
);
this.errorMessage.setColor(this.getTextColor(TextStyle.SUMMARY_PINK));
this.errorMessage.setShadowColor(this.getTextColor(TextStyle.SUMMARY_PINK, true));
@ -84,20 +88,28 @@ export abstract class FormModalUiHandler extends ModalUiHandler {
this.inputs = [];
this.formLabels = [];
fieldsConfig.forEach((config, f) => {
const label = addTextObject(10, (hasTitle ? 31 : 5) + 20 * f, config.label, TextStyle.TOOLTIP_CONTENT);
// The Pokédex Scan Window uses width `300` instead of `160` like the other forms
// Therefore, the label does not need to be shortened
const label = addTextObject(
10,
(hasTitle ? 31 : 5) + 20 * f,
config.label.length > 25 && this.getWidth() < 200 ? config.label.slice(0, 20) + "..." : config.label,
TextStyle.TOOLTIP_CONTENT,
);
label.name = "formLabel" + f;
this.formLabels.push(label);
this.modalContainer.add(this.formLabels[this.formLabels.length - 1]);
const inputContainer = globalScene.add.container(70, (hasTitle ? 28 : 2) + 20 * f);
const inputWidth = label.width < 320 ? 80 : 80 - (label.width - 320) / 5.5;
const inputContainer = globalScene.add.container(70 + (80 - inputWidth), (hasTitle ? 28 : 2) + 20 * f);
inputContainer.setVisible(false);
const inputBg = addWindow(0, 0, 80, 16, false, false, 0, 0, WindowVariant.XTHIN);
const inputBg = addWindow(0, 0, inputWidth, 16, false, false, 0, 0, WindowVariant.XTHIN);
const isPassword = config?.isPassword;
const isReadOnly = config?.isReadOnly;
const input = addTextInputObject(4, -2, 440, 116, TextStyle.TOOLTIP_CONTENT, {
const input = addTextInputObject(4, -2, inputWidth * 5.5, 116, TextStyle.TOOLTIP_CONTENT, {
type: isPassword ? "password" : "text",
maxLength: isPassword ? 64 : 20,
readOnly: isReadOnly,

View File

@ -8,7 +8,8 @@ import type { GameData } from "#system/game-data";
import { addTextObject } from "#ui/text";
import { UiHandler } from "#ui/ui-handler";
import { addWindow } from "#ui/ui-theme";
import { formatFancyLargeNumber, getPlayTimeString, toReadableString } from "#utils/common";
import { formatFancyLargeNumber, getPlayTimeString } from "#utils/common";
import { toTitleCase } from "#utils/strings";
import i18next from "i18next";
import Phaser from "phaser";
@ -502,11 +503,9 @@ export function initStatsKeys() {
sourceFunc: gameData => gameData.gameStats[key].toString(),
};
}
if (!(displayStats[key] as DisplayStat).label_key) {
if (!displayStats[key].label_key) {
const splittableKey = key.replace(/([a-z]{2,})([A-Z]{1}(?:[^A-Z]|$))/g, "$1_$2");
(displayStats[key] as DisplayStat).label_key = toReadableString(
`${splittableKey[0].toUpperCase()}${splittableKey.slice(1)}`,
);
displayStats[key].label_key = toTitleCase(splittableKey);
}
}
}

View File

@ -152,7 +152,12 @@ export abstract class ModalUiHandler extends UiHandler {
updateContainer(config?: ModalConfig): void {
const [marginTop, marginRight, marginBottom, marginLeft] = this.getMargin(config);
const [width, height] = [this.getWidth(config), this.getHeight(config)];
/**
* If the total amount of characters for the 2 buttons exceeds ~30 characters,
* the width in `registration-form-ui-handler.ts` and `login-form-ui-handler.ts` needs to be increased.
*/
const width = this.getWidth(config);
const height = this.getHeight(config);
this.modalContainer.setPosition(
(globalScene.game.canvas.width / 6 - (width + (marginRight - marginLeft))) / 2,
(-globalScene.game.canvas.height / 6 - (height + (marginBottom - marginTop))) / 2,
@ -166,10 +171,14 @@ export abstract class ModalUiHandler extends UiHandler {
this.titleText.setX(width / 2);
this.titleText.setVisible(!!title);
for (let b = 0; b < this.buttonContainers.length; b++) {
const sliceWidth = width / (this.buttonContainers.length + 1);
this.buttonContainers[b].setPosition(sliceWidth * (b + 1), this.modalBg.height - (this.buttonBgs[b].height + 8));
if (this.buttonContainers.length > 0) {
const spacing = 12;
const totalWidth = this.buttonBgs.reduce((sum, bg) => sum + bg.width, 0) + spacing * (this.buttonBgs.length - 1);
let x = (this.modalBg.width - totalWidth) / 2;
this.buttonContainers.forEach((container, i) => {
container.setPosition(x + this.buttonBgs[i].width / 2, this.modalBg.height - (this.buttonBgs[i].height + 8));
x += this.buttonBgs[i].width + spacing;
});
}
}

View File

@ -26,7 +26,8 @@ import { MoveInfoOverlay } from "#ui/move-info-overlay";
import { PokemonIconAnimHandler, PokemonIconAnimMode } from "#ui/pokemon-icon-anim-handler";
import { addBBCodeTextObject, addTextObject, getTextColor } from "#ui/text";
import { addWindow } from "#ui/ui-theme";
import { BooleanHolder, getLocalizedSpriteKey, randInt, toReadableString } from "#utils/common";
import { BooleanHolder, getLocalizedSpriteKey, randInt } from "#utils/common";
import { toTitleCase } from "#utils/strings";
import i18next from "i18next";
import type BBCodeText from "phaser3-rex-plugins/plugins/bbcodetext";
@ -1409,7 +1410,7 @@ export class PartyUiHandler extends MessageUiHandler {
if (this.localizedOptions.includes(option)) {
optionName = i18next.t(`partyUiHandler:${PartyOption[option]}`);
} else {
optionName = toReadableString(PartyOption[option]);
optionName = toTitleCase(PartyOption[option]);
}
}
break;

View File

@ -54,16 +54,10 @@ import { PokedexInfoOverlay } from "#ui/pokedex-info-overlay";
import { StatsContainer } from "#ui/stats-container";
import { addBBCodeTextObject, addTextObject, getTextColor, getTextStyleOptions } from "#ui/text";
import { addWindow } from "#ui/ui-theme";
import {
BooleanHolder,
getLocalizedSpriteKey,
isNullOrUndefined,
padInt,
rgbHexToRgba,
toReadableString,
} from "#utils/common";
import { BooleanHolder, getLocalizedSpriteKey, isNullOrUndefined, padInt, rgbHexToRgba } from "#utils/common";
import { getEnumValues } from "#utils/enums";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import { toTitleCase } from "#utils/strings";
import { argbFromRgba } from "@material/material-color-utilities";
import i18next from "i18next";
import type BBCodeText from "phaser3-rex-plugins/plugins/gameobjects/tagtext/bbcodetext/BBCodeText";
@ -2620,7 +2614,7 @@ export class PokedexPageUiHandler extends MessageUiHandler {
// Setting growth rate text
if (isFormCaught) {
let growthReadable = toReadableString(GrowthRate[species.growthRate]);
let growthReadable = toTitleCase(GrowthRate[species.growthRate]);
const growthAux = growthReadable.replace(" ", "_");
if (i18next.exists("growth:" + growthAux)) {
growthReadable = i18next.t(("growth:" + growthAux) as any);

View File

@ -158,8 +158,11 @@ export class PokedexScanUiHandler extends FormModalUiHandler {
if (super.show(args)) {
const config = args[0] as ModalConfig;
this.inputs[0].resize(1150, 116);
this.inputContainers[0].list[0].width = 200;
const label = this.formLabels[0];
const inputWidth = label.width < 420 ? 200 : 200 - (label.width - 420) / 5.75;
this.inputs[0].resize(inputWidth * 5.75, 116);
this.inputContainers[0].list[0].width = inputWidth;
if (args[1] && typeof (args[1] as PlayerPokemon).getNameToRender === "function") {
this.inputs[0].text = (args[1] as PlayerPokemon).getNameToRender();
} else {

View File

@ -8,19 +8,6 @@ import type { ModalConfig } from "#ui/modal-ui-handler";
import { addTextObject } from "#ui/text";
import i18next from "i18next";
interface LanguageSetting {
inputFieldFontSize?: string;
warningMessageFontSize?: string;
errorMessageFontSize?: string;
}
const languageSettings: { [key: string]: LanguageSetting } = {
"es-ES": {
inputFieldFontSize: "50px",
errorMessageFontSize: "40px",
},
};
export class RegistrationFormUiHandler extends FormModalUiHandler {
getModalTitle(_config?: ModalConfig): string {
return i18next.t("menu:register");
@ -35,7 +22,7 @@ export class RegistrationFormUiHandler extends FormModalUiHandler {
}
getButtonTopMargin(): number {
return 8;
return 12;
}
getButtonLabels(_config?: ModalConfig): string[] {
@ -76,18 +63,9 @@ export class RegistrationFormUiHandler extends FormModalUiHandler {
setup(): void {
super.setup();
this.modalContainer.list.forEach((child: Phaser.GameObjects.GameObject) => {
if (child instanceof Phaser.GameObjects.Text && child !== this.titleText) {
const inputFieldFontSize = languageSettings[i18next.resolvedLanguage!]?.inputFieldFontSize;
if (inputFieldFontSize) {
child.setFontSize(inputFieldFontSize);
}
}
});
const warningMessageFontSize = languageSettings[i18next.resolvedLanguage!]?.warningMessageFontSize ?? "42px";
const label = addTextObject(10, 87, i18next.t("menu:registrationAgeWarning"), TextStyle.TOOLTIP_CONTENT, {
fontSize: warningMessageFontSize,
fontSize: "42px",
wordWrap: { width: 850 },
});
this.modalContainer.add(label);
@ -107,10 +85,6 @@ export class RegistrationFormUiHandler extends FormModalUiHandler {
const onFail = error => {
globalScene.ui.setMode(UiMode.REGISTRATION_FORM, Object.assign(config, { errorMessage: error?.trim() }));
globalScene.ui.playError();
const errorMessageFontSize = languageSettings[i18next.resolvedLanguage!]?.errorMessageFontSize;
if (errorMessageFontSize) {
this.errorMessage.setFontSize(errorMessageFontSize);
}
};
if (!this.inputs[0].text) {
return onFail(i18next.t("menu:emptyUsername"));

View File

@ -10,6 +10,7 @@ import { ScrollBar } from "#ui/scroll-bar";
import { addTextObject } from "#ui/text";
import { UiHandler } from "#ui/ui-handler";
import { addWindow } from "#ui/ui-theme";
import { toCamelCase } from "#utils/strings";
import i18next from "i18next";
export interface InputsIcons {
@ -88,12 +89,6 @@ export abstract class AbstractControlSettingsUiHandler extends UiHandler {
return settings;
}
private camelize(string: string): string {
return string
.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => (index === 0 ? word.toLowerCase() : word.toUpperCase()))
.replace(/\s+/g, "");
}
/**
* Setup UI elements.
*/
@ -210,14 +205,15 @@ export abstract class AbstractControlSettingsUiHandler extends UiHandler {
settingFiltered.forEach((setting, s) => {
// Convert the setting key from format 'Key_Name' to 'Key name' for display.
const settingName = setting.replace(/_/g, " ");
// TODO: IDK if this can be followed by both an underscore and a space, so leaving it as a regex matching both for now
const i18nKey = toCamelCase(setting.replace(/Alt(_| )/, ""));
// Create and add a text object for the setting name to the scene.
const isLock = this.settingBlacklisted.includes(this.setting[setting]);
const labelStyle = isLock ? TextStyle.SETTINGS_LOCKED : TextStyle.SETTINGS_LABEL;
const isAlt = setting.includes("Alt");
let labelText: string;
const i18nKey = this.camelize(settingName.replace("Alt ", ""));
if (settingName.toLowerCase().includes("alt")) {
if (isAlt) {
labelText = `${i18next.t(`settings:${i18nKey}`)}${i18next.t("settings:alt")}`;
} else {
labelText = i18next.t(`settings:${i18nKey}`);

View File

@ -15,7 +15,8 @@ import {
import { AbstractControlSettingsUiHandler } from "#ui/abstract-control-settings-ui-handler";
import { NavigationManager } from "#ui/navigation-menu";
import { addTextObject } from "#ui/text";
import { reverseValueToKeySetting, truncateString } from "#utils/common";
import { truncateString } from "#utils/common";
import { toPascalSnakeCase } from "#utils/strings";
import i18next from "i18next";
/**
@ -101,7 +102,7 @@ export class SettingsKeyboardUiHandler extends AbstractControlSettingsUiHandler
}
const cursor = this.cursor + this.scrollCursor; // Calculate the absolute cursor position.
const selection = this.settingLabels[cursor].text;
const key = reverseValueToKeySetting(selection);
const key = toPascalSnakeCase(selection);
const settingName = SettingKeyboard[key];
const activeConfig = this.getActiveConfig();
const success = deleteBind(this.getActiveConfig(), settingName);

View File

@ -69,10 +69,10 @@ import {
padInt,
randIntRange,
rgbHexToRgba,
toReadableString,
} from "#utils/common";
import type { StarterPreferences } from "#utils/data";
import { loadStarterPreferences, saveStarterPreferences } from "#utils/data";
import { toTitleCase } from "#utils/strings";
import { argbFromRgba } from "@material/material-color-utilities";
import i18next from "i18next";
import type { GameObjects } from "phaser";
@ -3527,7 +3527,7 @@ export class StarterSelectUiHandler extends MessageUiHandler {
this.pokemonLuckLabelText.setVisible(this.pokemonLuckText.visible);
//Growth translate
let growthReadable = toReadableString(GrowthRate[species.growthRate]);
let growthReadable = toTitleCase(GrowthRate[species.growthRate]);
const growthAux = growthReadable.replace(" ", "_");
if (i18next.exists("growth:" + growthAux)) {
growthReadable = i18next.t(("growth:" + growthAux) as any);
@ -4303,7 +4303,10 @@ export class StarterSelectUiHandler extends MessageUiHandler {
return true;
}
tryExit(): boolean {
/**
* Attempt to back out of the starter selection screen into the appropriate parent modal
*/
tryExit(): void {
this.blockInput = true;
const ui = this.getUi();
@ -4317,12 +4320,13 @@ export class StarterSelectUiHandler extends MessageUiHandler {
UiMode.CONFIRM,
() => {
ui.setMode(UiMode.STARTER_SELECT);
globalScene.phaseManager.clearPhaseQueue();
if (globalScene.gameMode.isChallenge) {
// Non-challenge modes go directly back to title, while challenge modes go to the selection screen.
if (!globalScene.gameMode.isChallenge) {
globalScene.phaseManager.toTitleScreen();
} else {
globalScene.phaseManager.clearPhaseQueue();
globalScene.phaseManager.pushNew("SelectChallengePhase");
globalScene.phaseManager.pushNew("EncounterPhase");
} else {
globalScene.phaseManager.pushNew("TitlePhase");
}
this.clearText();
globalScene.phaseManager.getCurrentPhase()?.end();
@ -4333,8 +4337,6 @@ export class StarterSelectUiHandler extends MessageUiHandler {
19,
);
});
return true;
}
tryStart(manualTrigger = false): boolean {

View File

@ -35,9 +35,9 @@ import {
isNullOrUndefined,
padInt,
rgbHexToRgba,
toReadableString,
} from "#utils/common";
import { getEnumValues } from "#utils/enums";
import { toTitleCase } from "#utils/strings";
import { argbFromRgba } from "@material/material-color-utilities";
import i18next from "i18next";
@ -962,8 +962,8 @@ export class SummaryUiHandler extends UiHandler {
this.passiveContainer?.descriptionText?.setVisible(false);
const closeFragment = getBBCodeFrag("", TextStyle.WINDOW_ALT);
const rawNature = toReadableString(Nature[this.pokemon?.getNature()!]); // TODO: is this bang correct?
const nature = `${getBBCodeFrag(toReadableString(getNatureName(this.pokemon?.getNature()!)), TextStyle.SUMMARY_RED)}${closeFragment}`; // TODO: is this bang correct?
const rawNature = toTitleCase(Nature[this.pokemon?.getNature()!]); // TODO: is this bang correct?
const nature = `${getBBCodeFrag(toTitleCase(getNatureName(this.pokemon?.getNature()!)), TextStyle.SUMMARY_RED)}${closeFragment}`; // TODO: is this bang correct?
const memoString = i18next.t("pokemonSummary:memoString", {
metFragment: i18next.t(

View File

@ -1,6 +1,5 @@
import { pokerogueApi } from "#api/pokerogue-api";
import { MoneyFormat } from "#enums/money-format";
import { MoveId } from "#enums/move-id";
import type { Variant } from "#sprites/variant";
import i18next from "i18next";
@ -10,19 +9,6 @@ export const MissingTextureKey = "__MISSING";
// TODO: Draft tests for these utility functions
// TODO: Break up this file
/**
* Convert a `snake_case` string in any capitalization (such as one from an enum reverse mapping)
* into a readable `Title Case` version.
* @param str - The snake case string to be converted.
* @returns The result of converting `str` into title case.
*/
export function toReadableString(str: string): string {
return str
.replace(/_/g, " ")
.split(" ")
.map(s => capitalizeFirstLetter(s.toLowerCase()))
.join(" ");
}
export function randomString(length: number, seeded = false) {
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
@ -278,7 +264,7 @@ export function formatMoney(format: MoneyFormat, amount: number) {
}
export function formatStat(stat: number, forHp = false): string {
return formatLargeNumber(stat, forHp ? 100000 : 1000000);
return formatLargeNumber(stat, forHp ? 100_000 : 1_000_000);
}
export function executeIf<T>(condition: boolean, promiseFunc: () => Promise<T>): Promise<T | null> {
@ -359,31 +345,6 @@ export function fixedInt(value: number): number {
return new FixedInt(value) as unknown as number;
}
/**
* Formats a string to title case
* @param unformattedText Text to be formatted
* @returns the formatted string
*/
export function formatText(unformattedText: string): string {
const text = unformattedText.split("_");
for (let i = 0; i < text.length; i++) {
text[i] = text[i].charAt(0).toUpperCase() + text[i].substring(1).toLowerCase();
}
return text.join(" ");
}
export function toCamelCaseString(unformattedText: string): string {
if (!unformattedText) {
return "";
}
return unformattedText
.split(/[_ ]/)
.filter(f => f)
.map((f, i) => (i ? `${f[0].toUpperCase()}${f.slice(1).toLowerCase()}` : f.toLowerCase()))
.join("");
}
export function rgbToHsv(r: number, g: number, b: number) {
const v = Math.max(r, g, b);
const c = v - Math.min(r, g, b);
@ -510,41 +471,6 @@ export function truncateString(str: string, maxLength = 10) {
return str;
}
/**
* Convert a space-separated string into a capitalized and underscored string.
* @param input - The string to be converted.
* @returns The converted string with words capitalized and separated by underscores.
*/
export function reverseValueToKeySetting(input: string) {
// Split the input string into an array of words
const words = input.split(" ");
// Capitalize the first letter of each word and convert the rest to lowercase
const capitalizedWords = words.map((word: string) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
// Join the capitalized words with underscores and return the result
return capitalizedWords.join("_");
}
/**
* Capitalize a string.
* @param str - The string to be capitalized.
* @param sep - The separator between the words of the string.
* @param lowerFirstChar - Whether the first character of the string should be lowercase or not.
* @param returnWithSpaces - Whether the returned string should have spaces between the words or not.
* @returns The capitalized string.
*/
export function capitalizeString(str: string, sep: string, lowerFirstChar = true, returnWithSpaces = false) {
if (str) {
const splitedStr = str.toLowerCase().split(sep);
for (let i = +lowerFirstChar; i < splitedStr?.length; i++) {
splitedStr[i] = splitedStr[i].charAt(0).toUpperCase() + splitedStr[i].substring(1);
}
return returnWithSpaces ? splitedStr.join(" ") : splitedStr.join("");
}
return null;
}
/**
* Report whether a given value is nullish (`null`/`undefined`).
* @param val - The value whose nullishness is being checked
@ -554,15 +480,6 @@ export function isNullOrUndefined(val: any): val is null | undefined {
return val === null || val === undefined;
}
/**
* Capitalize the first letter of a string.
* @param str - The string whose first letter is being capitalized
* @return The original string with its first letter capitalized
*/
export function capitalizeFirstLetter(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
/**
* This function is used in the context of a Pokémon battle game to calculate the actual integer damage value from a float result.
* Many damage calculation formulas involve various parameters and result in float values.
@ -597,26 +514,6 @@ export function isBetween(num: number, min: number, max: number): boolean {
return min <= num && num <= max;
}
/**
* Helper method to return the animation filename for a given move
*
* @param move the move for which the animation filename is needed
*/
export function animationFileName(move: MoveId): string {
return MoveId[move].toLowerCase().replace(/_/g, "-");
}
/**
* Transforms a camelCase string into a kebab-case string
* @param str The camelCase string
* @returns A kebab-case string
*
* @source {@link https://stackoverflow.com/a/67243723/}
*/
export function camelCaseToKebabCase(str: string): string {
return str.replace(/[A-Z]+(?![a-z])|[A-Z]/g, (s, o) => (o ? "-" : "") + s.toLowerCase());
}
/** Get the localized shiny descriptor for the provided variant
* @param variant - The variant to get the shiny descriptor for
* @returns The localized shiny descriptor

View File

@ -1,5 +1,5 @@
import type { EnumOrObject, EnumValues, NormalEnum, TSNumericEnum } from "#app/@types/enum-types";
import type { InferKeys } from "#app/@types/type-helpers";
import type { EnumOrObject, NormalEnum, TSNumericEnum } from "#types/enum-types";
import type { InferKeys, ObjectValues } from "#types/type-helpers";
/**
* Return the string keys of an Enum object, excluding reverse-mapped numbers.
@ -61,7 +61,7 @@ export function getEnumValues<E extends EnumOrObject>(enumType: TSNumericEnum<E>
* If multiple keys map to the same value, the first one (in insertion order) will be retrieved,
* but the return type will be the union of ALL their corresponding keys.
*/
export function enumValueToKey<T extends EnumOrObject, V extends EnumValues<T>>(
export function enumValueToKey<T extends EnumOrObject, V extends ObjectValues<T>>(
object: NormalEnum<T>,
val: V,
): InferKeys<T, V> {

181
src/utils/strings.ts Normal file
View File

@ -0,0 +1,181 @@
// TODO: Standardize file and path casing to remove the need for all these different casing methods
// #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.*/
const SPLIT_LOWER_UPPER_RE = /([\p{Ll}\d])(\p{Lu})/gu;
/** Regex to split around single-letter uppercase words.*/
const SPLIT_UPPER_UPPER_RE = /(\p{Lu})([\p{Lu}][\p{Ll}])/gu;
/** Regexp involved with stripping non-word delimiters from the result. */
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 - The string to be split
* @returns The new string, delimited at each instance of one or more spaces, underscores, hyphens
* or lower-to-upper boundaries.
* @remarks
* **DO NOT USE THIS FUNCTION!**
* Exported only to allow for testing.
* @todo Consider tests into [in-source testing](https://vitest.dev/guide/in-source.html) and converting this to unexported
*/
export function splitWords(value: string): string[] {
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 str - The string to replace
* @param charToTrim - The string to remove
* @returns The result of removing all instances of {@linkcode charsToTrim} from either end of {@linkcode str}.
*/
function trimFromStartAndEnd(str: string, charToTrim: string): string {
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.
* @param str - The string whose first letter is to be capitalized
* @return The original string with its first letter capitalized.
* @example
* ```ts
* console.log(capitalizeFirstLetter("consectetur adipiscing elit")); // returns "Consectetur adipiscing elit"
* ```
*/
export function capitalizeFirstLetter(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
/**
* Helper method to convert a string into `Title Case` (such as one used for console logs).
* @param str - The string being converted
* @returns The result of converting `str` into title case.
* @example
* ```ts
* console.log(toTitleCase("lorem ipsum dolor sit amet")); // returns "Lorem Ipsum Dolor Sit Amet"
* ```
*/
export function toTitleCase(str: string): string {
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).
* @param str - The string being converted
* @returns The result of converting `str` into camel case.
* @example
* ```ts
* console.log(toCamelCase("BIG_ANGRY_TRAINER")); // returns "bigAngryTrainer"
* ```
*/
export function toCamelCase(str: string) {
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`.
* @param str - The string being converted
* @returns The result of converting `str` into pascal case.
* @example
* ```ts
* console.log(toPascalCase("hi how was your day")); // returns "HiHowWasYourDay"
* ```
* @remarks
*/
export function toPascalCase(str: string) {
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).
* @param str - The string being converted
* @returns The result of converting `str` into kebab case.
* @example
* ```ts
* console.log(toKebabCase("not_kebab-caSe String")); // returns "not-kebab-case-string"
* ```
*/
export function toKebabCase(str: string): string {
return splitWords(str)
.map(word => word.toLowerCase())
.join("-");
}
/**
* Helper method to convert a string into `snake_case` (such as one used for filenames).
* @param str - The string being converted
* @returns The result of converting `str` into snake case.
* @example
* ```ts
* console.log(toSnakeCase("not-in snake_CaSe")); // returns "not_in_snake_case"
* ```
*/
export function toSnakeCase(str: string) {
return splitWords(str)
.map(word => word.toLowerCase())
.join("_");
}
/**
* Helper method to convert a string into `UPPER_SNAKE_CASE`.
* @param str - The string being converted
* @returns The result of converting `str` into upper snake case.
* @example
* ```ts
* console.log(toUpperSnakeCase("apples bananas_oranGes-PearS")); // returns "APPLES_BANANAS_ORANGES_PEARS"
* ```
*/
export function toUpperSnakeCase(str: string) {
return splitWords(str)
.map(word => word.toUpperCase())
.join("_");
}
/**
* Helper method to convert a string into `Pascal_Snake_Case`.
* @param str - The string being converted
* @returns The result of converting `str` into pascal snake case.
* @example
* ```ts
* console.log(toPascalSnakeCase("apples-bananas_oranGes Pears")); // returns "Apples_Bananas_Oranges_Pears"
* ```
*/
export function toPascalSnakeCase(str: string) {
return splitWords(str)
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join("_");
}

View File

@ -0,0 +1,389 @@
import { getPokemonNameWithAffix } from "#app/messages";
import { AttackTypeBoosterModifier } from "#app/modifier/modifier";
import { allMoves } from "#data/data-lists";
import { AbilityId } from "#enums/ability-id";
import { BattleType } from "#enums/battle-type";
import { BattlerIndex } from "#enums/battler-index";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { PokemonType } from "#enums/pokemon-type";
import { PositionalTagType } from "#enums/positional-tag-type";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { GameManager } from "#test/test-utils/game-manager";
import i18next from "i18next";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - Delayed Attacks", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.ability(AbilityId.NO_GUARD)
.battleStyle("single")
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.STURDY)
.enemyMoveset(MoveId.SPLASH);
});
/**
* Wait until a number of turns have passed.
* @param numTurns - Number of turns to pass.
* @param toEndOfTurn - Whether to advance to the `TurnEndPhase` (`true`) or the `PositionalTagPhase` (`false`);
* default `true`
* @returns A Promise that resolves once the specified number of turns has elapsed
* and the specified phase has been reached.
*/
async function passTurns(numTurns: number, toEndOfTurn = true): Promise<void> {
for (let i = 0; i < numTurns; i++) {
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER);
if (game.scene.getPlayerField()[1]?.isActive()) {
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
}
await game.move.forceEnemyMove(MoveId.SPLASH);
if (game.scene.getEnemyField()[1]?.isActive()) {
await game.move.forceEnemyMove(MoveId.SPLASH);
}
await game.phaseInterceptor.to("PositionalTagPhase");
}
if (toEndOfTurn) {
await game.toEndOfTurn();
}
}
/**
* Expect that future sight is active with the specified number of attacks.
* @param numAttacks - The number of delayed attacks that should be queued; default `1`
*/
function expectFutureSightActive(numAttacks = 1) {
const delayedAttacks = game.scene.arena.positionalTagManager["tags"].filter(
t => t.tagType === PositionalTagType.DELAYED_ATTACK,
);
expect(delayedAttacks).toHaveLength(numAttacks);
}
it.each<{ name: string; move: MoveId }>([
{ name: "Future Sight", move: MoveId.FUTURE_SIGHT },
{ name: "Doom Desire", move: MoveId.DOOM_DESIRE },
])("$name should show message and strike 2 turns after use, ignoring player/enemy switches", async ({ move }) => {
game.override.battleType(BattleType.TRAINER);
await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.MILOTIC]);
game.move.use(move);
await game.toNextTurn();
expectFutureSightActive();
game.doSwitchPokemon(1);
game.forceEnemyToSwitch();
await game.toNextTurn();
await passTurns(1);
expectFutureSightActive(0);
const enemy = game.field.getEnemyPokemon();
expect(enemy.hp).toBeLessThan(enemy.getMaxHp());
expect(game.textInterceptor.logs).toContain(
i18next.t("moveTriggers:tookMoveAttack", {
pokemonName: getPokemonNameWithAffix(enemy),
moveName: allMoves[move].name,
}),
);
});
it("should fail (preserving prior instances) when used against the same target", async () => {
await game.classicMode.startBattle([SpeciesId.BRONZONG]);
game.move.use(MoveId.FUTURE_SIGHT);
await game.toNextTurn();
expectFutureSightActive();
const bronzong = game.field.getPlayerPokemon();
expect(bronzong.getLastXMoves()[0].result).toBe(MoveResult.OTHER);
game.move.use(MoveId.FUTURE_SIGHT);
await game.toNextTurn();
expectFutureSightActive();
expect(bronzong.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
it("should still be delayed when called by other moves", async () => {
await game.classicMode.startBattle([SpeciesId.BRONZONG]);
game.move.use(MoveId.METRONOME);
game.move.forceMetronomeMove(MoveId.FUTURE_SIGHT);
await game.toNextTurn();
expectFutureSightActive();
const enemy = game.field.getEnemyPokemon();
expect(enemy.hp).toBe(enemy.getMaxHp());
await passTurns(2);
expectFutureSightActive(0);
expect(enemy.hp).toBeLessThan(enemy.getMaxHp());
});
it("should work when used against different targets in doubles", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]);
const [karp, feebas, enemy1, enemy2] = game.scene.getField();
game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER, BattlerIndex.ENEMY);
game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2);
await game.toEndOfTurn();
expectFutureSightActive(2);
expect(enemy1.hp).toBe(enemy1.getMaxHp());
expect(enemy2.hp).toBe(enemy2.getMaxHp());
expect(karp.getLastXMoves()[0].result).toBe(MoveResult.OTHER);
expect(feebas.getLastXMoves()[0].result).toBe(MoveResult.OTHER);
await passTurns(2);
expect(enemy1.hp).toBeLessThan(enemy1.getMaxHp());
expect(enemy2.hp).toBeLessThan(enemy2.getMaxHp());
});
it("should trigger multiple pending attacks in order of creation, even if that order changes later on", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]);
const [alomomola, blissey] = game.scene.getField();
const oldOrder = game.field.getSpeedOrder();
game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER, BattlerIndex.ENEMY);
game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2);
await game.move.forceEnemyMove(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER);
await game.move.forceEnemyMove(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER_2);
// Ensure that the moves are used deterministically in speed order (for speed ties)
await game.setTurnOrder(oldOrder.map(p => p.getBattlerIndex()));
await game.toNextTurn();
expectFutureSightActive(4);
// Lower speed to change turn order
alomomola.setStatStage(Stat.SPD, 6);
blissey.setStatStage(Stat.SPD, -6);
const newOrder = game.field.getSpeedOrder();
expect(newOrder).not.toEqual(oldOrder);
await passTurns(2, false);
// All attacks have concluded at this point, unshifting new `MoveEffectPhase`s to the queue.
expectFutureSightActive(0);
const MEPs = game.scene.phaseManager.phaseQueue.filter(p => p.is("MoveEffectPhase"));
expect(MEPs).toHaveLength(4);
expect(MEPs.map(mep => mep.getPokemon())).toEqual(oldOrder);
});
it("should vanish silently if it would otherwise hit the user", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS, SpeciesId.MILOTIC]);
const [karp, feebas, milotic] = game.scene.getPlayerParty();
game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.toNextTurn();
expectFutureSightActive(1);
// Milotic / Feebas // Karp
game.doSwitchPokemon(2);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.toNextTurn();
expect(game.scene.getPlayerParty()).toEqual([milotic, feebas, karp]);
// Milotic / Karp // Feebas
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER);
game.doSwitchPokemon(2);
await passTurns(1);
expect(game.scene.getPlayerParty()).toEqual([milotic, karp, feebas]);
expect(karp.hp).toBe(karp.getMaxHp());
expect(feebas.hp).toBe(feebas.getMaxHp());
expect(game.textInterceptor.logs).not.toContain(
i18next.t("moveTriggers:tookMoveAttack", {
pokemonName: getPokemonNameWithAffix(karp),
moveName: allMoves[MoveId.FUTURE_SIGHT].name,
}),
);
});
it("should redirect normally if target is fainted when move is used", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const [enemy1, enemy2] = game.scene.getEnemyField();
game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2);
await game.killPokemon(enemy2);
await game.toNextTurn();
expect(enemy2.isFainted()).toBe(true);
expectFutureSightActive();
const attack = game.scene.arena.positionalTagManager.tags.find(
t => t.tagType === PositionalTagType.DELAYED_ATTACK,
)!;
expect(attack).toBeDefined();
expect(attack.targetIndex).toBe(enemy1.getBattlerIndex());
await passTurns(2);
expect(enemy1.hp).toBeLessThan(enemy1.getMaxHp());
expect(game.textInterceptor.logs).toContain(
i18next.t("moveTriggers:tookMoveAttack", {
pokemonName: getPokemonNameWithAffix(enemy1),
moveName: allMoves[MoveId.FUTURE_SIGHT].name,
}),
);
});
it("should vanish silently if slot is vacant when attack lands", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const [enemy1, enemy2] = game.scene.getEnemyField();
game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2);
await game.toNextTurn();
expectFutureSightActive(1);
game.move.use(MoveId.SPLASH);
await game.killPokemon(enemy2);
await game.toNextTurn();
game.move.use(MoveId.SPLASH);
await game.toNextTurn();
expectFutureSightActive(0);
expect(enemy1.hp).toBe(enemy1.getMaxHp());
expect(game.textInterceptor.logs).not.toContain(
i18next.t("moveTriggers:tookMoveAttack", {
pokemonName: getPokemonNameWithAffix(enemy1),
moveName: allMoves[MoveId.FUTURE_SIGHT].name,
}),
);
});
it("should consider type changes at moment of execution while ignoring redirection", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
// fake left enemy having lightning rod
const [enemy1, enemy2] = game.scene.getEnemyField();
game.field.mockAbility(enemy1, AbilityId.LIGHTNING_ROD);
game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2);
await game.toNextTurn();
expectFutureSightActive(1);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER);
await game.toNextTurn();
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER);
await game.move.forceEnemyMove(MoveId.ELECTRIFY, BattlerIndex.PLAYER);
await game.phaseInterceptor.to("PositionalTagPhase");
await game.phaseInterceptor.to("MoveEffectPhase", false);
// Wait until all normal attacks have triggered, then check pending MEP
const karp = game.field.getPlayerPokemon();
const typeMock = vi.spyOn(karp, "getMoveType");
await game.toEndOfTurn();
expect(enemy1.hp).toBe(enemy1.getMaxHp());
expect(enemy2.hp).toBeLessThan(enemy2.getMaxHp());
expect(game.textInterceptor.logs).toContain(
i18next.t("moveTriggers:tookMoveAttack", {
pokemonName: getPokemonNameWithAffix(enemy2),
moveName: allMoves[MoveId.FUTURE_SIGHT].name,
}),
);
expect(typeMock).toHaveLastReturnedWith(PokemonType.ELECTRIC);
});
// TODO: this is not implemented
it.todo("should not apply Shell Bell recovery, even if user is on field");
// TODO: Enable once code is added to MEP to do this
it.todo("should not apply the user's abilities when dealing damage if the user is inactive", async () => {
game.override.ability(AbilityId.NORMALIZE).enemySpecies(SpeciesId.LUNALA);
await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.MILOTIC]);
game.move.use(MoveId.DOOM_DESIRE);
await game.toNextTurn();
expectFutureSightActive();
await passTurns(1);
game.doSwitchPokemon(1);
const typeMock = vi.spyOn(game.field.getPlayerPokemon(), "getMoveType");
const powerMock = vi.spyOn(allMoves[MoveId.DOOM_DESIRE], "calculateBattlePower");
await game.toNextTurn();
// Player Normalize was not applied due to being off field
const enemy = game.field.getEnemyPokemon();
expect(enemy.hp).toBeLessThan(enemy.getMaxHp());
expect(game.textInterceptor.logs).toContain(
i18next.t("moveTriggers:tookMoveAttack", {
pokemonName: getPokemonNameWithAffix(enemy),
moveName: allMoves[MoveId.DOOM_DESIRE].name,
}),
);
expect(typeMock).toHaveLastReturnedWith(PokemonType.STEEL);
expect(powerMock).toHaveLastReturnedWith(150);
});
it.todo("should not apply the user's held items when dealing damage if the user is inactive", async () => {
game.override.startingHeldItems([{ name: "ATTACK_TYPE_BOOSTER", count: 99, type: PokemonType.PSYCHIC }]);
await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.MILOTIC]);
game.move.use(MoveId.FUTURE_SIGHT);
await game.toNextTurn();
expectFutureSightActive();
await passTurns(1);
game.doSwitchPokemon(1);
const powerMock = vi.spyOn(allMoves[MoveId.FUTURE_SIGHT], "calculateBattlePower");
const typeBoostSpy = vi.spyOn(AttackTypeBoosterModifier.prototype, "apply");
await game.toNextTurn();
expect(powerMock).toHaveLastReturnedWith(120);
expect(typeBoostSpy).not.toHaveBeenCalled();
});
// TODO: Implement and move to a power spot's test file
it.todo("Should activate ally's power spot when switched in during single battles");
});

View File

@ -1,45 +0,0 @@
import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Moves - Future Sight", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.startingLevel(50)
.moveset([MoveId.FUTURE_SIGHT, MoveId.SPLASH])
.battleStyle("single")
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.STURDY)
.enemyMoveset(MoveId.SPLASH);
});
it("hits 2 turns after use, ignores user switch out", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.MILOTIC]);
game.move.select(MoveId.FUTURE_SIGHT);
await game.toNextTurn();
game.doSwitchPokemon(1);
await game.toNextTurn();
game.move.select(MoveId.SPLASH);
await game.toNextTurn();
expect(game.scene.getEnemyPokemon()!.isFullHp()).toBe(false);
});
});

View File

@ -1,9 +1,8 @@
import { AbilityId } from "#enums/ability-id";
import { ArenaTagSide } from "#enums/arena-tag-side";
import { ArenaTagType } from "#enums/arena-tag-type";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { PositionalTagType } from "#enums/positional-tag-type";
import { SpeciesId } from "#enums/species-id";
import { WeatherType } from "#enums/weather-type";
import { GameManager } from "#test/test-utils/game-manager";
@ -68,22 +67,25 @@ describe("Moves - Heal Block", () => {
expect(enemy.isFullHp()).toBe(false);
});
it("should stop delayed heals, such as from Wish", async () => {
it("should prevent Wish from restoring HP", async () => {
await game.classicMode.startBattle([SpeciesId.CHARIZARD]);
const player = game.scene.getPlayerPokemon()!;
const player = game.field.getPlayerPokemon()!;
player.damageAndUpdate(player.getMaxHp() - 1);
player.hp = 1;
game.move.select(MoveId.WISH);
await game.phaseInterceptor.to("TurnEndPhase");
game.move.use(MoveId.WISH);
await game.toNextTurn();
expect(game.scene.arena.getTagOnSide(ArenaTagType.WISH, ArenaTagSide.PLAYER)).toBeDefined();
while (game.scene.arena.getTagOnSide(ArenaTagType.WISH, ArenaTagSide.PLAYER)) {
game.move.select(MoveId.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase");
}
expect(game.scene.arena.positionalTagManager.tags.filter(t => t.tagType === PositionalTagType.WISH)) //
.toHaveLength(1);
game.move.use(MoveId.SPLASH);
await game.toNextTurn();
// wish triggered, but did NOT heal the player
expect(game.scene.arena.positionalTagManager.tags.filter(t => t.tagType === PositionalTagType.WISH)) //
.toHaveLength(0);
expect(player.hp).toBe(1);
});

View File

@ -65,23 +65,4 @@ describe("Moves - Order Up", () => {
affectedStats.forEach(st => expect(dondozo.getStatStage(st)).toBe(st === stat ? 3 : 2));
},
);
it("should be boosted by Sheer Force while still applying a stat boost", async () => {
game.override.passiveAbility(AbilityId.SHEER_FORCE).starterForms({ [SpeciesId.TATSUGIRI]: 0 });
await game.classicMode.startBattle([SpeciesId.TATSUGIRI, SpeciesId.DONDOZO]);
const [tatsugiri, dondozo] = game.scene.getPlayerField();
expect(game.scene.triggerPokemonBattleAnim).toHaveBeenLastCalledWith(tatsugiri, PokemonAnimType.COMMANDER_APPLY);
expect(dondozo.getTag(BattlerTagType.COMMANDED)).toBeDefined();
game.move.select(MoveId.ORDER_UP, 1, BattlerIndex.ENEMY);
expect(game.scene.currentBattle.turnCommands[0]?.skip).toBeTruthy();
await game.phaseInterceptor.to("BerryPhase", false);
expect(dondozo.waveData.abilitiesApplied.has(AbilityId.SHEER_FORCE)).toBeTruthy();
expect(dondozo.getStatStage(Stat.ATK)).toBe(3);
});
});

183
test/moves/wish.test.ts Normal file
View File

@ -0,0 +1,183 @@
import { getPokemonNameWithAffix } from "#app/messages";
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { PositionalTagType } from "#enums/positional-tag-type";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { GameManager } from "#test/test-utils/game-manager";
import { toDmgValue } from "#utils/common";
import i18next from "i18next";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Move - Wish", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.criticalHits(false)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH)
.startingLevel(100)
.enemyLevel(100);
});
/**
* Expect that wish is active with the specified number of attacks.
* @param numAttacks - The number of wish instances that should be queued; default `1`
*/
function expectWishActive(numAttacks = 1) {
const wishes = game.scene.arena.positionalTagManager["tags"].filter(t => t.tagType === PositionalTagType.WISH);
expect(wishes).toHaveLength(numAttacks);
}
it("should heal the Pokemon in the current slot for 50% of the user's maximum HP", async () => {
await game.classicMode.startBattle([SpeciesId.ALOMOMOLA, SpeciesId.BLISSEY]);
const [alomomola, blissey] = game.scene.getPlayerParty();
alomomola.hp = 1;
blissey.hp = 1;
game.move.use(MoveId.WISH);
await game.toNextTurn();
expectWishActive();
game.doSwitchPokemon(1);
await game.toEndOfTurn();
expectWishActive(0);
expect(game.textInterceptor.logs).toContain(
i18next.t("arenaTag:wishTagOnAdd", {
pokemonNameWithAffix: getPokemonNameWithAffix(alomomola),
}),
);
expect(alomomola.hp).toBe(1);
expect(blissey.hp).toBe(toDmgValue(alomomola.getMaxHp() / 2) + 1);
});
it("should work if the user has full HP, but not if it already has an active Wish", async () => {
await game.classicMode.startBattle([SpeciesId.ALOMOMOLA, SpeciesId.BLISSEY]);
const alomomola = game.field.getPlayerPokemon();
alomomola.hp = 1;
game.move.use(MoveId.WISH);
await game.toNextTurn();
expectWishActive();
game.move.use(MoveId.WISH);
await game.toEndOfTurn();
expect(alomomola.hp).toBe(toDmgValue(alomomola.getMaxHp() / 2) + 1);
expect(alomomola.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
it("should function independently of Future Sight", async () => {
await game.classicMode.startBattle([SpeciesId.ALOMOMOLA, SpeciesId.BLISSEY]);
const [alomomola, blissey] = game.scene.getPlayerParty();
alomomola.hp = 1;
blissey.hp = 1;
game.move.use(MoveId.WISH);
await game.move.forceEnemyMove(MoveId.FUTURE_SIGHT);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
expectWishActive(1);
});
it("should work in double battles and trigger in order of creation", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.ALOMOMOLA, SpeciesId.BLISSEY]);
const [alomomola, blissey, karp1, karp2] = game.scene.getField();
alomomola.hp = 1;
blissey.hp = 1;
vi.spyOn(karp1, "getNameToRender").mockReturnValue("Karp 1");
vi.spyOn(karp2, "getNameToRender").mockReturnValue("Karp 2");
const oldOrder = game.field.getSpeedOrder();
game.move.use(MoveId.WISH, BattlerIndex.PLAYER);
game.move.use(MoveId.WISH, BattlerIndex.PLAYER_2);
await game.move.forceEnemyMove(MoveId.WISH);
await game.move.forceEnemyMove(MoveId.WISH);
// Ensure that the wishes are used deterministically in speed order (for speed ties)
await game.setTurnOrder(oldOrder.map(p => p.getBattlerIndex()));
await game.toNextTurn();
expectWishActive(4);
// Lower speed to change turn order
alomomola.setStatStage(Stat.SPD, 6);
blissey.setStatStage(Stat.SPD, -6);
const newOrder = game.field.getSpeedOrder();
expect(newOrder).not.toEqual(oldOrder);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.phaseInterceptor.to("PositionalTagPhase");
// all wishes have activated and added healing phases
expectWishActive(0);
const healPhases = game.scene.phaseManager.phaseQueue.filter(p => p.is("PokemonHealPhase"));
expect(healPhases).toHaveLength(4);
expect.soft(healPhases.map(php => php.getPokemon())).toEqual(oldOrder);
await game.toEndOfTurn();
expect(alomomola.hp).toBe(toDmgValue(alomomola.getMaxHp() / 2) + 1);
expect(blissey.hp).toBe(toDmgValue(blissey.getMaxHp() / 2) + 1);
});
it("should vanish and not play message if slot is empty", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.ALOMOMOLA, SpeciesId.BLISSEY]);
const [alomomola, blissey] = game.scene.getPlayerParty();
alomomola.hp = 1;
blissey.hp = 1;
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER);
game.move.use(MoveId.WISH, BattlerIndex.PLAYER_2);
await game.toNextTurn();
expectWishActive();
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER);
game.move.use(MoveId.MEMENTO, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2);
await game.toEndOfTurn();
// Wish went away without doing anything
expectWishActive(0);
expect(game.textInterceptor.logs).not.toContain(
i18next.t("arenaTag:wishTagOnAdd", {
pokemonNameWithAffix: getPokemonNameWithAffix(blissey),
}),
);
expect(alomomola.hp).toBe(1);
});
});

View File

@ -93,7 +93,7 @@ describe("Global Trade System - Mystery Encounter", () => {
describe("Option 1 - Check Trade Offers", () => {
it("should have the correct properties", () => {
const option = GlobalTradeSystemEncounter.options[0];
expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT);
expect(option.optionMode).toBe(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT);
expect(option.dialogue).toBeDefined();
expect(option.dialogue).toStrictEqual({
buttonLabel: `${namespace}:option.1.label`,
@ -154,7 +154,7 @@ describe("Global Trade System - Mystery Encounter", () => {
describe("Option 2 - Wonder Trade", () => {
it("should have the correct properties", () => {
const option = GlobalTradeSystemEncounter.options[1];
expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT);
expect(option.optionMode).toBe(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT);
expect(option.dialogue).toBeDefined();
expect(option.dialogue).toStrictEqual({
buttonLabel: `${namespace}:option.2.label`,

View File

@ -0,0 +1,63 @@
import { AbilityId } from "#enums/ability-id";
import { BerryType } from "#enums/berry-type";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { WeatherType } from "#enums/weather-type";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Check Biome End Phase", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.enemySpecies(SpeciesId.MAGIKARP)
.enemyMoveset(MoveId.SPLASH)
.enemyAbility(AbilityId.BALL_FETCH)
.ability(AbilityId.BALL_FETCH)
.startingLevel(100)
.battleStyle("single");
});
it("should not trigger end of turn effects when defeating the final pokemon of a biome in classic", async () => {
game.override
.startingWave(10)
.weather(WeatherType.SANDSTORM)
.startingHeldItems([{ name: "BERRY", type: BerryType.SITRUS }]);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const player = game.field.getPlayerPokemon();
player.hp = 1;
game.move.use(MoveId.EXTREME_SPEED);
await game.toEndOfTurn();
expect(player.hp).toBe(1);
});
it("should not prevent end of turn effects when transitioning waves within a biome", async () => {
game.override.weather(WeatherType.SANDSTORM);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const player = game.field.getPlayerPokemon();
game.move.use(MoveId.EXTREME_SPEED);
await game.toEndOfTurn();
expect(player.hp).toBeLessThan(player.getMaxHp());
});
});

View File

@ -1,5 +1,6 @@
import { getIconForLatestInput, getSettingNameWithKeycode } from "#inputs/config-handler";
import { SettingKeyboard } from "#system/settings-keyboard";
import { toPascalSnakeCase } from "#utils/strings";
import { expect } from "vitest";
export class InGameManip {
@ -56,22 +57,11 @@ export class InGameManip {
return this;
}
normalizeSettingNameString(input) {
// Convert the input string to lower case
const lowerCasedInput = input.toLowerCase();
// Replace underscores with spaces, capitalize the first letter of each word, and join them back with underscores
const words = lowerCasedInput.split("_").map(word => word.charAt(0).toUpperCase() + word.slice(1));
const result = words.join("_");
return result;
}
weShouldTriggerTheButton(settingName) {
if (!settingName.includes("Button_")) {
settingName = "Button_" + settingName;
}
this.settingName = SettingKeyboard[this.normalizeSettingNameString(settingName)];
this.settingName = SettingKeyboard[toPascalSnakeCase(settingName)];
expect(getSettingNameWithKeycode(this.config, this.keycode)).toEqual(this.settingName);
return this;
}

View File

@ -29,6 +29,7 @@ export class MenuManip {
this.specialCaseIcon = null;
}
// TODO: Review this
convertNameToButtonString(input) {
// Check if the input starts with "Alt_Button"
if (input.startsWith("Alt_Button")) {

View File

@ -103,12 +103,9 @@ export class GameManager {
if (!firstTimeScene) {
this.scene.reset(false, true);
(this.scene.ui.handlers[UiMode.STARTER_SELECT] as StarterSelectUiHandler).clearStarterPreferences();
this.scene.phaseManager.clearAllPhases();
// Must be run after phase interceptor has been initialized.
this.scene.phaseManager.pushNew("LoginPhase");
this.scene.phaseManager.pushNew("TitlePhase");
this.scene.phaseManager.toTitleScreen(true);
this.scene.phaseManager.shiftPhase();
this.gameWrapper.scene = this.scene;

View File

@ -5,7 +5,6 @@ import type { globalScene } from "#app/global-scene";
import type { Ability } from "#abilities/ability";
import { allAbilities } from "#data/data-lists";
import type { AbilityId } from "#enums/ability-id";
import type { BattlerIndex } from "#enums/battler-index";
import type { PokemonType } from "#enums/pokemon-type";
import { Stat } from "#enums/stat";
import type { EnemyPokemon, PlayerPokemon, Pokemon } from "#field/pokemon";
@ -45,18 +44,21 @@ export class FieldHelper extends GameManagerHelper {
}
/**
* @returns The {@linkcode BattlerIndex | indexes} of Pokemon on the field in order of decreasing Speed.
* Helper function to return all on-field {@linkcode Pokemon} in speed order (fastest first).
* @returns An array containing all {@linkcode Pokemon} on the field in order of descending Speed.
* Speed ties are returned in increasing order of index.
*
* @remarks
* This does not account for Trick Room as it does not modify the _speed_ of Pokemon on the field,
* only their turn order.
*/
public getSpeedOrder(): BattlerIndex[] {
public getSpeedOrder(): Pokemon[] {
return this.game.scene
.getField(true)
.sort((pA, pB) => pB.getEffectiveStat(Stat.SPD) - pA.getEffectiveStat(Stat.SPD))
.map(p => p.getBattlerIndex());
.sort(
(pA, pB) =>
pB.getEffectiveStat(Stat.SPD) - pA.getEffectiveStat(Stat.SPD) || pA.getBattlerIndex() - pB.getBattlerIndex(),
);
}
/**

View File

@ -12,7 +12,8 @@ import type { CommandPhase } from "#phases/command-phase";
import type { EnemyCommandPhase } from "#phases/enemy-command-phase";
import { MoveEffectPhase } from "#phases/move-effect-phase";
import { GameManagerHelper } from "#test/test-utils/helpers/game-manager-helper";
import { coerceArray, toReadableString } from "#utils/common";
import { coerceArray } from "#utils/common";
import { toTitleCase } from "#utils/strings";
import type { MockInstance } from "vitest";
import { expect, vi } from "vitest";
@ -66,12 +67,12 @@ export class MoveHelper extends GameManagerHelper {
const movePosition = this.getMovePosition(pkmIndex, move);
if (movePosition === -1) {
expect.fail(
`MoveHelper.select called with move '${toReadableString(MoveId[move])}' not in moveset!` +
`\nBattler Index: ${toReadableString(BattlerIndex[pkmIndex])}` +
`MoveHelper.select called with move '${toTitleCase(MoveId[move])}' not in moveset!` +
`\nBattler Index: ${toTitleCase(BattlerIndex[pkmIndex])}` +
`\nMoveset: [${this.game.scene
.getPlayerParty()
[pkmIndex].getMoveset()
.map(pm => toReadableString(MoveId[pm.moveId]))
.map(pm => toTitleCase(MoveId[pm.moveId]))
.join(", ")}]`,
);
}
@ -110,12 +111,12 @@ export class MoveHelper extends GameManagerHelper {
const movePosition = this.getMovePosition(pkmIndex, move);
if (movePosition === -1) {
expect.fail(
`MoveHelper.selectWithTera called with move '${toReadableString(MoveId[move])}' not in moveset!` +
`\nBattler Index: ${toReadableString(BattlerIndex[pkmIndex])}` +
`MoveHelper.selectWithTera called with move '${toTitleCase(MoveId[move])}' not in moveset!` +
`\nBattler Index: ${toTitleCase(BattlerIndex[pkmIndex])}` +
`\nMoveset: [${this.game.scene
.getPlayerParty()
[pkmIndex].getMoveset()
.map(pm => toReadableString(MoveId[pm.moveId]))
.map(pm => toTitleCase(MoveId[pm.moveId]))
.join(", ")}]`,
);
}
@ -324,10 +325,16 @@ export class MoveHelper extends GameManagerHelper {
}
/**
* Force the move used by Metronome to be a specific move.
* @param move - The move to force metronome to use
* @param once - If `true`, uses {@linkcode MockInstance#mockReturnValueOnce} when mocking, else uses {@linkcode MockInstance#mockReturnValue}.
* Force the next move(s) used by Metronome to be a specific move. \
* Triggers during the next upcoming {@linkcode MoveEffectPhase} that Metronome is used.
* @param move - The move to force Metronome to call
* @param once - If `true`, mocks the return value exactly once; default `false`
* @returns The spy that for Metronome that was mocked (Usually unneeded).
* @example
* ```ts
* game.move.use(MoveId.METRONOME);
* game.move.forceMetronomeMove(MoveId.FUTURE_SIGHT); // Can be in any order
* ```
*/
public forceMetronomeMove(move: MoveId, once = false): MockInstance {
const spy = vi.spyOn(allMoves[MoveId.METRONOME].getAttrs("RandomMoveAttr")[0], "getMoveOverride");

View File

@ -37,6 +37,7 @@ import { NextEncounterPhase } from "#phases/next-encounter-phase";
import { PartyExpPhase } from "#phases/party-exp-phase";
import { PartyHealPhase } from "#phases/party-heal-phase";
import { PokemonTransformPhase } from "#phases/pokemon-transform-phase";
import { PositionalTagPhase } from "#phases/positional-tag-phase";
import { PostGameOverPhase } from "#phases/post-game-over-phase";
import { PostSummonPhase } from "#phases/post-summon-phase";
import { QuietFormChangePhase } from "#phases/quiet-form-change-phase";
@ -142,6 +143,7 @@ export class PhaseInterceptor {
[LevelCapPhase, this.startPhase],
[AttemptRunPhase, this.startPhase],
[SelectBiomePhase, this.startPhase],
[PositionalTagPhase, this.startPhase],
[PokemonTransformPhase, this.startPhase],
[MysteryEncounterPhase, this.startPhase],
[MysteryEncounterOptionSelectedPhase, this.startPhase],

View File

@ -1,5 +1,6 @@
import type { EnumOrObject, EnumValues, NormalEnum, TSNumericEnum } from "#app/@types/enum-types";
import type { enumValueToKey, getEnumKeys, getEnumValues } from "#app/utils/enums";
import type { EnumOrObject, NormalEnum, TSNumericEnum } from "#types/enum-types";
import type { ObjectValues } from "#types/type-helpers";
import { describe, expectTypeOf, it } from "vitest";
enum testEnumNum {
@ -16,21 +17,33 @@ const testObjNum = { testON1: 1, testON2: 2 } as const;
const testObjString = { testOS1: "apple", testOS2: "banana" } as const;
describe("Enum Type Helpers", () => {
describe("EnumValues", () => {
it("should go from enum object type to value type", () => {
expectTypeOf<EnumValues<typeof testEnumNum>>().toEqualTypeOf<testEnumNum>();
expectTypeOf<EnumValues<typeof testEnumNum>>().branded.toEqualTypeOf<1 | 2>();
interface testObject {
key_1: "1";
key_2: "2";
key_3: "3";
}
expectTypeOf<EnumValues<typeof testEnumString>>().toEqualTypeOf<testEnumString>();
expectTypeOf<EnumValues<typeof testEnumString>>().toEqualTypeOf<testEnumString.testS1 | testEnumString.testS2>();
expectTypeOf<EnumValues<typeof testEnumString>>().toMatchTypeOf<"apple" | "banana">();
describe("Enum Type Helpers", () => {
describe("ObjectValues", () => {
it("should produce a union of an object's values", () => {
expectTypeOf<ObjectValues<testObject>>().toEqualTypeOf<"1" | "2" | "3">();
});
it("should go from enum object type to value type", () => {
expectTypeOf<ObjectValues<typeof testEnumNum>>().toEqualTypeOf<testEnumNum>();
expectTypeOf<ObjectValues<typeof testEnumNum>>().branded.toEqualTypeOf<1 | 2>();
expectTypeOf<ObjectValues<typeof testEnumString>>().toEqualTypeOf<testEnumString>();
expectTypeOf<ObjectValues<typeof testEnumString>>().toEqualTypeOf<
testEnumString.testS1 | testEnumString.testS2
>();
expectTypeOf<ObjectValues<typeof testEnumString>>().toExtend<"apple" | "banana">();
});
it("should produce union of const object values as type", () => {
expectTypeOf<EnumValues<typeof testObjNum>>().toEqualTypeOf<1 | 2>();
expectTypeOf<EnumValues<typeof testObjString>>().toEqualTypeOf<"apple" | "banana">();
expectTypeOf<ObjectValues<typeof testObjNum>>().toEqualTypeOf<1 | 2>();
expectTypeOf<ObjectValues<typeof testObjString>>().toEqualTypeOf<"apple" | "banana">();
});
});
@ -38,7 +51,6 @@ describe("Enum Type Helpers", () => {
it("should match numeric enums", () => {
expectTypeOf<TSNumericEnum<typeof testEnumNum>>().toEqualTypeOf<typeof testEnumNum>();
});
it("should not match string enums or const objects", () => {
expectTypeOf<TSNumericEnum<typeof testEnumString>>().toBeNever();
expectTypeOf<TSNumericEnum<typeof testObjNum>>().toBeNever();
@ -59,19 +71,19 @@ describe("Enum Type Helpers", () => {
describe("EnumOrObject", () => {
it("should match any enum or const object", () => {
expectTypeOf<typeof testEnumNum>().toMatchTypeOf<EnumOrObject>();
expectTypeOf<typeof testEnumString>().toMatchTypeOf<EnumOrObject>();
expectTypeOf<typeof testObjNum>().toMatchTypeOf<EnumOrObject>();
expectTypeOf<typeof testObjString>().toMatchTypeOf<EnumOrObject>();
expectTypeOf<typeof testEnumNum>().toExtend<EnumOrObject>();
expectTypeOf<typeof testEnumString>().toExtend<EnumOrObject>();
expectTypeOf<typeof testObjNum>().toExtend<EnumOrObject>();
expectTypeOf<typeof testObjString>().toExtend<EnumOrObject>();
});
it("should not match an enum value union w/o typeof", () => {
expectTypeOf<testEnumNum>().not.toMatchTypeOf<EnumOrObject>();
expectTypeOf<testEnumString>().not.toMatchTypeOf<EnumOrObject>();
expectTypeOf<testEnumNum>().not.toExtend<EnumOrObject>();
expectTypeOf<testEnumString>().not.toExtend<EnumOrObject>();
});
it("should be equivalent to `TSNumericEnum | NormalEnum`", () => {
expectTypeOf<EnumOrObject>().branded.toEqualTypeOf<TSNumericEnum<EnumOrObject> | NormalEnum<EnumOrObject>>();
expectTypeOf<EnumOrObject>().toEqualTypeOf<TSNumericEnum<EnumOrObject> | NormalEnum<EnumOrObject>>();
});
});
});
@ -80,6 +92,7 @@ describe("Enum Functions", () => {
describe("getEnumKeys", () => {
it("should retrieve keys of numeric enum", () => {
expectTypeOf<typeof getEnumKeys<typeof testEnumNum>>().returns.toEqualTypeOf<("testN1" | "testN2")[]>();
expectTypeOf<typeof getEnumKeys<typeof testObjNum>>().returns.toEqualTypeOf<("testON1" | "testON2")[]>();
});
});

View File

@ -0,0 +1,29 @@
import type { SerializedPositionalTag, serializedPosTagMap } from "#data/positional-tags/load-positional-tag";
import type { DelayedAttackTag, WishTag } from "#data/positional-tags/positional-tag";
import type { PositionalTagType } from "#enums/positional-tag-type";
import type { Mutable, NonFunctionPropertiesRecursive } from "#types/type-helpers";
import { describe, expectTypeOf, it } from "vitest";
// Needed to get around properties being readonly in certain classes
type NonFunctionMutable<T> = Mutable<NonFunctionPropertiesRecursive<T>>;
describe("serializedPositionalTagMap", () => {
it("should contain representations of each tag's serialized form", () => {
expectTypeOf<serializedPosTagMap[PositionalTagType.DELAYED_ATTACK]>().branded.toEqualTypeOf<
NonFunctionMutable<DelayedAttackTag>
>();
expectTypeOf<serializedPosTagMap[PositionalTagType.WISH]>().branded.toEqualTypeOf<NonFunctionMutable<WishTag>>();
});
});
describe("SerializedPositionalTag", () => {
it("should accept a union of all serialized tag forms", () => {
expectTypeOf<SerializedPositionalTag>().branded.toEqualTypeOf<
NonFunctionMutable<DelayedAttackTag> | NonFunctionMutable<WishTag>
>();
});
it("should accept a union of all unserialized tag forms", () => {
expectTypeOf<WishTag>().toExtend<SerializedPositionalTag>();
expectTypeOf<DelayedAttackTag>().toExtend<SerializedPositionalTag>();
});
});

View File

@ -0,0 +1,47 @@
import { splitWords } from "#utils/strings";
import { describe, expect, it } from "vitest";
interface testCase {
input: string;
words: string[];
}
const testCases: testCase[] = [
{
input: "Lorem ipsum dolor sit amet",
words: ["Lorem", "ipsum", "dolor", "sit", "amet"],
},
{
input: "consectetur-adipiscing-elit",
words: ["consectetur", "adipiscing", "elit"],
},
{
input: "sed_do_eiusmod_tempor_incididunt_ut_labore",
words: ["sed", "do", "eiusmod", "tempor", "incididunt", "ut", "labore"],
},
{
input: "Et Dolore Magna Aliqua",
words: ["Et", "Dolore", "Magna", "Aliqua"],
},
{
input: "BIG_ANGRY_TRAINER",
words: ["BIG", "ANGRY", "TRAINER"],
},
{
input: "ApplesBananasOrangesAndAPear",
words: ["Apples", "Bananas", "Oranges", "And", "A", "Pear"],
},
{
input: "mysteryEncounters/anOfferYouCantRefuse",
words: ["mystery", "Encounters/an", "Offer", "You", "Cant", "Refuse"],
},
];
describe("Utils - Casing -", () => {
describe("splitWords", () => {
it.each(testCases)("should split a string into its constituent words - $input", ({ input, words }) => {
const ret = splitWords(input);
expect(ret).toEqual(words);
});
});
});

View File

@ -1,11 +1,11 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"target": "ES2023",
"module": "ES2022",
// Modifying this option requires all values to be set manually because the defaults get overridden
// Values other than "ES2024.Promise" taken from https://github.com/microsoft/TypeScript/blob/main/src/lib/es2020.full.d.ts
// Values other than "ES2024.Promise" taken from https://github.com/microsoft/TypeScript/blob/main/src/lib/es2023.full.d.ts
"lib": [
"ES2020",
"ES2023",
"ES2024.Promise",
"DOM",
"DOM.AsyncIterable",
@ -49,7 +49,7 @@
"./system/*.ts"
],
"#trainers/*": ["./data/trainers/*.ts"],
"#types/*": ["./@types/*.ts", "./typings/phaser/*.ts"],
"#types/*": ["./@types/helpers/*.ts", "./@types/*.ts", "./typings/phaser/*.ts"],
"#ui/*": ["./ui/battle-info/*.ts", "./ui/settings/*.ts", "./ui/*.ts"],
"#utils/*": ["./utils/*.ts"],
"#data/*": ["./data/pokemon-forms/*.ts", "./data/pokemon/*.ts", "./data/*.ts"],

View File

@ -9,6 +9,10 @@
{
"tagName": "@linkcode",
"syntaxKind": "inline"
},
{
"tagName": "@module",
"syntaxKind": "modifier"
}
]
}