diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index f0ae86b30..60604abf0 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -1294,6 +1294,10 @@ "xlCandy": { "__name": "DEFAULT_FILTERS_TAPPABLES_XL_CANDY", "__format": "boolean" + }, + "pokemon": { + "__name": "DEFAULT_FILTERS_TAPPABLES_POKEMON", + "__format": "boolean" } }, "pokemon": { @@ -2479,4 +2483,4 @@ } } } -} \ No newline at end of file +} diff --git a/config/default.json b/config/default.json index db2572371..e2557ecdc 100644 --- a/config/default.json +++ b/config/default.json @@ -560,7 +560,8 @@ "items": true, "megaEnergy": true, "candy": true, - "xlCandy": true + "xlCandy": true, + "pokemon": true }, "pokemon": { "enabled": false, diff --git a/packages/logger/lib/tags.js b/packages/logger/lib/tags.js index bbd3d49eb..f8027db5a 100644 --- a/packages/logger/lib/tags.js +++ b/packages/logger/lib/tags.js @@ -40,6 +40,7 @@ const TAGS = /** @type {const} */ ({ pokestops: chalk.hex('#e91e63')('[POKESTOPS]'), gyms: chalk.hex('#9c27b0')('[GYMS]'), stations: chalk.hex('#00bcd4')('[STATIONS]'), + tappables: chalk.hex('#cddc39')('[TAPPABLES]'), weather: chalk.hex('#3f51b5')('[WEATHER]'), available: chalk.hex('#2196f3')('[AVAILABLE]'), scanAreas: chalk.hex('#00bcd4')('[SCAN AREAS]'), diff --git a/packages/types/lib/scanner.d.ts b/packages/types/lib/scanner.d.ts index dceb5b9c6..1a4e16aaf 100644 --- a/packages/types/lib/scanner.d.ts +++ b/packages/types/lib/scanner.d.ts @@ -296,6 +296,9 @@ export interface Tappable { type: string fort_id: string | null item_id: number | null + pokemon_id?: number | null + form?: number | null + costume?: number | null reward_type?: string | null reward_pokemon_id?: number | null temp_evolution?: number | null diff --git a/server/src/filters/builder/tappable.js b/server/src/filters/builder/tappable.js index b9d34fcac..02d4e22ec 100644 --- a/server/src/filters/builder/tappable.js +++ b/server/src/filters/builder/tappable.js @@ -25,6 +25,8 @@ function buildTappables(perms, defaults) { filters[key] = new BaseFilter(defaults.xlCandy) } else if (key.startsWith('m')) { filters[key] = new BaseFilter(defaults.megaEnergy) + } else if (Number.isInteger(Number(key.charAt(0)))) { + filters[key] = new BaseFilter(defaults.pokemon) } else { filters[key] = new BaseFilter(defaults.items) } diff --git a/server/src/graphql/typeDefs/scanner.graphql b/server/src/graphql/typeDefs/scanner.graphql index f4421423d..c0e75751a 100644 --- a/server/src/graphql/typeDefs/scanner.graphql +++ b/server/src/graphql/typeDefs/scanner.graphql @@ -214,6 +214,9 @@ type Tappable { type: String fort_id: String item_id: Int + pokemon_id: Int + form: Int + costume: Int reward_type: String reward_pokemon_id: Int temp_evolution: Int diff --git a/server/src/models/Tappable.js b/server/src/models/Tappable.js index 6e3d4ebcd..56d42fb4a 100644 --- a/server/src/models/Tappable.js +++ b/server/src/models/Tappable.js @@ -1,11 +1,22 @@ // @ts-check const { Model, raw } = require('objection') const config = require('@rm/config') +const { log, TAGS } = require('@rm/logger') -const { getAreaSql } = require('../utils/getAreaSql') -const { applyManualIdFilter } = require('../utils/manualFilter') +const { getAreaSql, areaRestrictionsDenyAll } = require('../utils/getAreaSql') +const { + applyManualIdFilter, + normalizeManualId, +} = require('../utils/manualFilter') const { getEpoch } = require('../utils/getClientTime') const { getMegaEnergyFilterKey } = require('../utils/megaEnergyFilterKey') +const { + evalScannerQuery, + describeScannerResponse, + fetchFortById, +} = require('../utils/evalScannerQuery') +const { filterRTree } = require('../utils/filterRTree') +const { golbatCapabilities } = require('../services/GolbatCapabilities') const MEGA_ENERGY_REWARD_TYPES = [ 'mega_energy_pokemon_id', @@ -20,6 +31,168 @@ const SUPPORTED_REWARD_TYPES = [ // No column carries the temp evolution, so read it from the rewards JSON. const TEMP_EVOLUTION_SQL = 'json_extract(rewards, "$[0].temp_evo_pokemon_branch.temp_evo_id")' +// A pokemon tappable only stores the species, the encountered pokemon shares +// its id and carries the form and costume shown on the marker. +const POKEMON_FORM_SQL = + '(SELECT form FROM pokemon WHERE pokemon.id = tappable.id)' +const POKEMON_COSTUME_SQL = + '(SELECT costume FROM pokemon WHERE pokemon.id = tappable.id)' + +/** + * Filter key of a tappable, null for rewards that are not shown. Pokemon + * tappables are keyed by species, as Golbat only knows the species. + * @param {{ + * pokemon_id?: number | null, + * item_id?: number | null, + * reward_type?: string | null, + * reward_pokemon_id?: number | null, + * count?: number | null, + * temp_evolution?: number | null, + * }} tappable + * @returns {string | null} + */ +function getTappableFilterKey(tappable) { + if (tappable.pokemon_id) return `${tappable.pokemon_id}` + if (tappable.item_id) return `q${tappable.item_id}` + if (!tappable.reward_pokemon_id) return null + switch (tappable.reward_type) { + case 'pokemon_candy': + return `c${tappable.reward_pokemon_id}` + case 'xl_candy': + return `x${tappable.reward_pokemon_id}` + case 'mega_energy_pokemon_id': + case 'temp_evo_pokemon_branch': + return getMegaEnergyFilterKey( + tappable.reward_pokemon_id, + tappable.count, + tappable.temp_evolution, + ) + default: + return null + } +} + +/** + * Splits the selected filter keys by reward kind. + * @param {Record} filterArgs + */ +function parseFilterKeys(filterArgs) { + const keys = new Set() + const itemIds = [] + const candyIds = [] + const xlCandyIds = [] + const megaEnergy = [] + const pokemonIds = [] + Object.keys(filterArgs).forEach((key) => { + if (!key || key.startsWith('only')) return + switch (key.charAt(0)) { + case 'q': { + const itemId = Number.parseInt(key.slice(1), 10) + if (!Number.isNaN(itemId) && itemId !== 0) { + itemIds.push(itemId) + keys.add(key) + } + break + } + case 'c': + case 'x': { + const pokemonId = Number.parseInt(key.slice(1), 10) + if (!Number.isNaN(pokemonId)) { + ;(key.charAt(0) === 'c' ? candyIds : xlCandyIds).push(pokemonId) + keys.add(key) + } + break + } + case 'm': { + const [pokemonId, amount, tempEvolution] = key + .slice(1) + .split('-', 3) + .map(Number) + if (Number.isInteger(pokemonId) && Number.isInteger(amount)) { + megaEnergy.push([pokemonId, amount, tempEvolution || 0]) + keys.add(key) + } + break + } + default: { + const pokemonId = Number(key) + if (Number.isInteger(pokemonId) && pokemonId > 0) { + pokemonIds.push(pokemonId) + keys.add(key) + } + break + } + } + }) + return { keys, itemIds, candyIds, xlCandyIds, megaEnergy, pokemonIds } +} + +/** + * DNF clauses for Golbat's /api/tappable/scan. Golbat cannot match the temp + * evolution, so the results are checked against the exact keys afterwards. + * @param {ReturnType} selected + */ +function buildDnfFilters(selected) { + const filters = [] + if (selected.itemIds.length) { + filters.push({ reward_type: ['item'], item_id: selected.itemIds }) + } + if (selected.candyIds.length) { + filters.push({ + reward_type: ['pokemon_candy'], + reward_pokemon_id: selected.candyIds, + }) + } + if (selected.xlCandyIds.length) { + filters.push({ + reward_type: ['xl_candy'], + reward_pokemon_id: selected.xlCandyIds, + }) + } + selected.megaEnergy.forEach(([pokemonId, amount]) => { + filters.push({ + reward_type: MEGA_ENERGY_REWARD_TYPES, + reward_pokemon_id: [pokemonId], + count: { min: amount, max: amount }, + }) + }) + if (selected.pokemonIds.length) { + filters.push({ pokemon_id: selected.pokemonIds }) + } + return filters +} + +/** + * Maps a Golbat tappable to the shape the SQL query returns. + * @param {Record} tappable + * @returns {import('@rm/types').FullTappable} + */ +function mapScanTappable(tappable) { + const rewards = Array.isArray(tappable.rewards) ? tappable.rewards : [] + return { + id: tappable.id, + lat: tappable.lat, + lon: tappable.lon, + type: tappable.type, + fort_id: tappable.fort_id ?? null, + item_id: tappable.item_id ?? null, + pokemon_id: tappable.pokemon_id ?? null, + form: null, + costume: null, + reward_type: tappable.reward_type ?? null, + reward_pokemon_id: tappable.reward_pokemon_id ?? null, + temp_evolution: + Number(rewards[0]?.temp_evo_pokemon_branch?.temp_evo_id) || null, + count: tappable.count ?? null, + expire_timestamp: tappable.expire_timestamp ?? null, + expire_timestamp_verified: !!tappable.expire_timestamp_verified, + updated: tappable.updated, + } +} + +/** @param {string} [mem] */ +const supportsTappableScan = (mem) => + !!mem && golbatCapabilities.get(mem)?.features?.tappable_scan === true class Tappable extends Model { static get tableName() { @@ -38,13 +211,84 @@ class Tappable extends Model { * @param {import('@rm/types').DbContext} ctx * @returns {Promise} */ - static async getAll(perms, args, { hasTappableRewards }) { + static async getAll( + perms, + args, + { hasTappableRewards, mem, secret, httpAuth }, + ) { if (!perms?.tappables) return [] const { filters: filterArgs = {}, minLat, maxLat, minLon, maxLon } = args const { queryLimits = {} } = config.getSafe('api') - const timestamp = getEpoch() + const limit = queryLimits.tappables || queryLimits.pokestops || 5000 + const onlyAreas = filterArgs.onlyAreas || [] + const selected = parseFilterKeys(filterArgs) + + if (supportsTappableScan(mem)) { + // filterRTree allows everything on empty area inputs, unlike getAreaSql + if (areaRestrictionsDenyAll(perms.areaRestrictions, onlyAreas)) return [] + try { + const res = await evalScannerQuery( + TAGS.tappables, + `${mem}/api/tappable/scan`, + JSON.stringify({ + min: { lat: minLat, lon: minLon }, + max: { lat: maxLat, lon: maxLon }, + // 0 = Golbat's default, the display limit applies after the local + // key and area checks + limit: 0, + // an empty list also matches everything, omit it to be explicit + filters: selected.keys.size ? buildDnfFilters(selected) : undefined, + }), + 'POST', + secret, + httpAuth, + ) + if (res && Array.isArray(res.tappables)) { + const manualId = normalizeManualId(filterArgs.onlyManualId) + if ( + manualId !== null && + !res.tappables.some((t) => t && `${t.id}` === `${manualId}`) + ) { + try { + const one = await fetchFortById( + TAGS.tappables, + `${mem}/api/tappable/id/${encodeURIComponent(manualId)}`, + secret, + httpAuth, + ) + if (one) res.tappables.unshift(one) + } catch { + // by-id miss mirrors SQL finding no such row + } + } + return res.tappables + .map(mapScanTappable) + .filter((tappable) => { + const key = getTappableFilterKey(tappable) + return ( + key && + (!selected.keys.size || selected.keys.has(key)) && + filterRTree(tappable, perms.areaRestrictions, onlyAreas) + ) + }) + .sort((a, b) => b.updated - a.updated) + .slice(0, limit) + } + log.warn( + TAGS.tappables, + `[TAPPABLE] /api/tappable/scan gave no tappables array — ${describeScannerResponse( + res, + )} — falling back to SQL for this source`, + ) + } catch (e) { + log.warn( + TAGS.tappables, + `[TAPPABLE] /api/tappable/scan error — falling back to SQL for this source: ${e}`, + ) + } + } const query = this.query().select([ 'id', @@ -53,6 +297,9 @@ class Tappable extends Model { 'type', 'fort_id', 'item_id', + 'pokemon_id', + raw(POKEMON_FORM_SQL).as('form'), + raw(POKEMON_COSTUME_SQL).as('costume'), 'count', 'expire_timestamp', 'expire_timestamp_verified', @@ -74,110 +321,79 @@ class Tappable extends Model { bounds: { minLat, maxLat, minLon, maxLon }, }) - const onlyAreas = filterArgs.onlyAreas || [] if (!getAreaSql(query, perms.areaRestrictions, onlyAreas)) { return [] } - query.whereNull('pokemon_id') - if (hasTappableRewards) { - query.whereIn('reward_type', SUPPORTED_REWARD_TYPES) - } else { - query.whereNotNull('item_id') - } + query.where((supported) => + supported.whereNotNull('pokemon_id').orWhere((reward) => { + reward.whereNull('pokemon_id') + if (hasTappableRewards) { + reward.whereIn('reward_type', SUPPORTED_REWARD_TYPES) + } else { + reward.whereNotNull('item_id') + } + }), + ) - query.andWhere('expire_timestamp', '>', timestamp) - - const itemIds = [] - const candyIds = [] - const xlCandyIds = [] - const megaEnergy = [] - Object.keys(filterArgs).forEach((key) => { - if (!key || key.startsWith('only')) return - switch (key.charAt(0)) { - case 'q': { - const itemId = Number.parseInt(key.slice(1), 10) - if (!Number.isNaN(itemId) && itemId !== 0) { - itemIds.push(itemId) - } - break + query.andWhere('expire_timestamp', '>', getEpoch()) + + const { itemIds, candyIds, xlCandyIds, megaEnergy, pokemonIds } = selected + const hasRewardFilters = + candyIds.length || xlCandyIds.length || megaEnergy.length + if ( + itemIds.length || + pokemonIds.length || + (hasTappableRewards && hasRewardFilters) + ) { + query.andWhere((rewards) => { + if (itemIds.length) { + rewards.orWhereIn('item_id', itemIds) } - case 'c': - case 'x': { - const pokemonId = Number.parseInt(key.slice(1), 10) - if (!Number.isNaN(pokemonId)) { - ;(key.charAt(0) === 'c' ? candyIds : xlCandyIds).push(pokemonId) - } - break + if (pokemonIds.length) { + rewards.orWhereIn('pokemon_id', pokemonIds) } - case 'm': { - const [pokemonId, amount, tempEvolution] = key - .slice(1) - .split('-', 3) - .map(Number) - if (Number.isInteger(pokemonId) && Number.isInteger(amount)) { - megaEnergy.push([pokemonId, amount, tempEvolution || 0]) - } - break + if (!hasTappableRewards) return + if (candyIds.length) { + rewards.orWhere((candy) => + candy + .where('reward_type', 'pokemon_candy') + .whereIn('reward_pokemon_id', candyIds), + ) } - default: - break - } - }) - - if (hasTappableRewards) { - if ( - itemIds.length || - candyIds.length || - xlCandyIds.length || - megaEnergy.length - ) { - query.andWhere((rewards) => { - if (itemIds.length) { - rewards.orWhereIn('item_id', itemIds) - } - if (candyIds.length) { - rewards.orWhere((candy) => - candy - .where('reward_type', 'pokemon_candy') - .whereIn('reward_pokemon_id', candyIds), - ) - } - if (xlCandyIds.length) { - rewards.orWhere((xlCandy) => - xlCandy - .where('reward_type', 'xl_candy') - .whereIn('reward_pokemon_id', xlCandyIds), - ) - } - megaEnergy.forEach(([pokemonId, amount, tempEvolution]) => { - rewards.orWhere((mega) => - mega - .whereIn('reward_type', MEGA_ENERGY_REWARD_TYPES) - .where('reward_pokemon_id', pokemonId) - .where('count', amount) - .whereRaw(`COALESCE(${TEMP_EVOLUTION_SQL}, 0) = ?`, [ - tempEvolution, - ]), - ) - }) + if (xlCandyIds.length) { + rewards.orWhere((xlCandy) => + xlCandy + .where('reward_type', 'xl_candy') + .whereIn('reward_pokemon_id', xlCandyIds), + ) + } + megaEnergy.forEach(([pokemonId, amount, tempEvolution]) => { + rewards.orWhere((mega) => + mega + .whereIn('reward_type', MEGA_ENERGY_REWARD_TYPES) + .where('reward_pokemon_id', pokemonId) + .where('count', amount) + .whereRaw(`COALESCE(${TEMP_EVOLUTION_SQL}, 0) = ?`, [ + tempEvolution, + ]), + ) }) - } - } else if (itemIds.length) { - query.whereIn('item_id', itemIds) - } else if (candyIds.length || xlCandyIds.length || megaEnergy.length) { - // Only item tappables exist without the reward columns. + }) + } else if (hasRewardFilters) { + // Only item and pokemon tappables exist without the reward columns. return [] } query.orderBy('updated', 'desc') - const limit = queryLimits.tappables || queryLimits.pokestops || 5000 const results = await query.limit(limit) return results.map((row) => ({ ...row, expire_timestamp_verified: !!row.expire_timestamp_verified, + form: row.form ?? null, + costume: Number(row.costume) || null, ...(hasTappableRewards && { temp_evolution: Number(row.temp_evolution) || null, }), @@ -188,13 +404,36 @@ class Tappable extends Model { * Retrieve tappable metadata for a specific tappable id. * @param {import('@rm/types').Permissions} perms * @param {string | number} tappableId + * @param {import('@rm/types').DbContext} ctx * @returns {Promise} */ - static async getById(perms, tappableId) { + static async getById(perms, tappableId, { mem, secret, httpAuth } = {}) { if (!perms?.tappables || !tappableId) { return [] } + if (supportsTappableScan(mem)) { + if (areaRestrictionsDenyAll(perms.areaRestrictions, [])) return [] + try { + const one = await fetchFortById( + TAGS.tappables, + `${mem}/api/tappable/id/${encodeURIComponent(tappableId)}`, + secret, + httpAuth, + ) + if (!one) return [] + const tappable = mapScanTappable(one) + return filterRTree(tappable, perms.areaRestrictions, []) + ? [tappable] + : [] + } catch (e) { + log.warn( + TAGS.tappables, + `[TAPPABLE] /api/tappable/id error — falling back to SQL for this source: ${e}`, + ) + } + } + const query = this.query().select([ 'id', 'lat', @@ -227,68 +466,119 @@ class Tappable extends Model { * @param {import('@rm/types').DbContext} ctx * @returns {Promise<{ available: string[] }>} */ - static async getAvailable({ hasTappableRewards }) { + static async getAvailable({ hasTappableRewards, mem, secret, httpAuth }) { + if (supportsTappableScan(mem)) { + try { + const available = await this.getAvailableFromEndpoint( + mem, + secret, + httpAuth, + ) + if (available) return { available } + log.warn( + TAGS.tappables, + '[TAPPABLE] /api/tappable/available malformed — falling back to SQL for this source', + ) + } catch (e) { + log.warn( + TAGS.tappables, + `[TAPPABLE] /api/tappable/available error — falling back to SQL for this source: ${e}`, + ) + } + } + + const columns = ['item_id', 'pokemon_id'] if (hasTappableRewards) { - const rewards = await this.query() - .distinct([ - 'reward_type', - 'item_id', - 'reward_pokemon_id', - raw( - `CASE WHEN reward_type IN (${MEGA_ENERGY_REWARD_TYPES.map( - () => '?', - ).join(', ')}) THEN \`count\` END`, - MEGA_ENERGY_REWARD_TYPES, - ).as('amount'), - raw(TEMP_EVOLUTION_SQL).as('temp_evolution'), - ]) - .whereNull('pokemon_id') - .whereIn('reward_type', SUPPORTED_REWARD_TYPES) - - const available = new Set() - rewards.forEach((reward) => { - if (reward.reward_type === 'item') { - if (reward.item_id) available.add(`q${reward.item_id}`) - } else if (reward.reward_pokemon_id) { - switch (reward.reward_type) { - case 'pokemon_candy': - available.add(`c${reward.reward_pokemon_id}`) - break - case 'xl_candy': - available.add(`x${reward.reward_pokemon_id}`) - break - default: - available.add( - getMegaEnergyFilterKey( - reward.reward_pokemon_id, - reward.amount, - reward.temp_evolution, - ), - ) - } + columns.push( + 'reward_type', + 'reward_pokemon_id', + raw( + `CASE WHEN reward_type IN (${MEGA_ENERGY_REWARD_TYPES.map( + () => '?', + ).join(', ')}) THEN \`count\` END`, + MEGA_ENERGY_REWARD_TYPES, + ).as('count'), + raw(TEMP_EVOLUTION_SQL).as('temp_evolution'), + ) + } + const rewards = await this.query() + .distinct(columns) + .whereNotNull('pokemon_id') + .orWhere((reward) => { + reward.whereNull('pokemon_id') + if (hasTappableRewards) { + reward.whereIn('reward_type', SUPPORTED_REWARD_TYPES) + } else { + reward.whereNotNull('item_id') } }) - return { available: [...available] } - } + const available = new Set(rewards.map(getTappableFilterKey)) + available.delete(null) + return { available: [...available] } + } - const rows = await this.query() - .select('item_id') - .count('id as total') - .whereNull('pokemon_id') - .whereNotNull('item_id') - .groupBy('item_id') - - const available = Array.from( - new Set( - rows - .map((row) => row.item_id) - .filter((itemId) => itemId !== null) - .map((itemId) => `q${itemId}`), - ), + /** + * Filter keys from Golbat's /api/tappable/available, null when malformed. + * Its reward options carry no amount, so mega energy amounts and temp + * evolutions are read from a scan of just the mega energy tappables. + * @param {string} mem + * @param {string} [secret] + * @param {{ username: string, password: string } | null} [httpAuth] + * @returns {Promise} + */ + static async getAvailableFromEndpoint(mem, secret, httpAuth) { + const res = await evalScannerQuery( + TAGS.tappables, + `${mem}/api/tappable/available`, + undefined, + 'GET', + secret, + httpAuth, ) - - return { available } + if (!res || !Array.isArray(res.rewards) || !Array.isArray(res.encounters)) { + return null + } + const available = new Set() + res.encounters.forEach((encounter) => { + available.add(getTappableFilterKey(encounter)) + }) + const megaPokemonIds = new Set() + res.rewards.forEach((reward) => { + if (MEGA_ENERGY_REWARD_TYPES.includes(reward.reward_type)) { + if (reward.reward_pokemon_id) { + megaPokemonIds.add(reward.reward_pokemon_id) + } + } else { + available.add(getTappableFilterKey(reward)) + } + }) + if (megaPokemonIds.size) { + const scan = await evalScannerQuery( + TAGS.tappables, + `${mem}/api/tappable/scan`, + JSON.stringify({ + min: { lat: -90, lon: -180 }, + max: { lat: 90, lon: 180 }, + limit: 0, + filters: [ + { + reward_type: MEGA_ENERGY_REWARD_TYPES, + reward_pokemon_id: [...megaPokemonIds], + }, + ], + }), + 'POST', + secret, + httpAuth, + ) + if (!scan || !Array.isArray(scan.tappables)) return null + scan.tappables.forEach((tappable) => { + available.add(getTappableFilterKey(mapScanTappable(tappable))) + }) + } + available.delete(null) + return [...available] } } diff --git a/server/test/clientFilterSelection.test.js b/server/test/clientFilterSelection.test.js index 344f110e1..686262e7c 100644 --- a/server/test/clientFilterSelection.test.js +++ b/server/test/clientFilterSelection.test.js @@ -269,6 +269,7 @@ const drawerCases = [ ['tappables', undefined, 'q1', 'c25'], ['tappables', undefined, 'q1', 'x25'], ['tappables', undefined, 'q1', 'm6-20-2'], + ['tappables', undefined, 'q1', '94'], ['gyms', undefined, 't1-0', 't2-0'], ['gyms', 'raids', 'e1', 'e2'], ['gyms', 'pokemon', '19-0', '25-0'], diff --git a/src/features/tappable/TappablePopup.jsx b/src/features/tappable/TappablePopup.jsx index 07b4df1d1..d230c2cfa 100644 --- a/src/features/tappable/TappablePopup.jsx +++ b/src/features/tappable/TappablePopup.jsx @@ -20,6 +20,7 @@ import { StatusIcon } from '@components/StatusIcon' import { Title } from '@components/popups/Title' import { getTimeUntil } from '@utils/getTimeUntil' +import { getFormDisplay } from '@utils/getFormDisplay' import { getTappableDisplaySettings } from './displayRules' import { getTappableReward } from './getTappableReward' @@ -47,6 +48,16 @@ export function TappablePopup({ tappable, rewardIcon }) { const count = tappable.count ?? 1 const rewardName = React.useMemo(() => { switch (reward?.rewardType) { + case 7: { + const formLabel = getFormDisplay( + reward.id, + tappable.form, + tappable.costume, + ) + return formLabel + ? `${t(`poke_${reward.id}`)} (${formLabel})` + : t(`poke_${reward.id}`) + } case 4: case 9: return `${t(`poke_${reward.id}`)} ${t( @@ -69,6 +80,8 @@ export function TappablePopup({ tappable, rewardIcon }) { i18n, masterfile.items, tappable.item_id, + tappable.form, + tappable.costume, tappable.temp_evolution, reward?.rewardType, reward?.id, diff --git a/src/features/tappable/TappableTile.jsx b/src/features/tappable/TappableTile.jsx index f874348f8..8fe7542bc 100644 --- a/src/features/tappable/TappableTile.jsx +++ b/src/features/tappable/TappableTile.jsx @@ -96,12 +96,15 @@ const BaseTappableTile = (tappable) => { tappableSize / 2 + (tappableMod?.popupY || 0), ] - const tappableReward = Icons.getRewards( - reward.rewardType, - reward.id, - tappable.count || 1, - tappable.temp_evolution, - ) + const tappableReward = + reward.rewardType === 7 + ? Icons.getPokemon(reward.id, tappable.form, 0, 0, tappable.costume) + : Icons.getRewards( + reward.rewardType, + reward.id, + tappable.count || 1, + tappable.temp_evolution, + ) const count = tappable.count || 1 if (useRewardPrimary && tappableReward) { const countBadge = @@ -238,6 +241,9 @@ const BaseTappableTile = (tappable) => { itemFilters, tappable.type, tappable.item_id, + tappable.pokemon_id, + tappable.form, + tappable.costume, tappable.reward_type, tappable.reward_pokemon_id, tappable.temp_evolution, diff --git a/src/features/tappable/getTappableReward.js b/src/features/tappable/getTappableReward.js index 1aa741b61..b4eeebf6f 100644 --- a/src/features/tappable/getTappableReward.js +++ b/src/features/tappable/getTappableReward.js @@ -4,9 +4,16 @@ * Describes a tappable's reward by the quest reward type whose icons and * names it shares, along with its filter key. Null for rewards not shown. * @param {import('@rm/types').Tappable} tappable - * @returns {{ rewardType: 2 | 4 | 9 | 12, id: number, filterKey: string } | null} + * @returns {{ rewardType: 2 | 4 | 7 | 9 | 12, id: number, filterKey: string } | null} */ export function getTappableReward(tappable) { + if (tappable.pokemon_id) { + return { + rewardType: 7, + id: tappable.pokemon_id, + filterKey: `${tappable.pokemon_id}`, + } + } if (tappable.item_id) { return { rewardType: 2, diff --git a/src/pages/map/hooks/useGenTappables.js b/src/pages/map/hooks/useGenTappables.js index 8e3bae4ac..4fc6bbc94 100644 --- a/src/pages/map/hooks/useGenTappables.js +++ b/src/pages/map/hooks/useGenTappables.js @@ -38,6 +38,10 @@ export function useGenTappables() { break } default: { + if (Number.isInteger(Number(id))) { + name = t(`poke_${id}`) + break + } const itemId = id.startsWith('q') ? id.slice(1) : id name = t(`item_${itemId}`, `#${itemId}`) } diff --git a/src/services/queries/tappable.js b/src/services/queries/tappable.js index 021d33204..89c6efea7 100644 --- a/src/services/queries/tappable.js +++ b/src/services/queries/tappable.js @@ -22,6 +22,9 @@ export const GET_ALL_TAPPABLES = gql` type fort_id item_id + pokemon_id + form + costume reward_type reward_pokemon_id temp_evolution diff --git a/src/utils/filterSelection.js b/src/utils/filterSelection.js index fd6638e7a..995289a55 100644 --- a/src/utils/filterSelection.js +++ b/src/utils/filterSelection.js @@ -40,7 +40,8 @@ export function matchesFilterSelection(category, key, scope = 'default') { return key.startsWith('t') case 'tappables': return ( - ['q', 'c', 'x', 'm'].some((prefix) => key.startsWith(prefix)) && + (['q', 'c', 'x', 'm'].some((prefix) => key.startsWith(prefix)) || + Number.isInteger(Number(key.charAt(0)))) && key !== 'q0' ) default: