@@ -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