From 9f46d395fac7418cbcda216988cf233eff0bf30b Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 21 Apr 2026 13:05:50 -0400 Subject: [PATCH 01/12] fix: properly detect splash+struggle fallback moves --- src/classes/PokeApi.ts | 8 +- src/classes/Pokemon.ts | 85 ++++++---- tests/pokemonPlaceholderMoves.test.js | 225 ++++++++++++++++++++++++++ 3 files changed, 287 insertions(+), 31 deletions(-) create mode 100644 tests/pokemonPlaceholderMoves.test.js diff --git a/src/classes/PokeApi.ts b/src/classes/PokeApi.ts index 73aa3e9..0ff7d2f 100644 --- a/src/classes/PokeApi.ts +++ b/src/classes/PokeApi.ts @@ -101,6 +101,10 @@ export default class PokeApi extends Masterfile { this.moveReference = parsed } + private isKnownMove(move?: number): move is number { + return !!move && !!this.moveReference?.[move] + } + private buildUrl(path: string) { return `${this.apiBaseUrl}/${path.replace(/^\//, '')}` } @@ -284,7 +288,7 @@ export default class PokeApi extends Masterfile { .replace(/-/g, '_')}_FAST` as MoveProto ], ) - .filter((move) => move && this.moveReference[move]?.power) + .filter((move): move is number => this.isKnownMove(move)) .sort((a, b) => a - b), chargedMoves: statsData.moves .map( @@ -293,7 +297,7 @@ export default class PokeApi extends Masterfile { move.move.name.toUpperCase().replace(/-/g, '_') as MoveProto ], ) - .filter((move) => move && this.moveReference[move]?.power) + .filter((move): move is number => this.isKnownMove(move)) .sort((a, b) => a - b), attack: this.inconsistentStats[id] ? this.inconsistentStats[id].attack || nerfCheck.attack diff --git a/src/classes/Pokemon.ts b/src/classes/Pokemon.ts index 51f31a7..2ed4db8 100644 --- a/src/classes/Pokemon.ts +++ b/src/classes/Pokemon.ts @@ -75,6 +75,27 @@ 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 + export default class Pokemon extends Masterfile { parsedPokemon: AllPokemon parsedPokeForms: AllPokemon @@ -1379,18 +1400,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,25 +1500,41 @@ export default class Pokemon extends Masterfile { } }) } + const actualQuickMoves = cleanNumberList(existing.quickMoves) + const actualChargedMoves = cleanNumberList(existing.chargedMoves) + const fallbackQuickMoves = cleanNumberList(baseEntry.quickMoves) + const fallbackChargedMoves = cleanNumberList(baseEntry.chargedMoves) + const preferEstimatedPlaceholderQuickMoves = + shouldPreferEstimatedPlaceholderQuickMoves( + actualQuickMoves, + actualChargedMoves, + fallbackQuickMoves, + ) + const preferEstimatedPlaceholderChargedMoves = + preferEstimatedPlaceholderQuickMoves && + fallbackChargedMoves.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, + baseEntry.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 + ? fallbackChargedMoves + : preferActualNumbers( + existing.chargedMoves, + baseEntry.chargedMoves, + 'charged moves', + ) ?? + (actualChargedMoves.length ? actualChargedMoves : undefined) const types = preferActualNumbers(existing.types, baseEntry.types, 'types') ?? (Array.isArray(existing.types) && existing.types.length diff --git a/tests/pokemonPlaceholderMoves.test.js b/tests/pokemonPlaceholderMoves.test.js new file mode 100644 index 0000000..b693ca9 --- /dev/null +++ b/tests/pokemonPlaceholderMoves.test.js @@ -0,0 +1,225 @@ +const Pokemon = require('../dist/classes/Pokemon').default +const PokeApi = require('../dist/classes/PokeApi').default +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 createPokeApiResponse = (name) => ({ + name, + moves: [ + { move: { name: 'splash' } }, + { move: { name: 'tackle' } }, + { move: { name: 'thunderbolt' } }, + ], + 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' } }, + ], + types: [{ type: { name: 'water' } }], +}) + +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.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('pokemonApi keeps known zero-power moves instead of filtering them out', async () => { + const pokeApi = createPokeApi() + + jest + .spyOn(pokeApi, 'fetch') + .mockResolvedValue(createPokeApiResponse('magikarp')) + + 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, + ]) + }) + + test.each([129, 789, 790, 1022])( + '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') + .mockResolvedValue(createPokeApiResponse(`pokemon-${pokedexId}`)) + + 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.TAKE_DOWN_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, + ]) + }) +}) From 9005f32f73f6559805c858c2f0b23e77b245ff2c Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 21 Apr 2026 15:11:18 -0400 Subject: [PATCH 02/12] Some fixes --- src/classes/PokeApi.ts | 27 +- static/baseStats.json | 1023 ++++++++++++++++++++++++- tests/pokemonPlaceholderMoves.test.js | 61 ++ 3 files changed, 1103 insertions(+), 8 deletions(-) diff --git a/src/classes/PokeApi.ts b/src/classes/PokeApi.ts index 0ff7d2f..c8d2be0 100644 --- a/src/classes/PokeApi.ts +++ b/src/classes/PokeApi.ts @@ -3,6 +3,7 @@ import type { AllMoves, AllPokemon, AllTypes, + SinglePokemon, TempEvolutions, } from '../typings/dataTypes' import type { SpeciesApi } from '../typings/general' @@ -105,6 +106,21 @@ export default class PokeApi extends Masterfile { 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(/^\//, '')}` } @@ -242,9 +258,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) } }), ) @@ -257,10 +274,10 @@ 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}`), @@ -309,7 +326,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}`) diff --git a/static/baseStats.json b/static/baseStats.json index 4f32a32..4a3eba7 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": [ @@ -23,10 +43,92 @@ "evoId": 292, "formId": 1439 } + ] + }, + "789": { + "pokemonName": "Cosmog", + "quickMoves": [ + 231 ], - "legendary": false, + "chargedMoves": [], + "attack": 54, + "defense": 57, + "stamina": 125, + "types": [ + 14 + ], + "legendary": true, "mythic": false }, + "790": { + "pokemonName": "Cosmoem", + "quickMoves": [], + "chargedMoves": [], + "attack": 54, + "defense": 242, + "stamina": 125, + "types": [ + 14 + ] + }, + "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, + 322, + 323, + 324, + 332, + 344 + ], + "attack": 246, + "defense": 225, + "stamina": 190, + "types": [ + 9, + 18 + ], + "legendary": false, + "mythic": true + }, "884": { "evolutions": [ { @@ -77,7 +179,922 @@ 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": false, + "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": false, + "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": false, + "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": false, + "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": false, + "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": false, "mythic": false } diff --git a/tests/pokemonPlaceholderMoves.test.js b/tests/pokemonPlaceholderMoves.test.js index b693ca9..dee5006 100644 --- a/tests/pokemonPlaceholderMoves.test.js +++ b/tests/pokemonPlaceholderMoves.test.js @@ -21,6 +21,20 @@ const createEntry = ({ pokemonName, pokedexId, quickMoves, chargedMoves }) => ({ 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) => ({ name, moves: [ @@ -102,6 +116,53 @@ describe('Pokemon placeholder moves', () => { expect(pokeApi.baseStats[129].chargedMoves).toEqual([ Rpc.HoloPokemonMove.THUNDERBOLT, ]) + expect(pokeApi.baseStats[129].unreleased).toBeUndefined() + }) + + test('pokemonApi marks extra estimated entries as unreleased when requested', async () => { + const pokeApi = createPokeApi() + + jest + .spyOn(pokeApi, 'fetch') + .mockResolvedValue(createPokeApiResponse('magikarp')) + + 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.TAKE_DOWN_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.each([129, 789, 790, 1022])( From 3e3c9979935d6ec0eaf0bede1634eb4012b03b22 Mon Sep 17 00:00:00 2001 From: Mygod Date: Wed, 22 Apr 2026 17:18:47 -0400 Subject: [PATCH 03/12] fix: pokeapi missing preevo moves --- src/classes/PokeApi.ts | 152 +++++++++++++++---- src/typings/general.ts | 10 +- static/baseStats.json | 23 ++- tests/pokemonPlaceholderMoves.test.js | 211 +++++++++++++++++++------- 4 files changed, 298 insertions(+), 98 deletions(-) diff --git a/src/classes/PokeApi.ts b/src/classes/PokeApi.ts index c8d2be0..b9622ae 100644 --- a/src/classes/PokeApi.ts +++ b/src/classes/PokeApi.ts @@ -2,8 +2,8 @@ import { Rpc } from '@na-ji/pogo-protos' import type { AllMoves, AllPokemon, - AllTypes, SinglePokemon, + AllTypes, TempEvolutions, } from '../typings/dataTypes' import type { SpeciesApi } from '../typings/general' @@ -25,6 +25,8 @@ 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 apiBaseUrl: string constructor(baseUrl?: string) { @@ -36,6 +38,8 @@ export default class PokeApi extends Masterfile { this.baseStats = {} this.tempEvos = {} this.types = {} + this.pokemonStatsCache = {} + this.speciesCache = {} this.maxPokemon = 1008 this.inconsistentStats = { 24: { @@ -161,6 +165,114 @@ 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 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 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.fetchSpecies(id) + const previousId = this.resolveStructId(speciesData.evolves_from_species) + if (!previousId) { + return { + quickMoves: [...currentMoves.quickMoves].sort((a, b) => a - b), + chargedMoves: [...currentMoves.chargedMoves].sort((a, b) => a - b), + } + } + 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, @@ -279,9 +391,8 @@ export default class PokeApi extends Masterfile { 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) @@ -296,26 +407,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 is number => this.isKnownMove(move)) - .sort((a, b) => a - b), - chargedMoves: statsData.moves - .map( - (move) => - Rpc.HoloPokemonMove[ - move.move.name.toUpperCase().replace(/-/g, '_') as MoveProto - ], - ) - .filter((move): move is number => this.isKnownMove(move)) - .sort((a, b) => a - b), + quickMoves: inheritedMoves.quickMoves, + chargedMoves: inheritedMoves.chargedMoves, attack: this.inconsistentStats[id] ? this.inconsistentStats[id].attack || nerfCheck.attack : nerfCheck.attack, @@ -338,20 +431,13 @@ 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 } 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] = {} 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 4a3eba7..928a428 100644 --- a/static/baseStats.json +++ b/static/baseStats.json @@ -33,9 +33,7 @@ "evoId": 899, "formId": 3218 } - ], - "legendary": false, - "mythic": false + ] }, "290": { "evolutions": [ @@ -62,7 +60,9 @@ }, "790": { "pokemonName": "Cosmoem", - "quickMoves": [], + "quickMoves": [ + 231 + ], "chargedMoves": [], "attack": 54, "defense": 242, @@ -135,9 +135,7 @@ "evoId": 1018, "formId": 3330 } - ], - "legendary": false, - "mythic": false + ] }, "902": { "pokemonName": "Basculegion-male", @@ -145,9 +143,11 @@ 202, 216, 221, + 223, 230, 234, 264, + 281, 282, 283, 327 @@ -156,11 +156,16 @@ 14, 39, 40, + 53, 57, + 58, 70, + 104, 105, + 106, 107, 111, + 125, 132, 265, 277, @@ -168,6 +173,8 @@ 284, 316, 321, + 322, + 323, 353, 383, 488 @@ -1098,4 +1105,4 @@ "legendary": false, "mythic": false } -} \ No newline at end of file +} diff --git a/tests/pokemonPlaceholderMoves.test.js b/tests/pokemonPlaceholderMoves.test.js index dee5006..c08bea3 100644 --- a/tests/pokemonPlaceholderMoves.test.js +++ b/tests/pokemonPlaceholderMoves.test.js @@ -35,13 +35,15 @@ const createCompleteEntry = ({ types, }) -const createPokeApiResponse = (name) => ({ +const createPokeApiResponse = ( name, - moves: [ - { move: { name: 'splash' } }, - { move: { name: 'tackle' } }, - { move: { name: 'thunderbolt' } }, - ], + moves = ['splash', 'tackle', 'thunderbolt'], +) => ({ + name, + moves: moves.map((move) => ({ + move: { name: move }, + version_group_details: [], + })), stats: [ { base_stat: 20, stat: { name: 'hp' } }, { base_stat: 10, stat: { name: 'attack' } }, @@ -53,6 +55,12 @@ const createPokeApiResponse = (name) => ({ 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 = { @@ -83,14 +91,17 @@ describe('Pokemon placeholder moves', () => { chargedMoves: [Rpc.HoloPokemonMove.STRUGGLE], }) - allPokemon.parsePokeApi({ - [pokedexId]: createEntry({ - pokemonName: 'Iron Boulder', - pokedexId, - quickMoves: [Rpc.HoloPokemonMove.TACKLE_FAST], - chargedMoves: [Rpc.HoloPokemonMove.THUNDERBOLT], - }), - }, {}) + 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, @@ -103,9 +114,15 @@ describe('Pokemon placeholder moves', () => { test('pokemonApi keeps known zero-power moves instead of filtering them out', async () => { const pokeApi = createPokeApi() - jest - .spyOn(pokeApi, 'fetch') - .mockResolvedValue(createPokeApiResponse('magikarp')) + 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) @@ -119,12 +136,60 @@ describe('Pokemon placeholder moves', () => { expect(pokeApi.baseStats[129].unreleased).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 marks extra estimated entries as unreleased when requested', async () => { const pokeApi = createPokeApi() - jest - .spyOn(pokeApi, 'fetch') - .mockResolvedValue(createPokeApiResponse('magikarp')) + 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) @@ -145,7 +210,7 @@ describe('Pokemon placeholder moves', () => { 1024: createCompleteEntry({ pokemonName: 'Terapagos', pokedexId: 1024, - quickMoves: [Rpc.HoloPokemonMove.TAKE_DOWN_FAST], + quickMoves: [Rpc.HoloPokemonMove.TACKLE_FAST], chargedMoves: [Rpc.HoloPokemonMove.STRUGGLE], }), }) @@ -165,7 +230,7 @@ describe('Pokemon placeholder moves', () => { expect(pokemonApiSpy).toHaveBeenCalledWith(1, true) }) - test.each([129, 789, 790, 1022])( + 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() @@ -178,9 +243,32 @@ describe('Pokemon placeholder moves', () => { chargedMoves: [Rpc.HoloPokemonMove.STRUGGLE], }) - jest - .spyOn(pokeApi, 'fetch') - .mockResolvedValue(createPokeApiResponse(`pokemon-${pokedexId}`)) + 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) @@ -198,8 +286,14 @@ describe('Pokemon placeholder moves', () => { 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.TAKE_DOWN_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) => { @@ -212,16 +306,21 @@ describe('Pokemon placeholder moves', () => { 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) + 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, ]) @@ -239,14 +338,17 @@ describe('Pokemon placeholder moves', () => { chargedMoves: [Rpc.HoloPokemonMove.STRUGGLE], }) - allPokemon.parsePokeApi({ - [pokedexId]: createEntry({ - pokemonName: 'Iron Crown', - pokedexId, - quickMoves: [Rpc.HoloPokemonMove.TACKLE_FAST], - chargedMoves: [], - }), - }, {}) + allPokemon.parsePokeApi( + { + [pokedexId]: createEntry({ + pokemonName: 'Iron Crown', + pokedexId, + quickMoves: [Rpc.HoloPokemonMove.TACKLE_FAST], + chargedMoves: [], + }), + }, + {}, + ) expect(allPokemon.parsedPokemon[pokedexId].quickMoves).toEqual([ Rpc.HoloPokemonMove.TACKLE_FAST, @@ -267,14 +369,17 @@ describe('Pokemon placeholder moves', () => { chargedMoves: [Rpc.HoloPokemonMove.STRUGGLE], }) - allPokemon.parsePokeApi({ - [pokedexId]: createEntry({ - pokemonName: 'Iron Boulder', - pokedexId, - quickMoves: [], - chargedMoves: [Rpc.HoloPokemonMove.THUNDERBOLT], - }), - }, {}) + allPokemon.parsePokeApi( + { + [pokedexId]: createEntry({ + pokemonName: 'Iron Boulder', + pokedexId, + quickMoves: [], + chargedMoves: [Rpc.HoloPokemonMove.THUNDERBOLT], + }), + }, + {}, + ) expect(allPokemon.parsedPokemon[pokedexId].quickMoves).toEqual([ Rpc.HoloPokemonMove.SPLASH_FAST, From 3716746efba30e4db3988191e7c124c7fb700c09 Mon Sep 17 00:00:00 2001 From: Mygod Date: Wed, 22 Apr 2026 17:37:20 -0400 Subject: [PATCH 04/12] fix: preserve legend/mythic on cached parent species --- src/classes/PokeApi.ts | 11 +++++++++ static/baseStats.json | 18 ++++++++++---- tests/pokemonPlaceholderMoves.test.js | 35 +++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/classes/PokeApi.ts b/src/classes/PokeApi.ts index b9622ae..716c37c 100644 --- a/src/classes/PokeApi.ts +++ b/src/classes/PokeApi.ts @@ -471,6 +471,17 @@ 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 = evoData.is_legendary + this.baseStats[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/static/baseStats.json b/static/baseStats.json index 928a428..25186bf 100644 --- a/static/baseStats.json +++ b/static/baseStats.json @@ -33,7 +33,9 @@ "evoId": 899, "formId": 3218 } - ] + ], + "legendary": false, + "mythic": false }, "290": { "evolutions": [ @@ -41,7 +43,9 @@ "evoId": 292, "formId": 1439 } - ] + ], + "legendary": false, + "mythic": false }, "789": { "pokemonName": "Cosmog", @@ -69,7 +73,9 @@ "stamina": 125, "types": [ 14 - ] + ], + "legendary": true, + "mythic": false }, "801": { "pokemonName": "Magearna", @@ -135,7 +141,9 @@ "evoId": 1018, "formId": 3330 } - ] + ], + "legendary": false, + "mythic": false }, "902": { "pokemonName": "Basculegion-male", @@ -1105,4 +1113,4 @@ "legendary": false, "mythic": false } -} +} \ No newline at end of file diff --git a/tests/pokemonPlaceholderMoves.test.js b/tests/pokemonPlaceholderMoves.test.js index c08bea3..4dd9e8c 100644 --- a/tests/pokemonPlaceholderMoves.test.js +++ b/tests/pokemonPlaceholderMoves.test.js @@ -230,6 +230,41 @@ describe('Pokemon placeholder moves', () => { 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.each([129, 789, 790])( 'keeps exact Splash and Struggle placeholders when pokemonApi fallback data still contains Splash for %i', async (pokedexId) => { From 982ceda0eb89f21bd4214a99b85a85183b1d43c6 Mon Sep 17 00:00:00 2001 From: Mygod Date: Wed, 22 Apr 2026 20:30:05 -0400 Subject: [PATCH 05/12] fix: keep inherited pokeapi moves form-aware Resolve inherited move parents through the species struct and keep the Basculegion overrides so form-restricted chains still inherit the right pre-evolution moves. This intentionally omits the transient-error fallback behavior from the old 27d321d batch. --- src/classes/PokeApi.ts | 40 ++++++++++++++++++++++++--- tests/pokemonPlaceholderMoves.test.js | 40 +++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/src/classes/PokeApi.ts b/src/classes/PokeApi.ts index 716c37c..0deca99 100644 --- a/src/classes/PokeApi.ts +++ b/src/classes/PokeApi.ts @@ -27,6 +27,7 @@ export default class PokeApi extends Masterfile { 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) { @@ -40,6 +41,10 @@ export default class PokeApi extends Masterfile { this.types = {} this.pokemonStatsCache = {} this.speciesCache = {} + this.inheritedMoveParentOverrides = { + 'basculegion-female': 'basculin-white-striped', + 'basculegion-male': 'basculin-white-striped', + } this.maxPokemon = 1008 this.inconsistentStats = { 24: { @@ -210,6 +215,20 @@ export default class PokeApi extends Masterfile { 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 @@ -237,6 +256,16 @@ export default class PokeApi extends Masterfile { 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(), @@ -249,12 +278,15 @@ export default class PokeApi extends Masterfile { try { const statsData = await this.fetchPokemonStats(id) const currentMoves = this.mapPokeApiMoves(statsData) - const speciesData = await this.fetchSpecies(id) - const previousId = this.resolveStructId(speciesData.evolves_from_species) + const speciesData = await this.fetchSpeciesForPokemon(id, statsData) + const previousId = this.resolveInheritedParentIdentifier( + statsData.name, + speciesData, + ) if (!previousId) { return { - quickMoves: [...currentMoves.quickMoves].sort((a, b) => a - b), - chargedMoves: [...currentMoves.chargedMoves].sort((a, b) => a - b), + quickMoves: this.mergeMoveLists(currentMoves.quickMoves), + chargedMoves: this.mergeMoveLists(currentMoves.chargedMoves), } } const previousMoves = await this.getInheritedMoves(previousId, seen) diff --git a/tests/pokemonPlaceholderMoves.test.js b/tests/pokemonPlaceholderMoves.test.js index 4dd9e8c..9092bd1 100644 --- a/tests/pokemonPlaceholderMoves.test.js +++ b/tests/pokemonPlaceholderMoves.test.js @@ -38,6 +38,7 @@ const createCompleteEntry = ({ const createPokeApiResponse = ( name, moves = ['splash', 'tackle', 'thunderbolt'], + species, ) => ({ name, moves: moves.map((move) => ({ @@ -52,6 +53,7 @@ const createPokeApiResponse = ( { base_stat: 20, stat: { name: 'special-defense' } }, { base_stat: 80, stat: { name: 'speed' } }, ], + ...(species ? { species } : {}), types: [{ type: { name: 'water' } }], }) @@ -178,6 +180,44 @@ describe('Pokemon placeholder moves', () => { ]) }) + 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() From 7823c0785b3108aa159e36dea44fde71a82057fd Mon Sep 17 00:00:00 2001 From: Mygod Date: Wed, 22 Apr 2026 18:55:00 -0400 Subject: [PATCH 06/12] fix: filter hidden placeholder fallback moves When replacing GM Splash/Struggle placeholders with PokeAPI data, only keep fallback charged moves that already exist in the normal GM charged-move pool. That prevents Rest, Return, Frustration, and similar hidden or special-case moves from leaking into standard learnsets. --- src/classes/Pokemon.ts | 17 ++++++++-- tests/pokemonPlaceholderMoves.test.js | 47 +++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/classes/Pokemon.ts b/src/classes/Pokemon.ts index 2ed4db8..4d87d92 100644 --- a/src/classes/Pokemon.ts +++ b/src/classes/Pokemon.ts @@ -1372,6 +1372,16 @@ export default class Pokemon extends Masterfile { this.options.includeEstimatedPokemon === true || this.options.includeEstimatedPokemon.baseStats ) { + const gmChargedMovePool = new Set() + const collectStandardMoves = (entry?: { + chargedMoves?: number[] + }) => { + cleanNumberList(entry?.chargedMoves).forEach((move) => + gmChargedMovePool.add(move), + ) + } + Object.values(this.parsedPokemon).forEach(collectStandardMoves) + Object.values(this.parsedForms).forEach(collectStandardMoves) Object.keys(baseStats).forEach((id) => { try { if (!this.parsedPokemon[id]) { @@ -1504,6 +1514,9 @@ export default class Pokemon extends Masterfile { const actualChargedMoves = cleanNumberList(existing.chargedMoves) const fallbackQuickMoves = cleanNumberList(baseEntry.quickMoves) const fallbackChargedMoves = cleanNumberList(baseEntry.chargedMoves) + const placeholderFallbackChargedMoves = fallbackChargedMoves.filter( + (move) => gmChargedMovePool.has(move), + ) const preferEstimatedPlaceholderQuickMoves = shouldPreferEstimatedPlaceholderQuickMoves( actualQuickMoves, @@ -1512,7 +1525,7 @@ export default class Pokemon extends Masterfile { ) const preferEstimatedPlaceholderChargedMoves = preferEstimatedPlaceholderQuickMoves && - fallbackChargedMoves.length > 0 + placeholderFallbackChargedMoves.length > 0 if (preferEstimatedPlaceholderQuickMoves) { console.warn( `[BASE_STATS] Replacing placeholder moves for ${id} with PokeApi data`, @@ -1528,7 +1541,7 @@ export default class Pokemon extends Masterfile { ) ?? (actualQuickMoves.length ? actualQuickMoves : undefined) const chargedMoves = preferEstimatedPlaceholderChargedMoves - ? fallbackChargedMoves + ? placeholderFallbackChargedMoves : preferActualNumbers( existing.chargedMoves, baseEntry.chargedMoves, diff --git a/tests/pokemonPlaceholderMoves.test.js b/tests/pokemonPlaceholderMoves.test.js index 9092bd1..e71fd84 100644 --- a/tests/pokemonPlaceholderMoves.test.js +++ b/tests/pokemonPlaceholderMoves.test.js @@ -86,6 +86,12 @@ describe('Pokemon placeholder moves', () => { const allPokemon = createPokemon() const pokedexId = 1022 + allPokemon.parsedPokemon[25] = createEntry({ + pokemonName: 'Pikachu', + pokedexId: 25, + quickMoves: [Rpc.HoloPokemonMove.TACKLE_FAST], + chargedMoves: [Rpc.HoloPokemonMove.THUNDERBOLT], + }) allPokemon.parsedPokemon[pokedexId] = createEntry({ pokemonName: 'Iron Boulder', pokedexId, @@ -113,6 +119,47 @@ describe('Pokemon placeholder moves', () => { ]) }) + test('filters hidden fallback charged moves during placeholder replacement', () => { + const allPokemon = createPokemon() + const pokedexId = 801 + + allPokemon.parsedPokemon[25] = createEntry({ + pokemonName: 'Pikachu', + pokedexId: 25, + quickMoves: [Rpc.HoloPokemonMove.TACKLE_FAST], + chargedMoves: [Rpc.HoloPokemonMove.THUNDERBOLT], + }) + 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, + ], + }), + }, + {}, + ) + + expect(allPokemon.parsedPokemon[pokedexId].quickMoves).toEqual([ + Rpc.HoloPokemonMove.TACKLE_FAST, + ]) + expect(allPokemon.parsedPokemon[pokedexId].chargedMoves).toEqual([ + Rpc.HoloPokemonMove.THUNDERBOLT, + ]) + }) + test('pokemonApi keeps known zero-power moves instead of filtering them out', async () => { const pokeApi = createPokeApi() From fe4e40c150f980ed8f9581ecc3086c92b85ef34b Mon Sep 17 00:00:00 2001 From: Mygod Date: Wed, 22 Apr 2026 19:05:07 -0400 Subject: [PATCH 07/12] fix: drop filtered placeholder charged moves If a placeholder replacement has charged fallback data but all of it is filtered out as hidden or special-case, remove the placeholder charged slot instead of leaving Struggle behind. --- src/classes/Pokemon.ts | 6 +++++ tests/pokemonPlaceholderMoves.test.js | 32 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/classes/Pokemon.ts b/src/classes/Pokemon.ts index 4d87d92..3e0fb91 100644 --- a/src/classes/Pokemon.ts +++ b/src/classes/Pokemon.ts @@ -1523,6 +1523,10 @@ export default class Pokemon extends Masterfile { actualChargedMoves, fallbackQuickMoves, ) + const shouldDropPlaceholderChargedMoves = + preferEstimatedPlaceholderQuickMoves && + fallbackChargedMoves.length > 0 && + placeholderFallbackChargedMoves.length === 0 const preferEstimatedPlaceholderChargedMoves = preferEstimatedPlaceholderQuickMoves && placeholderFallbackChargedMoves.length > 0 @@ -1542,6 +1546,8 @@ export default class Pokemon extends Masterfile { const chargedMoves = preferEstimatedPlaceholderChargedMoves ? placeholderFallbackChargedMoves + : shouldDropPlaceholderChargedMoves + ? undefined : preferActualNumbers( existing.chargedMoves, baseEntry.chargedMoves, diff --git a/tests/pokemonPlaceholderMoves.test.js b/tests/pokemonPlaceholderMoves.test.js index e71fd84..68c4f0e 100644 --- a/tests/pokemonPlaceholderMoves.test.js +++ b/tests/pokemonPlaceholderMoves.test.js @@ -160,6 +160,38 @@ describe('Pokemon placeholder moves', () => { ]) }) + 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('pokemonApi keeps known zero-power moves instead of filtering them out', async () => { const pokeApi = createPokeApi() From d873ef36b021d99096a01b4a0b1ffdac77f08d6b Mon Sep 17 00:00:00 2001 From: Mygod Date: Wed, 22 Apr 2026 19:11:15 -0400 Subject: [PATCH 08/12] fix: narrow placeholder fallback charge filtering Filter only the hidden or special-case charged moves that should not be published as standard learnset entries: Rest, Return, and Frustration. This keeps valid GO estimates like Last Resort or Chilling Water while still stripping the placeholder-leak cases. --- src/classes/Pokemon.ts | 18 +++++++----------- tests/pokemonPlaceholderMoves.test.js | 14 ++------------ 2 files changed, 9 insertions(+), 23 deletions(-) diff --git a/src/classes/Pokemon.ts b/src/classes/Pokemon.ts index 3e0fb91..8ea028c 100644 --- a/src/classes/Pokemon.ts +++ b/src/classes/Pokemon.ts @@ -96,6 +96,12 @@ const shouldPreferEstimatedPlaceholderQuickMoves = ( !fallbackQuickMoves.includes(Rpc.HoloPokemonMove.SPLASH_FAST) && fallbackQuickMoves.length > 0 +const excludedPlaceholderFallbackChargedMoves = new Set([ + Rpc.HoloPokemonMove.FRUSTRATION, + Rpc.HoloPokemonMove.REST, + Rpc.HoloPokemonMove.RETURN, +]) + export default class Pokemon extends Masterfile { parsedPokemon: AllPokemon parsedPokeForms: AllPokemon @@ -1372,16 +1378,6 @@ export default class Pokemon extends Masterfile { this.options.includeEstimatedPokemon === true || this.options.includeEstimatedPokemon.baseStats ) { - const gmChargedMovePool = new Set() - const collectStandardMoves = (entry?: { - chargedMoves?: number[] - }) => { - cleanNumberList(entry?.chargedMoves).forEach((move) => - gmChargedMovePool.add(move), - ) - } - Object.values(this.parsedPokemon).forEach(collectStandardMoves) - Object.values(this.parsedForms).forEach(collectStandardMoves) Object.keys(baseStats).forEach((id) => { try { if (!this.parsedPokemon[id]) { @@ -1515,7 +1511,7 @@ export default class Pokemon extends Masterfile { const fallbackQuickMoves = cleanNumberList(baseEntry.quickMoves) const fallbackChargedMoves = cleanNumberList(baseEntry.chargedMoves) const placeholderFallbackChargedMoves = fallbackChargedMoves.filter( - (move) => gmChargedMovePool.has(move), + (move) => !excludedPlaceholderFallbackChargedMoves.has(move), ) const preferEstimatedPlaceholderQuickMoves = shouldPreferEstimatedPlaceholderQuickMoves( diff --git a/tests/pokemonPlaceholderMoves.test.js b/tests/pokemonPlaceholderMoves.test.js index 68c4f0e..5d220c3 100644 --- a/tests/pokemonPlaceholderMoves.test.js +++ b/tests/pokemonPlaceholderMoves.test.js @@ -86,12 +86,6 @@ describe('Pokemon placeholder moves', () => { const allPokemon = createPokemon() const pokedexId = 1022 - allPokemon.parsedPokemon[25] = createEntry({ - pokemonName: 'Pikachu', - pokedexId: 25, - quickMoves: [Rpc.HoloPokemonMove.TACKLE_FAST], - chargedMoves: [Rpc.HoloPokemonMove.THUNDERBOLT], - }) allPokemon.parsedPokemon[pokedexId] = createEntry({ pokemonName: 'Iron Boulder', pokedexId, @@ -123,12 +117,6 @@ describe('Pokemon placeholder moves', () => { const allPokemon = createPokemon() const pokedexId = 801 - allPokemon.parsedPokemon[25] = createEntry({ - pokemonName: 'Pikachu', - pokedexId: 25, - quickMoves: [Rpc.HoloPokemonMove.TACKLE_FAST], - chargedMoves: [Rpc.HoloPokemonMove.THUNDERBOLT], - }) allPokemon.parsedPokemon[pokedexId] = createEntry({ pokemonName: 'Magearna', pokedexId, @@ -146,6 +134,7 @@ describe('Pokemon placeholder moves', () => { Rpc.HoloPokemonMove.THUNDERBOLT, Rpc.HoloPokemonMove.RETURN, Rpc.HoloPokemonMove.FRUSTRATION, + Rpc.HoloPokemonMove.REST, ], }), }, @@ -180,6 +169,7 @@ describe('Pokemon placeholder moves', () => { chargedMoves: [ Rpc.HoloPokemonMove.RETURN, Rpc.HoloPokemonMove.FRUSTRATION, + Rpc.HoloPokemonMove.REST, ], }), }, From ff63de7a6ba4667a5c1361fa58830474e1b5fa90 Mon Sep 17 00:00:00 2001 From: Mygod Date: Wed, 22 Apr 2026 19:28:08 -0400 Subject: [PATCH 09/12] fix: sanitize estimated charged fallback lists Use the same hidden-move filtering for ordinary charged fallback merges as for the Splash/Struggle replacement path. That keeps fully estimated species from exposing Rest, Return, or Frustration in generated standard learnsets. --- src/classes/Pokemon.ts | 10 ++++----- tests/pokemonPlaceholderMoves.test.js | 29 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/classes/Pokemon.ts b/src/classes/Pokemon.ts index 8ea028c..5c92285 100644 --- a/src/classes/Pokemon.ts +++ b/src/classes/Pokemon.ts @@ -1510,7 +1510,7 @@ export default class Pokemon extends Masterfile { const actualChargedMoves = cleanNumberList(existing.chargedMoves) const fallbackQuickMoves = cleanNumberList(baseEntry.quickMoves) const fallbackChargedMoves = cleanNumberList(baseEntry.chargedMoves) - const placeholderFallbackChargedMoves = fallbackChargedMoves.filter( + const sanitizedFallbackChargedMoves = fallbackChargedMoves.filter( (move) => !excludedPlaceholderFallbackChargedMoves.has(move), ) const preferEstimatedPlaceholderQuickMoves = @@ -1522,10 +1522,10 @@ export default class Pokemon extends Masterfile { const shouldDropPlaceholderChargedMoves = preferEstimatedPlaceholderQuickMoves && fallbackChargedMoves.length > 0 && - placeholderFallbackChargedMoves.length === 0 + sanitizedFallbackChargedMoves.length === 0 const preferEstimatedPlaceholderChargedMoves = preferEstimatedPlaceholderQuickMoves && - placeholderFallbackChargedMoves.length > 0 + sanitizedFallbackChargedMoves.length > 0 if (preferEstimatedPlaceholderQuickMoves) { console.warn( `[BASE_STATS] Replacing placeholder moves for ${id} with PokeApi data`, @@ -1541,12 +1541,12 @@ export default class Pokemon extends Masterfile { ) ?? (actualQuickMoves.length ? actualQuickMoves : undefined) const chargedMoves = preferEstimatedPlaceholderChargedMoves - ? placeholderFallbackChargedMoves + ? sanitizedFallbackChargedMoves : shouldDropPlaceholderChargedMoves ? undefined : preferActualNumbers( existing.chargedMoves, - baseEntry.chargedMoves, + sanitizedFallbackChargedMoves, 'charged moves', ) ?? (actualChargedMoves.length ? actualChargedMoves : undefined) diff --git a/tests/pokemonPlaceholderMoves.test.js b/tests/pokemonPlaceholderMoves.test.js index 5d220c3..6e6e001 100644 --- a/tests/pokemonPlaceholderMoves.test.js +++ b/tests/pokemonPlaceholderMoves.test.js @@ -182,6 +182,35 @@ describe('Pokemon placeholder moves', () => { 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, + ]) + }) + test('pokemonApi keeps known zero-power moves instead of filtering them out', async () => { const pokeApi = createPokeApi() From 6bb6fbffbf12e4fcb937dc3668f512de1339f7f5 Mon Sep 17 00:00:00 2001 From: Mygod Date: Wed, 22 Apr 2026 19:45:49 -0400 Subject: [PATCH 10/12] fix: preserve GM rarity flags in fallback metadata Keep GM legendary and mythic classifications when PokeAPI fallback data is enriching an existing species, and refresh the shipped fallback snapshot for paradox legendaries to match. --- src/classes/PokeApi.ts | 12 ++++++++---- static/baseStats.json | 14 +++++++------- tests/pokemonPlaceholderMoves.test.js | 27 +++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/src/classes/PokeApi.ts b/src/classes/PokeApi.ts index 0deca99..63d5c01 100644 --- a/src/classes/PokeApi.ts +++ b/src/classes/PokeApi.ts @@ -465,8 +465,10 @@ export default class PokeApi extends Masterfile { if (!evolvedPokemon.has(+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 = this.resolveStructId(evoData.evolves_from_species) @@ -507,8 +509,10 @@ export default class PokeApi extends Masterfile { Object.keys(this.baseStats).map(async (id) => { try { const evoData = await this.fetchSpecies(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 } catch (e) { console.warn(e, `Failed to apply PokeApi species flags for #${id}`) } diff --git a/static/baseStats.json b/static/baseStats.json index 25186bf..2bc8fb7 100644 --- a/static/baseStats.json +++ b/static/baseStats.json @@ -894,7 +894,7 @@ 11, 16 ], - "legendary": false, + "legendary": true, "mythic": false }, "1010": { @@ -935,7 +935,7 @@ 12, 14 ], - "legendary": false, + "legendary": true, "mythic": false }, "1020": { @@ -984,7 +984,7 @@ 10, 16 ], - "legendary": false, + "legendary": true, "mythic": false }, "1021": { @@ -1027,7 +1027,7 @@ 13, 16 ], - "legendary": false, + "legendary": true, "mythic": false }, "1022": { @@ -1072,7 +1072,7 @@ 6, 14 ], - "legendary": false, + "legendary": true, "mythic": false }, "1023": { @@ -1110,7 +1110,7 @@ 9, 14 ], - "legendary": false, + "legendary": true, "mythic": false } -} \ No newline at end of file +} diff --git a/tests/pokemonPlaceholderMoves.test.js b/tests/pokemonPlaceholderMoves.test.js index 6e6e001..ac365ce 100644 --- a/tests/pokemonPlaceholderMoves.test.js +++ b/tests/pokemonPlaceholderMoves.test.js @@ -403,6 +403,33 @@ describe('Pokemon placeholder moves', () => { }) }) + 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) => { From fcde14efaae437c9514e9a92ef2190c526e15b70 Mon Sep 17 00:00:00 2001 From: Mygod Date: Wed, 22 Apr 2026 20:30:36 -0400 Subject: [PATCH 11/12] fix: preserve hidden-only fallback charged state Keep hidden-only charged moves in live PokeAPI fallback data, but strip them from the serialized baseStats cache while preserving an explicit marker for placeholder merge logic. This replaces the old sanitize/cache/error-handling sequence with one durable cache-boundary fix. --- devWrapper.ts | 3 +- src/classes/PokeApi.ts | 36 +++++++- src/classes/Pokemon.ts | 21 +++-- src/typings/dataTypes.ts | 1 + static/baseStats.json | 33 +------ tests/pokemonPlaceholderMoves.test.js | 126 +++++++++++++++++++++++++- 6 files changed, 178 insertions(+), 42 deletions(-) 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 63d5c01..d5035c0 100644 --- a/src/classes/PokeApi.ts +++ b/src/classes/PokeApi.ts @@ -16,6 +16,37 @@ 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.REST, + 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 } @@ -269,7 +300,10 @@ export default class PokeApi extends Masterfile { private async getInheritedMoves( id: string | number, seen = new Set(), - ): Promise<{ quickMoves: number[]; chargedMoves: number[] }> { + ): Promise<{ + quickMoves: number[] + chargedMoves: number[] + }> { const cacheKey = `${id}` if (seen.has(cacheKey)) { return { quickMoves: [], chargedMoves: [] } diff --git a/src/classes/Pokemon.ts b/src/classes/Pokemon.ts index 5c92285..ee097f4 100644 --- a/src/classes/Pokemon.ts +++ b/src/classes/Pokemon.ts @@ -1506,13 +1506,19 @@ export default class Pokemon extends Masterfile { } }) } + const { + _hiddenOnlyChargedMoves, + ...cacheEntry + } = baseEntry const actualQuickMoves = cleanNumberList(existing.quickMoves) const actualChargedMoves = cleanNumberList(existing.chargedMoves) - const fallbackQuickMoves = cleanNumberList(baseEntry.quickMoves) - const fallbackChargedMoves = cleanNumberList(baseEntry.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, @@ -1521,7 +1527,8 @@ export default class Pokemon extends Masterfile { ) const shouldDropPlaceholderChargedMoves = preferEstimatedPlaceholderQuickMoves && - fallbackChargedMoves.length > 0 && + (fallbackChargedMoves.length > 0 || + hasHiddenOnlyFallbackChargedMoves) && sanitizedFallbackChargedMoves.length === 0 const preferEstimatedPlaceholderChargedMoves = preferEstimatedPlaceholderQuickMoves && @@ -1536,7 +1543,7 @@ export default class Pokemon extends Masterfile { ? fallbackQuickMoves : preferActualNumbers( existing.quickMoves, - baseEntry.quickMoves, + cacheEntry.quickMoves, 'quick moves', ) ?? (actualQuickMoves.length ? actualQuickMoves : undefined) const chargedMoves = @@ -1551,14 +1558,14 @@ export default class Pokemon extends Masterfile { ) ?? (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/static/baseStats.json b/static/baseStats.json index 2bc8fb7..59f1219 100644 --- a/static/baseStats.json +++ b/static/baseStats.json @@ -108,7 +108,6 @@ 123, 125, 131, - 132, 247, 248, 252, @@ -119,8 +118,6 @@ 300, 309, 321, - 322, - 323, 324, 332, 344 @@ -151,11 +148,9 @@ 202, 216, 221, - 223, 230, 234, 264, - 281, 282, 283, 327 @@ -164,25 +159,19 @@ 14, 39, 40, - 53, 57, 58, 70, - 104, 105, - 106, 107, 111, 125, - 132, 265, 277, 279, 284, 316, 321, - 322, - 323, 353, 383, 488 @@ -226,7 +215,6 @@ 123, 126, 131, - 132, 245, 268, 304, @@ -274,7 +262,6 @@ 108, 115, 131, - 132, 247, 267, 272, @@ -312,7 +299,6 @@ 114, 116, 131, - 132, 245, 272, 273, @@ -358,7 +344,6 @@ 87, 111, 125, - 132, 265, 273, 321, @@ -398,7 +383,6 @@ 123, 127, 131, - 132, 245, 251, 268, @@ -441,7 +425,6 @@ 95, 125, 131, - 132, 251, 252, 258, @@ -489,7 +472,6 @@ 95, 126, 131, - 132, 251, 267, 268, @@ -527,7 +509,6 @@ 121, 125, 131, - 132, 254, 321, 364, @@ -569,7 +550,6 @@ 115, 123, 131, - 132, 245, 247, 251, @@ -614,7 +594,6 @@ 121, 122, 131, - 132, 247, 277, 279, @@ -662,7 +641,6 @@ 116, 122, 125, - 132, 270, 273, 303, @@ -720,7 +698,6 @@ 115, 123, 131, - 132, 247, 251, 258, @@ -780,7 +757,6 @@ 122, 123, 131, - 132, 277, 279, 285, @@ -832,7 +808,6 @@ 117, 123, 125, - 132, 245, 247, 272, @@ -877,7 +852,6 @@ 122, 125, 131, - 132, 277, 279, 284, @@ -917,7 +891,6 @@ 117, 123, 125, - 132, 245, 247, 251, @@ -966,7 +939,6 @@ 103, 127, 131, - 132, 270, 277, 279, @@ -1010,7 +982,6 @@ 116, 127, 131, - 132, 251, 252, 268, @@ -1057,7 +1028,6 @@ 123, 126, 131, - 132, 245, 251, 259, @@ -1097,7 +1067,6 @@ 108, 123, 131, - 132, 247, 268, 321, @@ -1113,4 +1082,4 @@ "legendary": true, "mythic": false } -} +} \ No newline at end of file diff --git a/tests/pokemonPlaceholderMoves.test.js b/tests/pokemonPlaceholderMoves.test.js index ac365ce..a6bb623 100644 --- a/tests/pokemonPlaceholderMoves.test.js +++ b/tests/pokemonPlaceholderMoves.test.js @@ -1,5 +1,8 @@ const Pokemon = require('../dist/classes/Pokemon').default -const PokeApi = require('../dist/classes/PokeApi').default +const { + default: PokeApi, + sanitizePokeApiBaseStatsForCache, +} = require('../dist/classes/PokeApi') const base = require('../dist/base').default const { Rpc } = require('@na-ji/pogo-protos') @@ -68,6 +71,9 @@ const createPokeApi = () => { 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 @@ -236,6 +242,124 @@ describe('Pokemon placeholder moves', () => { 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.THUNDERBOLT, + ]) + expect(sanitized[801]._hiddenOnlyChargedMoves).toBeUndefined() + expect(sanitized[802].chargedMoves).toEqual([]) + expect(sanitized[802]._hiddenOnlyChargedMoves).toBe(true) + }) + + 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', 'rest']) + } + 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.REST], + }), + }), + {}, + ) + + 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() From 7ccb228d796ae14aef1aae5658f2f546405403b8 Mon Sep 17 00:00:00 2001 From: Mygod Date: Wed, 22 Apr 2026 20:46:33 -0400 Subject: [PATCH 12/12] fix: allow Rest in fallback charged moves Keep only Return and Frustration in the fallback charged-move exclusion policy so Rest is preserved in both live and cached PokeAPI fallback data. Refresh the placeholder-move regressions and static baseStats snapshot to match the narrower rule. --- src/classes/PokeApi.ts | 1 - src/classes/Pokemon.ts | 1 - static/baseStats.json | 22 ++++++++++++++++++++++ tests/pokemonPlaceholderMoves.test.js | 17 ++++++++--------- 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/classes/PokeApi.ts b/src/classes/PokeApi.ts index d5035c0..c70db02 100644 --- a/src/classes/PokeApi.ts +++ b/src/classes/PokeApi.ts @@ -18,7 +18,6 @@ import Masterfile from './Masterfile' const excludedFallbackChargedMoves = new Set([ Rpc.HoloPokemonMove.FRUSTRATION, - Rpc.HoloPokemonMove.REST, Rpc.HoloPokemonMove.RETURN, ]) diff --git a/src/classes/Pokemon.ts b/src/classes/Pokemon.ts index ee097f4..78910fd 100644 --- a/src/classes/Pokemon.ts +++ b/src/classes/Pokemon.ts @@ -98,7 +98,6 @@ const shouldPreferEstimatedPlaceholderQuickMoves = ( const excludedPlaceholderFallbackChargedMoves = new Set([ Rpc.HoloPokemonMove.FRUSTRATION, - Rpc.HoloPokemonMove.REST, Rpc.HoloPokemonMove.RETURN, ]) diff --git a/static/baseStats.json b/static/baseStats.json index 59f1219..b3d17af 100644 --- a/static/baseStats.json +++ b/static/baseStats.json @@ -108,6 +108,7 @@ 123, 125, 131, + 132, 247, 248, 252, @@ -166,6 +167,7 @@ 107, 111, 125, + 132, 265, 277, 279, @@ -215,6 +217,7 @@ 123, 126, 131, + 132, 245, 268, 304, @@ -262,6 +265,7 @@ 108, 115, 131, + 132, 247, 267, 272, @@ -299,6 +303,7 @@ 114, 116, 131, + 132, 245, 272, 273, @@ -344,6 +349,7 @@ 87, 111, 125, + 132, 265, 273, 321, @@ -383,6 +389,7 @@ 123, 127, 131, + 132, 245, 251, 268, @@ -425,6 +432,7 @@ 95, 125, 131, + 132, 251, 252, 258, @@ -472,6 +480,7 @@ 95, 126, 131, + 132, 251, 267, 268, @@ -509,6 +518,7 @@ 121, 125, 131, + 132, 254, 321, 364, @@ -550,6 +560,7 @@ 115, 123, 131, + 132, 245, 247, 251, @@ -594,6 +605,7 @@ 121, 122, 131, + 132, 247, 277, 279, @@ -641,6 +653,7 @@ 116, 122, 125, + 132, 270, 273, 303, @@ -698,6 +711,7 @@ 115, 123, 131, + 132, 247, 251, 258, @@ -757,6 +771,7 @@ 122, 123, 131, + 132, 277, 279, 285, @@ -808,6 +823,7 @@ 117, 123, 125, + 132, 245, 247, 272, @@ -852,6 +868,7 @@ 122, 125, 131, + 132, 277, 279, 284, @@ -891,6 +908,7 @@ 117, 123, 125, + 132, 245, 247, 251, @@ -939,6 +957,7 @@ 103, 127, 131, + 132, 270, 277, 279, @@ -982,6 +1001,7 @@ 116, 127, 131, + 132, 251, 252, 268, @@ -1028,6 +1048,7 @@ 123, 126, 131, + 132, 245, 251, 259, @@ -1067,6 +1088,7 @@ 108, 123, 131, + 132, 247, 268, 321, diff --git a/tests/pokemonPlaceholderMoves.test.js b/tests/pokemonPlaceholderMoves.test.js index a6bb623..b073275 100644 --- a/tests/pokemonPlaceholderMoves.test.js +++ b/tests/pokemonPlaceholderMoves.test.js @@ -152,6 +152,7 @@ describe('Pokemon placeholder moves', () => { ]) expect(allPokemon.parsedPokemon[pokedexId].chargedMoves).toEqual([ Rpc.HoloPokemonMove.THUNDERBOLT, + Rpc.HoloPokemonMove.REST, ]) }) @@ -172,11 +173,7 @@ describe('Pokemon placeholder moves', () => { pokemonName: 'Magearna', pokedexId, quickMoves: [Rpc.HoloPokemonMove.TACKLE_FAST], - chargedMoves: [ - Rpc.HoloPokemonMove.RETURN, - Rpc.HoloPokemonMove.FRUSTRATION, - Rpc.HoloPokemonMove.REST, - ], + chargedMoves: [Rpc.HoloPokemonMove.RETURN, Rpc.HoloPokemonMove.FRUSTRATION], }), }, {}, @@ -214,6 +211,7 @@ describe('Pokemon placeholder moves', () => { ]) expect(allPokemon.parsedPokemon[pokedexId].chargedMoves).toEqual([ Rpc.HoloPokemonMove.THUNDERBOLT, + Rpc.HoloPokemonMove.REST, ]) }) @@ -293,11 +291,12 @@ describe('Pokemon placeholder moves', () => { }) expect(sanitized[801].chargedMoves).toEqual([ + Rpc.HoloPokemonMove.REST, Rpc.HoloPokemonMove.THUNDERBOLT, ]) expect(sanitized[801]._hiddenOnlyChargedMoves).toBeUndefined() - expect(sanitized[802].chargedMoves).toEqual([]) - expect(sanitized[802]._hiddenOnlyChargedMoves).toBe(true) + expect(sanitized[802].chargedMoves).toEqual([Rpc.HoloPokemonMove.REST]) + expect(sanitized[802]._hiddenOnlyChargedMoves).toBeUndefined() }) test('live pokeapi placeholder replacement drops hidden-only charged moves', async () => { @@ -314,7 +313,7 @@ describe('Pokemon placeholder moves', () => { jest.spyOn(pokeApi, 'fetch').mockImplementation(async (url) => { if (url.endsWith('/pokemon/801')) { - return createPokeApiResponse('magearna', ['tackle', 'rest']) + return createPokeApiResponse('magearna', ['tackle', 'return']) } if (url.endsWith('/pokemon-species/801')) { return createSpeciesResponse() @@ -348,7 +347,7 @@ describe('Pokemon placeholder moves', () => { pokemonName: 'Magearna', pokedexId, quickMoves: [Rpc.HoloPokemonMove.TACKLE_FAST], - chargedMoves: [Rpc.HoloPokemonMove.REST], + chargedMoves: [Rpc.HoloPokemonMove.RETURN], }), }), {},