From c90e1e0021350baf9aa82957de1328502f62dbe1 Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 23 Sep 2025 16:57:50 -0700 Subject: [PATCH 1/8] feat: estimated shiny probability for pokemon --- config/local.example.json | 3 +- packages/locales/lib/human/en.json | 4 +- packages/types/lib/scanner.d.ts | 7 + packages/types/lib/server.d.ts | 13 +- server/src/graphql/typeDefs/scanner.graphql | 7 + server/src/models/Pokemon.js | 175 +++++++++++++++++++- server/src/services/DbManager.js | 50 ++++++ src/features/pokemon/PokemonPopup.jsx | 67 +++++++- src/features/pokestop/PokestopPopup.jsx | 23 +-- src/services/queries/pokemon.js | 5 + src/utils/readableProbability.js | 20 +++ 11 files changed, 339 insertions(+), 35 deletions(-) create mode 100644 src/utils/readableProbability.js diff --git a/config/local.example.json b/config/local.example.json index 385b3bdf5..5ae0270d6 100644 --- a/config/local.example.json +++ b/config/local.example.json @@ -38,7 +38,8 @@ "weather", "route", "nest", - "station" + "station", + "pokemonStats" ] }, { diff --git a/packages/locales/lib/human/en.json b/packages/locales/lib/human/en.json index 91fea7797..0cccf5364 100644 --- a/packages/locales/lib/human/en.json +++ b/packages/locales/lib/human/en.json @@ -377,7 +377,9 @@ "with_ar": "With AR", "both": "Both", "without_ar": "Without AR", - "shiny_probability": "Shiny probability: <0/>", + "shiny_probability": "Shiny rate: <0/>", + "shiny_sample": "{{percentage}}%: {{checks}} checks, {{shiny}} shiny", + "shiny_no_data": "Not enough shiny data yet", "exclude_quest_multi": "Exclude {{reward}}", "cluster_limit_0": "{{variable_0}} limit ({{variable_1}}) has been hit", "cluster_limit_1": "Please zoom in or narrow your filters", diff --git a/packages/types/lib/scanner.d.ts b/packages/types/lib/scanner.d.ts index e6db2b943..6359ff744 100644 --- a/packages/types/lib/scanner.d.ts +++ b/packages/types/lib/scanner.d.ts @@ -41,6 +41,12 @@ export interface PokemonDisplay { location_card: number } +export interface PokemonShinyStats { + shiny_seen: number + encounters_seen: number + shiny_rate: number +} + export interface Defender extends PokemonDisplay { pokemon_id: number deployed_ms: number @@ -252,6 +258,7 @@ export interface Pokemon { pvp_rankings_ultra_league?: import('ohbem').PvPRankEntry[] distance?: number shiny?: boolean + shiny_stats?: PokemonShinyStats } export type FullPokemon = FullModel diff --git a/packages/types/lib/server.d.ts b/packages/types/lib/server.d.ts index 76659e940..8cef52ff4 100644 --- a/packages/types/lib/server.d.ts +++ b/packages/types/lib/server.d.ts @@ -46,6 +46,7 @@ export interface DbContext { hasShowcaseForm: boolean hasShowcaseType: boolean hasStationedGmax: boolean + statsKnex?: import('knex').Knex } export interface ExpressUser extends User { @@ -70,11 +71,17 @@ export interface Available { stations: ModelReturn } +export type UseForValue = + | Lowercase + | 'pokemonStats' + | 'pokemonstats' + | 'pokemon_stats' + export interface ApiEndpoint { type: string endpoint: string secret: string - useFor: Lowercase[] + useFor: UseForValue[] } export interface DbConnection { @@ -83,7 +90,7 @@ export interface DbConnection { username: string password: string database: string - useFor: Lowercase[] + useFor: UseForValue[] } export type Schema = ApiEndpoint | DbConnection @@ -109,6 +116,8 @@ export interface DbManagerClass { Route: { maxDistance: number; maxDuration: number } Pokestop: { hasConfirmedInvasions: boolean } } + pokemonStatsConnections: Set + getPokemonStatsKnex(connectionIndex: number): import('knex').Knex | null } export interface RarityPercents { diff --git a/server/src/graphql/typeDefs/scanner.graphql b/server/src/graphql/typeDefs/scanner.graphql index 0d324f920..bc7704c32 100644 --- a/server/src/graphql/typeDefs/scanner.graphql +++ b/server/src/graphql/typeDefs/scanner.graphql @@ -148,6 +148,12 @@ type Pokestop { hasShowcase: Boolean } +type PokemonShinyStats { + shiny_seen: Int + encounters_seen: Int + shiny_rate: Float +} + type Pokemon { id: ID encounter_id: Int @@ -183,6 +189,7 @@ type Pokemon { first_seen_timestamp: Int expire_timestamp_verified: Boolean updated: Int + shiny_stats: PokemonShinyStats } type Portal { diff --git a/server/src/models/Pokemon.js b/server/src/models/Pokemon.js index e04a12388..f01918303 100644 --- a/server/src/models/Pokemon.js +++ b/server/src/models/Pokemon.js @@ -124,7 +124,16 @@ class Pokemon extends Model { static async getAll(perms, args, ctx) { const { iv: ivs, pvp, areaRestrictions } = perms const { onlyIvOr, onlyHundoIv, onlyZeroIv, onlyAreas = [] } = args.filters - const { hasSize, hasHeight, isMad, mem, secret, httpAuth, pvpV2 } = ctx + const { + hasSize, + hasHeight, + isMad, + mem, + secret, + httpAuth, + pvpV2, + statsKnex, + } = ctx const { filterMap, globalFilter } = this.getFilters(perms, args, ctx) let queryPvp = config @@ -380,6 +389,29 @@ class Pokemon extends Model { finalResults.push(result) } } + + if (finalResults.length && (statsKnex || !mem)) { + const shinyKeys = [ + ...new Set( + finalResults.map( + (result) => `${result.pokemon_id}-${result.form ?? 0}`, + ), + ), + ] + try { + const shinyStats = await this.fetchShinyStats(shinyKeys, statsKnex) + finalResults.forEach((result) => { + const key = `${result.pokemon_id}-${result.form ?? 0}` + const stats = shinyStats.get(key) + if (stats) { + result.shiny_stats = stats + } + }) + } catch (e) { + log.error(TAGS.pokemon, 'Failed to fetch shiny stats', e) + } + } + return finalResults } @@ -436,6 +468,110 @@ class Pokemon extends Model { return results || [] } + /** + * @param {string[]} keys + * @param {import('knex').Knex | null | undefined} [statsKnex] + * @returns {Promise>} + */ + static async fetchShinyStats(keys, statsKnex = null) { + if (!keys.length) return new Map() + + let knexInstance = statsKnex || null + if (!knexInstance) { + try { + knexInstance = this.knex() + } catch (e) { + knexInstance = null + } + } + + if (!knexInstance) return new Map() + + const pairs = keys + .map((key) => key.split('-')) + .map(([pokemonId, formId]) => { + const parsedPokemon = Number.parseInt(pokemonId, 10) + if (Number.isNaN(parsedPokemon)) return null + const parsedForm = Number.parseInt(formId, 10) + return [parsedPokemon, Number.isNaN(parsedForm) ? 0 : parsedForm] + }) + .filter(Boolean) + + if (!pairs.length) return new Map() + + const whereClause = pairs + .map(() => '(pokemon_id = ? AND COALESCE(form_id, 0) = ?)') + .join(' OR ') + const bindings = pairs.flatMap(([pokemonId, formId]) => [pokemonId, formId]) + const query = ` + SELECT + pokemon_id, + COALESCE(form_id, 0) AS form_id, + date, + SUM(count) AS shiny, + SUM(total) AS checks + FROM pokemon_shiny_stats + WHERE area = 'world' + AND fence = 'world' + AND (${whereClause}) + AND date >= CURRENT_DATE - INTERVAL 7 DAY + GROUP BY pokemon_id, form_id, date + ORDER BY pokemon_id, form_id, date DESC + ` + + const [rows] = await knexInstance.raw(query, bindings) + + const grouped = new Map() + for (let i = 0; i < rows.length; i += 1) { + const row = rows[i] + const key = `${row.pokemon_id}-${row.form_id ?? 0}` + const entry = grouped.get(key) + const rowDate = + row.date instanceof Date + ? row.date.toISOString().slice(0, 10) + : `${row.date}` + const payload = { + shiny: Number(row.shiny) || 0, + checks: Number(row.checks) || 0, + date: rowDate, + } + if (entry) { + entry.push(payload) + } else { + grouped.set(key, [payload]) + } + } + + const statsMap = new Map() + const today = new Date() + today.setHours(0, 0, 0, 0) + const cutoff = new Date(today) + cutoff.setDate(cutoff.getDate() - 1) + const cutoffStr = cutoff.toISOString().slice(0, 10) + + grouped.forEach((entries, key) => { + let shinySum = 0 + let checkSum = 0 + for (let i = 0; i < entries.length; i += 1) { + const { shiny, checks, date } = entries[i] + const includeRecent = date >= cutoffStr + // 20000 checks would give >99% of distinguishing even 1/512 from 1/256 + if (!includeRecent && checkSum >= 20000) { + break + } + shinySum += shiny + checkSum += checks + } + statsMap.set(key, { + shiny_seen: shinySum, + encounters_seen: checkSum, + shiny_rate: checkSum ? shinySum / checkSum : 0, + }) + }) + + return statsMap + } + /** * @param {import("@rm/types").Permissions} perms * @param {object} args @@ -443,7 +579,7 @@ class Pokemon extends Model { * @returns {Promise[]>} */ static async getLegacy(perms, args, ctx) { - const { isMad, hasSize, hasHeight, mem, secret, httpAuth } = ctx + const { isMad, hasSize, hasHeight, mem, secret, httpAuth, statsKnex } = ctx const ts = Math.floor(Date.now() / 1000) const { filterMap, globalFilter } = this.getFilters(perms, args, ctx) const queryLimits = config.getSafe('api.queryLimits') @@ -512,12 +648,13 @@ class Pokemon extends Model { secret, httpAuth, ) - return results - .filter( - (item) => - !mem || - filterRTree(item, perms.areaRestrictions, args.filters.onlyAreas), - ) + const filtered = results.filter( + (item) => + !mem || + filterRTree(item, perms.areaRestrictions, args.filters.onlyAreas), + ) + + const built = filtered .map((item) => { const filter = filterMap[ @@ -536,6 +673,28 @@ class Pokemon extends Model { ] || globalFilter return filter.valid(pkmn) }) + + if (built.length && (statsKnex || !mem)) { + const shinyKeys = [ + ...new Set( + built.map((result) => `${result.pokemon_id}-${result.form ?? 0}`), + ), + ] + try { + const shinyStats = await this.fetchShinyStats(shinyKeys, statsKnex) + built.forEach((result) => { + const key = `${result.pokemon_id}-${result.form ?? 0}` + const stats = shinyStats.get(key) + if (stats) { + result.shiny_stats = stats + } + }) + } catch (e) { + log.error(TAGS.pokemon, 'Failed to fetch shiny stats', e) + } + } + + return built } /** diff --git a/server/src/services/DbManager.js b/server/src/services/DbManager.js index ff7a3ec61..f197bd616 100644 --- a/server/src/services/DbManager.js +++ b/server/src/services/DbManager.js @@ -48,11 +48,17 @@ class DbManager extends Logger { Pokestop: { hasConfirmedInvasions: false }, }) this.reactMapDb = null + this.pokemonStatsConnections = new Set() this.connections = config .getSafe('database.schemas') .filter((s) => s.useFor.length) .map((schema, i) => { schema.useFor.forEach((category) => { + const normalized = category.toLowerCase().replace(/_/g, '') + if (normalized === 'pokemonstats') { + this.pokemonStatsConnections.add(i) + return + } const capital = /** @type {import('../models').ModelKeys} */ ( `${category.charAt(0).toUpperCase()}${category.slice(1)}` ) @@ -245,6 +251,45 @@ class DbManager extends Logger { ) } + /** + * @param {number} connectionIndex + * @returns {import('knex').Knex | null} + */ + getPokemonStatsKnex(connectionIndex) { + if ( + typeof connectionIndex === 'number' && + this.pokemonStatsConnections.has(connectionIndex) && + this.connections[connectionIndex] + ) { + return this.connections[connectionIndex] + } + + const availableStatsIndex = [...this.pokemonStatsConnections].find( + (index) => this.connections[index], + ) + if (availableStatsIndex !== undefined) { + return this.connections[availableStatsIndex] + } + + if ( + typeof connectionIndex === 'number' && + this.connections[connectionIndex] + ) { + return this.connections[connectionIndex] + } + + const spawnpointIndex = Array.isArray(this.models?.Spawnpoint) + ? this.models.Spawnpoint.map((source) => source.connection).find( + (idx) => this.connections[idx], + ) + : undefined + if (spawnpointIndex !== undefined) { + return this.connections[spawnpointIndex] + } + + return null + } + /** * @param {{ [key: string]: number }[]} results * @param {boolean} historical @@ -343,6 +388,11 @@ class DbManager extends Logger { } else { this.models[model][i].SubModel = models[model] } + if (model === 'Pokemon') { + this.models[model][i].statsKnex = this.getPokemonStatsKnex( + source.connection, + ) + } }) } else { this.log.warn(modelName, 'something unexpected happened') diff --git a/src/features/pokemon/PokemonPopup.jsx b/src/features/pokemon/PokemonPopup.jsx index 5593130c1..092a22f76 100644 --- a/src/features/pokemon/PokemonPopup.jsx +++ b/src/features/pokemon/PokemonPopup.jsx @@ -11,7 +11,7 @@ import Divider from '@mui/material/Divider' import Avatar from '@mui/material/Avatar' import Tooltip from '@mui/material/Tooltip' import Collapse from '@mui/material/Collapse' -import { useTranslation } from 'react-i18next' +import { useTranslation, Trans } from 'react-i18next' import { useMemory } from '@store/useMemory' import { setDeepStore, useStorage } from '@store/useStorage' @@ -26,6 +26,7 @@ import { ExtraInfo } from '@components/popups/ExtraInfo' import { useAnalytics } from '@hooks/useAnalytics' import { getTimeUntil } from '@utils/getTimeUntil' import { StatusIcon } from '@components/StatusIcon' +import { readableProbability } from '@utils/readableProbability' const rowClass = { width: 30, fontWeight: 'bold' } @@ -109,6 +110,7 @@ export function PokemonPopup({ pokemon, iconUrl, isTutorial = false }) { {!!pokemon.expire_timestamp && ( )} + {hasStats && pokePerms.iv && ( <> @@ -305,6 +307,69 @@ const Stats = ({ pokemon, t }) => { ) } +const ShinyOdds = ({ shinyStats, t }) => { + if (!shinyStats) { + return ( + + {t('shiny_no_data')} + + ) + } + + const { + shiny_rate: shinyRate, + encounters_seen: encounters, + shiny_seen: shinySeen, + } = shinyStats + + const encountersNumber = Number(encounters) || 0 + const shinyNumber = Number(shinySeen) || 0 + + if (!encountersNumber) { + return ( + + {t('shiny_no_data')} + + ) + } + + const rateNode = readableProbability(shinyRate) + + const sampleText = t('shiny_sample', { + percentage: (shinyRate * 100).toLocaleString(), + checks: encountersNumber.toLocaleString(), + shiny: shinyNumber.toLocaleString(), + }) + + return ( + + + + + ~{rateNode} + + , + ]} + /> + + + ) +} + const Info = ({ pokemon, metaData, perms, Icons, timeOfDay, t }) => { const { gender, size, weather, form } = pokemon const formTypes = metaData?.forms?.[form]?.types || metaData?.types || [] diff --git a/src/features/pokestop/PokestopPopup.jsx b/src/features/pokestop/PokestopPopup.jsx index 1cf478409..05accac0f 100644 --- a/src/features/pokestop/PokestopPopup.jsx +++ b/src/features/pokestop/PokestopPopup.jsx @@ -36,6 +36,7 @@ import { useAnalytics } from '@hooks/useAnalytics' import { useGetAvailable } from '@hooks/useGetAvailable' import { parseQuestConditions } from '@utils/parseConditions' import { Img } from '@components/Img' +import { readableProbability } from '@utils/readableProbability' /** * @@ -571,28 +572,6 @@ const RewardInfo = ({ with_ar, ...quest }) => { ) } -/** - * Converts a numeric probability into a more human-readable format. - * It decides whether to display the probability as a percentage (e.g., "21%") - * or as a fraction (e.g., "1/4") based on which representation is more accurate - * after rounding. For fractions, it returns a React Fragment to prevent - * issues with HTML entity escaping of the forward slash. - * - * @param {number} x The raw probability value (e.g., 0.25). - * @returns {React.ReactNode} A string for percentages, a React Fragment - * for fractions, or a '🚫' emoji if the probability is zero or less. - */ -const readableProbability = (x) => { - if (x <= 0) return '🚫' - const x_1 = Math.round(1 / x) - const percent = Math.round(x * 100) - return Math.abs(1 / x_1 - x) < Math.abs(percent * 0.01 - x) ? ( - <>1/{x_1} - ) : ( - `${percent}%` - ) -} - /** * * @param {Omit} props diff --git a/src/services/queries/pokemon.js b/src/services/queries/pokemon.js index 095101708..9da43b2ec 100644 --- a/src/services/queries/pokemon.js +++ b/src/services/queries/pokemon.js @@ -18,6 +18,11 @@ const core = gql` display_pokemon_id ditto_form seen_type + shiny_stats { + shiny_seen + encounters_seen + shiny_rate + } } ` diff --git a/src/utils/readableProbability.js b/src/utils/readableProbability.js new file mode 100644 index 000000000..fe0a22840 --- /dev/null +++ b/src/utils/readableProbability.js @@ -0,0 +1,20 @@ +// @ts-check +import * as React from 'react' + +/** + * Converts a numeric probability into a more human-readable format by + * choosing between a rounded percentage or a simplified fractional odds + * representation. + * + * @param {number} x The raw probability value (e.g., 0.25). + * @returns {React.ReactNode} Either a percentage string, an odds fragment, + * or a '🚫' emoji when the probability is zero or negative. + */ +export const readableProbability = (x) => { + if (x <= 0) return '🚫' + const roundedOdds = Math.round(1 / x) + const percent = Math.round(x * 100) + return Math.abs(1 / roundedOdds - x) < Math.abs(percent * 0.01 - x) + ? React.createElement(React.Fragment, null, '1/', roundedOdds) + : `${percent}%` +} From 740966a25ab87d3a4ecadf00c09c45d5c4096ad0 Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 23 Sep 2025 19:33:29 -0700 Subject: [PATCH 2/8] feat: shiny since date --- packages/locales/lib/human/en.json | 3 +-- packages/types/lib/scanner.d.ts | 1 + server/src/graphql/typeDefs/scanner.graphql | 1 + server/src/models/Pokemon.js | 5 +++++ src/features/pokemon/PokemonPopup.jsx | 19 ++++++++----------- src/services/queries/pokemon.js | 1 + 6 files changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/locales/lib/human/en.json b/packages/locales/lib/human/en.json index 0cccf5364..c2e936cda 100644 --- a/packages/locales/lib/human/en.json +++ b/packages/locales/lib/human/en.json @@ -378,8 +378,7 @@ "both": "Both", "without_ar": "Without AR", "shiny_probability": "Shiny rate: <0/>", - "shiny_sample": "{{percentage}}%: {{checks}} checks, {{shiny}} shiny", - "shiny_no_data": "Not enough shiny data yet", + "shiny_sample": "{{percentage}}%: {{shiny}} shiny/{{checks}} checks since {{date}}", "exclude_quest_multi": "Exclude {{reward}}", "cluster_limit_0": "{{variable_0}} limit ({{variable_1}}) has been hit", "cluster_limit_1": "Please zoom in or narrow your filters", diff --git a/packages/types/lib/scanner.d.ts b/packages/types/lib/scanner.d.ts index 6359ff744..391e80704 100644 --- a/packages/types/lib/scanner.d.ts +++ b/packages/types/lib/scanner.d.ts @@ -45,6 +45,7 @@ export interface PokemonShinyStats { shiny_seen: number encounters_seen: number shiny_rate: number + since_date?: string } export interface Defender extends PokemonDisplay { diff --git a/server/src/graphql/typeDefs/scanner.graphql b/server/src/graphql/typeDefs/scanner.graphql index bc7704c32..cc2223514 100644 --- a/server/src/graphql/typeDefs/scanner.graphql +++ b/server/src/graphql/typeDefs/scanner.graphql @@ -152,6 +152,7 @@ type PokemonShinyStats { shiny_seen: Int encounters_seen: Int shiny_rate: Float + since_date: String } type Pokemon { diff --git a/server/src/models/Pokemon.js b/server/src/models/Pokemon.js index f01918303..eb17651bf 100644 --- a/server/src/models/Pokemon.js +++ b/server/src/models/Pokemon.js @@ -552,6 +552,7 @@ class Pokemon extends Model { grouped.forEach((entries, key) => { let shinySum = 0 let checkSum = 0 + let sinceDate = null for (let i = 0; i < entries.length; i += 1) { const { shiny, checks, date } = entries[i] const includeRecent = date >= cutoffStr @@ -561,11 +562,15 @@ class Pokemon extends Model { } shinySum += shiny checkSum += checks + if (!sinceDate || date < sinceDate) { + sinceDate = date + } } statsMap.set(key, { shiny_seen: shinySum, encounters_seen: checkSum, shiny_rate: checkSum ? shinySum / checkSum : 0, + since_date: sinceDate, }) }) diff --git a/src/features/pokemon/PokemonPopup.jsx b/src/features/pokemon/PokemonPopup.jsx index 092a22f76..e0f7093cf 100644 --- a/src/features/pokemon/PokemonPopup.jsx +++ b/src/features/pokemon/PokemonPopup.jsx @@ -110,7 +110,6 @@ export function PokemonPopup({ pokemon, iconUrl, isTutorial = false }) { {!!pokemon.expire_timestamp && ( )} - {hasStats && pokePerms.iv && ( <> @@ -125,6 +124,7 @@ export function PokemonPopup({ pokemon, iconUrl, isTutorial = false }) { timeOfDay={timeOfDay} t={t} /> +