diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d56b868cff..0217ebd28a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,7 +81,7 @@ For example, here is how you could test a scenario where the player Pokemon has ```typescript const overrides = { ABILITY_OVERRIDE: AbilityId.DROUGHT, - OPP_MOVESET_OVERRIDE: MoveId.WATER_GUN, + ENEMY_MOVESET_OVERRIDE: MoveId.WATER_GUN, } satisfies Partial>; ``` diff --git a/docs/localization.md b/docs/localization.md index 0fe950a361d..c325aaf55a9 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -90,9 +90,13 @@ If this feature requires new text, the text should be integrated into the code w - For any feature pulled from the mainline Pokémon games (e.g. a Move or Ability implementation), it's best practice to include a source link for any added text. [Poké Corpus](https://abcboy101.github.io/poke-corpus/) is a great resource for finding text from the mainline games; otherwise, a video/picture showing the text being displayed should suffice. - You should also [notify the current Head of Translation](#notifying-translation) to ensure a fast response. -3. At this point, you may begin [testing locales integration in your main PR](#documenting-locales-changes). -4. The Translation Team will approve the locale PR (after corrections, if necessary), then merge it into `pokerogue-locales`. -5. The Dev Team will approve your main PR for your feature, then merge it into PokéRogue's beta environment. +3. Your locales should use the following format: + - File names should be in `kebab-case`. Example: `trainer-names.json` + - Key names should be in `camelCase`. Example: `aceTrainer` + - If you make use of i18next's inbuilt [context support](https://www.i18next.com/translation-function/context), you need to use `snake_case` for the context key. Example: `aceTrainer_male` +4. At this point, you may begin [testing locales integration in your main PR](#documenting-locales-changes). +5. The Translation Team will approve the locale PR (after corrections, if necessary), then merge it into `pokerogue-locales`. +6. The Dev Team will approve your main PR for your feature, then merge it into PokéRogue's beta environment. [^2]: For those wondering, the reason for choosing English specifically is due to it being the master language set in Pontoon (the program used by the Translation Team to perform locale updates). If a key is present in any language _except_ the master language, it won't appear anywhere else in the translation tool, rendering missing English keys quite a hassle. diff --git a/public/audio/bgm/desert.mp3 b/public/audio/bgm/desert.mp3 index febbacc0100..10938f814fe 100644 Binary files a/public/audio/bgm/desert.mp3 and b/public/audio/bgm/desert.mp3 differ diff --git a/public/audio/bgm/fairy_cave.mp3 b/public/audio/bgm/fairy_cave.mp3 index 4e1c9ea0eb4..32cc3dbaa41 100644 Binary files a/public/audio/bgm/fairy_cave.mp3 and b/public/audio/bgm/fairy_cave.mp3 differ diff --git a/src/@types/move-types.ts b/src/@types/move-types.ts index ff44c665e48..1def61f1329 100644 --- a/src/@types/move-types.ts +++ b/src/@types/move-types.ts @@ -1,13 +1,24 @@ +import type { Pokemon } from "#field/pokemon"; import type { AttackMove, ChargingAttackMove, ChargingSelfStatusMove, + Move, MoveAttr, MoveAttrConstructorMap, SelfStatusMove, StatusMove, } from "#moves/move"; +/** + * A generic function producing a message during a Move's execution. + * @param user - The {@linkcode Pokemon} using the move + * @param target - The {@linkcode Pokemon} targeted by the move + * @param move - The {@linkcode Move} being used + * @returns a string + */ +export type MoveMessageFunc = (user: Pokemon, target: Pokemon, move: Move) => string; + export type MoveAttrFilter = (attr: MoveAttr) => boolean; export type * from "#moves/move"; diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 271cde1aaa9..4a136a1696a 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -104,6 +104,7 @@ import { getLuckString, getLuckTextTint, getPartyLuckValue, + type ModifierType, PokemonHeldItemModifierType, } from "#modifiers/modifier-type"; import { MysteryEncounter } from "#mystery-encounters/mystery-encounter"; @@ -943,17 +944,17 @@ export class BattleScene extends SceneBase { dataSource?: PokemonData, postProcess?: (enemyPokemon: EnemyPokemon) => void, ): EnemyPokemon { - if (Overrides.OPP_LEVEL_OVERRIDE > 0) { - level = Overrides.OPP_LEVEL_OVERRIDE; + if (Overrides.ENEMY_LEVEL_OVERRIDE > 0) { + level = Overrides.ENEMY_LEVEL_OVERRIDE; } - if (Overrides.OPP_SPECIES_OVERRIDE) { - species = getPokemonSpecies(Overrides.OPP_SPECIES_OVERRIDE); + if (Overrides.ENEMY_SPECIES_OVERRIDE) { + species = getPokemonSpecies(Overrides.ENEMY_SPECIES_OVERRIDE); // The fact that a Pokemon is a boss or not can change based on its Species and level boss = this.getEncounterBossSegments(this.currentBattle.waveIndex, level, species) > 1; } const pokemon = new EnemyPokemon(species, level, trainerSlot, boss, shinyLock, dataSource); - if (Overrides.OPP_FUSION_OVERRIDE) { + if (Overrides.ENEMY_FUSION_OVERRIDE) { pokemon.generateFusionSpecies(); } @@ -1203,7 +1204,9 @@ export class BattleScene extends SceneBase { this.updateScoreText(); this.scoreText.setVisible(false); - [this.luckLabelText, this.luckText].map(t => t.setVisible(false)); + [this.luckLabelText, this.luckText].forEach(t => { + t.setVisible(false); + }); this.newArena(Overrides.STARTING_BIOME_OVERRIDE || BiomeId.TOWN); @@ -1237,8 +1240,7 @@ export class BattleScene extends SceneBase { Object.values(mp) .flat() .map(mt => mt.modifierType) - .filter(mt => "localize" in mt) - .map(lpb => lpb as unknown as Localizable), + .filter((mt): mt is ModifierType & Localizable => "localize" in mt && typeof mt.localize === "function"), ), ]; for (const item of localizable) { @@ -1513,8 +1515,8 @@ export class BattleScene extends SceneBase { return this.currentBattle; } - newArena(biome: BiomeId, playerFaints?: number): Arena { - this.arena = new Arena(biome, BiomeId[biome].toLowerCase(), playerFaints); + newArena(biome: BiomeId, playerFaints = 0): Arena { + this.arena = new Arena(biome, playerFaints); this.eventTarget.dispatchEvent(new NewArenaEvent()); this.arenaBg.pipelineData = { @@ -1764,10 +1766,10 @@ export class BattleScene extends SceneBase { } getEncounterBossSegments(waveIndex: number, level: number, species?: PokemonSpecies, forceBoss = false): number { - if (Overrides.OPP_HEALTH_SEGMENTS_OVERRIDE > 1) { - return Overrides.OPP_HEALTH_SEGMENTS_OVERRIDE; + if (Overrides.ENEMY_HEALTH_SEGMENTS_OVERRIDE > 1) { + return Overrides.ENEMY_HEALTH_SEGMENTS_OVERRIDE; } - if (Overrides.OPP_HEALTH_SEGMENTS_OVERRIDE === 1) { + if (Overrides.ENEMY_HEALTH_SEGMENTS_OVERRIDE === 1) { // The rest of the code expects to be returned 0 and not 1 if the enemy is not a boss return 0; } @@ -2711,7 +2713,9 @@ export class BattleScene extends SceneBase { } } - this.party.map(p => p.updateInfo(instant)); + this.party.forEach(p => { + p.updateInfo(instant); + }); } else { const args = [this]; if (modifier.shouldApply(...args)) { diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index f5fd9b19f72..03670835dbd 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -74,6 +74,7 @@ import { randSeedItem, toDmgValue, } from "#utils/common"; +import { toCamelCase } from "#utils/strings"; import i18next from "i18next"; export class Ability implements Localizable { @@ -109,13 +110,9 @@ export class Ability implements Localizable { } localize(): void { - const i18nKey = AbilityId[this.id] - .split("_") - .filter(f => f) - .map((f, i) => (i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase())) - .join("") as string; + const i18nKey = toCamelCase(AbilityId[this.id]); - this.name = this.id ? `${i18next.t(`ability:${i18nKey}.name`) as string}${this.nameAppend}` : ""; + this.name = this.id ? `${i18next.t(`ability:${i18nKey}.name`)}${this.nameAppend}` : ""; this.description = this.id ? (i18next.t(`ability:${i18nKey}.description`) as string) : ""; } @@ -1670,6 +1667,7 @@ export class MoveTypeChangeAbAttr extends PreAttackAbAttr { constructor( private newType: PokemonType, private powerMultiplier: number, + // TODO: all moves with this attr solely check the move being used... private condition?: PokemonAttackCondition, ) { super(false); diff --git a/src/data/balance/pokemon-evolutions.ts b/src/data/balance/pokemon-evolutions.ts index ab535682e86..5d3537f4255 100644 --- a/src/data/balance/pokemon-evolutions.ts +++ b/src/data/balance/pokemon-evolutions.ts @@ -1866,17 +1866,16 @@ interface PokemonPrevolutions { export const pokemonPrevolutions: PokemonPrevolutions = {}; export function initPokemonPrevolutions(): void { - const megaFormKeys = [ SpeciesFormKey.MEGA, "", SpeciesFormKey.MEGA_X, "", SpeciesFormKey.MEGA_Y ].map(sfk => sfk as string); - const prevolutionKeys = Object.keys(pokemonEvolutions); - prevolutionKeys.forEach(pk => { - const evolutions = pokemonEvolutions[pk]; + // TODO: Why do we have empty strings in our array? + const megaFormKeys = [ SpeciesFormKey.MEGA, "", SpeciesFormKey.MEGA_X, "", SpeciesFormKey.MEGA_Y ]; + for (const [pk, evolutions] of Object.entries(pokemonEvolutions)) { for (const ev of evolutions) { if (ev.evoFormKey && megaFormKeys.indexOf(ev.evoFormKey) > -1) { continue; } pokemonPrevolutions[ev.speciesId] = Number.parseInt(pk) as SpeciesId; } - }); + } } diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 067bd05c2ae..5c4061ec388 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -86,11 +86,11 @@ import { PokemonHealPhase } from "#phases/pokemon-heal-phase"; import { SwitchSummonPhase } from "#phases/switch-summon-phase"; 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 { ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindString, MoveMessageFunc } from "#types/move-types"; import type { TurnMove } from "#types/turn-move"; import { BooleanHolder, type Constructor, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue } from "#utils/common"; import { getEnumValues } from "#utils/enums"; -import { toTitleCase } from "#utils/strings"; +import { toCamelCase, toTitleCase } from "#utils/strings"; import i18next from "i18next"; import { applyChallenges } from "#utils/challenge-utils"; @@ -162,10 +162,16 @@ export abstract class Move implements Localizable { } localize(): void { - const i18nKey = MoveId[this.id].split("_").filter(f => f).map((f, i) => i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase()).join("") as unknown as string; + const i18nKey = toCamelCase(MoveId[this.id]) - this.name = this.id ? `${i18next.t(`move:${i18nKey}.name`)}${this.nameAppend}` : ""; - this.effect = this.id ? `${i18next.t(`move:${i18nKey}.effect`)}${this.nameAppend}` : ""; + if (this.id === MoveId.NONE) { + this.name = ""; + this.effect = "" + return; + } + + this.name = `${i18next.t(`move:${i18nKey}.name`)}${this.nameAppend}`; + this.effect = `${i18next.t(`move:${i18nKey}.effect`)}${this.nameAppend}`; } /** @@ -1357,20 +1363,20 @@ export class MoveHeaderAttr extends MoveAttr { /** * Header attribute to queue a message at the beginning of a turn. - * @see {@link MoveHeaderAttr} */ export class MessageHeaderAttr extends MoveHeaderAttr { - private message: string | ((user: Pokemon, move: Move) => string); + /** The message to display, or a function producing one. */ + private message: string | MoveMessageFunc; - constructor(message: string | ((user: Pokemon, move: Move) => string)) { + constructor(message: string | MoveMessageFunc) { super(); this.message = message; } - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + apply(user: Pokemon, target: Pokemon, move: Move): boolean { const message = typeof this.message === "string" ? this.message - : this.message(user, move); + : this.message(user, target, move); if (message) { globalScene.phaseManager.queueMessage(message); @@ -1418,21 +1424,21 @@ export class BeakBlastHeaderAttr extends AddBattlerTagHeaderAttr { */ export class PreMoveMessageAttr extends MoveAttr { /** The message to display or a function returning one */ - private message: string | ((user: Pokemon, target: Pokemon, move: Move) => string | undefined); + private message: string | MoveMessageFunc; /** * Create a new {@linkcode PreMoveMessageAttr} to display a message before move execution. - * @param message - The message to display before move use, either as a string or a function producing one. + * @param message - The message to display before move use, either` a literal string or a function producing one. * @remarks - * If {@linkcode message} evaluates to an empty string (`''`), no message will be displayed + * If {@linkcode message} evaluates to an empty string (`""`), no message will be displayed * (though the move will still succeed). */ - constructor(message: string | ((user: Pokemon, target: Pokemon, move: Move) => string)) { + constructor(message: string | MoveMessageFunc) { super(); this.message = message; } - apply(user: Pokemon, target: Pokemon, move: Move, _args: any[]): boolean { + apply(user: Pokemon, target: Pokemon, move: Move): boolean { const message = typeof this.message === "function" ? this.message(user, target, move) : this.message; @@ -1453,18 +1459,17 @@ export class PreMoveMessageAttr extends MoveAttr { * @extends MoveAttr */ export class PreUseInterruptAttr extends MoveAttr { - protected message?: string | ((user: Pokemon, target: Pokemon, move: Move) => string); - protected overridesFailedMessage: boolean; + protected message: string | MoveMessageFunc; protected conditionFunc: MoveConditionFunc; /** * Create a new MoveInterruptedMessageAttr. * @param message The message to display when the move is interrupted, or a function that formats the message based on the user, target, and move. */ - constructor(message?: string | ((user: Pokemon, target: Pokemon, move: Move) => string), conditionFunc?: MoveConditionFunc) { + constructor(message: string | MoveMessageFunc, conditionFunc: MoveConditionFunc) { super(); this.message = message; - this.conditionFunc = conditionFunc ?? (() => true); + this.conditionFunc = conditionFunc; } /** @@ -1485,11 +1490,9 @@ export class PreUseInterruptAttr extends MoveAttr { */ override getFailedText(user: Pokemon, target: Pokemon, move: Move): string | undefined { if (this.message && this.conditionFunc(user, target, move)) { - const message = - typeof this.message === "string" - ? (this.message as string) + return typeof this.message === "string" + ? this.message : this.message(user, target, move); - return message; } } } @@ -1694,17 +1697,30 @@ export class SurviveDamageAttr extends ModifiedDamageAttr { } } -export class SplashAttr extends MoveEffectAttr { - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:splash")); - return true; - } -} +/** + * Move attribute to display arbitrary text during a move's execution. + */ +export class MessageAttr extends MoveEffectAttr { + /** The message to display, either as a string or a function returning one. */ + private message: string | MoveMessageFunc; -export class CelebrateAttr extends MoveEffectAttr { - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:celebrate", { playerName: loggedInUser?.username })); - return true; + constructor(message: string | MoveMessageFunc, options?: MoveEffectAttrOptions) { + // TODO: Do we need to respect `selfTarget` if we're just displaying text? + super(false, options) + this.message = message; + } + + override apply(user: Pokemon, target: Pokemon, move: Move): boolean { + const message = typeof this.message === "function" + ? this.message(user, target, move) + : this.message; + + // TODO: Consider changing if/when MoveAttr `apply` return values become significant + if (message) { + globalScene.phaseManager.queueMessage(message, 500); + return true; + } + return false; } } @@ -5916,8 +5932,8 @@ export class ProtectAttr extends AddBattlerTagAttr { for (const turnMove of user.getLastXMoves(-1).slice()) { if ( // Quick & Wide guard increment the Protect counter without using it for fail chance - !(allMoves[turnMove.move].hasAttr("ProtectAttr") || - [MoveId.QUICK_GUARD, MoveId.WIDE_GUARD].includes(turnMove.move)) || + !(allMoves[turnMove.move].hasAttr("ProtectAttr") || + [MoveId.QUICK_GUARD, MoveId.WIDE_GUARD].includes(turnMove.move)) || turnMove.result !== MoveResult.SUCCESS ) { break; @@ -5931,38 +5947,6 @@ export class ProtectAttr extends AddBattlerTagAttr { } } -export class IgnoreAccuracyAttr extends AddBattlerTagAttr { - constructor() { - super(BattlerTagType.IGNORE_ACCURACY, true, false, 2); - } - - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - if (!super.apply(user, target, move, args)) { - return false; - } - - globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:tookAimAtTarget", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target) })); - - return true; - } -} - -export class FaintCountdownAttr extends AddBattlerTagAttr { - constructor() { - super(BattlerTagType.PERISH_SONG, false, true, 4); - } - - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - if (!super.apply(user, target, move, args)) { - return false; - } - - globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:faintCountdown", { pokemonName: getPokemonNameWithAffix(target), turnCount: this.turnCountMin - 1 })); - - return true; - } -} - /** * Attribute to remove all Substitutes from the field. * @extends MoveEffectAttr @@ -6603,8 +6587,10 @@ export class ChillyReceptionAttr extends ForceSwitchOutAttr { return (user, target, move) => globalScene.arena.weather?.weatherType !== WeatherType.SNOW || super.getSwitchOutCondition()(user, target, move); } } + export class RemoveTypeAttr extends MoveEffectAttr { + // TODO: Remove the message callback private removedType: PokemonType; private messageCallback: ((user: Pokemon) => void) | undefined; @@ -8299,8 +8285,6 @@ const MoveAttrs = Object.freeze({ RandomLevelDamageAttr, ModifiedDamageAttr, SurviveDamageAttr, - SplashAttr, - CelebrateAttr, RecoilAttr, SacrificialAttr, SacrificialAttrOnHit, @@ -8443,8 +8427,7 @@ const MoveAttrs = Object.freeze({ RechargeAttr, TrapAttr, ProtectAttr, - IgnoreAccuracyAttr, - FaintCountdownAttr, + MessageAttr, RemoveAllSubstitutesAttr, HitsTagAttr, HitsTagForDoubleDamageAttr, @@ -8938,7 +8921,7 @@ export function initMoves() { new AttackMove(MoveId.PSYWAVE, PokemonType.PSYCHIC, MoveCategory.SPECIAL, -1, 100, 15, -1, 0, 1) .attr(RandomLevelDamageAttr), new SelfStatusMove(MoveId.SPLASH, PokemonType.NORMAL, -1, 40, -1, 0, 1) - .attr(SplashAttr) + .attr(MessageAttr, i18next.t("moveTriggers:splash")) .condition(failOnGravityCondition), new SelfStatusMove(MoveId.ACID_ARMOR, PokemonType.POISON, -1, 20, -1, 0, 1) .attr(StatStageChangeAttr, [ Stat.DEF ], 2, true), @@ -9000,7 +8983,10 @@ export function initMoves() { .attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, true, 1) .reflectable(), new StatusMove(MoveId.MIND_READER, PokemonType.NORMAL, -1, 5, -1, 0, 2) - .attr(IgnoreAccuracyAttr), + .attr(AddBattlerTagAttr, BattlerTagType.IGNORE_ACCURACY, true, false, 2) + .attr(MessageAttr, (user, target) => + i18next.t("moveTriggers:tookAimAtTarget", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target) }) + ), new StatusMove(MoveId.NIGHTMARE, PokemonType.GHOST, 100, 15, -1, 0, 2) .attr(AddBattlerTagAttr, BattlerTagType.NIGHTMARE) .condition(targetSleptOrComatoseCondition), @@ -9088,7 +9074,9 @@ export function initMoves() { return lastTurnMove.length === 0 || lastTurnMove[0].move !== move.id || lastTurnMove[0].result !== MoveResult.SUCCESS; }), new StatusMove(MoveId.PERISH_SONG, PokemonType.NORMAL, -1, 5, -1, 0, 2) - .attr(FaintCountdownAttr) + .attr(AddBattlerTagAttr, BattlerTagType.PERISH_SONG, false, true, 4) + .attr(MessageAttr, (_user, target) => + i18next.t("moveTriggers:faintCountdown", { pokemonName: getPokemonNameWithAffix(target), turnCount: 3 })) .ignoresProtect() .soundBased() .condition(failOnBossCondition) @@ -9104,7 +9092,10 @@ export function initMoves() { .attr(MultiHitAttr) .makesContact(false), new StatusMove(MoveId.LOCK_ON, PokemonType.NORMAL, -1, 5, -1, 0, 2) - .attr(IgnoreAccuracyAttr), + .attr(AddBattlerTagAttr, BattlerTagType.IGNORE_ACCURACY, true, false, 2) + .attr(MessageAttr, (user, target) => + i18next.t("moveTriggers:tookAimAtTarget", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target) }) + ), new AttackMove(MoveId.OUTRAGE, PokemonType.DRAGON, MoveCategory.PHYSICAL, 120, 100, 10, -1, 0, 2) .attr(FrenzyAttr) .attr(MissEffectAttr, frenzyMissFunc) @@ -9331,8 +9322,8 @@ export function initMoves() { && (user.status.effect === StatusEffect.BURN || user.status.effect === StatusEffect.POISON || user.status.effect === StatusEffect.TOXIC || user.status.effect === StatusEffect.PARALYSIS) ? 2 : 1) .attr(BypassBurnDamageReductionAttr), new AttackMove(MoveId.FOCUS_PUNCH, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 150, 100, 20, -1, -3, 3) - .attr(MessageHeaderAttr, (user, move) => i18next.t("moveTriggers:isTighteningFocus", { pokemonName: getPokemonNameWithAffix(user) })) - .attr(PreUseInterruptAttr, (user, target, move) => i18next.t("moveTriggers:lostFocus", { pokemonName: getPokemonNameWithAffix(user) }), user => !!user.turnData.attacksReceived.find(r => r.damage)) + .attr(MessageHeaderAttr, (user) => i18next.t("moveTriggers:isTighteningFocus", { pokemonName: getPokemonNameWithAffix(user) })) + .attr(PreUseInterruptAttr, (user) => i18next.t("moveTriggers:lostFocus", { pokemonName: getPokemonNameWithAffix(user) }), user => user.turnData.attacksReceived.some(r => r.damage > 0)) .punchingMove(), new AttackMove(MoveId.SMELLING_SALTS, PokemonType.NORMAL, MoveCategory.PHYSICAL, 70, 100, 10, -1, 0, 3) .attr(MovePowerMultiplierAttr, (user, target, move) => target.status?.effect === StatusEffect.PARALYSIS ? 2 : 1) @@ -10433,7 +10424,8 @@ export function initMoves() { new AttackMove(MoveId.DAZZLING_GLEAM, PokemonType.FAIRY, MoveCategory.SPECIAL, 80, 100, 10, -1, 0, 6) .target(MoveTarget.ALL_NEAR_ENEMIES), new SelfStatusMove(MoveId.CELEBRATE, PokemonType.NORMAL, -1, 40, -1, 0, 6) - .attr(CelebrateAttr), + // NB: This needs a lambda function as the user will not be logged in by the time the moves are initialized + .attr(MessageAttr, () => i18next.t("moveTriggers:celebrate", { playerName: loggedInUser?.username })), new StatusMove(MoveId.HOLD_HANDS, PokemonType.NORMAL, -1, 40, -1, 0, 6) .ignoresSubstitute() .target(MoveTarget.NEAR_ALLY), @@ -10608,7 +10600,12 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.SPD ], -1) .reflectable(), new SelfStatusMove(MoveId.LASER_FOCUS, PokemonType.NORMAL, -1, 30, -1, 0, 7) - .attr(AddBattlerTagAttr, BattlerTagType.ALWAYS_CRIT, true, false), + .attr(AddBattlerTagAttr, BattlerTagType.ALWAYS_CRIT, true, false) + .attr(MessageAttr, (user) => + i18next.t("battlerTags:laserFocusOnAdd", { + pokemonNameWithAffix: getPokemonNameWithAffix(user), + }), + ), new StatusMove(MoveId.GEAR_UP, PokemonType.STEEL, -1, 20, -1, 0, 7) .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], 1, false, { condition: (user, target, move) => !![ AbilityId.PLUS, AbilityId.MINUS ].find(a => target.hasAbility(a, false)) }) .ignoresSubstitute() diff --git a/src/data/pokemon-forms/form-change-triggers.ts b/src/data/pokemon-forms/form-change-triggers.ts index 75734bf085b..c24466eb5ec 100644 --- a/src/data/pokemon-forms/form-change-triggers.ts +++ b/src/data/pokemon-forms/form-change-triggers.ts @@ -12,6 +12,7 @@ import { WeatherType } from "#enums/weather-type"; import type { Pokemon } from "#field/pokemon"; import type { PokemonFormChangeItemModifier } from "#modifiers/modifier"; import { type Constructor, coerceArray } from "#utils/common"; +import { toCamelCase } from "#utils/strings"; import i18next from "i18next"; export abstract class SpeciesFormChangeTrigger { @@ -143,11 +144,7 @@ export class SpeciesFormChangeMoveLearnedTrigger extends SpeciesFormChangeTrigge super(); this.move = move; this.known = known; - const moveKey = MoveId[this.move] - .split("_") - .filter(f => f) - .map((f, i) => (i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase())) - .join("") as unknown as string; + const moveKey = toCamelCase(MoveId[this.move]); this.description = known ? i18next.t("pokemonEvolutions:Forms.moveLearned", { move: i18next.t(`move:${moveKey}.name`), diff --git a/src/data/trainers/trainer-config.ts b/src/data/trainers/trainer-config.ts index d29b40e0972..67618df1ddd 100644 --- a/src/data/trainers/trainer-config.ts +++ b/src/data/trainers/trainer-config.ts @@ -1865,27 +1865,43 @@ export const trainerConfigs: TrainerConfigs = { .setPartyMemberFunc( 0, getRandomPartyMemberFunc([ + SpeciesId.METAPOD, + SpeciesId.LEDYBA, + SpeciesId.CLEFFA, + SpeciesId.WOOPER, + SpeciesId.TEDDIURSA, + SpeciesId.REMORAID, + SpeciesId.HOUNDOUR, + SpeciesId.SILCOON, SpeciesId.PLUSLE, SpeciesId.VOLBEAT, - SpeciesId.PACHIRISU, - SpeciesId.SILCOON, - SpeciesId.METAPOD, - SpeciesId.IGGLYBUFF, + SpeciesId.SPINDA, + SpeciesId.BONSLY, SpeciesId.PETILIL, - SpeciesId.EEVEE, + SpeciesId.SPRITZEE, + SpeciesId.MILCERY, + SpeciesId.PICHU, ]), ) .setPartyMemberFunc( 1, getRandomPartyMemberFunc( [ + SpeciesId.KAKUNA, + SpeciesId.SPINARAK, + SpeciesId.IGGLYBUFF, + SpeciesId.PALDEA_WOOPER, + SpeciesId.PHANPY, + SpeciesId.MANTYKE, + SpeciesId.ELECTRIKE, + SpeciesId.CASCOON, SpeciesId.MINUN, SpeciesId.ILLUMISE, - SpeciesId.EMOLGA, - SpeciesId.CASCOON, - SpeciesId.KAKUNA, - SpeciesId.CLEFFA, + SpeciesId.SPINDA, + SpeciesId.MIME_JR, SpeciesId.COTTONEE, + SpeciesId.SWIRLIX, + SpeciesId.FIDOUGH, SpeciesId.EEVEE, ], TrainerSlot.TRAINER_PARTNER, diff --git a/src/enums/ui-mode.ts b/src/enums/ui-mode.ts index bc93e747be2..75c07a5f63c 100644 --- a/src/enums/ui-mode.ts +++ b/src/enums/ui-mode.ts @@ -38,6 +38,7 @@ export enum UiMode { UNAVAILABLE, CHALLENGE_SELECT, RENAME_POKEMON, + RENAME_RUN, RUN_HISTORY, RUN_INFO, TEST_DIALOGUE, diff --git a/src/field/arena.ts b/src/field/arena.ts index 06ba6fdd334..2ce347b5337 100644 --- a/src/field/arena.ts +++ b/src/field/arena.ts @@ -54,7 +54,7 @@ export class Arena { public bgm: string; public ignoreAbilities: boolean; public ignoringEffectSource: BattlerIndex | null; - public playerTerasUsed: number; + public playerTerasUsed = 0; /** * Saves the number of times a party pokemon faints during a arena encounter. * {@linkcode globalScene.currentBattle.enemyFaints} is the corresponding faint counter for the enemy (this resets every wave). @@ -68,12 +68,11 @@ export class Arena { public readonly eventTarget: EventTarget = new EventTarget(); - constructor(biome: BiomeId, bgm: string, playerFaints = 0) { + constructor(biome: BiomeId, playerFaints = 0) { this.biomeType = biome; - this.bgm = bgm; + this.bgm = BiomeId[biome].toLowerCase(); this.trainerPool = biomeTrainerPools[biome]; this.updatePoolsForTimeOfDay(); - this.playerTerasUsed = 0; this.playerFaints = playerFaints; } @@ -895,7 +894,7 @@ export class Arena { case BiomeId.CAVE: return 14.24; case BiomeId.DESERT: - return 1.143; + return 9.02; case BiomeId.ICE_CAVE: return 0.0; case BiomeId.MEADOW: @@ -923,7 +922,7 @@ export class Arena { case BiomeId.JUNGLE: return 0.0; case BiomeId.FAIRY_CAVE: - return 4.542; + return 0.0; case BiomeId.TEMPLE: return 2.547; case BiomeId.ISLAND: diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 29f775ad094..7f13bf86e7d 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -1825,7 +1825,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { // Overrides moveset based on arrays specified in overrides.ts let overrideArray: MoveId | Array = this.isPlayer() ? Overrides.MOVESET_OVERRIDE - : Overrides.OPP_MOVESET_OVERRIDE; + : Overrides.ENEMY_MOVESET_OVERRIDE; overrideArray = coerceArray(overrideArray); if (overrideArray.length > 0) { if (!this.isPlayer()) { @@ -2030,8 +2030,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { if (Overrides.ABILITY_OVERRIDE && this.isPlayer()) { return allAbilities[Overrides.ABILITY_OVERRIDE]; } - if (Overrides.OPP_ABILITY_OVERRIDE && this.isEnemy()) { - return allAbilities[Overrides.OPP_ABILITY_OVERRIDE]; + if (Overrides.ENEMY_ABILITY_OVERRIDE && this.isEnemy()) { + return allAbilities[Overrides.ENEMY_ABILITY_OVERRIDE]; } if (this.isFusion()) { if (!isNullOrUndefined(this.fusionCustomPokemonData?.ability) && this.fusionCustomPokemonData.ability !== -1) { @@ -2060,8 +2060,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { if (Overrides.PASSIVE_ABILITY_OVERRIDE && this.isPlayer()) { return allAbilities[Overrides.PASSIVE_ABILITY_OVERRIDE]; } - if (Overrides.OPP_PASSIVE_ABILITY_OVERRIDE && this.isEnemy()) { - return allAbilities[Overrides.OPP_PASSIVE_ABILITY_OVERRIDE]; + if (Overrides.ENEMY_PASSIVE_ABILITY_OVERRIDE && this.isEnemy()) { + return allAbilities[Overrides.ENEMY_PASSIVE_ABILITY_OVERRIDE]; } if (!isNullOrUndefined(this.customPokemonData.passive) && this.customPokemonData.passive !== -1) { return allAbilities[this.customPokemonData.passive]; @@ -2128,14 +2128,14 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { // returns override if valid for current case if ( (Overrides.HAS_PASSIVE_ABILITY_OVERRIDE === false && this.isPlayer()) || - (Overrides.OPP_HAS_PASSIVE_ABILITY_OVERRIDE === false && this.isEnemy()) + (Overrides.ENEMY_HAS_PASSIVE_ABILITY_OVERRIDE === false && this.isEnemy()) ) { return false; } if ( ((Overrides.PASSIVE_ABILITY_OVERRIDE !== AbilityId.NONE || Overrides.HAS_PASSIVE_ABILITY_OVERRIDE) && this.isPlayer()) || - ((Overrides.OPP_PASSIVE_ABILITY_OVERRIDE !== AbilityId.NONE || Overrides.OPP_HAS_PASSIVE_ABILITY_OVERRIDE) && + ((Overrides.ENEMY_PASSIVE_ABILITY_OVERRIDE !== AbilityId.NONE || Overrides.ENEMY_HAS_PASSIVE_ABILITY_OVERRIDE) && this.isEnemy()) ) { return true; @@ -3001,8 +3001,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { if (forStarter && this.isPlayer() && Overrides.STARTER_FUSION_SPECIES_OVERRIDE) { fusionOverride = getPokemonSpecies(Overrides.STARTER_FUSION_SPECIES_OVERRIDE); - } else if (this.isEnemy() && Overrides.OPP_FUSION_SPECIES_OVERRIDE) { - fusionOverride = getPokemonSpecies(Overrides.OPP_FUSION_SPECIES_OVERRIDE); + } else if (this.isEnemy() && Overrides.ENEMY_FUSION_SPECIES_OVERRIDE) { + fusionOverride = getPokemonSpecies(Overrides.ENEMY_FUSION_SPECIES_OVERRIDE); } this.fusionSpecies = @@ -6241,22 +6241,22 @@ export class EnemyPokemon extends Pokemon { this.setBoss(boss, dataSource?.bossSegments); } - if (Overrides.OPP_STATUS_OVERRIDE) { - this.status = new Status(Overrides.OPP_STATUS_OVERRIDE, 0, 4); + if (Overrides.ENEMY_STATUS_OVERRIDE) { + this.status = new Status(Overrides.ENEMY_STATUS_OVERRIDE, 0, 4); } - if (Overrides.OPP_GENDER_OVERRIDE !== null) { - this.gender = Overrides.OPP_GENDER_OVERRIDE; + if (Overrides.ENEMY_GENDER_OVERRIDE !== null) { + this.gender = Overrides.ENEMY_GENDER_OVERRIDE; } const speciesId = this.species.speciesId; if ( - speciesId in Overrides.OPP_FORM_OVERRIDES && - !isNullOrUndefined(Overrides.OPP_FORM_OVERRIDES[speciesId]) && - this.species.forms[Overrides.OPP_FORM_OVERRIDES[speciesId]] + speciesId in Overrides.ENEMY_FORM_OVERRIDES && + !isNullOrUndefined(Overrides.ENEMY_FORM_OVERRIDES[speciesId]) && + this.species.forms[Overrides.ENEMY_FORM_OVERRIDES[speciesId]] ) { - this.formIndex = Overrides.OPP_FORM_OVERRIDES[speciesId]; + this.formIndex = Overrides.ENEMY_FORM_OVERRIDES[speciesId]; } else if (globalScene.gameMode.isDaily && globalScene.gameMode.isWaveFinal(globalScene.currentBattle.waveIndex)) { const eventBoss = getDailyEventSeedBoss(globalScene.seed); if (!isNullOrUndefined(eventBoss)) { @@ -6266,21 +6266,21 @@ export class EnemyPokemon extends Pokemon { if (!dataSource) { this.generateAndPopulateMoveset(); - if (shinyLock || Overrides.OPP_SHINY_OVERRIDE === false) { + if (shinyLock || Overrides.ENEMY_SHINY_OVERRIDE === false) { this.shiny = false; } else { this.trySetShiny(); } - if (!this.shiny && Overrides.OPP_SHINY_OVERRIDE) { + if (!this.shiny && Overrides.ENEMY_SHINY_OVERRIDE) { this.shiny = true; this.initShinySparkle(); } if (this.shiny) { this.variant = this.generateShinyVariant(); - if (Overrides.OPP_VARIANT_OVERRIDE !== null) { - this.variant = Overrides.OPP_VARIANT_OVERRIDE; + if (Overrides.ENEMY_VARIANT_OVERRIDE !== null) { + this.variant = Overrides.ENEMY_VARIANT_OVERRIDE; } } diff --git a/src/loading-scene.ts b/src/loading-scene.ts index 4680e96e882..248bd578290 100644 --- a/src/loading-scene.ts +++ b/src/loading-scene.ts @@ -448,7 +448,9 @@ export class LoadingScene extends SceneBase { ); if (!mobile) { - loadingGraphics.map(g => g.setVisible(false)); + loadingGraphics.forEach(g => { + g.setVisible(false); + }); } const intro = this.add.video(0, 0); diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts index 6907b6907ca..fb7243a7901 100644 --- a/src/modifier/modifier.ts +++ b/src/modifier/modifier.ts @@ -121,8 +121,8 @@ export class ModifierBar extends Phaser.GameObjects.Container { } updateModifierOverflowVisibility(ignoreLimit: boolean) { - const modifierIcons = this.getAll().reverse(); - for (const modifier of modifierIcons.map(m => m as Phaser.GameObjects.Container).slice(iconOverflowIndex)) { + const modifierIcons = this.getAll().reverse() as Phaser.GameObjects.Container[]; + for (const modifier of modifierIcons.slice(iconOverflowIndex)) { modifier.setVisible(ignoreLimit); } } @@ -3755,7 +3755,7 @@ export class EnemyFusionChanceModifier extends EnemyPersistentModifier { export function overrideModifiers(isPlayer = true): void { const modifiersOverride: ModifierOverride[] = isPlayer ? Overrides.STARTING_MODIFIER_OVERRIDE - : Overrides.OPP_MODIFIER_OVERRIDE; + : Overrides.ENEMY_MODIFIER_OVERRIDE; if (!modifiersOverride || modifiersOverride.length === 0 || !globalScene) { return; } @@ -3797,7 +3797,7 @@ export function overrideModifiers(isPlayer = true): void { export function overrideHeldItems(pokemon: Pokemon, isPlayer = true): void { const heldItemsOverride: ModifierOverride[] = isPlayer ? Overrides.STARTING_HELD_ITEMS_OVERRIDE - : Overrides.OPP_HELD_ITEMS_OVERRIDE; + : Overrides.ENEMY_HELD_ITEMS_OVERRIDE; if (!heldItemsOverride || heldItemsOverride.length === 0 || !globalScene) { return; } diff --git a/src/overrides.ts b/src/overrides.ts index de0d1d3f30a..48d7428cad9 100644 --- a/src/overrides.ts +++ b/src/overrides.ts @@ -179,25 +179,24 @@ class DefaultOverrides { // -------------------------- // OPPONENT / ENEMY OVERRIDES // -------------------------- - // TODO: rename `OPP_` to `ENEMY_` - readonly OPP_SPECIES_OVERRIDE: SpeciesId | number = 0; + readonly ENEMY_SPECIES_OVERRIDE: SpeciesId | number = 0; /** * This will make all opponents fused Pokemon */ - readonly OPP_FUSION_OVERRIDE: boolean = false; + readonly ENEMY_FUSION_OVERRIDE: boolean = false; /** * This will override the species of the fusion only when the opponent is already a fusion */ - readonly OPP_FUSION_SPECIES_OVERRIDE: SpeciesId | number = 0; - readonly OPP_LEVEL_OVERRIDE: number = 0; - readonly OPP_ABILITY_OVERRIDE: AbilityId = AbilityId.NONE; - readonly OPP_PASSIVE_ABILITY_OVERRIDE: AbilityId = AbilityId.NONE; - readonly OPP_HAS_PASSIVE_ABILITY_OVERRIDE: boolean | null = null; - readonly OPP_STATUS_OVERRIDE: StatusEffect = StatusEffect.NONE; - readonly OPP_GENDER_OVERRIDE: Gender | null = null; - readonly OPP_MOVESET_OVERRIDE: MoveId | Array = []; - readonly OPP_SHINY_OVERRIDE: boolean | null = null; - readonly OPP_VARIANT_OVERRIDE: Variant | null = null; + readonly ENEMY_FUSION_SPECIES_OVERRIDE: SpeciesId | number = 0; + readonly ENEMY_LEVEL_OVERRIDE: number = 0; + readonly ENEMY_ABILITY_OVERRIDE: AbilityId = AbilityId.NONE; + readonly ENEMY_PASSIVE_ABILITY_OVERRIDE: AbilityId = AbilityId.NONE; + readonly ENEMY_HAS_PASSIVE_ABILITY_OVERRIDE: boolean | null = null; + readonly ENEMY_STATUS_OVERRIDE: StatusEffect = StatusEffect.NONE; + readonly ENEMY_GENDER_OVERRIDE: Gender | null = null; + readonly ENEMY_MOVESET_OVERRIDE: MoveId | Array = []; + readonly ENEMY_SHINY_OVERRIDE: boolean | null = null; + readonly ENEMY_VARIANT_OVERRIDE: Variant | null = null; /** * Overrides the IVs of enemy pokemon. Values must never be outside the range `0` to `31`! * - If set to a number between `0` and `31`, set all IVs of all enemy pokemon to that number. @@ -207,7 +206,7 @@ class DefaultOverrides { readonly ENEMY_IVS_OVERRIDE: number | number[] | null = null; /** Override the nature of all enemy pokemon to the specified nature. Disabled if `null`. */ readonly ENEMY_NATURE_OVERRIDE: Nature | null = null; - readonly OPP_FORM_OVERRIDES: Partial> = {}; + readonly ENEMY_FORM_OVERRIDES: Partial> = {}; /** * Override to give the enemy Pokemon a given amount of health segments * @@ -215,7 +214,7 @@ class DefaultOverrides { * 1: the Pokemon will have a single health segment and therefore will not be a boss * 2+: the Pokemon will be a boss with the given number of health segments */ - readonly OPP_HEALTH_SEGMENTS_OVERRIDE: number = 0; + readonly ENEMY_HEALTH_SEGMENTS_OVERRIDE: number = 0; // ------------- // EGG OVERRIDES @@ -277,12 +276,12 @@ class DefaultOverrides { * * Note that any previous modifiers are cleared. */ - readonly OPP_MODIFIER_OVERRIDE: ModifierOverride[] = []; + readonly ENEMY_MODIFIER_OVERRIDE: ModifierOverride[] = []; /** Override array of {@linkcode ModifierOverride}s used to provide held items to first party member when starting a new game. */ readonly STARTING_HELD_ITEMS_OVERRIDE: ModifierOverride[] = []; /** Override array of {@linkcode ModifierOverride}s used to provide held items to enemies on spawn. */ - readonly OPP_HELD_ITEMS_OVERRIDE: ModifierOverride[] = []; + readonly ENEMY_HELD_ITEMS_OVERRIDE: ModifierOverride[] = []; /** * Override array of {@linkcode ModifierOverride}s used to replace the generated item rolls after a wave. diff --git a/src/phases/encounter-phase.ts b/src/phases/encounter-phase.ts index 79da7134e9a..b870f7f6e7a 100644 --- a/src/phases/encounter-phase.ts +++ b/src/phases/encounter-phase.ts @@ -229,7 +229,7 @@ export class EncounterPhase extends BattlePhase { }), ); } else { - const overridedBossSegments = Overrides.OPP_HEALTH_SEGMENTS_OVERRIDE > 1; + const overridedBossSegments = Overrides.ENEMY_HEALTH_SEGMENTS_OVERRIDE > 1; // for double battles, reduce the health segments for boss Pokemon unless there is an override if (!overridedBossSegments && battle.enemyParty.filter(p => p.isBoss()).length > 1) { for (const enemyPokemon of battle.enemyParty) { diff --git a/src/system/game-data.ts b/src/system/game-data.ts index ae559072e35..589e1271e3c 100644 --- a/src/system/game-data.ts +++ b/src/system/game-data.ts @@ -127,6 +127,7 @@ export interface SessionSaveData { battleType: BattleType; trainer: TrainerData; gameVersion: string; + runNameText: string; timestamp: number; challenges: ChallengeData[]; mysteryEncounterType: MysteryEncounterType | -1; // Only defined when current wave is ME, @@ -206,10 +207,12 @@ export interface StarterData { [key: number]: StarterDataEntry; } -export interface TutorialFlags { - [key: string]: boolean; -} +// TODO: Rework into a bitmask +export type TutorialFlags = { + [key in Tutorial]: boolean; +}; +// TODO: Rework into a bitmask export interface SeenDialogues { [key: string]: boolean; } @@ -822,52 +825,51 @@ export class GameData { return true; // TODO: is `true` the correct return value? } - private loadGamepadSettings(): boolean { - Object.values(SettingGamepad) - .map(setting => setting as SettingGamepad) - .forEach(setting => setSettingGamepad(setting, settingGamepadDefaults[setting])); + private loadGamepadSettings(): void { + Object.values(SettingGamepad).forEach(setting => { + setSettingGamepad(setting, settingGamepadDefaults[setting]); + }); if (!localStorage.hasOwnProperty("settingsGamepad")) { - return false; + return; } const settingsGamepad = JSON.parse(localStorage.getItem("settingsGamepad")!); // TODO: is this bang correct? for (const setting of Object.keys(settingsGamepad)) { setSettingGamepad(setting as SettingGamepad, settingsGamepad[setting]); } - - return true; // TODO: is `true` the correct return value? } - public saveTutorialFlag(tutorial: Tutorial, flag: boolean): boolean { - const key = getDataTypeKey(GameDataType.TUTORIALS); - let tutorials: object = {}; - if (localStorage.hasOwnProperty(key)) { - tutorials = JSON.parse(localStorage.getItem(key)!); // TODO: is this bang correct? + /** + * Save the specified tutorial as having the specified completion status. + * @param tutorial - The {@linkcode Tutorial} whose completion status is being saved + * @param status - The completion status to set + */ + public saveTutorialFlag(tutorial: Tutorial, status: boolean): void { + // Grab the prior save data tutorial + const saveDataKey = getDataTypeKey(GameDataType.TUTORIALS); + const tutorials: TutorialFlags = localStorage.hasOwnProperty(saveDataKey) + ? JSON.parse(localStorage.getItem(saveDataKey)!) + : {}; + + // TODO: We shouldn't be storing this like that + for (const key of Object.values(Tutorial)) { + if (key === tutorial) { + tutorials[key] = status; + } else { + tutorials[key] ??= false; + } } - Object.keys(Tutorial) - .map(t => t as Tutorial) - .forEach(t => { - const key = Tutorial[t]; - if (key === tutorial) { - tutorials[key] = flag; - } else { - tutorials[key] ??= false; - } - }); - - localStorage.setItem(key, JSON.stringify(tutorials)); - - return true; + localStorage.setItem(saveDataKey, JSON.stringify(tutorials)); } public getTutorialFlags(): TutorialFlags { const key = getDataTypeKey(GameDataType.TUTORIALS); - const ret: TutorialFlags = {}; - Object.values(Tutorial) - .map(tutorial => tutorial as Tutorial) - .forEach(tutorial => (ret[Tutorial[tutorial]] = false)); + const ret: TutorialFlags = Object.values(Tutorial).reduce((acc, tutorial) => { + acc[Tutorial[tutorial]] = false; + return acc; + }, {} as TutorialFlags); if (!localStorage.hasOwnProperty(key)) { return ret; @@ -979,6 +981,54 @@ export class GameData { }); } + async renameSession(slotId: number, newName: string): Promise { + return new Promise(async resolve => { + if (slotId < 0) { + return resolve(false); + } + const sessionData: SessionSaveData | null = await this.getSession(slotId); + + if (!sessionData) { + return resolve(false); + } + + if (newName === "") { + return resolve(true); + } + + sessionData.runNameText = newName; + const updatedDataStr = JSON.stringify(sessionData); + const encrypted = encrypt(updatedDataStr, bypassLogin); + const secretId = this.secretId; + const trainerId = this.trainerId; + + if (bypassLogin) { + localStorage.setItem( + `sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`, + encrypt(updatedDataStr, bypassLogin), + ); + resolve(true); + return; + } + pokerogueApi.savedata.session + .update({ slot: slotId, trainerId, secretId, clientSessionId }, encrypted) + .then(error => { + if (error) { + console.error("Failed to update session name:", error); + resolve(false); + } else { + localStorage.setItem(`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`, encrypted); + updateUserInfo().then(success => { + if (success !== null && !success) { + return resolve(false); + } + }); + resolve(true); + } + }); + }); + } + loadSession(slotId: number, sessionData?: SessionSaveData): Promise { // biome-ignore lint/suspicious/noAsyncPromiseExecutor: TODO: fix this return new Promise(async (resolve, reject) => { diff --git a/src/ui/rename-run-ui-handler.ts b/src/ui/rename-run-ui-handler.ts new file mode 100644 index 00000000000..23ba0137f2d --- /dev/null +++ b/src/ui/rename-run-ui-handler.ts @@ -0,0 +1,54 @@ +import i18next from "i18next"; +import type { InputFieldConfig } from "./form-modal-ui-handler"; +import { FormModalUiHandler } from "./form-modal-ui-handler"; +import type { ModalConfig } from "./modal-ui-handler"; + +export class RenameRunFormUiHandler extends FormModalUiHandler { + getModalTitle(_config?: ModalConfig): string { + return i18next.t("menu:renamerun"); + } + + getWidth(_config?: ModalConfig): number { + return 160; + } + + getMargin(_config?: ModalConfig): [number, number, number, number] { + return [0, 0, 48, 0]; + } + + getButtonLabels(_config?: ModalConfig): string[] { + return [i18next.t("menu:rename"), i18next.t("menu:cancel")]; + } + + getReadableErrorMessage(error: string): string { + const colonIndex = error?.indexOf(":"); + if (colonIndex > 0) { + error = error.slice(0, colonIndex); + } + + return super.getReadableErrorMessage(error); + } + + override getInputFieldConfigs(): InputFieldConfig[] { + return [{ label: i18next.t("menu:runName") }]; + } + + show(args: any[]): boolean { + if (!super.show(args)) { + return false; + } + if (this.inputs?.length) { + this.inputs.forEach(input => { + input.text = ""; + }); + } + const config = args[0] as ModalConfig; + this.submitAction = _ => { + this.sanitizeInputs(); + const sanitizedName = btoa(encodeURIComponent(this.inputs[0].text)); + config.buttonActions[0](sanitizedName); + return true; + }; + return true; + } +} diff --git a/src/ui/run-info-ui-handler.ts b/src/ui/run-info-ui-handler.ts index 2def302c1d5..8facd8e73b1 100644 --- a/src/ui/run-info-ui-handler.ts +++ b/src/ui/run-info-ui-handler.ts @@ -26,6 +26,7 @@ import { addBBCodeTextObject, addTextObject, getTextColor } from "#ui/text"; import { UiHandler } from "#ui/ui-handler"; import { addWindow } from "#ui/ui-theme"; import { formatFancyLargeNumber, formatLargeNumber, formatMoney, getPlayTimeString } from "#utils/common"; +import { toCamelCase } from "#utils/strings"; import i18next from "i18next"; import RoundRectangle from "phaser3-rex-plugins/plugins/roundrectangle"; @@ -207,6 +208,10 @@ export class RunInfoUiHandler extends UiHandler { headerText.setOrigin(0, 0); headerText.setPositionRelative(headerBg, 8, 4); this.runContainer.add(headerText); + const runName = addTextObject(0, 0, this.runInfo.runNameText, TextStyle.WINDOW); + runName.setOrigin(0, 0); + runName.setPositionRelative(headerBg, 60, 4); + this.runContainer.add(runName); } /** @@ -702,10 +707,7 @@ export class RunInfoUiHandler extends UiHandler { rules.push(i18next.t("challenges:inverseBattle.shortName")); break; default: { - const localizationKey = Challenges[this.runInfo.challenges[i].id] - .split("_") - .map((f, i) => (i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase())) - .join(""); + const localizationKey = toCamelCase(Challenges[this.runInfo.challenges[i].id]); rules.push(i18next.t(`challenges:${localizationKey}.name`)); break; } diff --git a/src/ui/save-slot-select-ui-handler.ts b/src/ui/save-slot-select-ui-handler.ts index 9c2f8488b22..52e145e6439 100644 --- a/src/ui/save-slot-select-ui-handler.ts +++ b/src/ui/save-slot-select-ui-handler.ts @@ -1,12 +1,14 @@ import { GameMode } from "#app/game-mode"; import { globalScene } from "#app/global-scene"; import { Button } from "#enums/buttons"; +import { GameModes } from "#enums/game-modes"; import { TextStyle } from "#enums/text-style"; import { UiMode } from "#enums/ui-mode"; // biome-ignore lint/performance/noNamespaceImport: See `src/system/game-data.ts` import * as Modifier from "#modifiers/modifier"; import type { SessionSaveData } from "#system/game-data"; import type { PokemonData } from "#system/pokemon-data"; +import type { OptionSelectConfig } from "#ui/abstract-option-select-ui-handler"; import { MessageUiHandler } from "#ui/message-ui-handler"; import { RunDisplayMode } from "#ui/run-info-ui-handler"; import { addTextObject } from "#ui/text"; @@ -15,7 +17,7 @@ import { fixedInt, formatLargeNumber, getPlayTimeString, isNullOrUndefined } fro import i18next from "i18next"; const SESSION_SLOTS_COUNT = 5; -const SLOTS_ON_SCREEN = 3; +const SLOTS_ON_SCREEN = 2; export enum SaveSlotUiMode { LOAD, @@ -33,6 +35,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler { private uiMode: SaveSlotUiMode; private saveSlotSelectCallback: SaveSlotSelectCallback | null; + protected manageDataConfig: OptionSelectConfig; private scrollCursor = 0; @@ -101,6 +104,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler { processInput(button: Button): boolean { const ui = this.getUi(); + const manageDataOptions: any[] = []; let success = false; let error = false; @@ -109,14 +113,115 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler { const originalCallback = this.saveSlotSelectCallback; if (button === Button.ACTION) { const cursor = this.cursor + this.scrollCursor; - if (this.uiMode === SaveSlotUiMode.LOAD && !this.sessionSlots[cursor].hasData) { + const sessionSlot = this.sessionSlots[cursor]; + if (this.uiMode === SaveSlotUiMode.LOAD && !sessionSlot.hasData) { error = true; } else { switch (this.uiMode) { case SaveSlotUiMode.LOAD: - this.saveSlotSelectCallback = null; - originalCallback?.(cursor); + if (!sessionSlot.malformed) { + manageDataOptions.push({ + label: i18next.t("menu:loadGame"), + handler: () => { + globalScene.ui.revertMode(); + originalCallback?.(cursor); + return true; + }, + keepOpen: false, + }); + + manageDataOptions.push({ + label: i18next.t("saveSlotSelectUiHandler:renameRun"), + handler: () => { + globalScene.ui.revertMode(); + ui.setOverlayMode( + UiMode.RENAME_RUN, + { + buttonActions: [ + (sanitizedName: string) => { + const name = decodeURIComponent(atob(sanitizedName)); + globalScene.gameData.renameSession(cursor, name).then(response => { + if (response[0] === false) { + globalScene.reset(true); + } else { + this.clearSessionSlots(); + this.cursorObj = null; + this.populateSessionSlots(); + this.setScrollCursor(0); + this.setCursor(0); + ui.revertMode(); + ui.showText("", 0); + } + }); + }, + () => { + ui.revertMode(); + }, + ], + }, + "", + ); + return true; + }, + }); + } + + this.manageDataConfig = { + xOffset: 0, + yOffset: 48, + options: manageDataOptions, + maxOptions: 4, + }; + + manageDataOptions.push({ + label: i18next.t("saveSlotSelectUiHandler:deleteRun"), + handler: () => { + globalScene.ui.revertMode(); + ui.showText(i18next.t("saveSlotSelectUiHandler:deleteData"), null, () => { + ui.setOverlayMode( + UiMode.CONFIRM, + () => { + globalScene.gameData.tryClearSession(cursor).then(response => { + if (response[0] === false) { + globalScene.reset(true); + } else { + this.clearSessionSlots(); + this.cursorObj = null; + this.populateSessionSlots(); + this.setScrollCursor(0); + this.setCursor(0); + ui.revertMode(); + ui.showText("", 0); + } + }); + }, + () => { + ui.revertMode(); + ui.showText("", 0); + }, + false, + 0, + 19, + import.meta.env.DEV ? 300 : 2000, + ); + }); + return true; + }, + keepOpen: false, + }); + + manageDataOptions.push({ + label: i18next.t("menuUiHandler:cancel"), + handler: () => { + globalScene.ui.revertMode(); + return true; + }, + keepOpen: true, + }); + + ui.setOverlayMode(UiMode.MENU_OPTION_SELECT, this.manageDataConfig); break; + case SaveSlotUiMode.SAVE: { const saveAndCallback = () => { const originalCallback = this.saveSlotSelectCallback; @@ -161,6 +266,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler { } } else { this.saveSlotSelectCallback = null; + ui.showText("", 0); originalCallback?.(-1); success = true; } @@ -267,33 +373,34 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler { this.cursorObj = globalScene.add.container(0, 0); const cursorBox = globalScene.add.nineslice( 0, - 0, + 15, "select_cursor_highlight_thick", undefined, - 296, - 44, + 294, + this.sessionSlots[prevSlotIndex ?? 0]?.saveData?.runNameText ? 50 : 60, 6, 6, 6, 6, ); const rightArrow = globalScene.add.image(0, 0, "cursor"); - rightArrow.setPosition(160, 0); + rightArrow.setPosition(160, 15); rightArrow.setName("rightArrow"); this.cursorObj.add([cursorBox, rightArrow]); this.sessionSlotsContainer.add(this.cursorObj); } const cursorPosition = cursor + this.scrollCursor; - const cursorIncrement = cursorPosition * 56; + const cursorIncrement = cursorPosition * 76; if (this.sessionSlots[cursorPosition] && this.cursorObj) { - const hasData = this.sessionSlots[cursorPosition].hasData; + const session = this.sessionSlots[cursorPosition]; + const hasData = session.hasData && !session.malformed; // If the session slot lacks session data, it does not move from its default, central position. // Only session slots with session data will move leftwards and have a visible arrow. if (!hasData) { - this.cursorObj.setPosition(151, 26 + cursorIncrement); + this.cursorObj.setPosition(151, 20 + cursorIncrement); this.sessionSlots[cursorPosition].setPosition(0, cursorIncrement); } else { - this.cursorObj.setPosition(145, 26 + cursorIncrement); + this.cursorObj.setPosition(145, 20 + cursorIncrement); this.sessionSlots[cursorPosition].setPosition(-6, cursorIncrement); } this.setArrowVisibility(hasData); @@ -311,7 +418,8 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler { revertSessionSlot(slotIndex: number): void { const sessionSlot = this.sessionSlots[slotIndex]; if (sessionSlot) { - sessionSlot.setPosition(0, slotIndex * 56); + const valueHeight = 76; + sessionSlot.setPosition(0, slotIndex * valueHeight); } } @@ -340,7 +448,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler { this.setCursor(this.cursor, prevSlotIndex); globalScene.tweens.add({ targets: this.sessionSlotsContainer, - y: this.sessionSlotsContainerInitialY - 56 * scrollCursor, + y: this.sessionSlotsContainerInitialY - 76 * scrollCursor, duration: fixedInt(325), ease: "Sine.easeInOut", }); @@ -374,12 +482,14 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler { class SessionSlot extends Phaser.GameObjects.Container { public slotId: number; public hasData: boolean; + /** Indicates the save slot ran into an error while being loaded */ + public malformed: boolean; + private slotWindow: Phaser.GameObjects.NineSlice; private loadingLabel: Phaser.GameObjects.Text; - public saveData: SessionSaveData; constructor(slotId: number) { - super(globalScene, 0, slotId * 56); + super(globalScene, 0, slotId * 76); this.slotId = slotId; @@ -387,32 +497,89 @@ class SessionSlot extends Phaser.GameObjects.Container { } setup() { - const slotWindow = addWindow(0, 0, 304, 52); - this.add(slotWindow); + this.slotWindow = addWindow(0, 0, 304, 70); + this.add(this.slotWindow); - this.loadingLabel = addTextObject(152, 26, i18next.t("saveSlotSelectUiHandler:loading"), TextStyle.WINDOW); + this.loadingLabel = addTextObject(152, 33, i18next.t("saveSlotSelectUiHandler:loading"), TextStyle.WINDOW); this.loadingLabel.setOrigin(0.5, 0.5); this.add(this.loadingLabel); } + /** + * Generates a name for sessions that don't have a name yet. + * @param data - The {@linkcode SessionSaveData} being checked + * @returns The default name for the given data. + */ + decideFallback(data: SessionSaveData): string { + let fallbackName = `${GameMode.getModeName(data.gameMode)}`; + switch (data.gameMode) { + case GameModes.CLASSIC: + fallbackName += ` (${globalScene.gameData.gameStats.classicSessionsPlayed + 1})`; + break; + case GameModes.ENDLESS: + case GameModes.SPLICED_ENDLESS: + fallbackName += ` (${globalScene.gameData.gameStats.endlessSessionsPlayed + 1})`; + break; + case GameModes.DAILY: { + const runDay = new Date(data.timestamp).toLocaleDateString(); + fallbackName += ` (${runDay})`; + break; + } + case GameModes.CHALLENGE: { + const activeChallenges = data.challenges.filter(c => c.value !== 0); + if (activeChallenges.length === 0) { + break; + } + + fallbackName = ""; + for (const challenge of activeChallenges.slice(0, 3)) { + if (fallbackName !== "") { + fallbackName += ", "; + } + fallbackName += challenge.toChallenge().getName(); + } + + if (activeChallenges.length > 3) { + fallbackName += ", ..."; + } else if (fallbackName === "") { + // Something went wrong when retrieving the names of the active challenges, + // so fall back to just naming the run "Challenge" + fallbackName = `${GameMode.getModeName(data.gameMode)}`; + } + break; + } + } + return fallbackName; + } + async setupWithData(data: SessionSaveData) { + const hasName = data?.runNameText; this.remove(this.loadingLabel, true); + if (hasName) { + const nameLabel = addTextObject(8, 5, data.runNameText, TextStyle.WINDOW); + this.add(nameLabel); + } else { + const fallbackName = this.decideFallback(data); + await globalScene.gameData.renameSession(this.slotId, fallbackName); + const nameLabel = addTextObject(8, 5, fallbackName, TextStyle.WINDOW); + this.add(nameLabel); + } const gameModeLabel = addTextObject( 8, - 5, + 19, `${GameMode.getModeName(data.gameMode) || i18next.t("gameMode:unknown")} - ${i18next.t("saveSlotSelectUiHandler:wave")} ${data.waveIndex}`, TextStyle.WINDOW, ); this.add(gameModeLabel); - const timestampLabel = addTextObject(8, 19, new Date(data.timestamp).toLocaleString(), TextStyle.WINDOW); + const timestampLabel = addTextObject(8, 33, new Date(data.timestamp).toLocaleString(), TextStyle.WINDOW); this.add(timestampLabel); - const playTimeLabel = addTextObject(8, 33, getPlayTimeString(data.playTime), TextStyle.WINDOW); + const playTimeLabel = addTextObject(8, 47, getPlayTimeString(data.playTime), TextStyle.WINDOW); this.add(playTimeLabel); - const pokemonIconsContainer = globalScene.add.container(144, 4); + const pokemonIconsContainer = globalScene.add.container(144, 16); data.party.forEach((p: PokemonData, i: number) => { const iconContainer = globalScene.add.container(26 * i, 0); iconContainer.setScale(0.75); @@ -427,13 +594,9 @@ class SessionSlot extends Phaser.GameObjects.Container { TextStyle.PARTY, { fontSize: "54px", color: "#f8f8f8" }, ); - text.setShadow(0, 0, undefined); - text.setStroke("#424242", 14); - text.setOrigin(1, 0); - - iconContainer.add(icon); - iconContainer.add(text); + text.setShadow(0, 0, undefined).setStroke("#424242", 14).setOrigin(1, 0); + iconContainer.add([icon, text]); pokemonIconsContainer.add(iconContainer); pokemon.destroy(); @@ -441,7 +604,7 @@ class SessionSlot extends Phaser.GameObjects.Container { this.add(pokemonIconsContainer); - const modifierIconsContainer = globalScene.add.container(148, 30); + const modifierIconsContainer = globalScene.add.container(148, 38); modifierIconsContainer.setScale(0.5); let visibleModifierIndex = 0; for (const m of data.modifiers) { @@ -464,22 +627,33 @@ class SessionSlot extends Phaser.GameObjects.Container { load(): Promise { return new Promise(resolve => { - globalScene.gameData.getSession(this.slotId).then(async sessionData => { - // Ignore the results if the view was exited - if (!this.active) { - return; - } - if (!sessionData) { - this.hasData = false; - this.loadingLabel.setText(i18next.t("saveSlotSelectUiHandler:empty")); - resolve(false); - return; - } - this.hasData = true; - this.saveData = sessionData; - await this.setupWithData(sessionData); - resolve(true); - }); + globalScene.gameData + .getSession(this.slotId) + .then(async sessionData => { + // Ignore the results if the view was exited + if (!this.active) { + return; + } + this.hasData = !!sessionData; + if (!sessionData) { + this.loadingLabel.setText(i18next.t("saveSlotSelectUiHandler:empty")); + resolve(false); + return; + } + this.saveData = sessionData; + this.setupWithData(sessionData); + resolve(true); + }) + .catch(e => { + if (!this.active) { + return; + } + console.warn(`Failed to load session slot #${this.slotId}:`, e); + this.loadingLabel.setText(i18next.t("menu:failedToLoadSession")); + this.hasData = true; + this.malformed = true; + resolve(true); + }); }); } } diff --git a/src/ui/test-dialogue-ui-handler.ts b/src/ui/test-dialogue-ui-handler.ts index 4f825ed95ea..6f7c79a151b 100644 --- a/src/ui/test-dialogue-ui-handler.ts +++ b/src/ui/test-dialogue-ui-handler.ts @@ -31,7 +31,7 @@ export class TestDialogueUiHandler extends FormModalUiHandler { // we check for null or undefined here as per above - the typeof is still an object but the value is null so we need to exit out of this and pass the null key // Return in the format expected by i18next - return middleKey ? `${topKey}:${middleKey.map(m => m).join(".")}.${t}` : `${topKey}:${t}`; + return middleKey ? `${topKey}:${middleKey.join(".")}.${t}` : `${topKey}:${t}`; } }) .filter(t => t); diff --git a/src/ui/ui.ts b/src/ui/ui.ts index d5baea07ed5..e381d205b78 100644 --- a/src/ui/ui.ts +++ b/src/ui/ui.ts @@ -60,6 +60,7 @@ import { addWindow } from "#ui/ui-theme"; import { UnavailableModalUiHandler } from "#ui/unavailable-modal-ui-handler"; import { executeIf } from "#utils/common"; import i18next from "i18next"; +import { RenameRunFormUiHandler } from "./rename-run-ui-handler"; const transitionModes = [ UiMode.SAVE_SLOT, @@ -98,6 +99,7 @@ const noTransitionModes = [ UiMode.SESSION_RELOAD, UiMode.UNAVAILABLE, UiMode.RENAME_POKEMON, + UiMode.RENAME_RUN, UiMode.TEST_DIALOGUE, UiMode.AUTO_COMPLETE, UiMode.ADMIN, @@ -168,6 +170,7 @@ export class UI extends Phaser.GameObjects.Container { new UnavailableModalUiHandler(), new GameChallengesUiHandler(), new RenameFormUiHandler(), + new RenameRunFormUiHandler(), new RunHistoryUiHandler(), new RunInfoUiHandler(), new TestDialogueUiHandler(UiMode.TEST_DIALOGUE), diff --git a/test/@types/vitest.d.ts b/test/@types/vitest.d.ts index 7b756c45a57..dc686a12083 100644 --- a/test/@types/vitest.d.ts +++ b/test/@types/vitest.d.ts @@ -130,7 +130,7 @@ declare module "vitest" { * @param ppUsed - The numerical amount of PP that should have been consumed, * or `all` to indicate the move should be _out_ of PP * @remarks - * If the Pokemon's moveset has been set via {@linkcode Overrides.MOVESET_OVERRIDE}/{@linkcode Overrides.OPP_MOVESET_OVERRIDE}, + * If the Pokemon's moveset has been set via {@linkcode Overrides.MOVESET_OVERRIDE}/{@linkcode Overrides.ENEMY_MOVESET_OVERRIDE}, * does not contain {@linkcode expectedMove} * or contains the desired move more than once, this will fail the test. */ diff --git a/test/moves/whirlwind.test.ts b/test/moves/whirlwind.test.ts index bbb2afe621a..61c05a30322 100644 --- a/test/moves/whirlwind.test.ts +++ b/test/moves/whirlwind.test.ts @@ -1,4 +1,3 @@ -import { globalScene } from "#app/global-scene"; import { Status } from "#data/status-effect"; import { AbilityId } from "#enums/ability-id"; import { BattleType } from "#enums/battle-type"; @@ -179,18 +178,13 @@ describe("Moves - Whirlwind", () => { const eligibleEnemy = enemyParty.filter(p => p.hp > 0 && p.isAllowedInBattle()); expect(eligibleEnemy.length).toBe(1); - // Spy on the queueMessage function - const queueSpy = vi.spyOn(globalScene.phaseManager, "queueMessage"); - // Player uses Whirlwind; opponent uses Splash game.move.select(MoveId.WHIRLWIND); await game.move.selectEnemyMove(MoveId.SPLASH); await game.toNextTurn(); - // Verify that the failure message is displayed for Whirlwind - expect(queueSpy).toHaveBeenCalledWith(expect.stringContaining("But it failed")); - // Verify the opponent's Splash message - expect(queueSpy).toHaveBeenCalledWith(expect.stringContaining("But nothing happened!")); + const player = game.field.getPlayerPokemon(); + expect(player).toHaveUsedMove({ move: MoveId.WHIRLWIND, result: MoveResult.FAIL }); }); it("should not pull in the other trainer's pokemon in a partner trainer battle", async () => { diff --git a/test/mystery-encounter/encounters/weird-dream-encounter.test.ts b/test/mystery-encounter/encounters/weird-dream-encounter.test.ts index ed0d612e967..9b430ec046e 100644 --- a/test/mystery-encounter/encounters/weird-dream-encounter.test.ts +++ b/test/mystery-encounter/encounters/weird-dream-encounter.test.ts @@ -112,7 +112,7 @@ describe("Weird Dream - Mystery Encounter", () => { it("should transform the new party into new species, 2 at +90/+110, the rest at +40/50 BST", async () => { await game.runToMysteryEncounter(MysteryEncounterType.WEIRD_DREAM, defaultParty); - const pokemonPrior = scene.getPlayerParty().map(pokemon => pokemon); + const pokemonPrior = scene.getPlayerParty().slice(); const bstsPrior = pokemonPrior.map(species => species.getSpeciesForm().getBaseStatTotal()); await runMysteryEncounterToEnd(game, 1); diff --git a/test/system/rename-run.test.ts b/test/system/rename-run.test.ts new file mode 100644 index 00000000000..5031d84245f --- /dev/null +++ b/test/system/rename-run.test.ts @@ -0,0 +1,82 @@ +import * as account from "#app/account"; +import * as bypassLoginModule from "#app/global-vars/bypass-login"; +import { pokerogueApi } from "#app/plugins/api/pokerogue-api"; +import type { SessionSaveData } from "#app/system/game-data"; +import { AbilityId } from "#enums/ability-id"; +import { MoveId } from "#enums/move-id"; +import { GameManager } from "#test/test-utils/game-manager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("System - Rename Run", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .moveset([MoveId.SPLASH]) + .battleStyle("single") + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + describe("renameSession", () => { + beforeEach(() => { + vi.spyOn(bypassLoginModule, "bypassLogin", "get").mockReturnValue(false); + vi.spyOn(account, "updateUserInfo").mockImplementation(async () => [true, 1]); + }); + + it("should return false if slotId < 0", async () => { + const result = await game.scene.gameData.renameSession(-1, "Named Run"); + + expect(result).toEqual(false); + }); + + it("should return false if getSession returns null", async () => { + vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue(null as unknown as SessionSaveData); + + const result = await game.scene.gameData.renameSession(-1, "Named Run"); + + expect(result).toEqual(false); + }); + + it("should return true if bypassLogin is true", async () => { + vi.spyOn(bypassLoginModule, "bypassLogin", "get").mockReturnValue(true); + vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue({} as SessionSaveData); + + const result = await game.scene.gameData.renameSession(0, "Named Run"); + + expect(result).toEqual(true); + }); + + it("should return false if api returns error", async () => { + vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue({} as SessionSaveData); + vi.spyOn(pokerogueApi.savedata.session, "update").mockResolvedValue("Unknown Error!"); + + const result = await game.scene.gameData.renameSession(0, "Named Run"); + + expect(result).toEqual(false); + }); + + it("should return true if api is succesfull", async () => { + vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue({} as SessionSaveData); + vi.spyOn(pokerogueApi.savedata.session, "update").mockResolvedValue(""); + + const result = await game.scene.gameData.renameSession(0, "Named Run"); + + expect(result).toEqual(true); + expect(account.updateUserInfo).toHaveBeenCalled(); + }); + }); +}); diff --git a/test/test-utils/game-manager.ts b/test/test-utils/game-manager.ts index f952557bb69..05b3be21d26 100644 --- a/test/test-utils/game-manager.ts +++ b/test/test-utils/game-manager.ts @@ -224,7 +224,7 @@ export class GameManager { // This will consider all battle entry dialog as seens and skip them vi.spyOn(this.scene.ui, "shouldSkipDialogue").mockReturnValue(true); - if (overrides.OPP_HELD_ITEMS_OVERRIDE.length === 0) { + if (overrides.ENEMY_HELD_ITEMS_OVERRIDE.length === 0) { this.removeEnemyHeldItems(); } diff --git a/test/test-utils/helpers/challenge-mode-helper.ts b/test/test-utils/helpers/challenge-mode-helper.ts index 3952685a560..a8a9ff89de6 100644 --- a/test/test-utils/helpers/challenge-mode-helper.ts +++ b/test/test-utils/helpers/challenge-mode-helper.ts @@ -50,7 +50,7 @@ export class ChallengeModeHelper extends GameManagerHelper { }); await this.game.phaseInterceptor.run(EncounterPhase); - if (overrides.OPP_HELD_ITEMS_OVERRIDE.length === 0 && this.game.override.removeEnemyStartingItems) { + if (overrides.ENEMY_HELD_ITEMS_OVERRIDE.length === 0 && this.game.override.removeEnemyStartingItems) { this.game.removeEnemyHeldItems(); } } diff --git a/test/test-utils/helpers/classic-mode-helper.ts b/test/test-utils/helpers/classic-mode-helper.ts index 5d73dc07615..008648fcd0d 100644 --- a/test/test-utils/helpers/classic-mode-helper.ts +++ b/test/test-utils/helpers/classic-mode-helper.ts @@ -53,7 +53,7 @@ export class ClassicModeHelper extends GameManagerHelper { }); await this.game.phaseInterceptor.to(EncounterPhase); - if (overrides.OPP_HELD_ITEMS_OVERRIDE.length === 0 && this.game.override.removeEnemyStartingItems) { + if (overrides.ENEMY_HELD_ITEMS_OVERRIDE.length === 0 && this.game.override.removeEnemyStartingItems) { this.game.removeEnemyHeldItems(); } } diff --git a/test/test-utils/helpers/daily-mode-helper.ts b/test/test-utils/helpers/daily-mode-helper.ts index 7aa1e699118..ca882eaf548 100644 --- a/test/test-utils/helpers/daily-mode-helper.ts +++ b/test/test-utils/helpers/daily-mode-helper.ts @@ -37,7 +37,7 @@ export class DailyModeHelper extends GameManagerHelper { await this.game.phaseInterceptor.to(EncounterPhase); - if (overrides.OPP_HELD_ITEMS_OVERRIDE.length === 0 && this.game.override.removeEnemyStartingItems) { + if (overrides.ENEMY_HELD_ITEMS_OVERRIDE.length === 0 && this.game.override.removeEnemyStartingItems) { this.game.removeEnemyHeldItems(); } } diff --git a/test/test-utils/helpers/move-helper.ts b/test/test-utils/helpers/move-helper.ts index 6a01e4110da..3d5e9ae6af9 100644 --- a/test/test-utils/helpers/move-helper.ts +++ b/test/test-utils/helpers/move-helper.ts @@ -228,8 +228,8 @@ export class MoveHelper extends GameManagerHelper { console.warn("Player moveset override disabled due to use of `game.move.changeMoveset`!"); } } else { - if (coerceArray(Overrides.OPP_MOVESET_OVERRIDE).length > 0) { - vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([]); + if (coerceArray(Overrides.ENEMY_MOVESET_OVERRIDE).length > 0) { + vi.spyOn(Overrides, "ENEMY_MOVESET_OVERRIDE", "get").mockReturnValue([]); console.warn("Enemy moveset override disabled due to use of `game.move.changeMoveset`!"); } } @@ -302,8 +302,8 @@ export class MoveHelper extends GameManagerHelper { (this.game.scene.phaseManager.getCurrentPhase() as EnemyCommandPhase).getFieldIndex() ]; - if ([Overrides.OPP_MOVESET_OVERRIDE].flat().length > 0) { - vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([]); + if ([Overrides.ENEMY_MOVESET_OVERRIDE].flat().length > 0) { + vi.spyOn(Overrides, "ENEMY_MOVESET_OVERRIDE", "get").mockReturnValue([]); console.warn( "Warning: `forceEnemyMove` overwrites the Pokemon's moveset and disables the enemy moveset override!", ); diff --git a/test/test-utils/helpers/overrides-helper.ts b/test/test-utils/helpers/overrides-helper.ts index d67ceedf891..93b89688935 100644 --- a/test/test-utils/helpers/overrides-helper.ts +++ b/test/test-utils/helpers/overrides-helper.ts @@ -406,7 +406,7 @@ export class OverridesHelper extends GameManagerHelper { * @returns `this` */ public enemySpecies(species: SpeciesId | number): this { - vi.spyOn(Overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(species); + vi.spyOn(Overrides, "ENEMY_SPECIES_OVERRIDE", "get").mockReturnValue(species); this.log(`Enemy Pokemon species set to ${SpeciesId[species]} (=${species})!`); return this; } @@ -416,7 +416,7 @@ export class OverridesHelper extends GameManagerHelper { * @returns `this` */ public enableEnemyFusion(): this { - vi.spyOn(Overrides, "OPP_FUSION_OVERRIDE", "get").mockReturnValue(true); + vi.spyOn(Overrides, "ENEMY_FUSION_OVERRIDE", "get").mockReturnValue(true); this.log("Enemy Pokemon is a random fusion!"); return this; } @@ -427,7 +427,7 @@ export class OverridesHelper extends GameManagerHelper { * @returns `this` */ public enemyFusionSpecies(species: SpeciesId | number): this { - vi.spyOn(Overrides, "OPP_FUSION_SPECIES_OVERRIDE", "get").mockReturnValue(species); + vi.spyOn(Overrides, "ENEMY_FUSION_SPECIES_OVERRIDE", "get").mockReturnValue(species); this.log(`Enemy Pokemon fusion species set to ${SpeciesId[species]} (=${species})!`); return this; } @@ -438,7 +438,7 @@ export class OverridesHelper extends GameManagerHelper { * @returns `this` */ public enemyAbility(ability: AbilityId): this { - vi.spyOn(Overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(ability); + vi.spyOn(Overrides, "ENEMY_ABILITY_OVERRIDE", "get").mockReturnValue(ability); this.log(`Enemy Pokemon ability set to ${AbilityId[ability]} (=${ability})!`); return this; } @@ -449,7 +449,7 @@ export class OverridesHelper extends GameManagerHelper { * @returns `this` */ public enemyPassiveAbility(passiveAbility: AbilityId): this { - vi.spyOn(Overrides, "OPP_PASSIVE_ABILITY_OVERRIDE", "get").mockReturnValue(passiveAbility); + vi.spyOn(Overrides, "ENEMY_PASSIVE_ABILITY_OVERRIDE", "get").mockReturnValue(passiveAbility); this.log(`Enemy Pokemon PASSIVE ability set to ${AbilityId[passiveAbility]} (=${passiveAbility})!`); return this; } @@ -460,7 +460,7 @@ export class OverridesHelper extends GameManagerHelper { * @returns `this` */ public enemyHasPassiveAbility(hasPassiveAbility: boolean | null): this { - vi.spyOn(Overrides, "OPP_HAS_PASSIVE_ABILITY_OVERRIDE", "get").mockReturnValue(hasPassiveAbility); + vi.spyOn(Overrides, "ENEMY_HAS_PASSIVE_ABILITY_OVERRIDE", "get").mockReturnValue(hasPassiveAbility); if (hasPassiveAbility === null) { this.log("Enemy Pokemon PASSIVE ability no longer force enabled or disabled!"); } else { @@ -475,7 +475,7 @@ export class OverridesHelper extends GameManagerHelper { * @returns `this` */ public enemyMoveset(moveset: MoveId | MoveId[]): this { - vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue(moveset); + vi.spyOn(Overrides, "ENEMY_MOVESET_OVERRIDE", "get").mockReturnValue(moveset); moveset = coerceArray(moveset); const movesetStr = moveset.map(moveId => MoveId[moveId]).join(", "); this.log(`Enemy Pokemon moveset set to ${movesetStr} (=[${moveset.join(", ")}])!`); @@ -488,7 +488,7 @@ export class OverridesHelper extends GameManagerHelper { * @returns `this` */ public enemyLevel(level: number): this { - vi.spyOn(Overrides, "OPP_LEVEL_OVERRIDE", "get").mockReturnValue(level); + vi.spyOn(Overrides, "ENEMY_LEVEL_OVERRIDE", "get").mockReturnValue(level); this.log(`Enemy Pokemon level set to ${level}!`); return this; } @@ -499,7 +499,7 @@ export class OverridesHelper extends GameManagerHelper { * @returns `this` */ public enemyStatusEffect(statusEffect: StatusEffect): this { - vi.spyOn(Overrides, "OPP_STATUS_OVERRIDE", "get").mockReturnValue(statusEffect); + vi.spyOn(Overrides, "ENEMY_STATUS_OVERRIDE", "get").mockReturnValue(statusEffect); this.log(`Enemy Pokemon status-effect set to ${StatusEffect[statusEffect]} (=${statusEffect})!`); return this; } @@ -510,7 +510,7 @@ export class OverridesHelper extends GameManagerHelper { * @returns `this` */ public enemyHeldItems(items: ModifierOverride[]): this { - vi.spyOn(Overrides, "OPP_HELD_ITEMS_OVERRIDE", "get").mockReturnValue(items); + vi.spyOn(Overrides, "ENEMY_HELD_ITEMS_OVERRIDE", "get").mockReturnValue(items); this.log("Enemy Pokemon held items set to:", items); return this; } @@ -571,7 +571,7 @@ export class OverridesHelper extends GameManagerHelper { * @param variant - (Optional) The enemy's shiny {@linkcode Variant}. */ enemyShiny(shininess: boolean | null, variant?: Variant): this { - vi.spyOn(Overrides, "OPP_SHINY_OVERRIDE", "get").mockReturnValue(shininess); + vi.spyOn(Overrides, "ENEMY_SHINY_OVERRIDE", "get").mockReturnValue(shininess); if (shininess === null) { this.log("Disabled enemy Pokemon shiny override!"); } else { @@ -579,7 +579,7 @@ export class OverridesHelper extends GameManagerHelper { } if (variant !== undefined) { - vi.spyOn(Overrides, "OPP_VARIANT_OVERRIDE", "get").mockReturnValue(variant); + vi.spyOn(Overrides, "ENEMY_VARIANT_OVERRIDE", "get").mockReturnValue(variant); this.log(`Set enemy shiny variant to be ${variant}!`); } return this; @@ -594,7 +594,7 @@ export class OverridesHelper extends GameManagerHelper { * @returns `this` */ public enemyHealthSegments(healthSegments: number): this { - vi.spyOn(Overrides, "OPP_HEALTH_SEGMENTS_OVERRIDE", "get").mockReturnValue(healthSegments); + vi.spyOn(Overrides, "ENEMY_HEALTH_SEGMENTS_OVERRIDE", "get").mockReturnValue(healthSegments); this.log("Enemy Pokemon health segments set to:", healthSegments); return this; } diff --git a/test/test-utils/matchers/to-have-used-pp.ts b/test/test-utils/matchers/to-have-used-pp.ts index 3b606a535bc..1a1b37ca665 100644 --- a/test/test-utils/matchers/to-have-used-pp.ts +++ b/test/test-utils/matchers/to-have-used-pp.ts @@ -33,7 +33,7 @@ export function toHaveUsedPP( }; } - const override = received.isPlayer() ? Overrides.MOVESET_OVERRIDE : Overrides.OPP_MOVESET_OVERRIDE; + const override = received.isPlayer() ? Overrides.MOVESET_OVERRIDE : Overrides.ENEMY_MOVESET_OVERRIDE; if (coerceArray(override).length > 0) { return { pass: false,