Skip to content

Commit d44a3df

Browse files
authored
feat: estimated shiny probability for pokemon (#1133)
* feat: estimated shiny probability for pokemon * feat: shiny since date * feat: lazy load shiny rates NB The database holding the spawnpoint table will be used to query pokemon_shiny_stats.
1 parent f457d51 commit d44a3df

16 files changed

Lines changed: 485 additions & 32 deletions

File tree

packages/locales/lib/human/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,8 @@
377377
"with_ar": "With AR",
378378
"both": "Both",
379379
"without_ar": "Without AR",
380-
"shiny_probability": "Shiny probability: <0/>",
380+
"shiny_probability": "Shiny rate: <0/>",
381+
"shiny_sample": "{{percentage}}%: {{shiny}} shiny/{{checks}} checks since {{date}}",
381382
"exclude_quest_multi": "Exclude {{reward}}",
382383
"cluster_limit_0": "{{variable_0}} limit ({{variable_1}}) has been hit",
383384
"cluster_limit_1": "Please zoom in or narrow your filters",

packages/types/lib/scanner.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ export interface PokemonDisplay {
4141
location_card: number
4242
}
4343

44+
export interface PokemonShinyStats {
45+
shiny_seen: number
46+
encounters_seen: number
47+
since_date?: string
48+
}
49+
4450
export interface Defender extends PokemonDisplay {
4551
pokemon_id: number
4652
deployed_ms: number

packages/types/lib/server.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ export interface DbContext {
4646
hasShowcaseForm: boolean
4747
hasShowcaseType: boolean
4848
hasStationedGmax: boolean
49+
hasPokemonShinyStats?: boolean
50+
connection?: number
4951
}
5052

5153
export interface ExpressUser extends User {

server/src/graphql/resolvers.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ const resolvers = {
2424
JSON: GraphQLJSON,
2525
Query: {
2626
available: (_, _args, { Event, Db, perms }) => {
27+
const supportsShinyStats = Array.isArray(Db.models?.Pokemon)
28+
? Db.models.Pokemon.some(({ SubModel, ...ctx }) =>
29+
typeof SubModel.supportsShinyStats === 'function'
30+
? SubModel.supportsShinyStats(ctx)
31+
: false,
32+
)
33+
: false
34+
2735
const data = {
2836
questConditions: perms.quests ? Db.questConditions : {},
2937
masterfile: { ...Event.masterfile, invasions: Event.invasions },
@@ -36,6 +44,7 @@ const resolvers = {
3644
...config.getSafe('icons'),
3745
styles: Event.uicons,
3846
},
47+
supportsShinyStats,
3948
}
4049
return data
4150
},
@@ -269,6 +278,23 @@ const resolvers = {
269278
}
270279
return {}
271280
},
281+
pokemonShinyStats: async (_, args, { perms, Db }) => {
282+
if (!perms?.pokemon) {
283+
return null
284+
}
285+
const sources = Db.models?.Pokemon
286+
if (!Array.isArray(sources)) {
287+
return null
288+
}
289+
const results = await Promise.all(
290+
sources.map(({ SubModel, ...ctx }) =>
291+
typeof SubModel.getShinyStats === 'function'
292+
? SubModel.getShinyStats(perms, args, ctx)
293+
: Promise.resolve(null),
294+
),
295+
)
296+
return results.find(Boolean) || null
297+
},
272298
portals: (_, args, { perms, Db }) => {
273299
if (perms?.portals) {
274300
return Db.query('Portal', 'getAll', perms, args)

server/src/graphql/typeDefs/index.graphql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ type Query {
5050
filters: JSON
5151
): [Pokemon]
5252
pokemonSingle(id: ID, perm: String): Pokemon
53+
pokemonShinyStats(pokemon_id: Int!, form: Int): PokemonShinyStats
5354
portals(
5455
minLat: Float
5556
maxLat: Float

server/src/graphql/typeDefs/map.graphql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ type MapData {
44
questConditions: JSON
55
icons: JSON
66
audio: JSON
7+
supportsShinyStats: Boolean
78
}
89

910
type Badge {

server/src/graphql/typeDefs/scanner.graphql

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,12 @@ type Pokestop {
148148
hasShowcase: Boolean
149149
}
150150

151+
type PokemonShinyStats {
152+
shiny_seen: Int
153+
encounters_seen: Int
154+
since_date: String
155+
}
156+
151157
type Pokemon {
152158
id: ID
153159
encounter_id: Int

server/src/models/Pokemon.js

Lines changed: 258 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,7 @@ class Pokemon extends Model {
380380
finalResults.push(result)
381381
}
382382
}
383+
383384
return finalResults
384385
}
385386

@@ -436,6 +437,210 @@ class Pokemon extends Model {
436437
return results || []
437438
}
438439

440+
/**
441+
* @param {number | null | undefined} preferredConnection
442+
* @returns {import('knex').Knex | null}
443+
*/
444+
static getStatsKnex(preferredConnection = null) {
445+
return this.getStatsHandle(preferredConnection)?.knex ?? null
446+
}
447+
448+
/**
449+
* @param {number | null | undefined} preferredConnection
450+
* @returns {{
451+
* knex: import('knex').Knex,
452+
* connection: number,
453+
* spawnSource?: import('@rm/types').DbContext & { connection: number },
454+
* } | null}
455+
*/
456+
static getStatsHandle(preferredConnection = null) {
457+
const dbManager = state.db
458+
if (!dbManager) return null
459+
const { connections } = dbManager
460+
if (!connections?.length) return null
461+
462+
const spawnSources = dbManager.models?.Spawnpoint
463+
464+
const getSpawnByConnection = (connection) =>
465+
Array.isArray(spawnSources)
466+
? spawnSources.find((source) => source.connection === connection)
467+
: undefined
468+
469+
const hasConnection = (connection) =>
470+
typeof connection === 'number' && Boolean(connections?.[connection])
471+
472+
if (!Array.isArray(spawnSources) || !spawnSources.length) {
473+
return null
474+
}
475+
476+
let candidate = null
477+
478+
if (typeof preferredConnection === 'number') {
479+
candidate = spawnSources.find(
480+
(source) =>
481+
source.connection === preferredConnection &&
482+
source.hasPokemonShinyStats &&
483+
hasConnection(source.connection),
484+
)
485+
}
486+
487+
if (!candidate) {
488+
candidate = spawnSources.find(
489+
(source) =>
490+
source.hasPokemonShinyStats && hasConnection(source.connection),
491+
)
492+
}
493+
494+
if (!candidate) {
495+
return null
496+
}
497+
498+
const knexInstance = connections?.[candidate.connection]
499+
if (!knexInstance) {
500+
return null
501+
}
502+
503+
return {
504+
knex: knexInstance,
505+
connection: candidate.connection,
506+
spawnSource: getSpawnByConnection(candidate.connection),
507+
}
508+
}
509+
510+
/**
511+
* @param {import("@rm/types").DbContext} ctx
512+
* @returns {boolean}
513+
*/
514+
static supportsShinyStats(ctx) {
515+
const statsHandle = this.getStatsHandle(ctx?.connection)
516+
if (!statsHandle?.knex) {
517+
return false
518+
}
519+
const flag =
520+
typeof statsHandle.spawnSource?.hasPokemonShinyStats === 'boolean'
521+
? statsHandle.spawnSource.hasPokemonShinyStats
522+
: typeof ctx?.hasPokemonShinyStats === 'boolean'
523+
? ctx.hasPokemonShinyStats
524+
: false
525+
return flag
526+
}
527+
528+
/**
529+
* @param {string[]} keys
530+
* @param {import('knex').Knex | null | undefined} [statsKnex]
531+
* @param {number | null | undefined} [preferredConnection]
532+
* @returns {Promise<Map<string, { shiny_seen: number, encounters_seen: number, since_date: string | null }>>}
533+
*/
534+
static async fetchShinyStats(
535+
keys,
536+
statsKnex = null,
537+
preferredConnection = null,
538+
) {
539+
if (!keys.length) return new Map()
540+
541+
let knexInstance = statsKnex || null
542+
if (!knexInstance) {
543+
const statsHandle = this.getStatsHandle(preferredConnection)
544+
knexInstance = statsHandle?.knex ?? null
545+
}
546+
if (!knexInstance) {
547+
try {
548+
knexInstance = this.knex()
549+
} catch (e) {
550+
knexInstance = null
551+
}
552+
}
553+
if (!knexInstance) return new Map()
554+
555+
const pairs = keys
556+
.map((key) => key.split('-'))
557+
.map(([pokemonId, formId]) => {
558+
const parsedPokemon = Number.parseInt(pokemonId, 10)
559+
if (Number.isNaN(parsedPokemon)) return null
560+
const parsedForm = Number.parseInt(formId, 10)
561+
return [parsedPokemon, Number.isNaN(parsedForm) ? 0 : parsedForm]
562+
})
563+
.filter(Boolean)
564+
565+
if (!pairs.length) return new Map()
566+
567+
const whereClause = pairs
568+
.map(() => '(pokemon_id = ? AND COALESCE(form_id, 0) = ?)')
569+
.join(' OR ')
570+
const bindings = pairs.flatMap(([pokemonId, formId]) => [pokemonId, formId])
571+
const query = `
572+
SELECT
573+
pokemon_id,
574+
COALESCE(form_id, 0) AS form_id,
575+
date,
576+
SUM(count) AS shiny,
577+
SUM(total) AS checks
578+
FROM pokemon_shiny_stats
579+
WHERE area = 'world'
580+
AND fence = 'world'
581+
AND (${whereClause})
582+
AND date >= CURRENT_DATE - INTERVAL 7 DAY
583+
GROUP BY pokemon_id, form_id, date
584+
ORDER BY pokemon_id, form_id, date DESC
585+
`
586+
587+
const [rows] = await knexInstance.raw(query, bindings)
588+
589+
const grouped = new Map()
590+
for (let i = 0; i < rows.length; i += 1) {
591+
const row = rows[i]
592+
const key = `${row.pokemon_id}-${row.form_id ?? 0}`
593+
const entry = grouped.get(key)
594+
const rowDate =
595+
row.date instanceof Date
596+
? row.date.toISOString().slice(0, 10)
597+
: `${row.date}`
598+
const payload = {
599+
shiny: Number(row.shiny) || 0,
600+
checks: Number(row.checks) || 0,
601+
date: rowDate,
602+
}
603+
if (entry) {
604+
entry.push(payload)
605+
} else {
606+
grouped.set(key, [payload])
607+
}
608+
}
609+
610+
const statsMap = new Map()
611+
const today = new Date()
612+
today.setHours(0, 0, 0, 0)
613+
const cutoff = new Date(today)
614+
cutoff.setDate(cutoff.getDate() - 1)
615+
const cutoffStr = cutoff.toISOString().slice(0, 10)
616+
617+
grouped.forEach((entries, key) => {
618+
let shinySum = 0
619+
let checkSum = 0
620+
let sinceDate = null
621+
for (let i = 0; i < entries.length; i += 1) {
622+
const { shiny, checks, date } = entries[i]
623+
const includeRecent = date >= cutoffStr
624+
// 20000 checks would give >99% of distinguishing even 1/512 from 1/256
625+
if (!includeRecent && checkSum >= 20000) {
626+
break
627+
}
628+
shinySum += shiny
629+
checkSum += checks
630+
if (!sinceDate || date < sinceDate) {
631+
sinceDate = date
632+
}
633+
}
634+
statsMap.set(key, {
635+
shiny_seen: shinySum,
636+
encounters_seen: checkSum,
637+
since_date: sinceDate,
638+
})
639+
})
640+
641+
return statsMap
642+
}
643+
439644
/**
440645
* @param {import("@rm/types").Permissions} perms
441646
* @param {object} args
@@ -512,12 +717,13 @@ class Pokemon extends Model {
512717
secret,
513718
httpAuth,
514719
)
515-
return results
516-
.filter(
517-
(item) =>
518-
!mem ||
519-
filterRTree(item, perms.areaRestrictions, args.filters.onlyAreas),
520-
)
720+
const filtered = results.filter(
721+
(item) =>
722+
!mem ||
723+
filterRTree(item, perms.areaRestrictions, args.filters.onlyAreas),
724+
)
725+
726+
const built = filtered
521727
.map((item) => {
522728
const filter =
523729
filterMap[
@@ -536,6 +742,52 @@ class Pokemon extends Model {
536742
] || globalFilter
537743
return filter.valid(pkmn)
538744
})
745+
746+
return built
747+
}
748+
749+
/**
750+
* @param {import("@rm/types").Permissions} _perms
751+
* @param {{ pokemon_id: number, form?: number | null }} args
752+
* @param {import("@rm/types").DbContext} ctx
753+
* @returns {Promise<import("@rm/types").PokemonShinyStats | null>}
754+
*/
755+
static async getShinyStats(_perms, args, ctx) {
756+
const statsHandle = this.getStatsHandle(ctx?.connection)
757+
if (!statsHandle?.knex) {
758+
return null
759+
}
760+
const hasStats =
761+
typeof statsHandle.spawnSource?.hasPokemonShinyStats === 'boolean'
762+
? statsHandle.spawnSource.hasPokemonShinyStats
763+
: typeof ctx?.hasPokemonShinyStats === 'boolean'
764+
? ctx.hasPokemonShinyStats
765+
: false
766+
if (!hasStats) {
767+
return null
768+
}
769+
const pokemonId = Number.parseInt(`${args.pokemon_id}`, 10)
770+
if (Number.isNaN(pokemonId)) {
771+
return null
772+
}
773+
const formId = Number.parseInt(`${args.form ?? 0}`, 10)
774+
const key = `${pokemonId}-${Number.isNaN(formId) ? 0 : formId}`
775+
try {
776+
const stats = await this.fetchShinyStats(
777+
[key],
778+
statsHandle.knex,
779+
statsHandle.connection,
780+
)
781+
return stats.get(key) || null
782+
} catch (e) {
783+
log.error(TAGS.pokemon, 'Failed to fetch shiny stats', e)
784+
if (e?.code === 'ER_NO_SUCH_TABLE') {
785+
if (statsHandle.spawnSource) {
786+
statsHandle.spawnSource.hasPokemonShinyStats = false
787+
}
788+
}
789+
return null
790+
}
539791
}
540792

541793
/**

0 commit comments

Comments
 (0)