diff --git a/devWrapper.ts b/devWrapper.ts index 1b3e54f..7446abf 100644 --- a/devWrapper.ts +++ b/devWrapper.ts @@ -1,4 +1,5 @@ import * as fs from 'fs' +import { sanitizePokeApiBaseStatsForCache } from './src/classes/PokeApi' import { generate, invasions } from './src/index' import { createNodeApkCache, primeApkCache } from './src/node' import baseStats from './static/baseStats.json' @@ -46,7 +47,7 @@ const main = async () => { const { baseStats, tempEvos, types } = data.AllPokeApi fs.writeFile( './static/baseStats.json', - JSON.stringify(baseStats, null, 2), + JSON.stringify(sanitizePokeApiBaseStatsForCache(baseStats), null, 2), 'utf8', () => {}, ) diff --git a/src/classes/PokeApi.ts b/src/classes/PokeApi.ts index 73aa3e9..c70db02 100644 --- a/src/classes/PokeApi.ts +++ b/src/classes/PokeApi.ts @@ -2,6 +2,7 @@ import { Rpc } from '@na-ji/pogo-protos' import type { AllMoves, AllPokemon, + SinglePokemon, AllTypes, TempEvolutions, } from '../typings/dataTypes' @@ -15,6 +16,36 @@ import type { MoveProto, PokemonIdProto, TypeProto } from '../typings/protos' import { sortTempEvolutions } from '../utils/tempEvolutions' import Masterfile from './Masterfile' +const excludedFallbackChargedMoves = new Set([ + Rpc.HoloPokemonMove.FRUSTRATION, + Rpc.HoloPokemonMove.RETURN, +]) + +export const sanitizePokeApiBaseStatsForCache = (baseStats: AllPokemon) => + Object.fromEntries( + Object.entries(baseStats).map(([id, entry]) => { + if (!entry?.chargedMoves?.length) { + return [id, entry] + } + const chargedMoves = entry.chargedMoves.filter( + (move) => !excludedFallbackChargedMoves.has(move), + ) + if (chargedMoves.length === entry.chargedMoves.length) { + return [id, entry] + } + return [ + id, + { + ...entry, + chargedMoves, + ...(chargedMoves.length === 0 + ? { _hiddenOnlyChargedMoves: true } + : {}), + }, + ] + }), + ) as AllPokemon + export default class PokeApi extends Masterfile { baseStats: AllPokemon tempEvos: { [id: string]: AllPokemon } @@ -24,6 +55,9 @@ export default class PokeApi extends Masterfile { [id: string]: { attack?: number; defense?: number; stamina?: number } } moveReference: AllMoves + private pokemonStatsCache: { [id: string]: Promise | PokeApiStats } + private speciesCache: { [id: string]: Promise | SpeciesApi } + private inheritedMoveParentOverrides: { [id: string]: string } private apiBaseUrl: string constructor(baseUrl?: string) { @@ -35,6 +69,12 @@ export default class PokeApi extends Masterfile { this.baseStats = {} this.tempEvos = {} this.types = {} + this.pokemonStatsCache = {} + this.speciesCache = {} + this.inheritedMoveParentOverrides = { + 'basculegion-female': 'basculin-white-striped', + 'basculegion-male': 'basculin-white-striped', + } this.maxPokemon = 1008 this.inconsistentStats = { 24: { @@ -101,6 +141,25 @@ export default class PokeApi extends Masterfile { this.moveReference = parsed } + private isKnownMove(move?: number): move is number { + return !!move && !!this.moveReference?.[move] + } + + private hasExactMoves(moves: number[] | undefined, expected: number[]) { + return ( + Array.isArray(moves) && + moves.length === expected.length && + moves.every((move, index) => move === expected[index]) + ) + } + + private shouldFetchPlaceholderMoves(pokemon?: SinglePokemon) { + return ( + this.hasExactMoves(pokemon?.quickMoves, [Rpc.HoloPokemonMove.SPLASH_FAST]) && + this.hasExactMoves(pokemon?.chargedMoves, [Rpc.HoloPokemonMove.STRUGGLE]) + ) + } + private buildUrl(path: string) { return `${this.apiBaseUrl}/${path.replace(/^\//, '')}` } @@ -141,6 +200,144 @@ export default class PokeApi extends Masterfile { .sort((a, b) => a - b) } + private resolveStructId(struct?: BasePokeApiStruct | null): number | undefined { + if (!struct) { + return undefined + } + const protoId = + Rpc.HoloPokemonId[ + struct.name.toUpperCase().replace(/-/g, '_') as PokemonIdProto + ] + if (protoId) { + return protoId + } + const idFromUrl = Number.parseInt(struct.url.split('/').at(-2) || '', 10) + return Number.isFinite(idFromUrl) ? idFromUrl : undefined + } + + private async fetchPokemonStats(id: string | number): Promise { + const cacheKey = `${id}` + if (!this.pokemonStatsCache[cacheKey]) { + this.pokemonStatsCache[cacheKey] = (this.fetch( + this.buildUrl(`pokemon/${id}`), + ) as Promise).catch((error) => { + delete this.pokemonStatsCache[cacheKey] + throw error + }) + } + const statsData = await this.pokemonStatsCache[cacheKey] + this.pokemonStatsCache[cacheKey] = statsData + return statsData + } + + private async fetchSpecies(id: string | number): Promise { + const cacheKey = `${id}` + if (!this.speciesCache[cacheKey]) { + this.speciesCache[cacheKey] = (this.fetch( + this.buildUrl(`pokemon-species/${id}`), + ) as Promise).catch((error) => { + delete this.speciesCache[cacheKey] + throw error + }) + } + const speciesData = await this.speciesCache[cacheKey] + this.speciesCache[cacheKey] = speciesData + return speciesData + } + + private async fetchSpeciesForPokemon( + id: string | number, + statsData: PokeApiStats, + ): Promise { + const speciesId = this.resolveStructId(statsData.species) + if (speciesId !== undefined) { + return this.fetchSpecies(speciesId) + } + if (statsData.species?.name) { + return this.fetchSpecies(statsData.species.name) + } + return this.fetchSpecies(id) + } + + private mapPokeApiMoves(statsData: PokeApiStats) { + return { + quickMoves: statsData.moves + .map( + (move) => + Rpc.HoloPokemonMove[ + `${move.move.name + .toUpperCase() + .replace(/-/g, '_')}_FAST` as MoveProto + ], + ) + .filter((move): move is number => this.isKnownMove(move)), + chargedMoves: statsData.moves + .map( + (move) => + Rpc.HoloPokemonMove[ + move.move.name.toUpperCase().replace(/-/g, '_') as MoveProto + ], + ) + .filter((move): move is number => this.isKnownMove(move)), + } + } + + private mergeMoveLists(...moveLists: number[][]) { + return Array.from(new Set(moveLists.flat())).sort((a, b) => a - b) + } + + private resolveInheritedParentIdentifier( + pokemonName: string, + speciesData: SpeciesApi, + ): string | number | undefined { + return ( + this.inheritedMoveParentOverrides[pokemonName] || + this.resolveStructId(speciesData.evolves_from_species) + ) + } + + private async getInheritedMoves( + id: string | number, + seen = new Set(), + ): Promise<{ + quickMoves: number[] + chargedMoves: number[] + }> { + const cacheKey = `${id}` + if (seen.has(cacheKey)) { + return { quickMoves: [], chargedMoves: [] } + } + seen.add(cacheKey) + try { + const statsData = await this.fetchPokemonStats(id) + const currentMoves = this.mapPokeApiMoves(statsData) + const speciesData = await this.fetchSpeciesForPokemon(id, statsData) + const previousId = this.resolveInheritedParentIdentifier( + statsData.name, + speciesData, + ) + if (!previousId) { + return { + quickMoves: this.mergeMoveLists(currentMoves.quickMoves), + chargedMoves: this.mergeMoveLists(currentMoves.chargedMoves), + } + } + const previousMoves = await this.getInheritedMoves(previousId, seen) + return { + quickMoves: this.mergeMoveLists( + currentMoves.quickMoves, + previousMoves.quickMoves, + ), + chargedMoves: this.mergeMoveLists( + currentMoves.chargedMoves, + previousMoves.chargedMoves, + ), + } + } finally { + seen.delete(cacheKey) + } + } + private calculatePogoStats( baseStats: { [stat: string]: number }, nerf: boolean = false, @@ -238,9 +435,10 @@ export default class PokeApi extends Masterfile { !parsedPokemon[id].defense || !parsedPokemon[id].stamina || parsedPokemon[id].types.length === 0 || - pokeApiIds?.includes(+id) + pokeApiIds?.includes(+id) || + this.shouldFetchPlaceholderMoves(parsedPokemon[id]) ) { - await this.pokemonApi(id) + await this.pokemonApi(id, false) } }), ) @@ -253,14 +451,13 @@ export default class PokeApi extends Masterfile { extraPokemon.push(i) } } - await Promise.all(extraPokemon.map((id) => this.pokemonApi(id))) + await Promise.all(extraPokemon.map((id) => this.pokemonApi(id, true))) } - async pokemonApi(id: string | number) { + async pokemonApi(id: string | number, unreleased = false) { try { - const statsData: PokeApiStats = await this.fetch( - this.buildUrl(`pokemon/${id}`), - ) + const statsData = await this.fetchPokemonStats(id) + const inheritedMoves = await this.getInheritedMoves(id) const baseStats = this.buildStatMap(statsData.stats) const initial = this.calculatePogoStats(baseStats) @@ -275,26 +472,8 @@ export default class PokeApi extends Masterfile { cp > 4000 ? this.calculatePogoStats(baseStats, true) : initial this.baseStats[id] = { pokemonName: this.capitalize(statsData.name), - quickMoves: statsData.moves - .map( - (move) => - Rpc.HoloPokemonMove[ - `${move.move.name - .toUpperCase() - .replace(/-/g, '_')}_FAST` as MoveProto - ], - ) - .filter((move) => move && this.moveReference[move]?.power) - .sort((a, b) => a - b), - chargedMoves: statsData.moves - .map( - (move) => - Rpc.HoloPokemonMove[ - move.move.name.toUpperCase().replace(/-/g, '_') as MoveProto - ], - ) - .filter((move) => move && this.moveReference[move]?.power) - .sort((a, b) => a - b), + quickMoves: inheritedMoves.quickMoves, + chargedMoves: inheritedMoves.chargedMoves, attack: this.inconsistentStats[id] ? this.inconsistentStats[id].attack || nerfCheck.attack : nerfCheck.attack, @@ -305,7 +484,7 @@ export default class PokeApi extends Masterfile { ? this.inconsistentStats[id].stamina || nerfCheck.stamina : nerfCheck.stamina, types: this.mapTypeIds(statsData.types), - unreleased: true, + ...(unreleased ? { unreleased: true } : {}), } } catch (e) { console.warn(e, `Failed to parse PokeApi Stats for #${id}`) @@ -317,20 +496,15 @@ export default class PokeApi extends Masterfile { Object.keys(parsedPokemon).map(async (id) => { try { if (!evolvedPokemon.has(+id)) { - const evoData: SpeciesApi = await this.fetch( - this.buildUrl(`pokemon-species/${id}`), - ) + const evoData = await this.fetchSpecies(id) if (this.baseStats[id]) { - this.baseStats[id].legendary = evoData.is_legendary - this.baseStats[id].mythic = evoData.is_mythical + this.baseStats[id].legendary = + parsedPokemon[id]?.legendary ?? evoData.is_legendary + this.baseStats[id].mythic = + parsedPokemon[id]?.mythic ?? evoData.is_mythical } if (evoData.evolves_from_species) { - const prevEvoId = - Rpc.HoloPokemonId[ - evoData.evolves_from_species.name - .toUpperCase() - .replace(/-/g, '_') as PokemonIdProto - ] ?? +evoData.evolves_from_species.url.split('/').at(-2) + const prevEvoId = this.resolveStructId(evoData.evolves_from_species) if (prevEvoId) { if (!this.baseStats[prevEvoId]) { this.baseStats[prevEvoId] = {} @@ -364,6 +538,19 @@ export default class PokeApi extends Masterfile { } }), ) + await Promise.all( + Object.keys(this.baseStats).map(async (id) => { + try { + const evoData = await this.fetchSpecies(id) + this.baseStats[id].legendary = + parsedPokemon[id]?.legendary ?? evoData.is_legendary + this.baseStats[id].mythic = + parsedPokemon[id]?.mythic ?? evoData.is_mythical + } catch (e) { + console.warn(e, `Failed to apply PokeApi species flags for #${id}`) + } + }), + ) } async tempEvoApi(parsedPokemon: AllPokemon) { diff --git a/src/classes/Pokemon.ts b/src/classes/Pokemon.ts index 51f31a7..78910fd 100644 --- a/src/classes/Pokemon.ts +++ b/src/classes/Pokemon.ts @@ -75,6 +75,32 @@ const reconcileBaseFormChanges = ( }) } +const cleanNumberList = (arr?: number[]) => + Array.isArray(arr) + ? Array.from( + new Set(arr.filter((value): value is number => typeof value === 'number')), + ) + : [] + +const hasExactPlaceholderMoves = (moves: number[], expected: number[]) => + moves.length === expected.length && + moves.every((value, index) => value === expected[index]) + +const shouldPreferEstimatedPlaceholderQuickMoves = ( + actualQuickMoves: number[], + actualChargedMoves: number[], + fallbackQuickMoves: number[], +) => + hasExactPlaceholderMoves(actualQuickMoves, [Rpc.HoloPokemonMove.SPLASH_FAST]) && + hasExactPlaceholderMoves(actualChargedMoves, [Rpc.HoloPokemonMove.STRUGGLE]) && + !fallbackQuickMoves.includes(Rpc.HoloPokemonMove.SPLASH_FAST) && + fallbackQuickMoves.length > 0 + +const excludedPlaceholderFallbackChargedMoves = new Set([ + Rpc.HoloPokemonMove.FRUSTRATION, + Rpc.HoloPokemonMove.RETURN, +]) + export default class Pokemon extends Masterfile { parsedPokemon: AllPokemon parsedPokeForms: AllPokemon @@ -1379,18 +1405,8 @@ export default class Pokemon extends Masterfile { fallback: number[] | undefined, label: string, ): number[] | undefined => { - const clean = (arr?: number[]) => - Array.isArray(arr) - ? Array.from( - new Set( - arr.filter( - (value): value is number => typeof value === 'number', - ), - ), - ) - : [] - const actualValues = clean(actual) - const fallbackValues = clean(fallback) + const actualValues = cleanNumberList(actual) + const fallbackValues = cleanNumberList(fallback) if (actualValues.length) { if (fallbackValues.length) { const fallbackSet = new Set(fallbackValues) @@ -1489,34 +1505,66 @@ export default class Pokemon extends Masterfile { } }) } + const { + _hiddenOnlyChargedMoves, + ...cacheEntry + } = baseEntry + const actualQuickMoves = cleanNumberList(existing.quickMoves) + const actualChargedMoves = cleanNumberList(existing.chargedMoves) + const fallbackQuickMoves = cleanNumberList(cacheEntry.quickMoves) + const fallbackChargedMoves = cleanNumberList(cacheEntry.chargedMoves) + const sanitizedFallbackChargedMoves = fallbackChargedMoves.filter( + (move) => !excludedPlaceholderFallbackChargedMoves.has(move), + ) + const hasHiddenOnlyFallbackChargedMoves = + _hiddenOnlyChargedMoves === true + const preferEstimatedPlaceholderQuickMoves = + shouldPreferEstimatedPlaceholderQuickMoves( + actualQuickMoves, + actualChargedMoves, + fallbackQuickMoves, + ) + const shouldDropPlaceholderChargedMoves = + preferEstimatedPlaceholderQuickMoves && + (fallbackChargedMoves.length > 0 || + hasHiddenOnlyFallbackChargedMoves) && + sanitizedFallbackChargedMoves.length === 0 + const preferEstimatedPlaceholderChargedMoves = + preferEstimatedPlaceholderQuickMoves && + sanitizedFallbackChargedMoves.length > 0 + if (preferEstimatedPlaceholderQuickMoves) { + console.warn( + `[BASE_STATS] Replacing placeholder moves for ${id} with PokeApi data`, + ) + } const quickMoves = - preferActualNumbers( - existing.quickMoves, - baseEntry.quickMoves, - 'quick moves', - ) ?? - (Array.isArray(existing.quickMoves) && existing.quickMoves.length - ? Array.from(new Set(existing.quickMoves)) - : undefined) + preferEstimatedPlaceholderQuickMoves + ? fallbackQuickMoves + : preferActualNumbers( + existing.quickMoves, + cacheEntry.quickMoves, + 'quick moves', + ) ?? (actualQuickMoves.length ? actualQuickMoves : undefined) const chargedMoves = - preferActualNumbers( - existing.chargedMoves, - baseEntry.chargedMoves, - 'charged moves', - ) ?? - (Array.isArray(existing.chargedMoves) && - existing.chargedMoves.length - ? Array.from(new Set(existing.chargedMoves)) - : undefined) + preferEstimatedPlaceholderChargedMoves + ? sanitizedFallbackChargedMoves + : shouldDropPlaceholderChargedMoves + ? undefined + : preferActualNumbers( + existing.chargedMoves, + sanitizedFallbackChargedMoves, + 'charged moves', + ) ?? + (actualChargedMoves.length ? actualChargedMoves : undefined) const types = - preferActualNumbers(existing.types, baseEntry.types, 'types') ?? + preferActualNumbers(existing.types, cacheEntry.types, 'types') ?? (Array.isArray(existing.types) && existing.types.length ? Array.from(new Set(existing.types)) : undefined) this.parsedPokemon[id] = { - ...baseEntry, + ...cacheEntry, ...existing, - pokemonName: existing.pokemonName || baseEntry.pokemonName, + pokemonName: existing.pokemonName || cacheEntry.pokemonName, quickMoves, chargedMoves, types, diff --git a/src/typings/dataTypes.ts b/src/typings/dataTypes.ts index f1e09b5..fca40b4 100644 --- a/src/typings/dataTypes.ts +++ b/src/typings/dataTypes.ts @@ -138,6 +138,7 @@ export interface SinglePokemon extends SingleForm { } interface SingleForm extends BaseStats { + _hiddenOnlyChargedMoves?: boolean formName?: string proto?: string formId?: number diff --git a/src/typings/general.ts b/src/typings/general.ts index eedf515..7c4346c 100644 --- a/src/typings/general.ts +++ b/src/typings/general.ts @@ -292,10 +292,12 @@ export interface EvoBranch { } export interface SpeciesApi { - evolves_from_species: { - name: string - url: string - } + evolves_from_species?: + | { + name: string + url: string + } + | null is_legendary: boolean is_mythical: boolean } diff --git a/static/baseStats.json b/static/baseStats.json index 4f32a32..b3d17af 100644 --- a/static/baseStats.json +++ b/static/baseStats.json @@ -1,11 +1,31 @@ { + "129": { + "pokemonName": "Magikarp", + "quickMoves": [ + 221, + 231 + ], + "chargedMoves": [ + 107 + ], + "attack": 29, + "defense": 85, + "stamina": 85, + "types": [ + 11 + ], + "legendary": false, + "mythic": false + }, "203": { "evolutions": [ { "evoId": 981, "formId": 3292 } - ] + ], + "legendary": false, + "mythic": false }, "234": { "evolutions": [ @@ -27,6 +47,92 @@ "legendary": false, "mythic": false }, + "789": { + "pokemonName": "Cosmog", + "quickMoves": [ + 231 + ], + "chargedMoves": [], + "attack": 54, + "defense": 57, + "stamina": 125, + "types": [ + 14 + ], + "legendary": true, + "mythic": false + }, + "790": { + "pokemonName": "Cosmoem", + "quickMoves": [ + 231 + ], + "chargedMoves": [], + "attack": 54, + "defense": 242, + "stamina": 125, + "types": [ + 14 + ], + "legendary": true, + "mythic": false + }, + "801": { + "pokemonName": "Magearna", + "quickMoves": [ + 234, + 249, + 250, + 281, + 282, + 325, + 368, + 402 + ], + "chargedMoves": [ + 14, + 30, + 36, + 39, + 60, + 70, + 74, + 79, + 84, + 85, + 86, + 88, + 99, + 108, + 116, + 123, + 125, + 131, + 132, + 247, + 248, + 252, + 267, + 268, + 272, + 273, + 300, + 309, + 321, + 324, + 332, + 344 + ], + "attack": 246, + "defense": 225, + "stamina": 190, + "types": [ + 9, + 18 + ], + "legendary": false, + "mythic": true + }, "884": { "evolutions": [ { @@ -55,10 +161,12 @@ 39, 40, 57, + 58, 70, 105, 107, 111, + 125, 132, 265, 277, @@ -77,8 +185,923 @@ 8, 11 ], - "unreleased": true, "legendary": false, "mythic": false + }, + "984": { + "pokemonName": "Great-tusk", + "quickMoves": [ + 216, + 233, + 234, + 240, + 282, + 297, + 326, + 327, + 368 + ], + "chargedMoves": [ + 14, + 22, + 26, + 31, + 32, + 36, + 60, + 63, + 64, + 74, + 88, + 95, + 123, + 126, + 131, + 132, + 245, + 268, + 304, + 321, + 377 + ], + "attack": 226, + "defense": 190, + "stamina": 229, + "types": [ + 2, + 5 + ], + "legendary": false, + "mythic": false + }, + "985": { + "pokemonName": "Scream-tail", + "quickMoves": [ + 202, + 222, + 234, + 240, + 282, + 326, + 327 + ], + "chargedMoves": [ + 14, + 24, + 26, + 30, + 33, + 39, + 40, + 60, + 63, + 77, + 78, + 79, + 86, + 88, + 103, + 105, + 108, + 115, + 131, + 132, + 247, + 267, + 272, + 279, + 314, + 321, + 353, + 380 + ], + "attack": 139, + "defense": 234, + "stamina": 251, + "types": [ + 14, + 18 + ], + "legendary": false, + "mythic": false + }, + "986": { + "pokemonName": "Brute-bonnet", + "quickMoves": [ + 203, + 234, + 263, + 264, + 271, + 357 + ], + "chargedMoves": [ + 14, + 16, + 48, + 59, + 114, + 116, + 131, + 132, + 245, + 272, + 273, + 277, + 279, + 304, + 321, + 333, + 343, + 392 + ], + "attack": 232, + "defense": 190, + "stamina": 244, + "types": [ + 12, + 17 + ], + "legendary": false, + "mythic": false + }, + "987": { + "pokemonName": "Flutter-mane", + "quickMoves": [ + 249, + 263, + 264, + 320, + 357 + ], + "chargedMoves": [ + 14, + 16, + 30, + 60, + 65, + 70, + 78, + 79, + 84, + 85, + 86, + 87, + 111, + 125, + 132, + 265, + 273, + 321, + 376, + 382 + ], + "attack": 280, + "defense": 235, + "stamina": 146, + "types": [ + 8, + 18 + ], + "legendary": false, + "mythic": false + }, + "988": { + "pokemonName": "Slither-wing", + "quickMoves": [ + 201, + 207, + 209, + 234, + 282, + 345 + ], + "chargedMoves": [ + 14, + 31, + 42, + 45, + 49, + 56, + 101, + 114, + 122, + 123, + 127, + 131, + 132, + 245, + 251, + 268, + 306, + 313, + 321, + 364, + 377, + 392 + ], + "attack": 261, + "defense": 193, + "stamina": 198, + "types": [ + 2, + 7 + ], + "legendary": false, + "mythic": false + }, + "989": { + "pokemonName": "Sandy-shocks", + "quickMoves": [ + 205, + 206, + 216, + 249, + 250, + 282, + 402 + ], + "chargedMoves": [ + 14, + 31, + 35, + 36, + 65, + 78, + 79, + 95, + 125, + 131, + 132, + 251, + 252, + 258, + 268, + 276, + 304, + 321, + 344, + 377, + 393 + ], + "attack": 244, + "defense": 195, + "stamina": 198, + "types": [ + 5, + 13 + ], + "legendary": false, + "mythic": false + }, + "990": { + "pokemonName": "Iron-treads", + "quickMoves": [ + 216, + 233, + 234, + 250, + 282, + 326, + 327, + 368, + 402 + ], + "chargedMoves": [ + 14, + 22, + 31, + 32, + 36, + 63, + 64, + 74, + 78, + 95, + 126, + 131, + 132, + 251, + 267, + 268, + 304, + 321, + 377 + ], + "attack": 227, + "defense": 216, + "stamina": 207, + "types": [ + 5, + 9 + ], + "legendary": false, + "mythic": false + }, + "991": { + "pokemonName": "Iron-bundle", + "quickMoves": [ + 244, + 282, + 291 + ], + "chargedMoves": [ + 14, + 33, + 38, + 39, + 40, + 88, + 105, + 107, + 111, + 121, + 125, + 131, + 132, + 254, + 321, + 364, + 488 + ], + "attack": 266, + "defense": 211, + "stamina": 148, + "types": [ + 11, + 15 + ], + "legendary": false, + "mythic": false + }, + "992": { + "pokemonName": "Iron-hands", + "quickMoves": [ + 207, + 221, + 250, + 282, + 403, + 462 + ], + "chargedMoves": [ + 14, + 31, + 33, + 56, + 63, + 64, + 74, + 77, + 78, + 79, + 88, + 95, + 115, + 123, + 131, + 132, + 245, + 247, + 251, + 268, + 314, + 321 + ], + "attack": 223, + "defense": 161, + "stamina": 291, + "types": [ + 2, + 13 + ], + "legendary": false, + "mythic": false + }, + "993": { + "pokemonName": "Iron-jugulis", + "quickMoves": [ + 204, + 234, + 240, + 249, + 253, + 255, + 278, + 282, + 402 + ], + "chargedMoves": [ + 14, + 16, + 24, + 36, + 42, + 63, + 74, + 82, + 103, + 107, + 121, + 122, + 131, + 132, + 247, + 277, + 279, + 304, + 321, + 341, + 344, + 364, + 372 + ], + "attack": 249, + "defense": 179, + "stamina": 214, + "types": [ + 3, + 17 + ], + "legendary": false, + "mythic": false + }, + "994": { + "pokemonName": "Iron-moth", + "quickMoves": [ + 209, + 249, + 255, + 261, + 269, + 282, + 345, + 402 + ], + "chargedMoves": [ + 14, + 24, + 35, + 36, + 42, + 49, + 86, + 91, + 101, + 103, + 108, + 116, + 122, + 125, + 132, + 270, + 273, + 303, + 306, + 321, + 364, + 372 + ], + "attack": 281, + "defense": 196, + "stamina": 190, + "types": [ + 4, + 10 + ], + "legendary": false, + "mythic": false + }, + "995": { + "pokemonName": "Iron-thorns", + "quickMoves": [ + 202, + 207, + 227, + 228, + 240, + 249, + 250, + 253, + 278, + 282, + 297, + 326, + 327 + ], + "chargedMoves": [ + 14, + 24, + 26, + 31, + 32, + 33, + 39, + 40, + 63, + 64, + 65, + 74, + 77, + 78, + 79, + 83, + 95, + 103, + 115, + 123, + 131, + 132, + 247, + 251, + 258, + 259, + 268, + 279, + 304, + 321, + 372, + 377, + 379 + ], + "attack": 250, + "defense": 200, + "stamina": 225, + "types": [ + 6, + 13 + ], + "legendary": false, + "mythic": false + }, + "1005": { + "pokemonName": "Roaring-moon", + "quickMoves": [ + 202, + 204, + 213, + 228, + 234, + 240, + 253, + 255, + 269, + 278, + 282, + 326, + 346 + ], + "chargedMoves": [ + 14, + 16, + 24, + 26, + 31, + 32, + 42, + 45, + 51, + 64, + 74, + 82, + 83, + 100, + 103, + 107, + 122, + 123, + 131, + 132, + 277, + 279, + 285, + 321, + 341, + 364, + 379 + ], + "attack": 254, + "defense": 178, + "stamina": 213, + "types": [ + 16, + 17 + ], + "legendary": false, + "mythic": false + }, + "1006": { + "pokemonName": "Iron-valiant", + "quickMoves": [ + 200, + 207, + 213, + 224, + 226, + 234, + 249, + 264, + 357 + ], + "chargedMoves": [ + 14, + 30, + 33, + 45, + 51, + 60, + 66, + 70, + 77, + 79, + 86, + 87, + 100, + 108, + 111, + 115, + 117, + 123, + 125, + 132, + 245, + 247, + 272, + 273, + 314, + 321, + 332, + 383 + ], + "attack": 279, + "defense": 171, + "stamina": 179, + "types": [ + 2, + 18 + ], + "legendary": false, + "mythic": false + }, + "1009": { + "pokemonName": "Walking-wake", + "quickMoves": [ + 202, + 204, + 207, + 216, + 240, + 278, + 282, + 283 + ], + "chargedMoves": [ + 14, + 24, + 57, + 80, + 82, + 83, + 105, + 106, + 107, + 122, + 125, + 131, + 132, + 277, + 279, + 284, + 285, + 321, + 379, + 383, + 488 + ], + "attack": 256, + "defense": 188, + "stamina": 223, + "types": [ + 11, + 16 + ], + "legendary": true, + "mythic": false + }, + "1010": { + "pokemonName": "Iron-leaves", + "quickMoves": [ + 219, + 255, + 282, + 357, + 402 + ], + "chargedMoves": [ + 14, + 22, + 45, + 51, + 100, + 114, + 116, + 117, + 123, + 125, + 132, + 245, + 247, + 251, + 272, + 273, + 321, + 330, + 343, + 392 + ], + "attack": 259, + "defense": 213, + "stamina": 207, + "types": [ + 12, + 14 + ], + "legendary": true, + "mythic": false + }, + "1020": { + "pokemonName": "Gouging-fire", + "quickMoves": [ + 202, + 240, + 253, + 269, + 278, + 282, + 326, + 346, + 356 + ], + "chargedMoves": [ + 14, + 24, + 31, + 32, + 42, + 62, + 74, + 82, + 83, + 95, + 101, + 103, + 127, + 131, + 132, + 270, + 277, + 279, + 285, + 307, + 321, + 353, + 379, + 393 + ], + "attack": 225, + "defense": 228, + "stamina": 233, + "types": [ + 10, + 16 + ], + "legendary": true, + "mythic": false + }, + "1021": { + "pokemonName": "Raging-bolt", + "quickMoves": [ + 204, + 249, + 250, + 253, + 278, + 282, + 326 + ], + "chargedMoves": [ + 14, + 31, + 35, + 62, + 78, + 79, + 80, + 82, + 116, + 127, + 131, + 132, + 251, + 252, + 268, + 277, + 279, + 285, + 321, + 379 + ], + "attack": 235, + "defense": 165, + "stamina": 245, + "types": [ + 13, + 16 + ], + "legendary": true, + "mythic": false + }, + "1022": { + "pokemonName": "Iron-boulder", + "quickMoves": [ + 219, + 224, + 226, + 227, + 234, + 243, + 255, + 282 + ], + "chargedMoves": [ + 14, + 22, + 31, + 32, + 45, + 60, + 63, + 74, + 95, + 100, + 108, + 123, + 126, + 131, + 132, + 245, + 251, + 259, + 321, + 330, + 372 + ], + "attack": 249, + "defense": 214, + "stamina": 207, + "types": [ + 6, + 14 + ], + "legendary": true, + "mythic": false + }, + "1023": { + "pokemonName": "Iron-crown", + "quickMoves": [ + 226, + 228, + 234, + 235, + 250, + 255, + 282, + 402 + ], + "chargedMoves": [ + 14, + 36, + 60, + 74, + 95, + 100, + 108, + 123, + 131, + 132, + 247, + 268, + 321, + 330 + ], + "attack": 243, + "defense": 220, + "stamina": 207, + "types": [ + 9, + 14 + ], + "legendary": true, + "mythic": false } } \ No newline at end of file diff --git a/tests/pokemonPlaceholderMoves.test.js b/tests/pokemonPlaceholderMoves.test.js new file mode 100644 index 0000000..b073275 --- /dev/null +++ b/tests/pokemonPlaceholderMoves.test.js @@ -0,0 +1,714 @@ +const Pokemon = require('../dist/classes/Pokemon').default +const { + default: PokeApi, + sanitizePokeApiBaseStatsForCache, +} = require('../dist/classes/PokeApi') +const base = require('../dist/base').default +const { Rpc } = require('@na-ji/pogo-protos') + +const createPokemon = () => { + const options = JSON.parse(JSON.stringify(base.pokemon.options)) + const pokemon = new Pokemon(options) + pokemon.parsedForms[Rpc.PokemonDisplayProto.Form.STANTLER_NORMAL] = { + formId: Rpc.PokemonDisplayProto.Form.STANTLER_NORMAL, + formName: 'Normal', + proto: 'STANTLER_NORMAL', + } + return pokemon +} + +const createEntry = ({ pokemonName, pokedexId, quickMoves, chargedMoves }) => ({ + pokemonName, + pokedexId, + quickMoves, + chargedMoves, +}) + +const createCompleteEntry = ({ + pokemonName, + pokedexId, + quickMoves, + chargedMoves, + types = [Rpc.HoloPokemonType.POKEMON_TYPE_PSYCHIC], +}) => ({ + ...createEntry({ pokemonName, pokedexId, quickMoves, chargedMoves }), + attack: 54, + defense: 57, + stamina: 125, + types, +}) + +const createPokeApiResponse = ( + name, + moves = ['splash', 'tackle', 'thunderbolt'], + species, +) => ({ + name, + moves: moves.map((move) => ({ + move: { name: move }, + version_group_details: [], + })), + stats: [ + { base_stat: 20, stat: { name: 'hp' } }, + { base_stat: 10, stat: { name: 'attack' } }, + { base_stat: 55, stat: { name: 'defense' } }, + { base_stat: 15, stat: { name: 'special-attack' } }, + { base_stat: 20, stat: { name: 'special-defense' } }, + { base_stat: 80, stat: { name: 'speed' } }, + ], + ...(species ? { species } : {}), + types: [{ type: { name: 'water' } }], +}) + +const createSpeciesResponse = (evolvesFrom = null) => ({ + evolves_from_species: evolvesFrom, + is_legendary: false, + is_mythical: false, +}) + +const createPokeApi = () => { + const pokeApi = new PokeApi('https://example.test/api/v2') + pokeApi.moves = { + [Rpc.HoloPokemonMove.SPLASH_FAST]: { power: 0 }, + [Rpc.HoloPokemonMove.TACKLE_FAST]: { power: 5 }, + [Rpc.HoloPokemonMove.REST]: { power: 0 }, + [Rpc.HoloPokemonMove.RETURN]: { power: 35 }, + [Rpc.HoloPokemonMove.FRUSTRATION]: { power: 10 }, + [Rpc.HoloPokemonMove.THUNDERBOLT]: { power: 80 }, + } + return pokeApi +} + +describe('Pokemon placeholder moves', () => { + beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + test('replaces placeholder moves when Splash is not legal and fallback charged moves exist', () => { + const allPokemon = createPokemon() + const pokedexId = 1022 + + allPokemon.parsedPokemon[pokedexId] = createEntry({ + pokemonName: 'Iron Boulder', + pokedexId, + quickMoves: [Rpc.HoloPokemonMove.SPLASH_FAST], + chargedMoves: [Rpc.HoloPokemonMove.STRUGGLE], + }) + + allPokemon.parsePokeApi( + { + [pokedexId]: createEntry({ + pokemonName: 'Iron Boulder', + pokedexId, + quickMoves: [Rpc.HoloPokemonMove.TACKLE_FAST], + chargedMoves: [Rpc.HoloPokemonMove.THUNDERBOLT], + }), + }, + {}, + ) + + expect(allPokemon.parsedPokemon[pokedexId].quickMoves).toEqual([ + Rpc.HoloPokemonMove.TACKLE_FAST, + ]) + expect(allPokemon.parsedPokemon[pokedexId].chargedMoves).toEqual([ + Rpc.HoloPokemonMove.THUNDERBOLT, + ]) + }) + + test('filters hidden fallback charged moves during placeholder replacement', () => { + const allPokemon = createPokemon() + const pokedexId = 801 + + allPokemon.parsedPokemon[pokedexId] = createEntry({ + pokemonName: 'Magearna', + pokedexId, + quickMoves: [Rpc.HoloPokemonMove.SPLASH_FAST], + chargedMoves: [Rpc.HoloPokemonMove.STRUGGLE], + }) + + allPokemon.parsePokeApi( + { + [pokedexId]: createEntry({ + pokemonName: 'Magearna', + pokedexId, + quickMoves: [Rpc.HoloPokemonMove.TACKLE_FAST], + chargedMoves: [ + Rpc.HoloPokemonMove.THUNDERBOLT, + Rpc.HoloPokemonMove.RETURN, + Rpc.HoloPokemonMove.FRUSTRATION, + Rpc.HoloPokemonMove.REST, + ], + }), + }, + {}, + ) + + expect(allPokemon.parsedPokemon[pokedexId].quickMoves).toEqual([ + Rpc.HoloPokemonMove.TACKLE_FAST, + ]) + expect(allPokemon.parsedPokemon[pokedexId].chargedMoves).toEqual([ + Rpc.HoloPokemonMove.THUNDERBOLT, + Rpc.HoloPokemonMove.REST, + ]) + }) + + test('drops placeholder charged moves when every fallback charged move is filtered out', () => { + const allPokemon = createPokemon() + const pokedexId = 801 + + allPokemon.parsedPokemon[pokedexId] = createEntry({ + pokemonName: 'Magearna', + pokedexId, + quickMoves: [Rpc.HoloPokemonMove.SPLASH_FAST], + chargedMoves: [Rpc.HoloPokemonMove.STRUGGLE], + }) + + allPokemon.parsePokeApi( + { + [pokedexId]: createEntry({ + pokemonName: 'Magearna', + pokedexId, + quickMoves: [Rpc.HoloPokemonMove.TACKLE_FAST], + chargedMoves: [Rpc.HoloPokemonMove.RETURN, Rpc.HoloPokemonMove.FRUSTRATION], + }), + }, + {}, + ) + + expect(allPokemon.parsedPokemon[pokedexId].quickMoves).toEqual([ + Rpc.HoloPokemonMove.TACKLE_FAST, + ]) + expect(allPokemon.parsedPokemon[pokedexId].chargedMoves).toBeUndefined() + }) + + test('filters hidden charged moves for fully estimated species entries too', () => { + const allPokemon = createPokemon() + const pokedexId = 801 + + allPokemon.parsePokeApi( + { + [pokedexId]: createEntry({ + pokemonName: 'Magearna', + pokedexId, + quickMoves: [Rpc.HoloPokemonMove.TACKLE_FAST], + chargedMoves: [ + Rpc.HoloPokemonMove.THUNDERBOLT, + Rpc.HoloPokemonMove.RETURN, + Rpc.HoloPokemonMove.FRUSTRATION, + Rpc.HoloPokemonMove.REST, + ], + }), + }, + {}, + ) + + expect(allPokemon.parsedPokemon[pokedexId].quickMoves).toEqual([ + Rpc.HoloPokemonMove.TACKLE_FAST, + ]) + expect(allPokemon.parsedPokemon[pokedexId].chargedMoves).toEqual([ + Rpc.HoloPokemonMove.THUNDERBOLT, + Rpc.HoloPokemonMove.REST, + ]) + }) + + test('pokemonApi keeps known zero-power moves instead of filtering them out', async () => { + const pokeApi = createPokeApi() + + jest.spyOn(pokeApi, 'fetch').mockImplementation(async (url) => { + if (url.endsWith('/pokemon/129')) { + return createPokeApiResponse('magikarp') + } + if (url.endsWith('/pokemon-species/129')) { + return createSpeciesResponse() + } + throw new Error(`Unexpected URL: ${url}`) + }) + + await pokeApi.pokemonApi(129) + + expect(pokeApi.baseStats[129].quickMoves).toEqual([ + Rpc.HoloPokemonMove.TACKLE_FAST, + Rpc.HoloPokemonMove.SPLASH_FAST, + ]) + expect(pokeApi.baseStats[129].chargedMoves).toEqual([ + Rpc.HoloPokemonMove.THUNDERBOLT, + ]) + expect(pokeApi.baseStats[129].unreleased).toBeUndefined() + }) + + test('pokemonApi keeps hidden-only charged fallback moves in live data', async () => { + const pokeApi = createPokeApi() + + jest.spyOn(pokeApi, 'fetch').mockImplementation(async (url) => { + if (url.endsWith('/pokemon/801')) { + return createPokeApiResponse('magearna', [ + 'tackle', + 'thunderbolt', + 'return', + 'frustration', + 'rest', + ]) + } + if (url.endsWith('/pokemon-species/801')) { + return createSpeciesResponse() + } + throw new Error(`Unexpected URL: ${url}`) + }) + + await pokeApi.pokemonApi(801) + + expect(pokeApi.baseStats[801].chargedMoves).toEqual([ + Rpc.HoloPokemonMove.THUNDERBOLT, + Rpc.HoloPokemonMove.REST, + Rpc.HoloPokemonMove.FRUSTRATION, + Rpc.HoloPokemonMove.RETURN, + ]) + }) + + test('sanitizePokeApiBaseStatsForCache filters hidden charged moves for static caches', () => { + const sanitized = sanitizePokeApiBaseStatsForCache({ + 801: createEntry({ + pokemonName: 'Magearna', + pokedexId: 801, + quickMoves: [Rpc.HoloPokemonMove.TACKLE_FAST], + chargedMoves: [ + Rpc.HoloPokemonMove.REST, + Rpc.HoloPokemonMove.RETURN, + Rpc.HoloPokemonMove.FRUSTRATION, + Rpc.HoloPokemonMove.THUNDERBOLT, + ], + }), + 802: createEntry({ + pokemonName: 'Marshadow', + pokedexId: 802, + quickMoves: [Rpc.HoloPokemonMove.TACKLE_FAST], + chargedMoves: [Rpc.HoloPokemonMove.REST], + }), + }) + + expect(sanitized[801].chargedMoves).toEqual([ + Rpc.HoloPokemonMove.REST, + Rpc.HoloPokemonMove.THUNDERBOLT, + ]) + expect(sanitized[801]._hiddenOnlyChargedMoves).toBeUndefined() + expect(sanitized[802].chargedMoves).toEqual([Rpc.HoloPokemonMove.REST]) + expect(sanitized[802]._hiddenOnlyChargedMoves).toBeUndefined() + }) + + test('live pokeapi placeholder replacement drops hidden-only charged moves', async () => { + const allPokemon = createPokemon() + const pokeApi = createPokeApi() + const pokedexId = 801 + + allPokemon.parsedPokemon[pokedexId] = createEntry({ + pokemonName: 'Magearna', + pokedexId, + quickMoves: [Rpc.HoloPokemonMove.SPLASH_FAST], + chargedMoves: [Rpc.HoloPokemonMove.STRUGGLE], + }) + + jest.spyOn(pokeApi, 'fetch').mockImplementation(async (url) => { + if (url.endsWith('/pokemon/801')) { + return createPokeApiResponse('magearna', ['tackle', 'return']) + } + if (url.endsWith('/pokemon-species/801')) { + return createSpeciesResponse() + } + throw new Error(`Unexpected URL: ${url}`) + }) + + await pokeApi.pokemonApi(pokedexId) + allPokemon.parsePokeApi(pokeApi.baseStats, {}) + + expect(allPokemon.parsedPokemon[pokedexId].quickMoves).toEqual([ + Rpc.HoloPokemonMove.TACKLE_FAST, + ]) + expect(allPokemon.parsedPokemon[pokedexId].chargedMoves).toBeUndefined() + }) + + test('static cache placeholder replacement drops hidden-only charged moves', () => { + const allPokemon = createPokemon() + const pokedexId = 801 + + allPokemon.parsedPokemon[pokedexId] = createEntry({ + pokemonName: 'Magearna', + pokedexId, + quickMoves: [Rpc.HoloPokemonMove.SPLASH_FAST], + chargedMoves: [Rpc.HoloPokemonMove.STRUGGLE], + }) + + allPokemon.parsePokeApi( + sanitizePokeApiBaseStatsForCache({ + [pokedexId]: createEntry({ + pokemonName: 'Magearna', + pokedexId, + quickMoves: [Rpc.HoloPokemonMove.TACKLE_FAST], + chargedMoves: [Rpc.HoloPokemonMove.RETURN], + }), + }), + {}, + ) + + expect(allPokemon.parsedPokemon[pokedexId].quickMoves).toEqual([ + Rpc.HoloPokemonMove.TACKLE_FAST, + ]) + expect(allPokemon.parsedPokemon[pokedexId].chargedMoves).toBeUndefined() + }) + + test('pokemonApi inherits pre-evolution moves from the full chain', async () => { + const pokeApi = createPokeApi() + + jest.spyOn(pokeApi, 'fetch').mockImplementation(async (url) => { + if (url.endsWith('/pokemon/791')) { + return createPokeApiResponse('solgaleo', ['thunderbolt']) + } + if (url.endsWith('/pokemon-species/791')) { + return createSpeciesResponse({ + name: 'cosmoem', + url: 'https://example.test/api/v2/pokemon-species/790/', + }) + } + if (url.endsWith('/pokemon/790')) { + return createPokeApiResponse('cosmoem', ['tackle']) + } + if (url.endsWith('/pokemon-species/790')) { + return createSpeciesResponse({ + name: 'cosmog', + url: 'https://example.test/api/v2/pokemon-species/789/', + }) + } + if (url.endsWith('/pokemon/789')) { + return createPokeApiResponse('cosmog', ['splash']) + } + if (url.endsWith('/pokemon-species/789')) { + return createSpeciesResponse() + } + throw new Error(`Unexpected URL: ${url}`) + }) + + await pokeApi.pokemonApi(791) + + expect(pokeApi.baseStats[791].quickMoves).toEqual([ + Rpc.HoloPokemonMove.TACKLE_FAST, + Rpc.HoloPokemonMove.SPLASH_FAST, + ]) + expect(pokeApi.baseStats[791].chargedMoves).toEqual([ + Rpc.HoloPokemonMove.THUNDERBOLT, + ]) + }) + + test('pokemonApi keeps inherited moves form-aware for form-restricted evolutions', async () => { + const pokeApi = createPokeApi() + + jest.spyOn(pokeApi, 'fetch').mockImplementation(async (url) => { + if (url.endsWith('/pokemon/902')) { + return createPokeApiResponse('basculegion-male', ['thunderbolt'], { + name: 'basculegion', + url: 'https://example.test/api/v2/pokemon-species/902/', + }) + } + if (url.endsWith('/pokemon-species/902')) { + return createSpeciesResponse({ + name: 'basculin', + url: 'https://example.test/api/v2/pokemon-species/550/', + }) + } + if (url.endsWith('/pokemon/basculin-white-striped')) { + return createPokeApiResponse('basculin-white-striped', ['splash'], { + name: 'basculin', + url: 'https://example.test/api/v2/pokemon-species/550/', + }) + } + if (url.endsWith('/pokemon-species/550')) { + return createSpeciesResponse() + } + throw new Error(`Unexpected URL: ${url}`) + }) + + await pokeApi.pokemonApi(902) + + expect(pokeApi.baseStats[902].quickMoves).toEqual([ + Rpc.HoloPokemonMove.SPLASH_FAST, + ]) + expect(pokeApi.baseStats[902].chargedMoves).toEqual([ + Rpc.HoloPokemonMove.THUNDERBOLT, + ]) + }) + + test('pokemonApi marks extra estimated entries as unreleased when requested', async () => { + const pokeApi = createPokeApi() + + jest.spyOn(pokeApi, 'fetch').mockImplementation(async (url) => { + if (url.endsWith('/pokemon/129')) { + return createPokeApiResponse('magikarp') + } + if (url.endsWith('/pokemon-species/129')) { + return createSpeciesResponse() + } + throw new Error(`Unexpected URL: ${url}`) + }) + + await pokeApi.pokemonApi(129, true) + + expect(pokeApi.baseStats[129].unreleased).toBe(true) + }) + + test('baseStatsApi fetches exact Splash and Struggle placeholders even when GM stats and types are present', async () => { + const pokeApi = createPokeApi() + const pokemonApiSpy = jest.spyOn(pokeApi, 'pokemonApi').mockResolvedValue() + + await pokeApi.baseStatsApi({ + 789: createCompleteEntry({ + pokemonName: 'Cosmog', + pokedexId: 789, + quickMoves: [Rpc.HoloPokemonMove.SPLASH_FAST], + chargedMoves: [Rpc.HoloPokemonMove.STRUGGLE], + }), + 1024: createCompleteEntry({ + pokemonName: 'Terapagos', + pokedexId: 1024, + quickMoves: [Rpc.HoloPokemonMove.TACKLE_FAST], + chargedMoves: [Rpc.HoloPokemonMove.STRUGGLE], + }), + }) + + expect(pokemonApiSpy).toHaveBeenCalledTimes(1) + expect(pokemonApiSpy).toHaveBeenCalledWith('789', false) + }) + + test('extraPokemon marks missing species as unreleased estimates', async () => { + const pokeApi = createPokeApi() + const pokemonApiSpy = jest.spyOn(pokeApi, 'pokemonApi').mockResolvedValue() + pokeApi.maxPokemon = 1 + + await pokeApi.extraPokemon({}) + + expect(pokemonApiSpy).toHaveBeenCalledTimes(1) + expect(pokemonApiSpy).toHaveBeenCalledWith(1, true) + }) + + test('evoApi preserves legendary and mythic flags for parent placeholders even when species cache is warm', async () => { + const pokeApi = createPokeApi() + + pokeApi.speciesCache['234'] = createSpeciesResponse() + + jest.spyOn(pokeApi, 'fetch').mockImplementation(async (url) => { + if (url.endsWith('/pokemon-species/899')) { + return createSpeciesResponse({ + name: 'stantler', + url: 'https://example.test/api/v2/pokemon-species/234/', + }) + } + throw new Error(`Unexpected URL: ${url}`) + }) + + await pokeApi.evoApi(new Set(), { + 234: { + pokemonName: 'Stantler', + pokedexId: 234, + defaultFormId: 0, + }, + 899: { + pokemonName: 'Wyrdeer', + pokedexId: 899, + defaultFormId: 3218, + }, + }) + + expect(pokeApi.baseStats[234]).toEqual({ + evolutions: [{ evoId: 899, formId: 3218 }], + legendary: false, + mythic: false, + }) + }) + + test('evoApi keeps GM legendary flags when species data disagrees', async () => { + const pokeApi = createPokeApi() + + pokeApi.baseStats[1009] = { + pokemonName: 'Walking-wake', + } + + jest.spyOn(pokeApi, 'fetch').mockImplementation(async (url) => { + if (url.endsWith('/pokemon-species/1009')) { + return createSpeciesResponse() + } + throw new Error(`Unexpected URL: ${url}`) + }) + + await pokeApi.evoApi(new Set(), { + 1009: { + pokemonName: 'Walking Wake', + pokedexId: 1009, + legendary: true, + mythic: false, + }, + }) + + expect(pokeApi.baseStats[1009].legendary).toBe(true) + expect(pokeApi.baseStats[1009].mythic).toBe(false) + }) + + test.each([129, 789, 790])( + 'keeps exact Splash and Struggle placeholders when pokemonApi fallback data still contains Splash for %i', + async (pokedexId) => { + const allPokemon = createPokemon() + const pokeApi = createPokeApi() + + allPokemon.parsedPokemon[pokedexId] = createEntry({ + pokemonName: `Pokemon ${pokedexId}`, + pokedexId, + quickMoves: [Rpc.HoloPokemonMove.SPLASH_FAST], + chargedMoves: [Rpc.HoloPokemonMove.STRUGGLE], + }) + + jest.spyOn(pokeApi, 'fetch').mockImplementation(async (url) => { + if (url.endsWith(`/pokemon/${pokedexId}`)) { + return createPokeApiResponse( + `pokemon-${pokedexId}`, + pokedexId === 790 + ? ['tackle', 'thunderbolt'] + : ['splash', 'tackle', 'thunderbolt'], + ) + } + if (url.endsWith(`/pokemon-species/${pokedexId}`)) { + if (pokedexId === 790) { + return createSpeciesResponse({ + name: 'cosmog', + url: 'https://example.test/api/v2/pokemon-species/789/', + }) + } + return createSpeciesResponse() + } + if (pokedexId === 790 && url.endsWith('/pokemon/789')) { + return createPokeApiResponse('cosmog', ['splash']) + } + if (pokedexId === 790 && url.endsWith('/pokemon-species/789')) { + return createSpeciesResponse() + } + throw new Error(`Unexpected URL: ${url}`) + }) + + await pokeApi.pokemonApi(pokedexId) + + allPokemon.parsePokeApi(pokeApi.baseStats, {}) + + expect(allPokemon.parsedPokemon[pokedexId].quickMoves).toEqual([ + Rpc.HoloPokemonMove.SPLASH_FAST, + ]) + expect(allPokemon.parsedPokemon[pokedexId].chargedMoves).toEqual([ + Rpc.HoloPokemonMove.STRUGGLE, + ]) + }, + ) + + test.each([ + [824, [Rpc.HoloPokemonMove.STRUGGLE_BUG_FAST]], + [840, [Rpc.HoloPokemonMove.ASTONISH_FAST]], + [ + 885, + [ + Rpc.HoloPokemonMove.QUICK_ATTACK_FAST, + Rpc.HoloPokemonMove.ASTONISH_FAST, + ], + ], + [1024, [Rpc.HoloPokemonMove.TACKLE_FAST]], + ])( + 'keeps non-Splash quick moves with Struggle unchanged for %i', + (pokedexId, quickMoves) => { + const allPokemon = createPokemon() + + allPokemon.parsedPokemon[pokedexId] = createEntry({ + pokemonName: `Pokemon ${pokedexId}`, + pokedexId, + quickMoves, + chargedMoves: [Rpc.HoloPokemonMove.STRUGGLE], + }) + + allPokemon.parsePokeApi( + { + [pokedexId]: createEntry({ + pokemonName: `Pokemon ${pokedexId}`, + pokedexId, + quickMoves: [Rpc.HoloPokemonMove.TACKLE_FAST], + chargedMoves: [Rpc.HoloPokemonMove.THUNDERBOLT], + }), + }, + {}, + ) + + expect(allPokemon.parsedPokemon[pokedexId].quickMoves).toEqual( + quickMoves, + ) + expect(allPokemon.parsedPokemon[pokedexId].chargedMoves).toEqual([ + Rpc.HoloPokemonMove.STRUGGLE, + ]) + }, + ) + + test('still replaces the placeholder quick move when charged fallback data is empty', () => { + const allPokemon = createPokemon() + const pokedexId = 1023 + + allPokemon.parsedPokemon[pokedexId] = createEntry({ + pokemonName: 'Iron Crown', + pokedexId, + quickMoves: [Rpc.HoloPokemonMove.SPLASH_FAST], + chargedMoves: [Rpc.HoloPokemonMove.STRUGGLE], + }) + + allPokemon.parsePokeApi( + { + [pokedexId]: createEntry({ + pokemonName: 'Iron Crown', + pokedexId, + quickMoves: [Rpc.HoloPokemonMove.TACKLE_FAST], + chargedMoves: [], + }), + }, + {}, + ) + + expect(allPokemon.parsedPokemon[pokedexId].quickMoves).toEqual([ + Rpc.HoloPokemonMove.TACKLE_FAST, + ]) + expect(allPokemon.parsedPokemon[pokedexId].chargedMoves).toEqual([ + Rpc.HoloPokemonMove.STRUGGLE, + ]) + }) + + test('keeps placeholder moves when fallback quick-move data is empty', () => { + const allPokemon = createPokemon() + const pokedexId = 1022 + + allPokemon.parsedPokemon[pokedexId] = createEntry({ + pokemonName: 'Iron Boulder', + pokedexId, + quickMoves: [Rpc.HoloPokemonMove.SPLASH_FAST], + chargedMoves: [Rpc.HoloPokemonMove.STRUGGLE], + }) + + allPokemon.parsePokeApi( + { + [pokedexId]: createEntry({ + pokemonName: 'Iron Boulder', + pokedexId, + quickMoves: [], + chargedMoves: [Rpc.HoloPokemonMove.THUNDERBOLT], + }), + }, + {}, + ) + + expect(allPokemon.parsedPokemon[pokedexId].quickMoves).toEqual([ + Rpc.HoloPokemonMove.SPLASH_FAST, + ]) + expect(allPokemon.parsedPokemon[pokedexId].chargedMoves).toEqual([ + Rpc.HoloPokemonMove.STRUGGLE, + ]) + }) +})