fix: preserve default forms and complete dex mappings

This commit is contained in:
Josh Creek
2026-09-13 12:45:07 +01:00
parent c5a47439d9
commit 7c32813cf2
6 changed files with 395 additions and 59 deletions
+1
View File
@@ -29,6 +29,7 @@ export interface PokedexEntryDB {
form: string | null;
spriteKey: string | null;
canGigantamax: boolean;
isDefaultForm: boolean;
regionToCatchIn: string | null;
gamesToCatchIn: string[] | null;
regionToEvolveIn: string | null;
+15 -8
View File
@@ -60,8 +60,9 @@ class CombinedDataRepository {
let query = this.supabase.from('pokedex_entries').select('*');
if (!enableForms) {
// Filter to base forms: NULL or 'male' (gendered species), plus Unown "A".
query = query.or('form.is.null,form.eq.male,and(pokemon.eq.Unown,form.eq.A)');
// Filter to base forms only. Gendered species (form='male') and Unown ('A') are
// also flagged isDefaultForm in the pokemon table, so this single check covers them.
query = query.eq('isDefaultForm', true);
}
if (region) {
@@ -88,8 +89,9 @@ class CombinedDataRepository {
let query = this.supabase.from('game_pokedex_entry_details').select('*').in('dexId', dexScopes);
if (!enableForms) {
// Filter to base forms: NULL or 'male' (gendered species), plus Unown "A".
query = query.or('form.is.null,form.eq.male,and(pokemon.eq.Unown,form.eq.A)');
// Filter to base forms only. Gendered species (form='male') and Unown ('A') are
// also flagged isDefaultForm in the pokemon table, so this single check covers them.
query = query.eq('isDefaultForm', true);
}
if (region) {
@@ -109,8 +111,12 @@ class CombinedDataRepository {
return query;
}
// Fetch all non-base form entries for a game from pokedex_entries, excluding already-seen IDs.
// game_pokedex_entries is seeded with base forms only, so forms must be supplemented from here.
// Fetch all named-form entries for a game from pokedex_entries, excluding already-seen IDs.
// game_pokedex_entries is seeded from form IS NULL rows, so named forms must be supplemented
// from here. Deliberately keyed on `form`, NOT isDefaultForm: a default form with a name
// (e.g. Rotom "Lightbulb") is usually absent from the game dex tables, so filtering on
// isDefaultForm would drop it from game-scoped form dexes entirely. excludeIds already
// dedupes anything the dex table does list.
private async fetchFormsForGame(
game: string,
region: string,
@@ -470,8 +476,9 @@ class CombinedDataRepository {
// Apply same filters as in findCombinedData
if (!enableForms) {
// Filter to base forms: NULL or 'male' (gendered species), plus Unown "A".
query = query.or('form.is.null,form.eq.male,and(pokemon.eq.Unown,form.eq.A)');
// Filter to base forms only. Gendered species (form='male') and Unown ('A') are
// also flagged isDefaultForm in the pokemon table, so this single check covers them.
query = query.eq('isDefaultForm', true);
}
if (region) {
+97 -49
View File
@@ -2,6 +2,44 @@ import type { Pokedex } from '$lib/models/Pokedex';
import type { SupabaseClient } from '@supabase/supabase-js';
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
// PostgREST caps every response at `max_rows` (1000, see supabase/config.toml) and truncates
// SILENTLY rather than erroring, so any query here that can return more must page. All three
// can: game_pokedex_entries holds 11k+ rows, the base-form set is 1025 and a form dex 1390.
const MAX_ROWS_PER_REQUEST = 1000;
// Keeps `.in()` lists well clear of URL length limits.
const ID_CHUNK_SIZE = 500;
type PageResult = { data: unknown[] | null; error: { message: string } | null };
/** Repeatedly fetch `fetchPage` ranges until a short page signals the end. */
async function fetchAllRows<T>(
fetchPage: (from: number, to: number) => PromiseLike<PageResult>,
context: string
): Promise<T[]> {
const rows: T[] = [];
for (let from = 0; ; from += MAX_ROWS_PER_REQUEST) {
const { data, error } = await fetchPage(from, from + MAX_ROWS_PER_REQUEST - 1);
if (error) {
console.error(`Error ${context}:`, error);
throw new Error(`Failed to ${context}: ${error.message}`);
}
if (!data?.length) break;
rows.push(...(data as T[]));
if (data.length < MAX_ROWS_PER_REQUEST) break;
}
return rows;
}
function chunk<T>(values: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < values.length; i += size) chunks.push(values.slice(i, i + size));
return chunks;
}
/**
* Calculate expected pokedex entries based on pokedex configuration
* Applies the same filtering logic as CombinedDataRepository
@@ -14,54 +52,51 @@ export async function calculateExpectedEntries(
const dexScopes = await resolveDexScopes(supabase, pokedex);
if (dexScopes.length > 0) {
const { data: dexEntries, error: dexError } = await supabase
.from('game_pokedex_entries')
.select('pokemonId')
.in('dexId', dexScopes);
const dexEntries = await fetchAllRows<{ pokemonId: number }>(
(from, to) =>
supabase
.from('game_pokedex_entries')
.select('pokemonId')
.in('dexId', dexScopes)
// (dexId, pokemonId) is the primary key, so this orders pages deterministically.
.order('dexId', { ascending: true })
.order('pokemonId', { ascending: true })
.range(from, to),
'calculate dex-scoped entries'
);
if (dexError) {
console.error('Error calculating dex-scoped entries:', dexError);
throw new Error(`Failed to calculate dex-scoped entries: ${dexError.message}`);
}
const uniqueIds = Array.from(new Set(dexEntries?.map((row) => row.pokemonId) || []));
const uniqueIds = Array.from(new Set(dexEntries.map((row) => row.pokemonId)));
if (uniqueIds.length === 0) return [];
if (!pokedex.isFormDex) {
const { data: entries, error: entryError } = await supabase
.from('pokedex_entries')
.select('id, pokemon, form')
.in('id', uniqueIds);
const entries: { id: number; isDefaultForm: boolean }[] = [];
if (entryError) {
console.error('Error filtering base forms for dex scopes:', entryError);
throw new Error(`Failed to filter base forms: ${entryError.message}`);
for (const ids of chunk(uniqueIds, ID_CHUNK_SIZE)) {
const { data, error } = await supabase
.from('pokedex_entries')
.select('id, isDefaultForm')
.in('id', ids)
.returns<{ id: number; isDefaultForm: boolean }[]>();
if (error) {
console.error('Error filtering base forms for dex scopes:', error);
throw new Error(`Failed to filter base forms: ${error.message}`);
}
if (data) entries.push(...data);
}
return (
entries
?.filter(
(entry) =>
entry.form === null ||
entry.form === 'male' ||
(entry.pokemon === 'Unown' && entry.form === 'A')
)
.map((entry) => entry.id) || []
);
// Gendered species (form='male') and Unown ('A') are also flagged isDefaultForm
// in the pokemon table, so this single check covers them.
return entries.filter((entry) => entry.isDefaultForm).map((entry) => entry.id);
}
return uniqueIds;
}
let query = supabase.from('pokedex_entries').select('id');
// Apply form filter: if isFormDex is false, only include base forms
// Base forms have form IS NULL, except Unown which has no base form (use 'A')
if (!pokedex.isFormDex) {
query = query.or('form.is.null,form.eq.male,and(pokemon.eq.Unown,form.eq.A)');
}
// Apply region filter: if gameScope is specified, filter by region
// Resolve the region up front so each page can be built from scratch below. A PostgREST
// builder is single-use, so it must be rebuilt per page rather than reused.
let region: string | null = null;
if (pokedex.gameScope) {
// Determine region from gameScope using games table.
// We keep games.region as a denormalized convenience column.
@@ -78,24 +113,37 @@ export async function calculateExpectedEntries(
);
}
if (regionData?.region) {
query = query.eq('regionToCatchIn', regionData.region);
region = regionData?.region ?? null;
}
const buildQuery = () => {
let query = supabase.from('pokedex_entries').select('id');
// Apply form filter: if isFormDex is false, only include base forms. Gendered species
// (form='male') and Unown ('A') are also flagged isDefaultForm, so this covers them too.
if (!pokedex.isFormDex) {
query = query.eq('isDefaultForm', true);
}
}
// Apply game filter: if gameScope is specified, filter by gamesToCatchIn array
if (pokedex.gameScope) {
query = query.contains('gamesToCatchIn', [pokedex.gameScope]);
}
// Apply region filter: if gameScope is specified, filter by region
if (region) {
query = query.eq('regionToCatchIn', region);
}
const { data, error } = await query;
// Apply game filter: if gameScope is specified, filter by gamesToCatchIn array
if (pokedex.gameScope) {
query = query.contains('gamesToCatchIn', [pokedex.gameScope]);
}
if (error) {
console.error('Error calculating expected entries:', error);
throw new Error(`Failed to calculate expected entries: ${error.message}`);
}
return query;
};
return data?.map((entry) => entry.id) || [];
const entries = await fetchAllRows<{ id: number }>(
(from, to) => buildQuery().order('id', { ascending: true }).range(from, to),
'calculate expected entries'
);
return entries.map((entry) => entry.id);
}
/**