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/package.json b/package.json index d3494da677c..3f523ed5c3e 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "devDependencies": { "@biomejs/biome": "2.0.0", "@ls-lint/ls-lint": "2.3.1", + "@types/crypto-js": "^4.2.0", "@types/jsdom": "^21.1.7", "@types/node": "^22.16.5", "@vitest/coverage-istanbul": "^3.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 900be6fd76e..c3b58a60f48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: '@ls-lint/ls-lint': specifier: 2.3.1 version: 2.3.1 + '@types/crypto-js': + specifier: ^4.2.0 + version: 4.2.2 '@types/jsdom': specifier: ^21.1.7 version: 21.1.7 @@ -718,6 +721,9 @@ packages: '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/crypto-js@4.2.2': + resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -2525,6 +2531,8 @@ snapshots: '@types/cookie@0.6.0': {} + '@types/crypto-js@4.2.2': {} + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.8': {} diff --git a/public/images/ui/champion_ribbon_emerald.png b/public/images/ui/champion_ribbon_emerald.png new file mode 100644 index 00000000000..81b111a05a9 Binary files /dev/null and b/public/images/ui/champion_ribbon_emerald.png differ diff --git a/public/images/ui/legacy/champion_ribbon_emerald.png b/public/images/ui/legacy/champion_ribbon_emerald.png new file mode 100644 index 00000000000..81b111a05a9 Binary files /dev/null and b/public/images/ui/legacy/champion_ribbon_emerald.png differ diff --git a/public/images/ui/party_bg_double_manage.png b/public/images/ui/party_bg_double_manage.png index e85413b5fb5..f1561422867 100644 Binary files a/public/images/ui/party_bg_double_manage.png and b/public/images/ui/party_bg_double_manage.png differ diff --git a/public/images/ui/party_slot_main_short.json b/public/images/ui/party_slot_main_short.json new file mode 100644 index 00000000000..d738d524a5b --- /dev/null +++ b/public/images/ui/party_slot_main_short.json @@ -0,0 +1,146 @@ +{ + "textures": [ + { + "image": "party_slot_main_short.png", + "format": "RGBA8888", + "size": { + "w": 110, + "h": 294 + }, + "scale": 1, + "frames": [ + { + "filename": "party_slot_main_short", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 110, + "h": 41 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 110, + "h": 41 + }, + "frame": { + "x": 0, + "y": 0, + "w": 110, + "h": 41 + } + }, + { + "filename": "party_slot_main_short_sel", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 110, + "h": 41 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 110, + "h": 41 + }, + "frame": { + "x": 0, + "y": 41, + "w": 110, + "h": 41 + } + }, + { + "filename": "party_slot_main_short_fnt", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 110, + "h": 41 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 110, + "h": 41 + }, + "frame": { + "x": 0, + "y": 82, + "w": 110, + "h": 41 + } + }, + { + "filename": "party_slot_main_short_fnt_sel", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 110, + "h": 41 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 110, + "h": 41 + }, + "frame": { + "x": 0, + "y": 123, + "w": 110, + "h": 41 + } + }, + { + "filename": "party_slot_main_short_swap", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 110, + "h": 41 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 110, + "h": 41 + }, + "frame": { + "x": 0, + "y": 164, + "w": 110, + "h": 41 + } + }, + { + "filename": "party_slot_main_short_swap_sel", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 110, + "h": 41 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 110, + "h": 41 + }, + "frame": { + "x": 0, + "y": 205, + "w": 110, + "h": 41 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:29685f2f538901cf5bf7f0ed2ea867c3:a080ea6c8cccd1e03244214053e79796:565f7afc5ca419b6ba8dbce51ea30818$" + } +} diff --git a/public/images/ui/party_slot_main_short.png b/public/images/ui/party_slot_main_short.png new file mode 100644 index 00000000000..4a4ef9ae937 Binary files /dev/null and b/public/images/ui/party_slot_main_short.png differ diff --git a/src/@types/dex-data.ts b/src/@types/dex-data.ts index 88cc16886bd..005e8034b18 100644 --- a/src/@types/dex-data.ts +++ b/src/@types/dex-data.ts @@ -1,3 +1,5 @@ +import type { RibbonData } from "#system/ribbons/ribbon-data"; + export interface DexData { [key: number]: DexEntry; } @@ -10,4 +12,5 @@ export interface DexEntry { caughtCount: number; hatchedCount: number; ivs: number[]; + ribbons: RibbonData; } diff --git a/src/@types/helpers/type-helpers.ts b/src/@types/helpers/type-helpers.ts index 7ad20b88956..0be391aa3c4 100644 --- a/src/@types/helpers/type-helpers.ts +++ b/src/@types/helpers/type-helpers.ts @@ -103,3 +103,12 @@ export type CoerceNullPropertiesToUndefined = { * @typeParam T - The type to render partial */ export type AtLeastOne = Partial & ObjectValues<{ [K in keyof T]: Pick, K> }>; + +/** Type helper that adds a brand to a type, used for nominal typing. + * + * @remarks + * Brands should be either a string or unique symbol. This prevents overlap with other types. + */ +export declare class Brander { + private __brand: B; +} diff --git a/src/account.ts b/src/account.ts index b01691ce940..c97721889ae 100644 --- a/src/account.ts +++ b/src/account.ts @@ -17,45 +17,42 @@ export function initLoggedInUser(): void { }; } -export function updateUserInfo(): Promise<[boolean, number]> { - return new Promise<[boolean, number]>(resolve => { - if (bypassLogin) { - loggedInUser = { - username: "Guest", - lastSessionSlot: -1, - discordId: "", - googleId: "", - hasAdminRole: false, - }; - let lastSessionSlot = -1; - for (let s = 0; s < 5; s++) { - if (localStorage.getItem(`sessionData${s ? s : ""}_${loggedInUser.username}`)) { - lastSessionSlot = s; - break; - } +export async function updateUserInfo(): Promise<[boolean, number]> { + if (bypassLogin) { + loggedInUser = { + username: "Guest", + lastSessionSlot: -1, + discordId: "", + googleId: "", + hasAdminRole: false, + }; + let lastSessionSlot = -1; + for (let s = 0; s < 5; s++) { + if (localStorage.getItem(`sessionData${s ? s : ""}_${loggedInUser.username}`)) { + lastSessionSlot = s; + break; } - loggedInUser.lastSessionSlot = lastSessionSlot; - // Migrate old data from before the username was appended - ["data", "sessionData", "sessionData1", "sessionData2", "sessionData3", "sessionData4"].map(d => { - const lsItem = localStorage.getItem(d); - if (lsItem && !!loggedInUser?.username) { - const lsUserItem = localStorage.getItem(`${d}_${loggedInUser.username}`); - if (lsUserItem) { - localStorage.setItem(`${d}_${loggedInUser.username}_bak`, lsUserItem); - } - localStorage.setItem(`${d}_${loggedInUser.username}`, lsItem); - localStorage.removeItem(d); - } - }); - return resolve([true, 200]); } - pokerogueApi.account.getInfo().then(([accountInfo, status]) => { - if (!accountInfo) { - resolve([false, status]); - return; + loggedInUser.lastSessionSlot = lastSessionSlot; + // Migrate old data from before the username was appended + ["data", "sessionData", "sessionData1", "sessionData2", "sessionData3", "sessionData4"].forEach(d => { + const lsItem = localStorage.getItem(d); + if (lsItem && !!loggedInUser?.username) { + const lsUserItem = localStorage.getItem(`${d}_${loggedInUser.username}`); + if (lsUserItem) { + localStorage.setItem(`${d}_${loggedInUser.username}_bak`, lsUserItem); + } + localStorage.setItem(`${d}_${loggedInUser.username}`, lsItem); + localStorage.removeItem(d); } - loggedInUser = accountInfo; - resolve([true, 200]); }); - }); + return [true, 200]; + } + + const [accountInfo, status] = await pokerogueApi.account.getInfo(); + if (!accountInfo) { + return [false, status]; + } + loggedInUser = accountInfo; + return [true, 200]; } diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 271cde1aaa9..4d3f190c02a 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -27,13 +27,7 @@ import { UiInputs } from "#app/ui-inputs"; import { biomeDepths, getBiomeName } from "#balance/biomes"; import { pokemonPrevolutions } from "#balance/pokemon-evolutions"; import { FRIENDSHIP_GAIN_FROM_BATTLE } from "#balance/starters"; -import { - initCommonAnims, - initMoveAnim, - loadCommonAnimAssets, - loadMoveAnimAssets, - populateAnims, -} from "#data/battle-anims"; +import { initCommonAnims, initMoveAnim, loadCommonAnimAssets, loadMoveAnimAssets } from "#data/battle-anims"; import { allAbilities, allMoves, allSpecies, modifierTypes } from "#data/data-lists"; import { battleSpecDialogue } from "#data/dialogue"; import type { SpeciesFormChangeTrigger } from "#data/form-change-triggers"; @@ -104,6 +98,7 @@ import { getLuckString, getLuckTextTint, getPartyLuckValue, + type ModifierType, PokemonHeldItemModifierType, } from "#modifiers/modifier-type"; import { MysteryEncounter } from "#mystery-encounters/mystery-encounter"; @@ -387,7 +382,6 @@ export class BattleScene extends SceneBase { const defaultMoves = [MoveId.TACKLE, MoveId.TAIL_WHIP, MoveId.FOCUS_ENERGY, MoveId.STRUGGLE]; await Promise.all([ - populateAnims(), this.initVariantData(), initCommonAnims().then(() => loadCommonAnimAssets(true)), Promise.all(defaultMoves.map(m => initMoveAnim(m))).then(() => loadMoveAnimAssets(defaultMoves, true)), @@ -943,17 +937,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 +1197,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 +1233,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 +1508,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 +1759,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 +2706,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/constants.ts b/src/constants.ts index 6f9f4a6d2fb..17cf08aa7e2 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -101,3 +101,9 @@ export const ANTI_VARIANCE_WEIGHT_MODIFIER = 15; * Default: `10000` (0.01%) */ export const FAKE_TITLE_LOGO_CHANCE = 10000; + +/** + * The ceiling on friendship amount that can be reached through the use of rare candies. + * Using rare candies will never increase friendship beyond this value. + */ +export const RARE_CANDY_FRIENDSHIP_CAP = 200; diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index c7c10d46d38..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) : ""; } 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/battle-anims.ts b/src/data/battle-anims.ts index 55a3cc4e916..aa4951f3263 100644 --- a/src/data/battle-anims.ts +++ b/src/data/battle-anims.ts @@ -404,22 +404,18 @@ export const chargeAnims = new Map(); export const encounterAnims = new Map(); -export function initCommonAnims(): Promise { - return new Promise(resolve => { - const commonAnimNames = getEnumKeys(CommonAnim); - const commonAnimIds = getEnumValues(CommonAnim); - const commonAnimFetches: Promise>[] = []; - for (let ca = 0; ca < commonAnimIds.length; ca++) { - const commonAnimId = commonAnimIds[ca]; - commonAnimFetches.push( - globalScene - .cachedFetch(`./battle-anims/common-${toKebabCase(commonAnimNames[ca])}.json`) - .then(response => response.json()) - .then(cas => commonAnims.set(commonAnimId, new AnimConfig(cas))), - ); - } - Promise.allSettled(commonAnimFetches).then(() => resolve()); - }); +export async function initCommonAnims(): Promise { + const commonAnimFetches: Promise>[] = []; + for (const commonAnimName of getEnumKeys(CommonAnim)) { + const commonAnimId = CommonAnim[commonAnimName]; + commonAnimFetches.push( + globalScene + .cachedFetch(`./battle-anims/common-${toKebabCase(commonAnimName)}.json`) + .then(response => response.json()) + .then(cas => commonAnims.set(commonAnimId, new AnimConfig(cas))), + ); + } + await Promise.allSettled(commonAnimFetches); } export function initMoveAnim(move: MoveId): Promise { @@ -1396,279 +1392,3 @@ export class EncounterBattleAnim extends BattleAnim { return this.oppAnim; } } - -export async function populateAnims() { - const commonAnimNames = getEnumKeys(CommonAnim).map(k => k.toLowerCase()); - const commonAnimMatchNames = commonAnimNames.map(k => k.replace(/_/g, "")); - const commonAnimIds = getEnumValues(CommonAnim); - const chargeAnimNames = getEnumKeys(ChargeAnim).map(k => k.toLowerCase()); - const chargeAnimMatchNames = chargeAnimNames.map(k => k.replace(/_/g, " ")); - 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; - } - - const seNames: string[] = []; //(await fs.readdir('./public/audio/se/battle_anims/')).map(se => se.toString()); - - const animsData: any[] = []; //battleAnimRawData.split('!ruby/array:PBAnimation').slice(1); // TODO: add a proper type - for (let a = 0; a < animsData.length; a++) { - const fields = animsData[a].split("@").slice(1); - - const nameField = fields.find(f => f.startsWith("name: ")); - - let isOppMove: boolean | undefined; - let commonAnimId: CommonAnim | undefined; - let chargeAnimId: ChargeAnim | undefined; - if (!nameField.startsWith("name: Move:") && !(isOppMove = nameField.startsWith("name: OppMove:"))) { - const nameMatch = commonNamePattern.exec(nameField)!; // TODO: is this bang correct? - const name = nameMatch[2].toLowerCase(); - if (commonAnimMatchNames.indexOf(name) > -1) { - commonAnimId = commonAnimIds[commonAnimMatchNames.indexOf(name)]; - } else if (chargeAnimMatchNames.indexOf(name) > -1) { - isOppMove = nameField.startsWith("name: Opp "); - chargeAnimId = chargeAnimIds[chargeAnimMatchNames.indexOf(name)]; - } - } - const nameIndex = nameField.indexOf(":", 5) + 1; - const animName = nameField.slice(nameIndex, nameField.indexOf("\n", nameIndex)); - if (!moveNameToId.hasOwnProperty(animName) && !commonAnimId && !chargeAnimId) { - continue; - } - const anim = commonAnimId || chargeAnimId ? new AnimConfig() : new AnimConfig(); - if (anim instanceof AnimConfig) { - (anim as AnimConfig).id = moveNameToId[animName]; - } - if (commonAnimId) { - commonAnims.set(commonAnimId, anim); - } else if (chargeAnimId) { - chargeAnims.set(chargeAnimId, !isOppMove ? anim : [chargeAnims.get(chargeAnimId) as AnimConfig, anim]); - } else { - moveAnims.set( - moveNameToId[animName], - !isOppMove ? (anim as AnimConfig) : [moveAnims.get(moveNameToId[animName]) as AnimConfig, anim as AnimConfig], - ); - } - for (let f = 0; f < fields.length; f++) { - const field = fields[f]; - const fieldName = field.slice(0, field.indexOf(":")); - const fieldData = field.slice(fieldName.length + 1, field.lastIndexOf("\n")).trim(); - switch (fieldName) { - case "array": { - const framesData = fieldData.split(" - - - ").slice(1); - for (let fd = 0; fd < framesData.length; fd++) { - anim.frames.push([]); - const frameData = framesData[fd]; - const focusFramesData = frameData.split(" - - "); - for (let tf = 0; tf < focusFramesData.length; tf++) { - const values = focusFramesData[tf].replace(/ {6}- /g, "").split("\n"); - const targetFrame = new AnimFrame( - Number.parseFloat(values[0]), - Number.parseFloat(values[1]), - Number.parseFloat(values[2]), - Number.parseFloat(values[11]), - Number.parseFloat(values[3]), - Number.parseInt(values[4]) === 1, - Number.parseInt(values[6]) === 1, - Number.parseInt(values[5]), - Number.parseInt(values[7]), - Number.parseInt(values[8]), - Number.parseInt(values[12]), - Number.parseInt(values[13]), - Number.parseInt(values[14]), - Number.parseInt(values[15]), - Number.parseInt(values[16]), - Number.parseInt(values[17]), - Number.parseInt(values[18]), - Number.parseInt(values[19]), - Number.parseInt(values[21]), - Number.parseInt(values[22]), - Number.parseInt(values[23]), - Number.parseInt(values[24]), - Number.parseInt(values[20]) === 1, - Number.parseInt(values[25]), - Number.parseInt(values[26]) as AnimFocus, - ); - anim.frames[fd].push(targetFrame); - } - } - break; - } - case "graphic": { - const graphic = fieldData !== "''" ? fieldData : ""; - anim.graphic = graphic.indexOf(".") > -1 ? graphic.slice(0, fieldData.indexOf(".")) : graphic; - break; - } - case "timing": { - const timingEntries = fieldData.split("- !ruby/object:PBAnimTiming ").slice(1); - for (let t = 0; t < timingEntries.length; t++) { - const timingData = timingEntries[t] - .replace(/\n/g, " ") - .replace(/[ ]{2,}/g, " ") - .replace(/[a-z]+: ! '', /gi, "") - .replace(/name: (.*?),/, 'name: "$1",') - .replace( - /flashColor: !ruby\/object:Color { alpha: ([\d.]+), blue: ([\d.]+), green: ([\d.]+), red: ([\d.]+)}/, - "flashRed: $4, flashGreen: $3, flashBlue: $2, flashAlpha: $1", - ); - const frameIndex = Number.parseInt(/frame: (\d+)/.exec(timingData)![1]); // TODO: is the bang correct? - let resourceName = /name: "(.*?)"/.exec(timingData)![1].replace("''", ""); // TODO: is the bang correct? - const timingType = Number.parseInt(/timingType: (\d)/.exec(timingData)![1]); // TODO: is the bang correct? - let timedEvent: AnimTimedEvent | undefined; - switch (timingType) { - case 0: - if (resourceName && resourceName.indexOf(".") === -1) { - let ext: string | undefined; - ["wav", "mp3", "m4a"].every(e => { - if (seNames.indexOf(`${resourceName}.${e}`) > -1) { - ext = e; - return false; - } - return true; - }); - if (!ext) { - ext = ".wav"; - } - resourceName += `.${ext}`; - } - timedEvent = new AnimTimedSoundEvent(frameIndex, resourceName); - break; - case 1: - timedEvent = new AnimTimedAddBgEvent(frameIndex, resourceName.slice(0, resourceName.indexOf("."))); - break; - case 2: - timedEvent = new AnimTimedUpdateBgEvent(frameIndex, resourceName.slice(0, resourceName.indexOf("."))); - break; - } - if (!timedEvent) { - continue; - } - const propPattern = /([a-z]+): (.*?)(?:,|\})/gi; - let propMatch: RegExpExecArray; - while ((propMatch = propPattern.exec(timingData)!)) { - // TODO: is this bang correct? - const prop = propMatch[1]; - let value: any = propMatch[2]; - switch (prop) { - case "bgX": - case "bgY": - value = Number.parseFloat(value); - break; - case "volume": - case "pitch": - case "opacity": - case "colorRed": - case "colorGreen": - case "colorBlue": - case "colorAlpha": - case "duration": - case "flashScope": - case "flashRed": - case "flashGreen": - case "flashBlue": - case "flashAlpha": - case "flashDuration": - value = Number.parseInt(value); - break; - } - if (timedEvent.hasOwnProperty(prop)) { - timedEvent[prop] = value; - } - } - if (!anim.frameTimedEvents.has(frameIndex)) { - anim.frameTimedEvents.set(frameIndex, []); - } - anim.frameTimedEvents.get(frameIndex)!.push(timedEvent); // TODO: is this bang correct? - } - break; - } - case "position": - anim.position = Number.parseInt(fieldData); - break; - case "hue": - anim.hue = Number.parseInt(fieldData); - break; - } - } - } - - // biome-ignore lint/correctness/noUnusedVariables: used in commented code - const animReplacer = (k, v) => { - if (k === "id" && !v) { - return undefined; - } - if (v instanceof Map) { - return Object.fromEntries(v); - } - if (v instanceof AnimTimedEvent) { - v["eventType"] = v.getEventType(); - } - return v; - }; - - const animConfigProps = ["id", "graphic", "frames", "frameTimedEvents", "position", "hue"]; - const animFrameProps = [ - "x", - "y", - "zoomX", - "zoomY", - "angle", - "mirror", - "visible", - "blendType", - "target", - "graphicFrame", - "opacity", - "color", - "tone", - "flash", - "locked", - "priority", - "focus", - ]; - const propSets = [animConfigProps, animFrameProps]; - - // biome-ignore lint/correctness/noUnusedVariables: used in commented code - const animComparator = (a: Element, b: Element) => { - let props: string[]; - for (let p = 0; p < propSets.length; p++) { - props = propSets[p]; - // @ts-expect-error TODO - const ai = props.indexOf(a.key); - if (ai === -1) { - continue; - } - // @ts-expect-error TODO - const bi = props.indexOf(b.key); - - return ai < bi ? -1 : ai > bi ? 1 : 0; - } - - return 0; - }; - - /*for (let ma of moveAnims.keys()) { - const data = moveAnims.get(ma); - (async () => { - await fs.writeFile(`../public/battle-anims/${Moves[ma].toLowerCase().replace(/\_/g, '-')}.json`, stringify(data, { replacer: animReplacer, cmp: animComparator, space: ' ' })); - })(); - } - - for (let ca of chargeAnims.keys()) { - const data = chargeAnims.get(ca); - (async () => { - await fs.writeFile(`../public/battle-anims/${chargeAnimNames[chargeAnimIds.indexOf(ca)].replace(/\_/g, '-')}.json`, stringify(data, { replacer: animReplacer, cmp: animComparator, space: ' ' })); - })(); - } - - for (let cma of commonAnims.keys()) { - const data = commonAnims.get(cma); - (async () => { - await fs.writeFile(`../public/battle-anims/common-${commonAnimNames[commonAnimIds.indexOf(cma)].replace(/\_/g, '-')}.json`, stringify(data, { replacer: animReplacer, cmp: animComparator, space: ' ' })); - })(); - }*/ -} diff --git a/src/data/challenge.ts b/src/data/challenge.ts index 724d1f302da..89435149d2f 100644 --- a/src/data/challenge.ts +++ b/src/data/challenge.ts @@ -20,6 +20,7 @@ import { Trainer } from "#field/trainer"; import type { ModifierTypeOption } from "#modifiers/modifier-type"; import { PokemonMove } from "#moves/pokemon-move"; import type { DexAttrProps, GameData } from "#system/game-data"; +import { RibbonData, type RibbonFlag } from "#system/ribbons/ribbon-data"; import { type BooleanHolder, isBetween, type NumberHolder, randSeedItem } from "#utils/common"; import { deepCopy } from "#utils/data"; import { getPokemonSpecies, getPokemonSpeciesForm } from "#utils/pokemon-utils"; @@ -42,6 +43,15 @@ export abstract class Challenge { public conditions: ChallengeCondition[]; + /** + * The Ribbon awarded on challenge completion, or 0 if the challenge has no ribbon or is not enabled + * + * @defaultValue 0 + */ + public get ribbonAwarded(): RibbonFlag { + return 0 as RibbonFlag; + } + /** * @param id {@link Challenges} The enum value for the challenge */ @@ -423,6 +433,12 @@ type ChallengeCondition = (data: GameData) => boolean; * Implements a mono generation challenge. */ export class SingleGenerationChallenge extends Challenge { + public override get ribbonAwarded(): RibbonFlag { + // NOTE: This logic will not work for the eventual mono gen 10 ribbon, as + // as its flag will not be in sequence with the other mono gen ribbons. + return this.value ? ((RibbonData.MONO_GEN_1 << (this.value - 1)) as RibbonFlag) : 0; + } + constructor() { super(Challenges.SINGLE_GENERATION, 9); } @@ -686,6 +702,12 @@ interface monotypeOverride { * Implements a mono type challenge. */ export class SingleTypeChallenge extends Challenge { + public override get ribbonAwarded(): RibbonFlag { + // `this.value` represents the 1-based index of pokemon type + // `RibbonData.MONO_NORMAL` starts the flag position for the types, + // and we shift it by 1 for the specific type. + return this.value ? ((RibbonData.MONO_NORMAL << (this.value - 1)) as RibbonFlag) : 0; + } private static TYPE_OVERRIDES: monotypeOverride[] = [ { species: SpeciesId.CASTFORM, type: PokemonType.NORMAL, fusion: false }, ]; @@ -755,6 +777,9 @@ export class SingleTypeChallenge extends Challenge { * Implements a fresh start challenge. */ export class FreshStartChallenge extends Challenge { + public override get ribbonAwarded(): RibbonFlag { + return this.value ? RibbonData.FRESH_START : 0; + } constructor() { super(Challenges.FRESH_START, 2); } @@ -828,6 +853,9 @@ export class FreshStartChallenge extends Challenge { * Implements an inverse battle challenge. */ export class InverseBattleChallenge extends Challenge { + public override get ribbonAwarded(): RibbonFlag { + return this.value ? RibbonData.INVERSE : 0; + } constructor() { super(Challenges.INVERSE_BATTLE, 1); } @@ -861,6 +889,9 @@ export class InverseBattleChallenge extends Challenge { * Implements a flip stat challenge. */ export class FlipStatChallenge extends Challenge { + public override get ribbonAwarded(): RibbonFlag { + return this.value ? RibbonData.FLIP_STATS : 0; + } constructor() { super(Challenges.FLIP_STAT, 1); } @@ -941,6 +972,9 @@ export class LowerStarterPointsChallenge extends Challenge { * Implements a No Support challenge */ export class LimitedSupportChallenge extends Challenge { + public override get ribbonAwarded(): RibbonFlag { + return this.value ? ((RibbonData.NO_HEAL << (this.value - 1)) as RibbonFlag) : 0; + } constructor() { super(Challenges.LIMITED_SUPPORT, 3); } @@ -973,6 +1007,9 @@ export class LimitedSupportChallenge extends Challenge { * Implements a Limited Catch challenge */ export class LimitedCatchChallenge extends Challenge { + public override get ribbonAwarded(): RibbonFlag { + return this.value ? RibbonData.LIMITED_CATCH : 0; + } constructor() { super(Challenges.LIMITED_CATCH, 1); } @@ -997,6 +1034,9 @@ export class LimitedCatchChallenge extends Challenge { * Implements a Permanent Faint challenge */ export class HardcoreChallenge extends Challenge { + public override get ribbonAwarded(): RibbonFlag { + return this.value ? RibbonData.HARDCORE : 0; + } constructor() { super(Challenges.HARDCORE, 1); } diff --git a/src/data/egg-hatch-data.ts b/src/data/egg-hatch-data.ts index 6aead19eb7f..e78dc4d7984 100644 --- a/src/data/egg-hatch-data.ts +++ b/src/data/egg-hatch-data.ts @@ -47,6 +47,7 @@ export class EggHatchData { caughtCount: currDexEntry.caughtCount, hatchedCount: currDexEntry.hatchedCount, ivs: [...currDexEntry.ivs], + ribbons: currDexEntry.ribbons, }; this.starterDataEntryBeforeUpdate = { moveset: currStarterDataEntry.moveset, diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 442b6fabb51..5c4061ec388 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -90,7 +90,7 @@ import type { ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindS 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}`; } /** @@ -5926,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; diff --git a/src/data/moves/pokemon-move.ts b/src/data/moves/pokemon-move.ts index 3c96cbea598..cdb8d628be1 100644 --- a/src/data/moves/pokemon-move.ts +++ b/src/data/moves/pokemon-move.ts @@ -11,7 +11,7 @@ import { BooleanHolder, toDmgValue } from "#utils/common"; * These are the moves assigned to a {@linkcode Pokemon} object. * It links to {@linkcode Move} class via the move ID. * Compared to {@linkcode Move}, this class also tracks things like - * PP Ups recieved, PP used, etc. + * PP Ups received, PP used, etc. * @see {@linkcode isUsable} - checks if move is restricted, out of PP, or not implemented. * @see {@linkcode getMove} - returns {@linkcode Move} object by looking it up via ID. * @see {@linkcode usePp} - removes a point of PP from the move. 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/field/arena.ts b/src/field/arena.ts index 6f2310b95c2..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; } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 29f775ad094..3a5d435fb36 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -1,7 +1,7 @@ import type { Ability, PreAttackModifyDamageAbAttrParams } from "#abilities/ability"; import { applyAbAttrs, applyOnGainAbAttrs, applyOnLoseAbAttrs } from "#abilities/apply-ab-attrs"; import type { AnySound, BattleScene } from "#app/battle-scene"; -import { PLAYER_PARTY_MAX_SIZE } from "#app/constants"; +import { PLAYER_PARTY_MAX_SIZE, RARE_CANDY_FRIENDSHIP_CAP } from "#app/constants"; import { timedEventManager } from "#app/global-event-manager"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; @@ -139,6 +139,8 @@ import { populateVariantColors, variantColorCache, variantData } from "#sprites/ import { achvs } from "#system/achv"; import type { StarterDataEntry, StarterMoveset } from "#system/game-data"; import type { PokemonData } from "#system/pokemon-data"; +import { RibbonData } from "#system/ribbons/ribbon-data"; +import { awardRibbonsToSpeciesLine } from "#system/ribbons/ribbon-methods"; import type { AbAttrMap, AbAttrString, TypeMultiplierAbAttrParams } from "#types/ability-types"; import type { DamageCalculationResult, DamageResult } from "#types/damage-result"; import type { IllusionData } from "#types/illusion-data"; @@ -1825,7 +1827,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 +2032,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 +2062,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 +2130,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 +3003,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 = @@ -5822,45 +5824,59 @@ export class PlayerPokemon extends Pokemon { ); }); } - - addFriendship(friendship: number): void { - if (friendship > 0) { - const starterSpeciesId = this.species.getRootSpeciesId(); - const fusionStarterSpeciesId = this.isFusion() && this.fusionSpecies ? this.fusionSpecies.getRootSpeciesId() : 0; - const starterData = [ - globalScene.gameData.starterData[starterSpeciesId], - fusionStarterSpeciesId ? globalScene.gameData.starterData[fusionStarterSpeciesId] : null, - ].filter(d => !!d); - const amount = new NumberHolder(friendship); - globalScene.applyModifier(PokemonFriendshipBoosterModifier, true, this, amount); - const candyFriendshipMultiplier = globalScene.gameMode.isClassic - ? timedEventManager.getClassicFriendshipMultiplier() - : 1; - const fusionReduction = fusionStarterSpeciesId - ? timedEventManager.areFusionsBoosted() - ? 1.5 // Divide candy gain for fusions by 1.5 during events - : 2 // 2 for fusions outside events - : 1; // 1 for non-fused mons - const starterAmount = new NumberHolder(Math.floor((amount.value * candyFriendshipMultiplier) / fusionReduction)); - - // Add friendship to this PlayerPokemon - this.friendship = Math.min(this.friendship + amount.value, 255); - if (this.friendship === 255) { - globalScene.validateAchv(achvs.MAX_FRIENDSHIP); - } - // Add to candy progress for this mon's starter species and its fused species (if it has one) - starterData.forEach((sd: StarterDataEntry, i: number) => { - const speciesId = !i ? starterSpeciesId : (fusionStarterSpeciesId as SpeciesId); - sd.friendship = (sd.friendship || 0) + starterAmount.value; - if (sd.friendship >= getStarterValueFriendshipCap(speciesStarterCosts[speciesId])) { - globalScene.gameData.addStarterCandy(getPokemonSpecies(speciesId), 1); - sd.friendship = 0; - } - }); - } else { - // Lose friendship upon fainting + /** + * Add friendship to this Pokemon + * + * @remarks + * This adds friendship to the pokemon's friendship stat (used for evolution, return, etc.) and candy progress. + * For fusions, candy progress for each species in the fusion is halved. + * + * @param friendship - The amount of friendship to add. Negative values will reduce friendship, though not below 0. + * @param capped - If true, don't allow the friendship gain to exceed 200. Used to cap friendship gains from rare candies. + */ + addFriendship(friendship: number, capped = false): void { + // Short-circuit friendship loss, which doesn't impact candy friendship + if (friendship <= 0) { this.friendship = Math.max(this.friendship + friendship, 0); + return; } + + const starterSpeciesId = this.species.getRootSpeciesId(); + const fusionStarterSpeciesId = this.isFusion() && this.fusionSpecies ? this.fusionSpecies.getRootSpeciesId() : 0; + const starterGameData = globalScene.gameData.starterData; + const starterData: [StarterDataEntry, SpeciesId][] = [[starterGameData[starterSpeciesId], starterSpeciesId]]; + if (fusionStarterSpeciesId) { + starterData.push([starterGameData[fusionStarterSpeciesId], fusionStarterSpeciesId]); + } + const amount = new NumberHolder(friendship); + globalScene.applyModifier(PokemonFriendshipBoosterModifier, true, this, amount); + friendship = amount.value; + + const newFriendship = this.friendship + friendship; + // If capped is true, only adjust friendship if the new friendship is less than or equal to 200. + if (!capped || newFriendship <= RARE_CANDY_FRIENDSHIP_CAP) { + this.friendship = Math.min(newFriendship, 255); + if (newFriendship >= 255) { + globalScene.validateAchv(achvs.MAX_FRIENDSHIP); + awardRibbonsToSpeciesLine(this.species.speciesId, RibbonData.FRIENDSHIP); + } + } + + let candyFriendshipMultiplier = globalScene.gameMode.isClassic + ? timedEventManager.getClassicFriendshipMultiplier() + : 1; + if (fusionStarterSpeciesId) { + candyFriendshipMultiplier /= timedEventManager.areFusionsBoosted() ? 1.5 : 2; + } + const candyFriendshipAmount = Math.floor(friendship * candyFriendshipMultiplier); + // Add to candy progress for this mon's starter species and its fused species (if it has one) + starterData.forEach(([sd, id]: [StarterDataEntry, SpeciesId]) => { + sd.friendship = (sd.friendship || 0) + candyFriendshipAmount; + if (sd.friendship >= getStarterValueFriendshipCap(speciesStarterCosts[id])) { + globalScene.gameData.addStarterCandy(getPokemonSpecies(id), 1); + sd.friendship = 0; + } + }); } getPossibleEvolution(evolution: SpeciesFormEvolution | null): Promise { @@ -6241,22 +6257,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 +6282,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 d2b4a76ef10..bf4d87a99f3 100644 --- a/src/loading-scene.ts +++ b/src/loading-scene.ts @@ -90,6 +90,7 @@ export class LoadingScene extends SceneBase { this.loadAtlas("shiny_icons", "ui"); this.loadImage("ha_capsule", "ui", "ha_capsule.png"); this.loadImage("champion_ribbon", "ui", "champion_ribbon.png"); + this.loadImage("champion_ribbon_emerald", "ui", "champion_ribbon_emerald.png"); this.loadImage("icon_spliced", "ui"); this.loadImage("icon_lock", "ui", "icon_lock.png"); this.loadImage("icon_stop", "ui", "icon_stop.png"); @@ -122,6 +123,7 @@ export class LoadingScene extends SceneBase { this.loadImage("party_bg_double", "ui"); this.loadImage("party_bg_double_manage", "ui"); this.loadAtlas("party_slot_main", "ui"); + this.loadAtlas("party_slot_main_short", "ui"); this.loadAtlas("party_slot", "ui"); this.loadImage("party_slot_overlay_lv", "ui"); this.loadImage("party_slot_hp_bar", "ui"); @@ -447,7 +449,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..076e2656b5c 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); } } @@ -2304,7 +2304,7 @@ export class PokemonLevelIncrementModifier extends ConsumablePokemonModifier { playerPokemon.levelExp = 0; } - playerPokemon.addFriendship(FRIENDSHIP_GAIN_FROM_RARE_CANDY); + playerPokemon.addFriendship(FRIENDSHIP_GAIN_FROM_RARE_CANDY, true); globalScene.phaseManager.unshiftNew( "LevelUpPhase", @@ -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/phases/game-over-phase.ts b/src/phases/game-over-phase.ts index d4562b5a237..25dfffaa582 100644 --- a/src/phases/game-over-phase.ts +++ b/src/phases/game-over-phase.ts @@ -19,8 +19,11 @@ import { ChallengeData } from "#system/challenge-data"; import type { SessionSaveData } from "#system/game-data"; import { ModifierData as PersistentModifierData } from "#system/modifier-data"; import { PokemonData } from "#system/pokemon-data"; +import { RibbonData, type RibbonFlag } from "#system/ribbons/ribbon-data"; +import { awardRibbonsToSpeciesLine } from "#system/ribbons/ribbon-methods"; import { TrainerData } from "#system/trainer-data"; import { trainerConfigs } from "#trainers/trainer-config"; +import { checkSpeciesValidForChallenge, isNuzlockeChallenge } from "#utils/challenge-utils"; import { isLocal, isLocalServerConnected } from "#utils/common"; import { getPokemonSpecies } from "#utils/pokemon-utils"; import i18next from "i18next"; @@ -111,6 +114,40 @@ export class GameOverPhase extends BattlePhase { } } + /** + * Submethod of {@linkcode handleGameOver} that awards ribbons to Pokémon in the player's party based on the current + * game mode and challenges. + */ + private awardRibbons(): void { + let ribbonFlags = 0; + if (globalScene.gameMode.isClassic) { + ribbonFlags |= RibbonData.CLASSIC; + } + if (isNuzlockeChallenge()) { + ribbonFlags |= RibbonData.NUZLOCKE; + } + for (const challenge of globalScene.gameMode.challenges) { + const ribbon = challenge.ribbonAwarded; + if (challenge.value && ribbon) { + ribbonFlags |= ribbon; + } + } + // Award ribbons to all Pokémon in the player's party that are considered valid + // for the current game mode and challenges. + for (const pokemon of globalScene.getPlayerParty()) { + const species = pokemon.species; + if ( + checkSpeciesValidForChallenge( + species, + globalScene.gameData.getSpeciesDexAttrProps(species, pokemon.getDexAttr()), + false, + ) + ) { + awardRibbonsToSpeciesLine(species.speciesId, ribbonFlags as RibbonFlag); + } + } + } + handleGameOver(): void { const doGameOver = (newClear: boolean) => { globalScene.disableMenu = true; @@ -122,12 +159,12 @@ export class GameOverPhase extends BattlePhase { globalScene.validateAchv(achvs.UNEVOLVED_CLASSIC_VICTORY); globalScene.gameData.gameStats.sessionsWon++; for (const pokemon of globalScene.getPlayerParty()) { - this.awardRibbon(pokemon); - + this.awardFirstClassicCompletion(pokemon); if (pokemon.species.getRootSpeciesId() !== pokemon.species.getRootSpeciesId(true)) { - this.awardRibbon(pokemon, true); + this.awardFirstClassicCompletion(pokemon, true); } } + this.awardRibbons(); } else if (globalScene.gameMode.isDaily && newClear) { globalScene.gameData.gameStats.dailyRunSessionsWon++; } @@ -263,7 +300,7 @@ export class GameOverPhase extends BattlePhase { } } - awardRibbon(pokemon: Pokemon, forStarter = false): void { + awardFirstClassicCompletion(pokemon: Pokemon, forStarter = false): void { const speciesId = getPokemonSpecies(pokemon.species.speciesId); const speciesRibbonCount = globalScene.gameData.incrementRibbonCount(speciesId, forStarter); // first time classic win, award voucher diff --git a/src/plugins/api/pokerogue-session-savedata-api.ts b/src/plugins/api/pokerogue-session-savedata-api.ts index 4ffb0a5d8da..39fa292f9f1 100644 --- a/src/plugins/api/pokerogue-session-savedata-api.ts +++ b/src/plugins/api/pokerogue-session-savedata-api.ts @@ -56,15 +56,15 @@ export class PokerogueSessionSavedataApi extends ApiBase { /** * Update a session savedata. - * @param params The {@linkcode UpdateSessionSavedataRequest} to send - * @param rawSavedata The raw savedata (as `string`) + * @param params - The request to send + * @param rawSavedata - The raw, unencrypted savedata * @returns An error message if something went wrong */ - public async update(params: UpdateSessionSavedataRequest, rawSavedata: string) { + public async update(params: UpdateSessionSavedataRequest, rawSavedata: string): Promise { try { const urlSearchParams = this.toUrlSearchParams(params); - const response = await this.doPost(`/savedata/session/update?${urlSearchParams}`, rawSavedata); + const response = await this.doPost(`/savedata/session/update?${urlSearchParams}`, rawSavedata); return await response.text(); } catch (err) { console.warn("Could not update session savedata!", err); diff --git a/src/system/achv.ts b/src/system/achv.ts index 6edd0349fad..f238acbda3a 100644 --- a/src/system/achv.ts +++ b/src/system/achv.ts @@ -5,7 +5,6 @@ import { FlipStatChallenge, FreshStartChallenge, InverseBattleChallenge, - LimitedCatchChallenge, SingleGenerationChallenge, SingleTypeChallenge, } from "#data/challenge"; @@ -14,6 +13,7 @@ import { PlayerGender } from "#enums/player-gender"; import { getShortenedStatKey, Stat } from "#enums/stat"; import { TurnHeldItemTransferModifier } from "#modifiers/modifier"; import type { ConditionFn } from "#types/common"; +import { isNuzlockeChallenge } from "#utils/challenge-utils"; import { NumberHolder } from "#utils/common"; import i18next from "i18next"; import type { Modifier } from "typescript"; @@ -926,18 +926,7 @@ export const achvs = { globalScene.gameMode.challenges.some(c => c.id === Challenges.INVERSE_BATTLE && c.value > 0), ).setSecret(), // TODO: Decide on icon - NUZLOCKE: new ChallengeAchv( - "NUZLOCKE", - "", - "NUZLOCKE.description", - "leaf_stone", - 100, - c => - c instanceof LimitedCatchChallenge && - c.value > 0 && - globalScene.gameMode.challenges.some(c => c.id === Challenges.HARDCORE && c.value > 0) && - globalScene.gameMode.challenges.some(c => c.id === Challenges.FRESH_START && c.value > 0), - ), + NUZLOCKE: new ChallengeAchv("NUZLOCKE", "", "NUZLOCKE.description", "leaf_stone", 100, isNuzlockeChallenge), BREEDERS_IN_SPACE: new Achv("BREEDERS_IN_SPACE", "", "BREEDERS_IN_SPACE.description", "moon_stone", 50).setSecret(), }; diff --git a/src/system/game-data.ts b/src/system/game-data.ts index 0313d64dd80..90cbf6e18cc 100644 --- a/src/system/game-data.ts +++ b/src/system/game-data.ts @@ -48,6 +48,7 @@ import { EggData } from "#system/egg-data"; import { GameStats } from "#system/game-stats"; import { ModifierData as PersistentModifierData } from "#system/modifier-data"; import { PokemonData } from "#system/pokemon-data"; +import { RibbonData } from "#system/ribbons/ribbon-data"; import { resetSettings, SettingKeys, setSetting } from "#system/settings"; import { SettingGamepad, setSettingGamepad, settingGamepadDefaults } from "#system/settings-gamepad"; import type { SettingKeyboard } from "#system/settings-keyboard"; @@ -127,7 +128,8 @@ export interface SessionSaveData { battleType: BattleType; trainer: TrainerData; gameVersion: string; - runNameText: string; + /** The player-chosen name of the run */ + name: string; timestamp: number; challenges: ChallengeData[]; mysteryEncounterType: MysteryEncounterType | -1; // Only defined when current wave is ME, @@ -207,10 +209,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; } @@ -400,121 +404,121 @@ export class GameData { } public initSystem(systemDataStr: string, cachedSystemDataStr?: string): Promise { - return new Promise(resolve => { - try { - let systemData = this.parseSystemData(systemDataStr); + const { promise, resolve } = Promise.withResolvers(); + try { + let systemData = this.parseSystemData(systemDataStr); - if (cachedSystemDataStr) { - const cachedSystemData = this.parseSystemData(cachedSystemDataStr); - if (cachedSystemData.timestamp > systemData.timestamp) { - console.debug("Use cached system"); - systemData = cachedSystemData; - systemDataStr = cachedSystemDataStr; - } else { - this.clearLocalData(); - } - } - - console.debug(systemData); - - localStorage.setItem(`data_${loggedInUser?.username}`, encrypt(systemDataStr, bypassLogin)); - - const lsItemKey = `runHistoryData_${loggedInUser?.username}`; - const lsItem = localStorage.getItem(lsItemKey); - if (!lsItem) { - localStorage.setItem(lsItemKey, ""); - } - - applySystemVersionMigration(systemData); - - this.trainerId = systemData.trainerId; - this.secretId = systemData.secretId; - - this.gender = systemData.gender; - - this.saveSetting(SettingKeys.Player_Gender, systemData.gender === PlayerGender.FEMALE ? 1 : 0); - - if (!systemData.starterData) { - this.initStarterData(); - - if (systemData["starterMoveData"]) { - const starterMoveData = systemData["starterMoveData"]; - for (const s of Object.keys(starterMoveData)) { - this.starterData[s].moveset = starterMoveData[s]; - } - } - - if (systemData["starterEggMoveData"]) { - const starterEggMoveData = systemData["starterEggMoveData"]; - for (const s of Object.keys(starterEggMoveData)) { - this.starterData[s].eggMoves = starterEggMoveData[s]; - } - } - - this.migrateStarterAbilities(systemData, this.starterData); - - const starterIds = Object.keys(this.starterData).map(s => Number.parseInt(s) as SpeciesId); - for (const s of starterIds) { - this.starterData[s].candyCount += systemData.dexData[s].caughtCount; - this.starterData[s].candyCount += systemData.dexData[s].hatchedCount * 2; - if (systemData.dexData[s].caughtAttr & DexAttr.SHINY) { - this.starterData[s].candyCount += 4; - } - } + if (cachedSystemDataStr) { + const cachedSystemData = this.parseSystemData(cachedSystemDataStr); + if (cachedSystemData.timestamp > systemData.timestamp) { + console.debug("Use cached system"); + systemData = cachedSystemData; + systemDataStr = cachedSystemDataStr; } else { - this.starterData = systemData.starterData; + this.clearLocalData(); } - - if (systemData.gameStats) { - this.gameStats = systemData.gameStats; - } - - if (systemData.unlocks) { - for (const key of Object.keys(systemData.unlocks)) { - if (this.unlocks.hasOwnProperty(key)) { - this.unlocks[key] = systemData.unlocks[key]; - } - } - } - - if (systemData.achvUnlocks) { - for (const a of Object.keys(systemData.achvUnlocks)) { - if (achvs.hasOwnProperty(a)) { - this.achvUnlocks[a] = systemData.achvUnlocks[a]; - } - } - } - - if (systemData.voucherUnlocks) { - for (const v of Object.keys(systemData.voucherUnlocks)) { - if (vouchers.hasOwnProperty(v)) { - this.voucherUnlocks[v] = systemData.voucherUnlocks[v]; - } - } - } - - if (systemData.voucherCounts) { - getEnumKeys(VoucherType).forEach(key => { - const index = VoucherType[key]; - this.voucherCounts[index] = systemData.voucherCounts[index] || 0; - }); - } - - this.eggs = systemData.eggs ? systemData.eggs.map(e => e.toEgg()) : []; - - this.eggPity = systemData.eggPity ? systemData.eggPity.slice(0) : [0, 0, 0, 0]; - this.unlockPity = systemData.unlockPity ? systemData.unlockPity.slice(0) : [0, 0, 0, 0]; - - this.dexData = Object.assign(this.dexData, systemData.dexData); - this.consolidateDexData(this.dexData); - this.defaultDexData = null; - - resolve(true); - } catch (err) { - console.error(err); - resolve(false); } - }); + + console.debug(systemData); + + localStorage.setItem(`data_${loggedInUser?.username}`, encrypt(systemDataStr, bypassLogin)); + + const lsItemKey = `runHistoryData_${loggedInUser?.username}`; + const lsItem = localStorage.getItem(lsItemKey); + if (!lsItem) { + localStorage.setItem(lsItemKey, ""); + } + + applySystemVersionMigration(systemData); + + this.trainerId = systemData.trainerId; + this.secretId = systemData.secretId; + + this.gender = systemData.gender; + + this.saveSetting(SettingKeys.Player_Gender, systemData.gender === PlayerGender.FEMALE ? 1 : 0); + + if (!systemData.starterData) { + this.initStarterData(); + + if (systemData["starterMoveData"]) { + const starterMoveData = systemData["starterMoveData"]; + for (const s of Object.keys(starterMoveData)) { + this.starterData[s].moveset = starterMoveData[s]; + } + } + + if (systemData["starterEggMoveData"]) { + const starterEggMoveData = systemData["starterEggMoveData"]; + for (const s of Object.keys(starterEggMoveData)) { + this.starterData[s].eggMoves = starterEggMoveData[s]; + } + } + + this.migrateStarterAbilities(systemData, this.starterData); + + const starterIds = Object.keys(this.starterData).map(s => Number.parseInt(s) as SpeciesId); + for (const s of starterIds) { + this.starterData[s].candyCount += systemData.dexData[s].caughtCount; + this.starterData[s].candyCount += systemData.dexData[s].hatchedCount * 2; + if (systemData.dexData[s].caughtAttr & DexAttr.SHINY) { + this.starterData[s].candyCount += 4; + } + } + } else { + this.starterData = systemData.starterData; + } + + if (systemData.gameStats) { + this.gameStats = systemData.gameStats; + } + + if (systemData.unlocks) { + for (const key of Object.keys(systemData.unlocks)) { + if (this.unlocks.hasOwnProperty(key)) { + this.unlocks[key] = systemData.unlocks[key]; + } + } + } + + if (systemData.achvUnlocks) { + for (const a of Object.keys(systemData.achvUnlocks)) { + if (achvs.hasOwnProperty(a)) { + this.achvUnlocks[a] = systemData.achvUnlocks[a]; + } + } + } + + if (systemData.voucherUnlocks) { + for (const v of Object.keys(systemData.voucherUnlocks)) { + if (vouchers.hasOwnProperty(v)) { + this.voucherUnlocks[v] = systemData.voucherUnlocks[v]; + } + } + } + + if (systemData.voucherCounts) { + getEnumKeys(VoucherType).forEach(key => { + const index = VoucherType[key]; + this.voucherCounts[index] = systemData.voucherCounts[index] || 0; + }); + } + + this.eggs = systemData.eggs ? systemData.eggs.map(e => e.toEgg()) : []; + + this.eggPity = systemData.eggPity ? systemData.eggPity.slice(0) : [0, 0, 0, 0]; + this.unlockPity = systemData.unlockPity ? systemData.unlockPity.slice(0) : [0, 0, 0, 0]; + + this.dexData = Object.assign(this.dexData, systemData.dexData); + this.consolidateDexData(this.dexData); + this.defaultDexData = null; + + resolve(true); + } catch (err) { + console.error(err); + resolve(false); + } + return promise; } /** @@ -625,6 +629,9 @@ export class GameData { } return ret; } + if (k === "ribbons") { + return RibbonData.fromJSON(v); + } return k.endsWith("Attr") && !["natureAttr", "abilityAttr", "passiveAttr"].includes(k) ? BigInt(v ?? 0) : v; }) as SystemSaveData; @@ -823,52 +830,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; @@ -981,51 +987,45 @@ 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 (slotId < 0) { + return false; + } + if (newName === "") { + return true; + } + const sessionData: SessionSaveData | null = await this.getSession(slotId); - if (!sessionData) { - return resolve(false); - } + if (!sessionData) { + return false; + } - if (newName === "") { - return resolve(true); - } + sessionData.name = newName; + // update timestamp by 1 to ensure the session is saved + sessionData.timestamp += 1; + const updatedDataStr = JSON.stringify(sessionData); + const encrypted = encrypt(updatedDataStr, bypassLogin); + const secretId = this.secretId; + const trainerId = this.trainerId; - 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), + ); + return true; + } - 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); - } - }); - }); + const response = await pokerogueApi.savedata.session.update( + { slot: slotId, trainerId, secretId, clientSessionId }, + updatedDataStr, + ); + + if (response) { + return false; + } + localStorage.setItem(`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`, encrypted); + const success = await updateUserInfo(); + return !(success !== null && !success); } loadSession(slotId: number, sessionData?: SessionSaveData): Promise { @@ -1633,6 +1633,7 @@ export class GameData { caughtCount: 0, hatchedCount: 0, ivs: [0, 0, 0, 0, 0, 0], + ribbons: new RibbonData(0), }; } @@ -1877,6 +1878,12 @@ export class GameData { }); } + /** + * Increase the number of classic ribbons won with this species. + * @param species - The species to increment the ribbon count for + * @param forStarter - If true, will increment the ribbon count for the root species of the given species + * @returns The number of classic wins after incrementing. + */ incrementRibbonCount(species: PokemonSpecies, forStarter = false): number { const speciesIdToIncrement: SpeciesId = species.getRootSpeciesId(forStarter); @@ -2176,6 +2183,9 @@ export class GameData { if (!entry.hasOwnProperty("natureAttr") || (entry.caughtAttr && !entry.natureAttr)) { entry.natureAttr = this.defaultDexData?.[k].natureAttr || 1 << randInt(25, 1); } + if (!entry.hasOwnProperty("ribbons")) { + entry.ribbons = new RibbonData(0); + } } } diff --git a/src/system/ribbons/ribbon-data.ts b/src/system/ribbons/ribbon-data.ts new file mode 100644 index 00000000000..42c523afc0e --- /dev/null +++ b/src/system/ribbons/ribbon-data.ts @@ -0,0 +1,148 @@ +import type { Brander } from "#types/type-helpers"; + +export type RibbonFlag = (number & Brander<"RibbonFlag">) | 0; + +/** + * Class for ribbon data management. Usually constructed via the {@linkcode fromJSON} method. + * + * @remarks + * Stores information about the ribbons earned by a species using a bitfield. + */ +export class RibbonData { + /** Internal bitfield storing the unlock state for each ribbon */ + private payload: number; + + //#region Ribbons + //#region Monotype challenge ribbons + /** Ribbon for winning the normal monotype challenge */ + public static readonly MONO_NORMAL = 0x1 as RibbonFlag; + /** Ribbon for winning the fighting monotype challenge */ + public static readonly MONO_FIGHTING = 0x2 as RibbonFlag; + /** Ribbon for winning the flying monotype challenge */ + public static readonly MONO_FLYING = 0x4 as RibbonFlag; + /** Ribbon for winning the poision monotype challenge */ + public static readonly MONO_POISON = 0x8 as RibbonFlag; + /** Ribbon for winning the ground monotype challenge */ + public static readonly MONO_GROUND = 0x10 as RibbonFlag; + /** Ribbon for winning the rock monotype challenge */ + public static readonly MONO_ROCK = 0x20 as RibbonFlag; + /** Ribbon for winning the bug monotype challenge */ + public static readonly MONO_BUG = 0x40 as RibbonFlag; + /** Ribbon for winning the ghost monotype challenge */ + public static readonly MONO_GHOST = 0x80 as RibbonFlag; + /** Ribbon for winning the steel monotype challenge */ + public static readonly MONO_STEEL = 0x100 as RibbonFlag; + /** Ribbon for winning the fire monotype challenge */ + public static readonly MONO_FIRE = 0x200 as RibbonFlag; + /** Ribbon for winning the water monotype challenge */ + public static readonly MONO_WATER = 0x400 as RibbonFlag; + /** Ribbon for winning the grass monotype challenge */ + public static readonly MONO_GRASS = 0x800 as RibbonFlag; + /** Ribbon for winning the electric monotype challenge */ + public static readonly MONO_ELECTRIC = 0x1000 as RibbonFlag; + /** Ribbon for winning the psychic monotype challenge */ + public static readonly MONO_PSYCHIC = 0x2000 as RibbonFlag; + /** Ribbon for winning the ice monotype challenge */ + public static readonly MONO_ICE = 0x4000 as RibbonFlag; + /** Ribbon for winning the dragon monotype challenge */ + public static readonly MONO_DRAGON = 0x8000 as RibbonFlag; + /** Ribbon for winning the dark monotype challenge */ + public static readonly MONO_DARK = 0x10000 as RibbonFlag; + /** Ribbon for winning the fairy monotype challenge */ + public static readonly MONO_FAIRY = 0x20000 as RibbonFlag; + //#endregion Monotype ribbons + + //#region Monogen ribbons + /** Ribbon for winning the the mono gen 1 challenge */ + public static readonly MONO_GEN_1 = 0x40000 as RibbonFlag; + /** Ribbon for winning the the mono gen 2 challenge */ + public static readonly MONO_GEN_2 = 0x80000 as RibbonFlag; + /** Ribbon for winning the mono gen 3 challenge */ + public static readonly MONO_GEN_3 = 0x100000 as RibbonFlag; + /** Ribbon for winning the mono gen 4 challenge */ + public static readonly MONO_GEN_4 = 0x200000 as RibbonFlag; + /** Ribbon for winning the mono gen 5 challenge */ + public static readonly MONO_GEN_5 = 0x400000 as RibbonFlag; + /** Ribbon for winning the mono gen 6 challenge */ + public static readonly MONO_GEN_6 = 0x800000 as RibbonFlag; + /** Ribbon for winning the mono gen 7 challenge */ + public static readonly MONO_GEN_7 = 0x1000000 as RibbonFlag; + /** Ribbon for winning the mono gen 8 challenge */ + public static readonly MONO_GEN_8 = 0x2000000 as RibbonFlag; + /** Ribbon for winning the mono gen 9 challenge */ + public static readonly MONO_GEN_9 = 0x4000000 as RibbonFlag; + //#endregion Monogen ribbons + + /** Ribbon for winning classic */ + public static readonly CLASSIC = 0x8000000 as RibbonFlag; + /** Ribbon for winning the nuzzlocke challenge */ + public static readonly NUZLOCKE = 0x10000000 as RibbonFlag; + /** Ribbon for reaching max friendship */ + public static readonly FRIENDSHIP = 0x20000000 as RibbonFlag; + /** Ribbon for winning the flip stats challenge */ + public static readonly FLIP_STATS = 0x40000000 as RibbonFlag; + /** Ribbon for winning the inverse challenge */ + public static readonly INVERSE = 0x80000000 as RibbonFlag; + /** Ribbon for winning the fresh start challenge */ + public static readonly FRESH_START = 0x100000000 as RibbonFlag; + /** Ribbon for winning the hardcore challenge */ + public static readonly HARDCORE = 0x200000000 as RibbonFlag; + /** Ribbon for winning the limited catch challenge */ + public static readonly LIMITED_CATCH = 0x400000000 as RibbonFlag; + /** Ribbon for winning the limited support challenge set to no heal */ + public static readonly NO_HEAL = 0x800000000 as RibbonFlag; + /** Ribbon for winning the limited uspport challenge set to no shop */ + public static readonly NO_SHOP = 0x1000000000 as RibbonFlag; + /** Ribbon for winning the limited support challenge set to both*/ + public static readonly NO_SUPPORT = 0x2000000000 as RibbonFlag; + + // NOTE: max possible ribbon flag is 0x20000000000000 (53 total ribbons) + // Once this is exceeded, bitfield needs to be changed to a bigint or even a uint array + // Note that this has no impact on serialization as it is stored in hex. + + //#endregion Ribbons + + /** Create a new instance of RibbonData. Generally, {@linkcode fromJSON} is used instead. */ + constructor(value: number) { + this.payload = value; + } + + /** Serialize the bitfield payload as a hex encoded string */ + public toJSON(): string { + return this.payload.toString(16); + } + + /** + * Decode a hexadecimal string representation of the bitfield into a `RibbonData` instance + * + * @param value - Hexadecimal string representation of the bitfield (without the leading 0x) + * @returns A new instance of `RibbonData` initialized with the provided bitfield. + */ + public static fromJSON(value: string): RibbonData { + try { + return new RibbonData(Number.parseInt(value, 16)); + } catch { + return new RibbonData(0); + } + } + + /** + * Award one or more ribbons to the ribbon data by setting the corresponding flags in the bitfield. + * + * @param flags - The flags to set. Can be a single flag or multiple flags. + */ + public award(...flags: [RibbonFlag, ...RibbonFlag[]]): void { + for (const f of flags) { + this.payload |= f; + } + } + + /** + * Check if a specific ribbon has been awarded + * @param flag - The ribbon to check + * @returns Whether the specified flag has been awarded + */ + public has(flag: RibbonFlag): boolean { + return !!(this.payload & flag); + } +} diff --git a/src/system/ribbons/ribbon-methods.ts b/src/system/ribbons/ribbon-methods.ts new file mode 100644 index 00000000000..a465357ab8c --- /dev/null +++ b/src/system/ribbons/ribbon-methods.ts @@ -0,0 +1,20 @@ +import { globalScene } from "#app/global-scene"; +import { pokemonPrevolutions } from "#balance/pokemon-evolutions"; +import type { SpeciesId } from "#enums/species-id"; +import type { RibbonFlag } from "#system/ribbons/ribbon-data"; +import { isNullOrUndefined } from "#utils/common"; + +/** + * Award one or more ribbons to a species and its pre-evolutions + * + * @param id - The ID of the species to award ribbons to + * @param ribbons - The ribbon(s) to award (use bitwise OR to combine multiple) + */ +export function awardRibbonsToSpeciesLine(id: SpeciesId, ribbons: RibbonFlag): void { + const dexData = globalScene.gameData.dexData; + dexData[id].ribbons.award(ribbons); + // Mark all pre-evolutions of the Pokémon with the same ribbon flags. + for (let prevoId = pokemonPrevolutions[id]; !isNullOrUndefined(prevoId); prevoId = pokemonPrevolutions[prevoId]) { + dexData[id].ribbons.award(ribbons); + } +} diff --git a/src/ui/party-ui-handler.ts b/src/ui/party-ui-handler.ts index ff5e7246a6f..3101f46f098 100644 --- a/src/ui/party-ui-handler.ts +++ b/src/ui/party-ui-handler.ts @@ -31,6 +31,11 @@ import { toTitleCase } from "#utils/strings"; import i18next from "i18next"; import type BBCodeText from "phaser3-rex-plugins/plugins/bbcodetext"; +const DISCARD_BUTTON_X = 60; +const DISCARD_BUTTON_X_DOUBLES = 64; +const DISCARD_BUTTON_Y = -73; +const DISCARD_BUTTON_Y_DOUBLES = -58; + const defaultMessage = i18next.t("partyUiHandler:choosePokemon"); /** @@ -301,7 +306,7 @@ export class PartyUiHandler extends MessageUiHandler { const partyMessageText = addTextObject(10, 8, defaultMessage, TextStyle.WINDOW, { maxLines: 2 }); partyMessageText.setName("text-party-msg"); - partyMessageText.setOrigin(0, 0); + partyMessageText.setOrigin(0); partyMessageBoxContainer.add(partyMessageText); this.message = partyMessageText; @@ -317,10 +322,8 @@ export class PartyUiHandler extends MessageUiHandler { this.iconAnimHandler = new PokemonIconAnimHandler(); this.iconAnimHandler.setup(); - const partyDiscardModeButton = new PartyDiscardModeButton(60, -globalScene.game.canvas.height / 15 - 1, this); - + const partyDiscardModeButton = new PartyDiscardModeButton(DISCARD_BUTTON_X, DISCARD_BUTTON_Y, this); partyContainer.add(partyDiscardModeButton); - this.partyDiscardModeButton = partyDiscardModeButton; // prepare move overlay @@ -1233,7 +1236,7 @@ export class PartyUiHandler extends MessageUiHandler { } if (!this.optionsCursorObj) { this.optionsCursorObj = globalScene.add.image(0, 0, "cursor"); - this.optionsCursorObj.setOrigin(0, 0); + this.optionsCursorObj.setOrigin(0); this.optionsContainer.add(this.optionsCursorObj); } this.optionsCursorObj.setPosition( @@ -1605,7 +1608,7 @@ export class PartyUiHandler extends MessageUiHandler { optionText.setColor("#40c8f8"); optionText.setShadowColor("#006090"); } - optionText.setOrigin(0, 0); + optionText.setOrigin(0); /** For every item that has stack bigger than 1, display the current quantity selection */ const itemModifiers = this.getItemModifiers(pokemon); @@ -1802,6 +1805,7 @@ class PartySlot extends Phaser.GameObjects.Container { private selected: boolean; private transfer: boolean; private slotIndex: number; + private isBenched: boolean; private pokemon: PlayerPokemon; private slotBg: Phaser.GameObjects.Image; @@ -1812,6 +1816,7 @@ class PartySlot extends Phaser.GameObjects.Container { public slotHpText: Phaser.GameObjects.Text; public slotDescriptionLabel: Phaser.GameObjects.Text; // this is used to show text instead of the HP bar i.e. for showing "Able"/"Not Able" for TMs when you try to learn them + private slotBgKey: string; private pokemonIcon: Phaser.GameObjects.Container; private iconAnimHandler: PokemonIconAnimHandler; @@ -1822,19 +1827,34 @@ class PartySlot extends Phaser.GameObjects.Container { partyUiMode: PartyUiMode, tmMoveId: MoveId, ) { - super( - globalScene, - slotIndex >= globalScene.currentBattle.getBattlerCount() ? 230.5 : 64, - slotIndex >= globalScene.currentBattle.getBattlerCount() - ? -184 + - (globalScene.currentBattle.double ? -40 : 0) + - (28 + (globalScene.currentBattle.double ? 8 : 0)) * slotIndex - : partyUiMode === PartyUiMode.MODIFIER_TRANSFER - ? -124 + (globalScene.currentBattle.double ? -20 : 0) + slotIndex * 55 - : -124 + (globalScene.currentBattle.double ? -8 : 0) + slotIndex * 64, - ); + const isBenched = slotIndex >= globalScene.currentBattle.getBattlerCount(); + const isDoubleBattle = globalScene.currentBattle.double; + const isItemManageMode = partyUiMode === PartyUiMode.MODIFIER_TRANSFER || partyUiMode === PartyUiMode.DISCARD; + + /* + * Here we determine the position of the slot. + * The x coordinate depends on whether the pokemon is on the field or in the bench. + * The y coordinate depends on various factors, such as the number of pokémon on the field, + * and whether the transfer/discard button is also on the screen. + */ + const slotPositionX = isBenched ? 143 : 9; + + let slotPositionY: number; + if (isBenched) { + slotPositionY = -196 + (isDoubleBattle ? -40 : 0); + slotPositionY += (28 + (isDoubleBattle ? 8 : 0)) * slotIndex; + } else { + slotPositionY = -148.5; + if (isDoubleBattle) { + slotPositionY += isItemManageMode ? -20 : -8; + } + slotPositionY += (isItemManageMode ? (isDoubleBattle ? 47 : 55) : 64) * slotIndex; + } + + super(globalScene, slotPositionX, slotPositionY); this.slotIndex = slotIndex; + this.isBenched = isBenched; this.pokemon = pokemon; this.iconAnimHandler = iconAnimHandler; @@ -1848,27 +1868,75 @@ class PartySlot extends Phaser.GameObjects.Container { setup(partyUiMode: PartyUiMode, tmMoveId: MoveId) { const currentLanguage = i18next.resolvedLanguage ?? "en"; const offsetJa = currentLanguage === "ja"; + const isItemManageMode = partyUiMode === PartyUiMode.MODIFIER_TRANSFER || partyUiMode === PartyUiMode.DISCARD; - const battlerCount = globalScene.currentBattle.getBattlerCount(); + this.slotBgKey = this.isBenched + ? "party_slot" + : isItemManageMode && globalScene.currentBattle.double + ? "party_slot_main_short" + : "party_slot_main"; + const fullSlotBgKey = this.pokemon.hp ? this.slotBgKey : `${this.slotBgKey}${"_fnt"}`; + this.slotBg = globalScene.add.sprite(0, 0, this.slotBgKey, fullSlotBgKey); + this.slotBg.setOrigin(0); + this.add(this.slotBg); - const slotKey = `party_slot${this.slotIndex >= battlerCount ? "" : "_main"}`; + const genderSymbol = getGenderSymbol(this.pokemon.getGender(true)); + const isFusion = this.pokemon.isFusion(); - const slotBg = globalScene.add.sprite(0, 0, slotKey, `${slotKey}${this.pokemon.hp ? "" : "_fnt"}`); - this.slotBg = slotBg; + // Here we define positions and offsets + // Base values are for the active pokemon; they are changed for benched pokemon, + // or for active pokemon if in a double battle in item management mode. - this.add(slotBg); + // icon position relative to slot background + let slotPb = { x: 4, y: 4 }; + // name position relative to slot background + let namePosition = { x: 24, y: 10 + (offsetJa ? 2 : 0) }; + // maximum allowed length of name; must accomodate fusion symbol + let maxNameTextWidth = 76 - (isFusion ? 8 : 0); + // "Lv." label position relative to slot background + let levelLabelPosition = { x: 24 + 8, y: 10 + 12 }; + // offset from "Lv." to the level number; should not be changed. + const levelTextToLevelLabelOffset = { x: 9, y: offsetJa ? 1.5 : 0 }; + // offests from "Lv." to gender, spliced and status icons, these depend on the type of slot. + let genderTextToLevelLabelOffset = { x: 68 - (isFusion ? 8 : 0), y: -9 }; + let splicedIconToLevelLabelOffset = { x: 68, y: 3.5 - 12 }; + let statusIconToLevelLabelOffset = { x: 55, y: 0 }; + // offset from the name to the shiny icon (on the left); should not be changed. + const shinyIconToNameOffset = { x: -9, y: 3 }; + // hp bar position relative to slot background + let hpBarPosition = { x: 8, y: 31 }; + // offsets of hp bar overlay (showing the remaining hp) and number; should not be changed. + const hpOverlayToBarOffset = { x: 16, y: 2 }; + const hpTextToBarOffset = { x: -3, y: -2 + (offsetJa ? 2 : 0) }; + // description position relative to slot background + let descriptionLabelPosition = { x: 32, y: 46 }; - const slotPb = globalScene.add.sprite( - this.slotIndex >= battlerCount ? -85.5 : -51, - this.slotIndex >= battlerCount ? 0 : -20.5, - "party_pb", - ); - this.slotPb = slotPb; + // If in item management mode, the active slots are shorter + if (isItemManageMode && globalScene.currentBattle.double && !this.isBenched) { + namePosition.y -= 8; + levelLabelPosition.y -= 8; + hpBarPosition.y -= 8; + descriptionLabelPosition.y -= 8; + } - this.add(slotPb); + // Benched slots have significantly different parameters + if (this.isBenched) { + slotPb = { x: 2, y: 12 }; + namePosition = { x: 21, y: 2 + (offsetJa ? 2 : 0) }; + maxNameTextWidth = 52; + levelLabelPosition = { x: 21 + 8, y: 2 + 12 }; + genderTextToLevelLabelOffset = { x: 36, y: 0 }; + splicedIconToLevelLabelOffset = { x: 36 + (genderSymbol ? 8 : 0), y: 0.5 }; + statusIconToLevelLabelOffset = { x: 43, y: 0 }; + hpBarPosition = { x: 72, y: 6 }; + descriptionLabelPosition = { x: 94, y: 16 }; + } - this.pokemonIcon = globalScene.addPokemonIcon(this.pokemon, slotPb.x, slotPb.y, 0.5, 0.5, true); + this.slotPb = globalScene.add.sprite(0, 0, "party_pb"); + this.slotPb.setPosition(slotPb.x, slotPb.y); + this.add(this.slotPb); + this.pokemonIcon = globalScene.addPokemonIcon(this.pokemon, this.slotPb.x, this.slotPb.y, 0.5, 0.5, true); this.add(this.pokemonIcon); this.iconAnimHandler.addOrUpdate(this.pokemonIcon, PokemonIconAnimMode.PASSIVE); @@ -1882,7 +1950,7 @@ class PartySlot extends Phaser.GameObjects.Container { const nameSizeTest = addTextObject(0, 0, displayName, TextStyle.PARTY); nameTextWidth = nameSizeTest.displayWidth; - while (nameTextWidth > (this.slotIndex >= battlerCount ? 52 : 76 - (this.pokemon.fusionSpecies ? 8 : 0))) { + while (nameTextWidth > maxNameTextWidth) { displayName = `${displayName.slice(0, displayName.endsWith(".") ? -2 : -1).trimEnd()}.`; nameSizeTest.setText(displayName); nameTextWidth = nameSizeTest.displayWidth; @@ -1891,78 +1959,59 @@ class PartySlot extends Phaser.GameObjects.Container { nameSizeTest.destroy(); this.slotName = addTextObject(0, 0, displayName, TextStyle.PARTY); - this.slotName.setPositionRelative( - slotBg, - this.slotIndex >= battlerCount ? 21 : 24, - (this.slotIndex >= battlerCount ? 2 : 10) + (offsetJa ? 2 : 0), - ); - this.slotName.setOrigin(0, 0); + this.slotName.setPositionRelative(this.slotBg, namePosition.x, namePosition.y); + this.slotName.setOrigin(0); - const slotLevelLabel = globalScene.add.image(0, 0, "party_slot_overlay_lv"); - slotLevelLabel.setPositionRelative( - slotBg, - (this.slotIndex >= battlerCount ? 21 : 24) + 8, - (this.slotIndex >= battlerCount ? 2 : 10) + 12, - ); - slotLevelLabel.setOrigin(0, 0); + const slotLevelLabel = globalScene.add + .image(0, 0, "party_slot_overlay_lv") + .setPositionRelative(this.slotBg, levelLabelPosition.x, levelLabelPosition.y) + .setOrigin(0); const slotLevelText = addTextObject( 0, 0, this.pokemon.level.toString(), this.pokemon.level < globalScene.getMaxExpLevel() ? TextStyle.PARTY : TextStyle.PARTY_RED, - ); - slotLevelText.setPositionRelative(slotLevelLabel, 9, offsetJa ? 1.5 : 0); - slotLevelText.setOrigin(0, 0.25); - + ) + .setPositionRelative(slotLevelLabel, levelTextToLevelLabelOffset.x, levelTextToLevelLabelOffset.y) + .setOrigin(0, 0.25); slotInfoContainer.add([this.slotName, slotLevelLabel, slotLevelText]); - const genderSymbol = getGenderSymbol(this.pokemon.getGender(true)); - if (genderSymbol) { - const slotGenderText = addTextObject(0, 0, genderSymbol, TextStyle.PARTY); - slotGenderText.setColor(getGenderColor(this.pokemon.getGender(true))); - slotGenderText.setShadowColor(getGenderColor(this.pokemon.getGender(true), true)); - if (this.slotIndex >= battlerCount) { - slotGenderText.setPositionRelative(slotLevelLabel, 36, 0); - } else { - slotGenderText.setPositionRelative(this.slotName, 76 - (this.pokemon.fusionSpecies ? 8 : 0), 3); - } - slotGenderText.setOrigin(0, 0.25); - + const slotGenderText = addTextObject(0, 0, genderSymbol, TextStyle.PARTY) + .setColor(getGenderColor(this.pokemon.getGender(true))) + .setShadowColor(getGenderColor(this.pokemon.getGender(true), true)) + .setPositionRelative(slotLevelLabel, genderTextToLevelLabelOffset.x, genderTextToLevelLabelOffset.y) + .setOrigin(0, 0.25); slotInfoContainer.add(slotGenderText); } - if (this.pokemon.fusionSpecies) { - const splicedIcon = globalScene.add.image(0, 0, "icon_spliced"); - splicedIcon.setScale(0.5); - splicedIcon.setOrigin(0, 0); - if (this.slotIndex >= battlerCount) { - splicedIcon.setPositionRelative(slotLevelLabel, 36 + (genderSymbol ? 8 : 0), 0.5); - } else { - splicedIcon.setPositionRelative(this.slotName, 76, 3.5); - } - + if (isFusion) { + const splicedIcon = globalScene.add + .image(0, 0, "icon_spliced") + .setScale(0.5) + .setOrigin(0) + .setPositionRelative(slotLevelLabel, splicedIconToLevelLabelOffset.x, splicedIconToLevelLabelOffset.y); slotInfoContainer.add(splicedIcon); } if (this.pokemon.status) { - const statusIndicator = globalScene.add.sprite(0, 0, getLocalizedSpriteKey("statuses")); - statusIndicator.setFrame(StatusEffect[this.pokemon.status?.effect].toLowerCase()); - statusIndicator.setOrigin(0, 0); - statusIndicator.setPositionRelative(slotLevelLabel, this.slotIndex >= battlerCount ? 43 : 55, 0); - + const statusIndicator = globalScene.add + .sprite(0, 0, getLocalizedSpriteKey("statuses")) + .setFrame(StatusEffect[this.pokemon.status?.effect].toLowerCase()) + .setOrigin(0) + .setPositionRelative(slotLevelLabel, statusIconToLevelLabelOffset.x, statusIconToLevelLabelOffset.y); slotInfoContainer.add(statusIndicator); } if (this.pokemon.isShiny()) { const doubleShiny = this.pokemon.isDoubleShiny(false); - const shinyStar = globalScene.add.image(0, 0, `shiny_star_small${doubleShiny ? "_1" : ""}`); - shinyStar.setOrigin(0, 0); - shinyStar.setPositionRelative(this.slotName, -9, 3); - shinyStar.setTint(getVariantTint(this.pokemon.getBaseVariant())); - + const shinyStar = globalScene.add + .image(0, 0, `shiny_star_small${doubleShiny ? "_1" : ""}`) + .setOrigin(0) + .setPositionRelative(this.slotName, shinyIconToNameOffset.x, shinyIconToNameOffset.y) + .setTint(getVariantTint(this.pokemon.getBaseVariant())); slotInfoContainer.add(shinyStar); if (doubleShiny) { @@ -1971,50 +2020,38 @@ class PartySlot extends Phaser.GameObjects.Container { .setOrigin(0) .setPosition(shinyStar.x, shinyStar.y) .setTint(getVariantTint(this.pokemon.fusionVariant)); - slotInfoContainer.add(fusionShinyStar); } } - this.slotHpBar = globalScene.add.image(0, 0, "party_slot_hp_bar"); - this.slotHpBar.setPositionRelative( - slotBg, - this.slotIndex >= battlerCount ? 72 : 8, - this.slotIndex >= battlerCount ? 6 : 31, - ); - this.slotHpBar.setOrigin(0, 0); - this.slotHpBar.setVisible(false); + this.slotHpBar = globalScene.add + .image(0, 0, "party_slot_hp_bar") + .setOrigin(0) + .setVisible(false) + .setPositionRelative(this.slotBg, hpBarPosition.x, hpBarPosition.y); const hpRatio = this.pokemon.getHpRatio(); - this.slotHpOverlay = globalScene.add.sprite( - 0, - 0, - "party_slot_hp_overlay", - hpRatio > 0.5 ? "high" : hpRatio > 0.25 ? "medium" : "low", - ); - this.slotHpOverlay.setPositionRelative(this.slotHpBar, 16, 2); - this.slotHpOverlay.setOrigin(0, 0); - this.slotHpOverlay.setScale(hpRatio, 1); - this.slotHpOverlay.setVisible(false); + this.slotHpOverlay = globalScene.add + .sprite(0, 0, "party_slot_hp_overlay", hpRatio > 0.5 ? "high" : hpRatio > 0.25 ? "medium" : "low") + .setOrigin(0) + .setPositionRelative(this.slotHpBar, hpOverlayToBarOffset.x, hpOverlayToBarOffset.y) + .setScale(hpRatio, 1) + .setVisible(false); - this.slotHpText = addTextObject(0, 0, `${this.pokemon.hp}/${this.pokemon.getMaxHp()}`, TextStyle.PARTY); - this.slotHpText.setPositionRelative( - this.slotHpBar, - this.slotHpBar.width - 3, - this.slotHpBar.height - 2 + (offsetJa ? 2 : 0), - ); - this.slotHpText.setOrigin(1, 0); - this.slotHpText.setVisible(false); + this.slotHpText = addTextObject(0, 0, `${this.pokemon.hp}/${this.pokemon.getMaxHp()}`, TextStyle.PARTY) + .setOrigin(1, 0) + .setPositionRelative( + this.slotHpBar, + this.slotHpBar.width + hpTextToBarOffset.x, + this.slotHpBar.height + hpTextToBarOffset.y, + ) // TODO: annoying because it contains the width + .setVisible(false); - this.slotDescriptionLabel = addTextObject(0, 0, "", TextStyle.MESSAGE); - this.slotDescriptionLabel.setPositionRelative( - slotBg, - this.slotIndex >= battlerCount ? 94 : 32, - this.slotIndex >= battlerCount ? 16 : 46, - ); - this.slotDescriptionLabel.setOrigin(0, 1); - this.slotDescriptionLabel.setVisible(false); + this.slotDescriptionLabel = addTextObject(0, 0, "", TextStyle.MESSAGE) + .setOrigin(0, 1) + .setVisible(false) + .setPositionRelative(this.slotBg, descriptionLabelPosition.x, descriptionLabelPosition.y); slotInfoContainer.add([this.slotHpBar, this.slotHpOverlay, this.slotHpText, this.slotDescriptionLabel]); @@ -2076,10 +2113,9 @@ class PartySlot extends Phaser.GameObjects.Container { } private updateSlotTexture(): void { - const battlerCount = globalScene.currentBattle.getBattlerCount(); this.slotBg.setTexture( - `party_slot${this.slotIndex >= battlerCount ? "" : "_main"}`, - `party_slot${this.slotIndex >= battlerCount ? "" : "_main"}${this.transfer ? "_swap" : this.pokemon.hp ? "" : "_fnt"}${this.selected ? "_sel" : ""}`, + this.slotBgKey, + `${this.slotBgKey}${this.transfer ? "_swap" : this.pokemon.hp ? "" : "_fnt"}${this.selected ? "_sel" : ""}`, ); } } @@ -2106,7 +2142,12 @@ class PartyCancelButton extends Phaser.GameObjects.Container { this.partyCancelPb = partyCancelPb; - const partyCancelText = addTextObject(-10, -7, i18next.t("partyUiHandler:cancel"), TextStyle.PARTY_CANCEL_BUTTON); + const partyCancelText = addTextObject( + -10, + -7, + i18next.t("partyUiHandler:cancelButton"), + TextStyle.PARTY_CANCEL_BUTTON, + ); this.add(partyCancelText); } @@ -2198,10 +2239,6 @@ class PartyDiscardModeButton extends Phaser.GameObjects.Container { this.discardIcon.setVisible(false); this.textBox.setVisible(true); this.textBox.setText(i18next.t("partyUiHandler:TRANSFER")); - this.setPosition( - globalScene.currentBattle.double ? 64 : 60, - globalScene.currentBattle.double ? -48 : -globalScene.game.canvas.height / 15 - 1, - ); this.transferIcon.displayWidth = this.textBox.text.length * 9 + 3; break; case PartyUiMode.DISCARD: @@ -2209,13 +2246,13 @@ class PartyDiscardModeButton extends Phaser.GameObjects.Container { this.discardIcon.setVisible(true); this.textBox.setVisible(true); this.textBox.setText(i18next.t("partyUiHandler:DISCARD")); - this.setPosition( - globalScene.currentBattle.double ? 64 : 60, - globalScene.currentBattle.double ? -48 : -globalScene.game.canvas.height / 15 - 1, - ); this.discardIcon.displayWidth = this.textBox.text.length * 9 + 3; break; } + this.setPosition( + globalScene.currentBattle.double ? DISCARD_BUTTON_X_DOUBLES : DISCARD_BUTTON_X, + globalScene.currentBattle.double ? DISCARD_BUTTON_Y_DOUBLES : DISCARD_BUTTON_Y, + ); } clear() { diff --git a/src/ui/run-info-ui-handler.ts b/src/ui/run-info-ui-handler.ts index 072eefad65a..db0790275fc 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,9 +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); + const runName = addTextObject(0, 0, this.runInfo.name, TextStyle.WINDOW); runName.setOrigin(0, 0); - runName.setPositionRelative(headerBg, 60, 4); + const runNameX = headerText.width / 6 + headerText.x + 4; + runName.setPositionRelative(headerBg, runNameX, 4); this.runContainer.add(runName); } @@ -706,10 +708,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 52e145e6439..e9f9c5a0038 100644 --- a/src/ui/save-slot-select-ui-handler.ts +++ b/src/ui/save-slot-select-ui-handler.ts @@ -377,7 +377,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler { "select_cursor_highlight_thick", undefined, 294, - this.sessionSlots[prevSlotIndex ?? 0]?.saveData?.runNameText ? 50 : 60, + this.sessionSlots[prevSlotIndex ?? 0]?.saveData?.name ? 50 : 60, 6, 6, 6, @@ -553,10 +553,10 @@ class SessionSlot extends Phaser.GameObjects.Container { } async setupWithData(data: SessionSaveData) { - const hasName = data?.runNameText; + const hasName = data?.name; this.remove(this.loadingLabel, true); if (hasName) { - const nameLabel = addTextObject(8, 5, data.runNameText, TextStyle.WINDOW); + const nameLabel = addTextObject(8, 5, data.name, TextStyle.WINDOW); this.add(nameLabel); } else { const fallbackName = this.decideFallback(data); diff --git a/src/ui/starter-select-ui-handler.ts b/src/ui/starter-select-ui-handler.ts index fbcc6ae7e32..82467506720 100644 --- a/src/ui/starter-select-ui-handler.ts +++ b/src/ui/starter-select-ui-handler.ts @@ -45,6 +45,7 @@ import type { Variant } from "#sprites/variant"; import { getVariantIcon, getVariantTint } from "#sprites/variant"; import { achvs } from "#system/achv"; import type { DexAttrProps, StarterAttributes, StarterMoveset } from "#system/game-data"; +import { RibbonData } from "#system/ribbons/ribbon-data"; import { SettingKeyboard } from "#system/settings-keyboard"; import type { DexEntry } from "#types/dex-data"; import type { OptionSelectItem } from "#ui/abstract-option-select-ui-handler"; @@ -3226,6 +3227,8 @@ export class StarterSelectUiHandler extends MessageUiHandler { onScreenFirstIndex + maxRows * maxColumns - 1, ); + const gameData = globalScene.gameData; + this.starterSelectScrollBar.setScrollCursor(this.scrollCursor); let pokerusCursorIndex = 0; @@ -3265,9 +3268,9 @@ export class StarterSelectUiHandler extends MessageUiHandler { container.label.setVisible(true); const speciesVariants = - speciesId && globalScene.gameData.dexData[speciesId].caughtAttr & DexAttr.SHINY + speciesId && gameData.dexData[speciesId].caughtAttr & DexAttr.SHINY ? [DexAttr.DEFAULT_VARIANT, DexAttr.VARIANT_2, DexAttr.VARIANT_3].filter( - v => !!(globalScene.gameData.dexData[speciesId].caughtAttr & v), + v => !!(gameData.dexData[speciesId].caughtAttr & v), ) : []; for (let v = 0; v < 3; v++) { @@ -3282,12 +3285,15 @@ export class StarterSelectUiHandler extends MessageUiHandler { } } - container.starterPassiveBgs.setVisible(!!globalScene.gameData.starterData[speciesId].passiveAttr); + container.starterPassiveBgs.setVisible(!!gameData.starterData[speciesId].passiveAttr); container.hiddenAbilityIcon.setVisible( - !!globalScene.gameData.dexData[speciesId].caughtAttr && - !!(globalScene.gameData.starterData[speciesId].abilityAttr & 4), + !!gameData.dexData[speciesId].caughtAttr && !!(gameData.starterData[speciesId].abilityAttr & 4), ); - container.classicWinIcon.setVisible(globalScene.gameData.starterData[speciesId].classicWinCount > 0); + container.classicWinIcon + .setVisible(gameData.starterData[speciesId].classicWinCount > 0) + .setTexture( + gameData.dexData[speciesId].ribbons.has(RibbonData.NUZLOCKE) ? "champion_ribbon_emerald" : "champion_ribbon", + ); container.favoriteIcon.setVisible(this.starterPreferences[speciesId]?.favorite ?? false); // 'Candy Icon' mode 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/utils/challenge-utils.ts b/src/utils/challenge-utils.ts index 43297027e04..c4fac3a0323 100644 --- a/src/utils/challenge-utils.ts +++ b/src/utils/challenge-utils.ts @@ -4,6 +4,7 @@ import { pokemonEvolutions } from "#balance/pokemon-evolutions"; import { pokemonFormChanges } from "#data/pokemon-forms"; import type { PokemonSpecies } from "#data/pokemon-species"; import { ChallengeType } from "#enums/challenge-type"; +import { Challenges } from "#enums/challenges"; import type { MoveId } from "#enums/move-id"; import type { MoveSourceType } from "#enums/move-source-type"; import type { SpeciesId } from "#enums/species-id"; @@ -378,7 +379,7 @@ export function checkStarterValidForChallenge(species: PokemonSpecies, props: De * @param soft - If `true`, allow it if it could become valid through a form change. * @returns `true` if the species is considered valid. */ -function checkSpeciesValidForChallenge(species: PokemonSpecies, props: DexAttrProps, soft: boolean) { +export function checkSpeciesValidForChallenge(species: PokemonSpecies, props: DexAttrProps, soft: boolean) { const isValidForChallenge = new BooleanHolder(true); applyChallenges(ChallengeType.STARTER_CHOICE, species, isValidForChallenge, props); if (!soft || !pokemonFormChanges.hasOwnProperty(species.speciesId)) { @@ -407,3 +408,28 @@ function checkSpeciesValidForChallenge(species: PokemonSpecies, props: DexAttrPr }); return result; } + +/** @returns Whether the current game mode meets the criteria to be considered a Nuzlocke challenge */ +export function isNuzlockeChallenge(): boolean { + let isFreshStart = false; + let isLimitedCatch = false; + let isHardcore = false; + for (const challenge of globalScene.gameMode.challenges) { + // value is 0 if challenge is not active + if (!challenge.value) { + continue; + } + switch (challenge.id) { + case Challenges.FRESH_START: + isFreshStart = true; + break; + case Challenges.LIMITED_CATCH: + isLimitedCatch = true; + break; + case Challenges.HARDCORE: + isHardcore = true; + break; + } + } + return isFreshStart && isLimitedCatch && isHardcore; +} diff --git a/src/utils/data.ts b/src/utils/data.ts index 932ea38d504..6580ecf2ee9 100644 --- a/src/utils/data.ts +++ b/src/utils/data.ts @@ -45,17 +45,17 @@ export function deepMergeSpriteData(dest: object, source: object) { } export function encrypt(data: string, bypassLogin: boolean): string { - return (bypassLogin - ? (data: string) => btoa(encodeURIComponent(data)) - : (data: string) => AES.encrypt(data, saveKey))(data) as unknown as string; // TODO: is this correct? + if (bypassLogin) { + return btoa(encodeURIComponent(data)); + } + return AES.encrypt(data, saveKey).toString(); } export function decrypt(data: string, bypassLogin: boolean): string { - return ( - bypassLogin - ? (data: string) => decodeURIComponent(atob(data)) - : (data: string) => AES.decrypt(data, saveKey).toString(enc.Utf8) - )(data); + if (bypassLogin) { + return decodeURIComponent(atob(data)); + } + return AES.decrypt(data, saveKey).toString(enc.Utf8); } // the latest data saved/loaded for the Starter Preferences. Required to reduce read/writes. Initialize as "{}", since this is the default value and no data needs to be stored if present. diff --git a/test/@types/test-helpers.ts b/test/@types/test-helpers.ts new file mode 100644 index 00000000000..b867eb32570 --- /dev/null +++ b/test/@types/test-helpers.ts @@ -0,0 +1,27 @@ +import type { AtLeastOne, NonFunctionPropertiesRecursive as nonFunc } from "#types/type-helpers"; + +/** + * Helper type to admit an object containing the given properties + * _and_ at least 1 other non-function property. + * @example + * ```ts + * type foo = { + * qux: 1 | 2 | 3, + * bar: number, + * baz: string + * quux: () => void; // ignored! + * } + * + * type quxAndSomethingElse = OneOther + * + * const good1: quxAndSomethingElse = {qux: 1, bar: 3} // OK! + * const good2: quxAndSomethingElse = {qux: 2, baz: "4", bar: 12} // OK! + * const bad1: quxAndSomethingElse = {baz: "4", bar: 12} // Errors because `qux` is required + * const bad2: quxAndSomethingElse = {qux: 1} // Errors because at least 1 thing _other_ than `qux` is required + * ``` + * @typeParam O - The object to source keys from + * @typeParam K - One or more of O's keys to render mandatory + */ +export type OneOther = AtLeastOne, K>> & { + [key in K]: O[K]; +}; diff --git a/test/@types/vitest.d.ts b/test/@types/vitest.d.ts index 7b756c45a57..21cf76ed352 100644 --- a/test/@types/vitest.d.ts +++ b/test/@types/vitest.d.ts @@ -1,23 +1,32 @@ import type { TerrainType } from "#app/data/terrain"; +import type Overrides from "#app/overrides"; +import type { ArenaTag } from "#data/arena-tag"; +import type { PositionalTag } from "#data/positional-tags/positional-tag"; import type { AbilityId } from "#enums/ability-id"; +import type { ArenaTagSide } from "#enums/arena-tag-side"; +import type { ArenaTagType } from "#enums/arena-tag-type"; import type { BattlerTagType } from "#enums/battler-tag-type"; import type { MoveId } from "#enums/move-id"; import type { PokemonType } from "#enums/pokemon-type"; +import type { PositionalTagType } from "#enums/positional-tag-type"; import type { BattleStat, EffectiveStat, Stat } from "#enums/stat"; import type { StatusEffect } from "#enums/status-effect"; import type { WeatherType } from "#enums/weather-type"; +import type { Arena } from "#field/arena"; import type { Pokemon } from "#field/pokemon"; -import type { ToHaveEffectiveStatMatcherOptions } from "#test/test-utils/matchers/to-have-effective-stat"; +import type { PokemonMove } from "#moves/pokemon-move"; +import type { toHaveArenaTagOptions } from "#test/test-utils/matchers/to-have-arena-tag"; +import type { toHaveEffectiveStatOptions } from "#test/test-utils/matchers/to-have-effective-stat"; +import type { toHavePositionalTagOptions } from "#test/test-utils/matchers/to-have-positional-tag"; import type { expectedStatusType } from "#test/test-utils/matchers/to-have-status-effect"; import type { toHaveTypesOptions } from "#test/test-utils/matchers/to-have-types"; import type { TurnMove } from "#types/turn-move"; import type { AtLeastOne } from "#types/type-helpers"; +import type { toDmgValue } from "utils/common"; import type { expect } from "vitest"; -import type Overrides from "#app/overrides"; -import type { PokemonMove } from "#moves/pokemon-move"; declare module "vitest" { - interface Assertion { + interface Assertion { /** * Check whether an array contains EXACTLY the given items (in any order). * @@ -27,45 +36,9 @@ declare module "vitest" { * @param expected - The expected contents of the array, in any order * @see {@linkcode expect.arrayContaining} */ - toEqualArrayUnsorted(expected: E[]): void; + toEqualArrayUnsorted(expected: T[]): void; - /** - * Check whether a {@linkcode Pokemon}'s current typing includes the given types. - * - * @param expected - The expected types (in any order) - * @param options - The options passed to the matcher - */ - toHaveTypes(expected: [PokemonType, ...PokemonType[]], options?: toHaveTypesOptions): void; - - /** - * Matcher to check the contents of a {@linkcode Pokemon}'s move history. - * - * @param expectedValue - The expected value; can be a {@linkcode MoveId} or a partially filled {@linkcode TurnMove} - * containing the desired properties to check - * @param index - The index of the move history entry to check, in order from most recent to least recent. - * Default `0` (last used move) - * @see {@linkcode Pokemon.getLastXMoves} - */ - toHaveUsedMove(expected: MoveId | AtLeastOne, index?: number): void; - - /** - * Check whether a {@linkcode Pokemon}'s effective stat is as expected - * (checked after all stat value modifications). - * - * @param stat - The {@linkcode EffectiveStat} to check - * @param expectedValue - The expected value of {@linkcode stat} - * @param options - (Optional) The {@linkcode ToHaveEffectiveStatMatcherOptions} - * @remarks - * If you want to check the stat **before** modifiers are applied, use {@linkcode Pokemon.getStat} instead. - */ - toHaveEffectiveStat(stat: EffectiveStat, expectedValue: number, options?: ToHaveEffectiveStatMatcherOptions): void; - - /** - * Check whether a {@linkcode Pokemon} has taken a specific amount of damage. - * @param expectedDamageTaken - The expected amount of damage taken - * @param roundDown - Whether to round down {@linkcode expectedDamageTaken} with {@linkcode toDmgValue}; default `true` - */ - toHaveTakenDamage(expectedDamageTaken: number, roundDown?: boolean): void; + // #region Arena Matchers /** * Check whether the current {@linkcode WeatherType} is as expected. @@ -80,9 +53,60 @@ declare module "vitest" { toHaveTerrain(expectedTerrainType: TerrainType): void; /** - * Check whether a {@linkcode Pokemon} is at full HP. + * Check whether the current {@linkcode Arena} contains the given {@linkcode ArenaTag}. + * @param expectedTag - A partially-filled {@linkcode ArenaTag} containing the desired properties */ - toHaveFullHp(): void; + toHaveArenaTag(expectedTag: toHaveArenaTagOptions): void; + /** + * Check whether the current {@linkcode Arena} contains the given {@linkcode ArenaTag}. + * @param expectedType - The {@linkcode ArenaTagType} of the desired tag + * @param side - The {@linkcode ArenaTagSide | side(s) of the field} the tag should affect; default {@linkcode ArenaTagSide.BOTH} + */ + toHaveArenaTag(expectedType: ArenaTagType, side?: ArenaTagSide): void; + + /** + * Check whether the current {@linkcode Arena} contains the given {@linkcode PositionalTag}. + * @param expectedTag - A partially-filled `PositionalTag` containing the desired properties + */ + toHavePositionalTag

(expectedTag: toHavePositionalTagOptions

): void; + /** + * Check whether the current {@linkcode Arena} contains the given number of {@linkcode PositionalTag}s. + * @param expectedType - The {@linkcode PositionalTagType} of the desired tag + * @param count - The number of instances of {@linkcode expectedType} that should be active; + * defaults to `1` and must be within the range `[0, 4]` + */ + toHavePositionalTag(expectedType: PositionalTagType, count?: number): void; + + // #endregion Arena Matchers + + // #region Pokemon Matchers + + /** + * Check whether a {@linkcode Pokemon}'s current typing includes the given types. + * @param expectedTypes - The expected {@linkcode PokemonType}s to check against; must have length `>0` + * @param options - The {@linkcode toHaveTypesOptions | options} passed to the matcher + */ + toHaveTypes(expectedTypes: PokemonType[], options?: toHaveTypesOptions): void; + + /** + * Check whether a {@linkcode Pokemon} has used a move matching the given criteria. + * @param expectedMove - The {@linkcode MoveId} the Pokemon is expected to have used, + * or a partially filled {@linkcode TurnMove} containing the desired properties to check + * @param index - The index of the move history entry to check, in order from most recent to least recent; default `0` + * @see {@linkcode Pokemon.getLastXMoves} + */ + toHaveUsedMove(expectedMove: MoveId | AtLeastOne, index?: number): void; + + /** + * Check whether a {@linkcode Pokemon}'s effective stat is as expected + * (checked after all stat value modifications). + * @param stat - The {@linkcode EffectiveStat} to check + * @param expectedValue - The expected value of {@linkcode stat} + * @param options - The {@linkcode toHaveEffectiveStatOptions | options} passed to the matcher + * @remarks + * If you want to check the stat **before** modifiers are applied, use {@linkcode Pokemon.getStat} instead. + */ + toHaveEffectiveStat(stat: EffectiveStat, expectedValue: number, options?: toHaveEffectiveStatOptions): void; /** * Check whether a {@linkcode Pokemon} has a specific {@linkcode StatusEffect | non-volatile status effect}. @@ -106,7 +130,7 @@ declare module "vitest" { /** * Check whether a {@linkcode Pokemon} has applied a specific {@linkcode AbilityId}. - * @param expectedAbilityId - The expected {@linkcode AbilityId} + * @param expectedAbilityId - The `AbilityId` to check for */ toHaveAbilityApplied(expectedAbilityId: AbilityId): void; @@ -116,24 +140,36 @@ declare module "vitest" { */ toHaveHp(expectedHp: number): void; + /** + * Check whether a {@linkcode Pokemon} has taken a specific amount of damage. + * @param expectedDamageTaken - The expected amount of damage taken + * @param roundDown - Whether to round down `expectedDamageTaken` with {@linkcode toDmgValue}; default `true` + */ + toHaveTakenDamage(expectedDamageTaken: number, roundDown?: boolean): void; + /** * Check whether a {@linkcode Pokemon} is currently fainted (as determined by {@linkcode Pokemon.isFainted}). * @remarks - * When checking whether an enemy wild Pokemon is fainted, one must reference it in a variable _before_ the fainting effect occurs - * as otherwise the Pokemon will be GC'ed and rendered `undefined`. + * When checking whether an enemy wild Pokemon is fainted, one must store a reference to it in a variable _before_ the fainting effect occurs. + * Otherwise, the Pokemon will be removed from the field and garbage collected. */ toHaveFainted(): void; + /** + * Check whether a {@linkcode Pokemon} is at full HP. + */ + toHaveFullHp(): void; /** * Check whether a {@linkcode Pokemon} has consumed the given amount of PP for one of its moves. - * @param expectedValue - The {@linkcode MoveId} of the {@linkcode PokemonMove} that should have consumed PP + * @param moveId - The {@linkcode MoveId} corresponding to the {@linkcode PokemonMove} that should have consumed PP * @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}, - * does not contain {@linkcode expectedMove} - * or contains the desired move more than once, this will fail the test. + * If the Pokemon's moveset has been set via {@linkcode Overrides.MOVESET_OVERRIDE}/{@linkcode Overrides.ENEMY_MOVESET_OVERRIDE} + * or does not contain exactly one copy of `moveId`, this will fail the test. */ - toHaveUsedPP(expectedMove: MoveId, ppUsed: number | "all"): void; + toHaveUsedPP(moveId: MoveId, ppUsed: number | "all"): void; + + // #endregion Pokemon Matchers } } diff --git a/test/matchers.setup.ts b/test/matchers.setup.ts index 03b29302916..f76a9423ab3 100644 --- a/test/matchers.setup.ts +++ b/test/matchers.setup.ts @@ -1,10 +1,12 @@ import { toEqualArrayUnsorted } from "#test/test-utils/matchers/to-equal-array-unsorted"; import { toHaveAbilityApplied } from "#test/test-utils/matchers/to-have-ability-applied"; +import { toHaveArenaTag } from "#test/test-utils/matchers/to-have-arena-tag"; import { toHaveBattlerTag } from "#test/test-utils/matchers/to-have-battler-tag"; import { toHaveEffectiveStat } from "#test/test-utils/matchers/to-have-effective-stat"; import { toHaveFainted } from "#test/test-utils/matchers/to-have-fainted"; import { toHaveFullHp } from "#test/test-utils/matchers/to-have-full-hp"; import { toHaveHp } from "#test/test-utils/matchers/to-have-hp"; +import { toHavePositionalTag } from "#test/test-utils/matchers/to-have-positional-tag"; import { toHaveStatStage } from "#test/test-utils/matchers/to-have-stat-stage"; import { toHaveStatusEffect } from "#test/test-utils/matchers/to-have-status-effect"; import { toHaveTakenDamage } from "#test/test-utils/matchers/to-have-taken-damage"; @@ -22,18 +24,20 @@ import { expect } from "vitest"; expect.extend({ toEqualArrayUnsorted, + toHaveWeather, + toHaveTerrain, + toHaveArenaTag, + toHavePositionalTag, toHaveTypes, toHaveUsedMove, toHaveEffectiveStat, - toHaveTakenDamage, - toHaveWeather, - toHaveTerrain, - toHaveFullHp, toHaveStatusEffect, toHaveStatStage, toHaveBattlerTag, toHaveAbilityApplied, toHaveHp, + toHaveTakenDamage, + toHaveFullHp, toHaveFainted, toHaveUsedPP, }); diff --git a/test/moves/wish.test.ts b/test/moves/wish.test.ts index 147c598106b..55877edbfd4 100644 --- a/test/moves/wish.test.ts +++ b/test/moves/wish.test.ts @@ -39,15 +39,6 @@ describe("Move - Wish", () => { .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]); @@ -58,19 +49,19 @@ describe("Move - Wish", () => { game.move.use(MoveId.WISH); await game.toNextTurn(); - expectWishActive(); + expect(game).toHavePositionalTag(PositionalTagType.WISH); game.doSwitchPokemon(1); await game.toEndOfTurn(); - expectWishActive(0); + expect(game).toHavePositionalTag(PositionalTagType.WISH, 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); + expect(alomomola).toHaveHp(1); + expect(blissey).toHaveHp(toDmgValue(alomomola.getMaxHp() / 2) + 1); }); it("should work if the user has full HP, but not if it already has an active Wish", async () => { @@ -82,13 +73,13 @@ describe("Move - Wish", () => { game.move.use(MoveId.WISH); await game.toNextTurn(); - expectWishActive(); + expect(game).toHavePositionalTag(PositionalTagType.WISH); 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); + expect(alomomola).toHaveUsedMove({ result: MoveResult.FAIL }); }); it("should function independently of Future Sight", async () => { @@ -103,7 +94,8 @@ describe("Move - Wish", () => { await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.toNextTurn(); - expectWishActive(1); + expect(game).toHavePositionalTag(PositionalTagType.WISH); + expect(game).toHavePositionalTag(PositionalTagType.DELAYED_ATTACK); }); it("should work in double battles and trigger in order of creation", async () => { @@ -127,7 +119,7 @@ describe("Move - Wish", () => { await game.setTurnOrder(oldOrder.map(p => p.getBattlerIndex())); await game.toNextTurn(); - expectWishActive(4); + expect(game).toHavePositionalTag(PositionalTagType.WISH, 4); // Lower speed to change turn order alomomola.setStatStage(Stat.SPD, 6); @@ -141,7 +133,7 @@ describe("Move - Wish", () => { await game.phaseInterceptor.to("PositionalTagPhase"); // all wishes have activated and added healing phases - expectWishActive(0); + expect(game).toHavePositionalTag(PositionalTagType.WISH, 0); const healPhases = game.scene.phaseManager.phaseQueue.filter(p => p.is("PokemonHealPhase")); expect(healPhases).toHaveLength(4); @@ -165,14 +157,14 @@ describe("Move - Wish", () => { game.move.use(MoveId.WISH, BattlerIndex.PLAYER_2); await game.toNextTurn(); - expectWishActive(); + expect(game).toHavePositionalTag(PositionalTagType.WISH); 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).toHavePositionalTag(PositionalTagType.WISH, 0); expect(game.textInterceptor.logs).not.toContain( i18next.t("arenaTag:wishTagOnAdd", { pokemonNameWithAffix: getPokemonNameWithAffix(blissey), 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/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-equal-array-unsorted.ts b/test/test-utils/matchers/to-equal-array-unsorted.ts index 846ea9e7779..97398689032 100644 --- a/test/test-utils/matchers/to-equal-array-unsorted.ts +++ b/test/test-utils/matchers/to-equal-array-unsorted.ts @@ -1,4 +1,5 @@ import { getOnelineDiffStr } from "#test/test-utils/string-utils"; +import { receivedStr } from "#test/test-utils/test-utils"; import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; /** @@ -14,22 +15,22 @@ export function toEqualArrayUnsorted( ): SyncExpectationResult { if (!Array.isArray(received)) { return { - pass: false, - message: () => `Expected an array, but got ${this.utils.stringify(received)}!`, + pass: this.isNot, + message: () => `Expected to receive an array, but got ${receivedStr(received)}!`, }; } if (received.length !== expected.length) { return { pass: false, - message: () => `Expected to receive array of length ${received.length}, but got ${expected.length} instead!`, - actual: received, + message: () => `Expected to receive an array of length ${received.length}, but got ${expected.length} instead!`, expected, + actual: received, }; } - const actualSorted = received.slice().sort(); - const expectedSorted = expected.slice().sort(); + const actualSorted = received.toSorted(); + const expectedSorted = expected.toSorted(); const pass = this.equals(actualSorted, expectedSorted, [...this.customTesters, this.utils.iterableEquality]); const actualStr = getOnelineDiffStr.call(this, actualSorted); diff --git a/test/test-utils/matchers/to-have-ability-applied.ts b/test/test-utils/matchers/to-have-ability-applied.ts index a3921e6371c..1ed74410de0 100644 --- a/test/test-utils/matchers/to-have-ability-applied.ts +++ b/test/test-utils/matchers/to-have-ability-applied.ts @@ -21,8 +21,8 @@ export function toHaveAbilityApplied( ): SyncExpectationResult { if (!isPokemonInstance(received)) { return { - pass: false, - message: () => `Expected to recieve a Pokemon, but got ${receivedStr(received)}!`, + pass: this.isNot, + message: () => `Expected to receive a Pokemon, but got ${receivedStr(received)}!`, }; } diff --git a/test/test-utils/matchers/to-have-arena-tag.ts b/test/test-utils/matchers/to-have-arena-tag.ts new file mode 100644 index 00000000000..dee7c133f25 --- /dev/null +++ b/test/test-utils/matchers/to-have-arena-tag.ts @@ -0,0 +1,77 @@ +import type { ArenaTag, ArenaTagTypeMap } from "#data/arena-tag"; +import type { ArenaTagSide } from "#enums/arena-tag-side"; +import type { ArenaTagType } from "#enums/arena-tag-type"; +import type { OneOther } from "#test/@types/test-helpers"; +// biome-ignore lint/correctness/noUnusedImports: TSDoc +import type { GameManager } from "#test/test-utils/game-manager"; +import { getOnelineDiffStr } from "#test/test-utils/string-utils"; +import { isGameManagerInstance, receivedStr } from "#test/test-utils/test-utils"; +import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; + +// intersection required to preserve T for inferences +export type toHaveArenaTagOptions = OneOther & { + tagType: T; +}; + +/** + * Matcher to check if the {@linkcode Arena} has a given {@linkcode ArenaTag} active. + * @param received - The object to check. Should be the current {@linkcode GameManager}. + * @param expectedTag - The `ArenaTagType` of the desired tag, or a partially-filled object + * containing the desired properties + * @param side - The {@linkcode ArenaTagSide | side of the field} the tag should affect, or + * {@linkcode ArenaTagSide.BOTH} to check both sides + * @returns The result of the matching + */ +export function toHaveArenaTag( + this: MatcherState, + received: unknown, + expectedTag: T | toHaveArenaTagOptions, + side?: ArenaTagSide, +): SyncExpectationResult { + if (!isGameManagerInstance(received)) { + return { + pass: this.isNot, + message: () => `Expected to receive a GameManager, but got ${receivedStr(received)}!`, + }; + } + + if (!received.scene?.arena) { + return { + pass: this.isNot, + message: () => `Expected GameManager.${received.scene ? "scene.arena" : "scene"} to be defined!`, + }; + } + + // Coerce lone `tagType`s into objects + // Bangs are ok as we enforce safety via overloads + // @ts-expect-error - Typescript is being stupid as tag type and side will always exist + const etag: Partial & { tagType: T; side: ArenaTagSide } = + typeof expectedTag === "object" ? expectedTag : { tagType: expectedTag, side: side! }; + + // We need to get all tags for the case of checking properties of a tag present on both sides of the arena + const tags = received.scene.arena.findTagsOnSide(t => t.tagType === etag.tagType, etag.side); + if (tags.length === 0) { + return { + pass: false, + message: () => `Expected the Arena to have a tag of type ${etag.tagType}, but it didn't!`, + expected: etag.tagType, + actual: received.scene.arena.tags.map(t => t.tagType), + }; + } + + // Pass if any of the matching tags meet our criteria + const pass = tags.some(tag => + this.equals(tag, expectedTag, [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality]), + ); + + const expectedStr = getOnelineDiffStr.call(this, expectedTag); + return { + pass, + message: () => + pass + ? `Expected the Arena to NOT have a tag matching ${expectedStr}, but it did!` + : `Expected the Arena to have a tag matching ${expectedStr}, but it didn't!`, + expected: expectedTag, + actual: tags, + }; +} diff --git a/test/test-utils/matchers/to-have-effective-stat.ts b/test/test-utils/matchers/to-have-effective-stat.ts index bc10a646c02..dda6bc7e91e 100644 --- a/test/test-utils/matchers/to-have-effective-stat.ts +++ b/test/test-utils/matchers/to-have-effective-stat.ts @@ -6,7 +6,7 @@ import { getStatName } from "#test/test-utils/string-utils"; import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils"; import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; -export interface ToHaveEffectiveStatMatcherOptions { +export interface toHaveEffectiveStatOptions { /** * The target {@linkcode Pokemon} * @see {@linkcode Pokemon.getEffectiveStat} @@ -30,7 +30,7 @@ export interface ToHaveEffectiveStatMatcherOptions { * @param received - The object to check. Should be a {@linkcode Pokemon} * @param stat - The {@linkcode EffectiveStat} to check * @param expectedValue - The expected value of the {@linkcode stat} - * @param options - The {@linkcode ToHaveEffectiveStatMatcherOptions} + * @param options - The {@linkcode toHaveEffectiveStatOptions} * @returns Whether the matcher passed */ export function toHaveEffectiveStat( @@ -38,11 +38,11 @@ export function toHaveEffectiveStat( received: unknown, stat: EffectiveStat, expectedValue: number, - { enemy, move, isCritical = false }: ToHaveEffectiveStatMatcherOptions = {}, + { enemy, move, isCritical = false }: toHaveEffectiveStatOptions = {}, ): SyncExpectationResult { if (!isPokemonInstance(received)) { return { - pass: false, + pass: this.isNot, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, }; } diff --git a/test/test-utils/matchers/to-have-fainted.ts b/test/test-utils/matchers/to-have-fainted.ts index 73ca96a31b5..f3e84e7a425 100644 --- a/test/test-utils/matchers/to-have-fainted.ts +++ b/test/test-utils/matchers/to-have-fainted.ts @@ -12,7 +12,7 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; export function toHaveFainted(this: MatcherState, received: unknown): SyncExpectationResult { if (!isPokemonInstance(received)) { return { - pass: false, + pass: this.isNot, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, }; } diff --git a/test/test-utils/matchers/to-have-full-hp.ts b/test/test-utils/matchers/to-have-full-hp.ts index 3d7c8f9458d..893bb647283 100644 --- a/test/test-utils/matchers/to-have-full-hp.ts +++ b/test/test-utils/matchers/to-have-full-hp.ts @@ -12,7 +12,7 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; export function toHaveFullHp(this: MatcherState, received: unknown): SyncExpectationResult { if (!isPokemonInstance(received)) { return { - pass: false, + pass: this.isNot, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, }; } diff --git a/test/test-utils/matchers/to-have-hp.ts b/test/test-utils/matchers/to-have-hp.ts index 20d171b23ce..e6463383ac2 100644 --- a/test/test-utils/matchers/to-have-hp.ts +++ b/test/test-utils/matchers/to-have-hp.ts @@ -13,7 +13,7 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; export function toHaveHp(this: MatcherState, received: unknown, expectedHp: number): SyncExpectationResult { if (!isPokemonInstance(received)) { return { - pass: false, + pass: this.isNot, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, }; } diff --git a/test/test-utils/matchers/to-have-positional-tag.ts b/test/test-utils/matchers/to-have-positional-tag.ts new file mode 100644 index 00000000000..448339d6a8d --- /dev/null +++ b/test/test-utils/matchers/to-have-positional-tag.ts @@ -0,0 +1,107 @@ +// biome-ignore-start lint/correctness/noUnusedImports: TSDoc +import type { GameManager } from "#test/test-utils/game-manager"; +// biome-ignore-end lint/correctness/noUnusedImports: TSDoc + +import type { serializedPosTagMap } from "#data/positional-tags/load-positional-tag"; +import type { PositionalTagType } from "#enums/positional-tag-type"; +import type { OneOther } from "#test/@types/test-helpers"; +import { getOnelineDiffStr } from "#test/test-utils/string-utils"; +import { isGameManagerInstance, receivedStr } from "#test/test-utils/test-utils"; +import { toTitleCase } from "#utils/strings"; +import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; + +export type toHavePositionalTagOptions

= OneOther & { + tagType: P; +}; + +/** + * Matcher to check if the {@linkcode Arena} has a certain number of {@linkcode PositionalTag}s active. + * @param received - The object to check. Should be the current {@linkcode GameManager} + * @param expectedTag - The {@linkcode PositionalTagType} of the desired tag, or a partially-filled {@linkcode PositionalTag} + * containing the desired properties + * @param count - The number of tags that should be active; defaults to `1` and must be within the range `[0, 4]` + * @returns The result of the matching + */ +export function toHavePositionalTag

( + this: MatcherState, + received: unknown, + expectedTag: P | toHavePositionalTagOptions

, + count = 1, +): SyncExpectationResult { + if (!isGameManagerInstance(received)) { + return { + pass: this.isNot, + message: () => `Expected to receive a GameManager, but got ${receivedStr(received)}!`, + }; + } + + if (!received.scene?.arena?.positionalTagManager) { + return { + pass: this.isNot, + message: () => + `Expected GameManager.${received.scene?.arena ? "scene.arena.positionalTagManager" : received.scene ? "scene.arena" : "scene"} to be defined!`, + }; + } + + // TODO: Increase limit if triple battles are added + if (count < 0 || count > 4) { + return { + pass: this.isNot, + message: () => `Expected count to be between 0 and 4, but got ${count} instead!`, + }; + } + + const allTags = received.scene.arena.positionalTagManager.tags; + const tagType = typeof expectedTag === "string" ? expectedTag : expectedTag.tagType; + const matchingTags = allTags.filter(t => t.tagType === tagType); + + // If checking exclusively tag type, check solely the number of matching tags on field + if (typeof expectedTag === "string") { + const pass = matchingTags.length === count; + const expectedStr = getPosTagStr(expectedTag); + + return { + pass, + message: () => + pass + ? `Expected the Arena to NOT have ${count} ${expectedStr} active, but it did!` + : `Expected the Arena to have ${count} ${expectedStr} active, but got ${matchingTags.length} instead!`, + expected: expectedTag, + actual: allTags, + }; + } + + // Check for equality with the provided object + if (matchingTags.length === 0) { + return { + pass: false, + message: () => `Expected the Arena to have a tag of type ${expectedTag.tagType}, but it didn't!`, + expected: expectedTag.tagType, + actual: received.scene.arena.tags.map(t => t.tagType), + }; + } + + // Pass if any of the matching tags meet the criteria + const pass = matchingTags.some(tag => + this.equals(tag, expectedTag, [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality]), + ); + + const expectedStr = getOnelineDiffStr.call(this, expectedTag); + return { + pass, + message: () => + pass + ? `Expected the Arena to NOT have a tag matching ${expectedStr}, but it did!` + : `Expected the Arena to have a tag matching ${expectedStr}, but it didn't!`, + expected: expectedTag, + actual: matchingTags, + }; +} + +function getPosTagStr(pType: PositionalTagType, count = 1): string { + let ret = toTitleCase(pType) + "Tag"; + if (count > 1) { + ret += "s"; + } + return ret; +} diff --git a/test/test-utils/matchers/to-have-stat-stage.ts b/test/test-utils/matchers/to-have-stat-stage.ts index feecd650bef..a9ae910aece 100644 --- a/test/test-utils/matchers/to-have-stat-stage.ts +++ b/test/test-utils/matchers/to-have-stat-stage.ts @@ -23,14 +23,14 @@ export function toHaveStatStage( ): SyncExpectationResult { if (!isPokemonInstance(received)) { return { - pass: false, + pass: this.isNot, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, }; } if (expectedStage < -6 || expectedStage > 6) { return { - pass: false, + pass: this.isNot, message: () => `Expected ${expectedStage} to be within the range [-6, 6]!`, }; } diff --git a/test/test-utils/matchers/to-have-status-effect.ts b/test/test-utils/matchers/to-have-status-effect.ts index a46800632f3..fa5f0346ebd 100644 --- a/test/test-utils/matchers/to-have-status-effect.ts +++ b/test/test-utils/matchers/to-have-status-effect.ts @@ -28,7 +28,7 @@ export function toHaveStatusEffect( ): SyncExpectationResult { if (!isPokemonInstance(received)) { return { - pass: false, + pass: this.isNot, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, }; } @@ -37,10 +37,8 @@ export function toHaveStatusEffect( const actualEffect = received.status?.effect ?? StatusEffect.NONE; // Check exclusively effect equality first, coercing non-matching status effects to numbers. - if (actualEffect !== (expectedStatus as Exclude)?.effect) { - // This is actually 100% safe as `expectedStatus?.effect` will evaluate to `undefined` if a StatusEffect was passed, - // which will never match actualEffect by definition - expectedStatus = (expectedStatus as Exclude).effect; + if (typeof expectedStatus === "object" && actualEffect !== expectedStatus.effect) { + expectedStatus = expectedStatus.effect; } if (typeof expectedStatus === "number") { diff --git a/test/test-utils/matchers/to-have-taken-damage.ts b/test/test-utils/matchers/to-have-taken-damage.ts index 77c60ae836a..55c163a2dc7 100644 --- a/test/test-utils/matchers/to-have-taken-damage.ts +++ b/test/test-utils/matchers/to-have-taken-damage.ts @@ -24,7 +24,7 @@ export function toHaveTakenDamage( ): SyncExpectationResult { if (!isPokemonInstance(received)) { return { - pass: false, + pass: this.isNot, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, }; } diff --git a/test/test-utils/matchers/to-have-terrain.ts b/test/test-utils/matchers/to-have-terrain.ts index 292c32abafc..f951abed0b3 100644 --- a/test/test-utils/matchers/to-have-terrain.ts +++ b/test/test-utils/matchers/to-have-terrain.ts @@ -20,15 +20,15 @@ export function toHaveTerrain( ): SyncExpectationResult { if (!isGameManagerInstance(received)) { return { - pass: false, - message: () => `Expected GameManager, but got ${receivedStr(received)}!`, + pass: this.isNot, + message: () => `Expected to receive a GameManager, but got ${receivedStr(received)}!`, }; } if (!received.scene?.arena) { return { - pass: false, - message: () => `Expected GameManager.${received.scene ? "scene" : "scene.arena"} to be defined!`, + pass: this.isNot, + message: () => `Expected GameManager.${received.scene ? "scene.arena" : "scene"} to be defined!`, }; } @@ -41,8 +41,8 @@ export function toHaveTerrain( pass, message: () => pass - ? `Expected Arena to NOT have ${expectedStr} active, but it did!` - : `Expected Arena to have ${expectedStr} active, but got ${actualStr} instead!`, + ? `Expected the Arena to NOT have ${expectedStr} active, but it did!` + : `Expected the Arena to have ${expectedStr} active, but got ${actualStr} instead!`, expected: expectedTerrainType, actual, }; diff --git a/test/test-utils/matchers/to-have-types.ts b/test/test-utils/matchers/to-have-types.ts index 3f16f740583..1c13fc083ae 100644 --- a/test/test-utils/matchers/to-have-types.ts +++ b/test/test-utils/matchers/to-have-types.ts @@ -7,10 +7,16 @@ import { isPokemonInstance, receivedStr } from "../test-utils"; export interface toHaveTypesOptions { /** - * Whether to enforce exact matches (`true`) or superset matches (`false`). - * @defaultValue `true` + * Value dictating the strength of the enforced typing match. + * + * Possible values (in ascending order of strength) are: + * - `"ordered"`: Enforce that the {@linkcode Pokemon}'s types are identical **and in the same order** + * - `"unordered"`: Enforce that the {@linkcode Pokemon}'s types are identical **without checking order** + * - `"superset"`: Enforce that the {@linkcode Pokemon}'s types are **a superset of** the expected types + * (all must be present, but extras can be there) + * @defaultValue `"unordered"` */ - exact?: boolean; + mode?: "ordered" | "unordered" | "superset"; /** * Optional arguments to pass to {@linkcode Pokemon.getTypes}. */ @@ -18,35 +24,54 @@ export interface toHaveTypesOptions { } /** - * Matcher that checks if an array contains exactly the given items, disregarding order. - * @param received - The object to check. Should be an array of one or more {@linkcode PokemonType}s. - * @param options - The {@linkcode toHaveTypesOptions | options} for this matcher + * Matcher that checks if a Pokemon's typing is as expected. + * @param received - The object to check. Should be a {@linkcode Pokemon} + * @param expectedTypes - An array of one or more {@linkcode PokemonType}s to compare against. + * @param mode - The mode to perform the matching in. + * Possible values (in ascending order of strength) are: + * - `"ordered"`: Enforce that the {@linkcode Pokemon}'s types are identical **and in the same order** + * - `"unordered"`: Enforce that the {@linkcode Pokemon}'s types are identical **without checking order** + * - `"superset"`: Enforce that the {@linkcode Pokemon}'s types are **a superset of** the expected types + * (all must be present, but extras can be there) + * + * Default `unordered` + * @param args - Extra arguments passed to {@linkcode Pokemon.getTypes} * @returns The result of the matching */ export function toHaveTypes( this: MatcherState, received: unknown, - expected: [PokemonType, ...PokemonType[]], - options: toHaveTypesOptions = {}, + expectedTypes: [PokemonType, ...PokemonType[]], + { mode = "unordered", args = [] }: toHaveTypesOptions = {}, ): SyncExpectationResult { if (!isPokemonInstance(received)) { return { - pass: false, - message: () => `Expected to recieve a Pokémon, but got ${receivedStr(received)}!`, + pass: this.isNot, + message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, }; } - const actualTypes = received.getTypes(...(options.args ?? [])).sort(); - const expectedTypes = expected.slice().sort(); + // Return early if no types were passed in + if (expectedTypes.length === 0) { + return { + pass: this.isNot, + message: () => "Expected to receive a non-empty array of PokemonTypes!", + }; + } + + // Avoid sorting the types if strict ordering is desired + const actualSorted = mode === "ordered" ? received.getTypes(...args) : received.getTypes(...args).toSorted(); + const expectedSorted = mode === "ordered" ? expectedTypes : expectedTypes.toSorted(); // Exact matches do not care about subset equality - const matchers = options.exact - ? [...this.customTesters, this.utils.iterableEquality] - : [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality]; - const pass = this.equals(actualTypes, expectedTypes, matchers); + const matchers = + mode === "superset" + ? [...this.customTesters, this.utils.iterableEquality] + : [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality]; + const pass = this.equals(actualSorted, expectedSorted, matchers); - const actualStr = stringifyEnumArray(PokemonType, actualTypes); - const expectedStr = stringifyEnumArray(PokemonType, expectedTypes); + const actualStr = stringifyEnumArray(PokemonType, actualSorted); + const expectedStr = stringifyEnumArray(PokemonType, expectedSorted); const pkmName = getPokemonNameWithAffix(received); return { @@ -55,7 +80,7 @@ export function toHaveTypes( pass ? `Expected ${pkmName} to NOT have types ${expectedStr}, but it did!` : `Expected ${pkmName} to have types ${expectedStr}, but got ${actualStr} instead!`, - expected: expectedTypes, - actual: actualTypes, + expected: expectedSorted, + actual: actualSorted, }; } diff --git a/test/test-utils/matchers/to-have-used-move.ts b/test/test-utils/matchers/to-have-used-move.ts index ef90e4dbad9..3697b3e0bc6 100644 --- a/test/test-utils/matchers/to-have-used-move.ts +++ b/test/test-utils/matchers/to-have-used-move.ts @@ -13,7 +13,7 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; /** * Matcher to check the contents of a {@linkcode Pokemon}'s move history. * @param received - The actual value received. Should be a {@linkcode Pokemon} - * @param expectedValue - The {@linkcode MoveId} the Pokemon is expected to have used, + * @param expectedMove - The {@linkcode MoveId} the Pokemon is expected to have used, * or a partially filled {@linkcode TurnMove} containing the desired properties to check * @param index - The index of the move history entry to check, in order from most recent to least recent. * Default `0` (last used move) @@ -22,12 +22,12 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; export function toHaveUsedMove( this: MatcherState, received: unknown, - expectedResult: MoveId | AtLeastOne, + expectedMove: MoveId | AtLeastOne, index = 0, ): SyncExpectationResult { if (!isPokemonInstance(received)) { return { - pass: false, + pass: this.isNot, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, }; } @@ -37,34 +37,33 @@ export function toHaveUsedMove( if (move === undefined) { return { - pass: false, + pass: this.isNot, message: () => `Expected ${pkmName} to have used ${index + 1} moves, but it didn't!`, actual: received.getLastXMoves(-1), }; } // Coerce to a `TurnMove` - if (typeof expectedResult === "number") { - expectedResult = { move: expectedResult }; + if (typeof expectedMove === "number") { + expectedMove = { move: expectedMove }; } const moveIndexStr = index === 0 ? "last move" : `${getOrdinal(index)} most recent move`; - const pass = this.equals(move, expectedResult, [ + const pass = this.equals(move, expectedMove, [ ...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality, ]); - const expectedStr = getOnelineDiffStr.call(this, expectedResult); + const expectedStr = getOnelineDiffStr.call(this, expectedMove); return { pass, message: () => pass ? `Expected ${pkmName}'s ${moveIndexStr} to NOT match ${expectedStr}, but it did!` - : // Replace newlines with spaces to preserve one-line ness - `Expected ${pkmName}'s ${moveIndexStr} to match ${expectedStr}, but it didn't!`, - expected: expectedResult, + : `Expected ${pkmName}'s ${moveIndexStr} to match ${expectedStr}, but it didn't!`, + expected: expectedMove, actual: move, }; } diff --git a/test/test-utils/matchers/to-have-used-pp.ts b/test/test-utils/matchers/to-have-used-pp.ts index 3b606a535bc..4815cfcadab 100644 --- a/test/test-utils/matchers/to-have-used-pp.ts +++ b/test/test-utils/matchers/to-have-used-pp.ts @@ -13,7 +13,7 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; /** * Matcher to check the amount of PP consumed by a {@linkcode Pokemon}. * @param received - The actual value received. Should be a {@linkcode Pokemon} - * @param expectedValue - The {@linkcode MoveId} that should have consumed PP + * @param moveId - The {@linkcode MoveId} that should have consumed PP * @param ppUsed - The numerical amount of PP that should have been consumed, * or `all` to indicate the move should be _out_ of PP * @returns Whether the matcher passed @@ -23,35 +23,35 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; export function toHaveUsedPP( this: MatcherState, received: unknown, - expectedMove: MoveId, + moveId: MoveId, ppUsed: number | "all", ): SyncExpectationResult { if (!isPokemonInstance(received)) { return { - pass: false, + pass: this.isNot, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, }; } - 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, + pass: this.isNot, message: () => `Cannot test for PP consumption with ${received.isPlayer() ? "player" : "enemy"} moveset overrides active!`, }; } const pkmName = getPokemonNameWithAffix(received); - const moveStr = getEnumStr(MoveId, expectedMove); + const moveStr = getEnumStr(MoveId, moveId); - const movesetMoves = received.getMoveset().filter(pm => pm.moveId === expectedMove); + const movesetMoves = received.getMoveset().filter(pm => pm.moveId === moveId); if (movesetMoves.length !== 1) { return { - pass: false, + pass: this.isNot, message: () => `Expected MoveId.${moveStr} to appear in ${pkmName}'s moveset exactly once, but got ${movesetMoves.length} times!`, - expected: expectedMove, + expected: moveId, actual: received.getMoveset(), }; } diff --git a/test/test-utils/matchers/to-have-weather.ts b/test/test-utils/matchers/to-have-weather.ts index 49433b2137b..ffb1e0aad97 100644 --- a/test/test-utils/matchers/to-have-weather.ts +++ b/test/test-utils/matchers/to-have-weather.ts @@ -20,15 +20,15 @@ export function toHaveWeather( ): SyncExpectationResult { if (!isGameManagerInstance(received)) { return { - pass: false, - message: () => `Expected GameManager, but got ${receivedStr(received)}!`, + pass: this.isNot, + message: () => `Expected to receive a GameManager, but got ${receivedStr(received)}!`, }; } if (!received.scene?.arena) { return { - pass: false, - message: () => `Expected GameManager.${received.scene ? "scene" : "scene.arena"} to be defined!`, + pass: this.isNot, + message: () => `Expected GameManager.${received.scene ? "scene.arena" : "scene"} to be defined!`, }; } @@ -41,8 +41,8 @@ export function toHaveWeather( pass, message: () => pass - ? `Expected Arena to NOT have ${expectedStr} weather active, but it did!` - : `Expected Arena to have ${expectedStr} weather active, but got ${actualStr} instead!`, + ? `Expected the Arena to NOT have ${expectedStr} weather active, but it did!` + : `Expected the Arena to have ${expectedStr} weather active, but got ${actualStr} instead!`, expected: expectedWeatherType, actual, }; diff --git a/test/test-utils/string-utils.ts b/test/test-utils/string-utils.ts index bd3dd7c2fa9..6c29c04c107 100644 --- a/test/test-utils/string-utils.ts +++ b/test/test-utils/string-utils.ts @@ -34,10 +34,10 @@ interface getEnumStrOptions { * @returns The stringified representation of `val` as dictated by the options. * @example * ```ts - * enum fakeEnum { - * ONE: 1, - * TWO: 2, - * THREE: 3, + * enum testEnum { + * ONE = 1, + * TWO = 2, + * THREE = 3, * } * getEnumStr(fakeEnum, fakeEnum.ONE); // Output: "ONE (=1)" * getEnumStr(fakeEnum, fakeEnum.TWO, {casing: "Title", prefix: "fakeEnum.", suffix: "!!!"}); // Output: "fakeEnum.TWO!!! (=2)" @@ -174,10 +174,14 @@ export function getStatName(s: Stat): string { * Convert an object into a oneline diff to be shown in an error message. * @param obj - The object to return the oneline diff of * @returns The updated diff + * @example + * ```ts + * const diff = getOnelineDiffStr.call(this, obj) + * ``` */ export function getOnelineDiffStr(this: MatcherState, obj: unknown): string { return this.utils .stringify(obj, undefined, { maxLength: 35, indent: 0, printBasicPrototype: false }) .replace(/\n/g, " ") // Replace newlines with spaces - .replace(/,(\s*)}$/g, "$1}"); + .replace(/,(\s*)}$/g, "$1}"); // Trim trailing commas }