diff --git a/data/csvs/pokemon.csv b/data/csvs/pokemon.csv index 0492cd7..1b0492c 100644 --- a/data/csvs/pokemon.csv +++ b/data/csvs/pokemon.csv @@ -1248,10 +1248,10 @@ pokedexNumber,pokemon,form,originRegionToCatchIn,originGamesToCatchIn,notes,spri 896,Glastrier,,Galar,Sword/Shield,,896 897,Spectrier,,Galar,Sword/Shield,,897 898,Calyrex,,Galar,Sword/Shield,,898 -999,Wyrdeer,,Johto,Gold/Silver/Crystal/Heart Gold/Soul Silver,,999 +899,Wyrdeer,,Johto,Gold/Silver/Crystal/Heart Gold/Soul Silver,,899 900,Kleavor,,Kanto,Red/Yellow/LG: Pikachu,,900 901,Ursaluna,,Johto,Gold/Crystal/Heart Gold/Soul Silver,,901 -902,Ursaluna,Bloodmoon,Paldea,Violet,,10272 +901,Ursaluna,Bloodmoon,Paldea,Violet,,10272 902,Basculegion,,Hisui,Legends: Arceus,,902 902,Basculegion,Female,Hisui,Legends: Arceus,,902 903,Sneasler,,Hisui,Legends: Arceus,,903 diff --git a/src/lib/models/PokedexEntry.ts b/src/lib/models/PokedexEntry.ts index 237b9b4..aad7af8 100644 --- a/src/lib/models/PokedexEntry.ts +++ b/src/lib/models/PokedexEntry.ts @@ -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; diff --git a/src/lib/repositories/CombinedDataRepository.ts b/src/lib/repositories/CombinedDataRepository.ts index 51d8014..2e2659f 100644 --- a/src/lib/repositories/CombinedDataRepository.ts +++ b/src/lib/repositories/CombinedDataRepository.ts @@ -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) { diff --git a/src/lib/services/PokedexMappingService.ts b/src/lib/services/PokedexMappingService.ts index e8b50e8..d0a5856 100644 --- a/src/lib/services/PokedexMappingService.ts +++ b/src/lib/services/PokedexMappingService.ts @@ -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( + fetchPage: (from: number, to: number) => PromiseLike, + context: string +): Promise { + 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(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); } /** diff --git a/supabase/migrations/20260913000000_add_is_default_form_to_pokemon.sql b/supabase/migrations/20260913000000_add_is_default_form_to_pokemon.sql new file mode 100644 index 0000000..842eeb3 --- /dev/null +++ b/supabase/migrations/20260913000000_add_is_default_form_to_pokemon.sql @@ -0,0 +1,227 @@ +-- Add an explicit "is this the default/base form" flag to pokemon, so species whose only +-- catchable forms are all named variants (e.g. Basculin's Red/Blue/White-striped, +-- Tornadus/Thundurus/Landorus/Enamorus's Incarnate/Therian, or Oricorio's 4 costumes) can +-- keep their form labels while still having one row treated as the base representative when +-- the "Form Dex (all forms included)" toggle is off. Previously only form IS NULL meant +-- "base form", which forced blanking a form's name (e.g. Darumaka's plain row) to make it +-- default - species with no unnamed form at all (like Basculin) had no base row and were +-- excluded entirely by the base-form filters. + +ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS "isDefaultForm" BOOLEAN NOT NULL DEFAULT FALSE; + +-- Preserve existing base-form species: every row that was previously the base row still is. +UPDATE pokemon SET "isDefaultForm" = TRUE WHERE form IS NULL; + +-- pg_temp function: flags one (pokedexNumber, pokemon, form) row as the default and asserts +-- it matched exactly one row, so a typo'd form string fails the migration loudly instead of +-- silently leaving a species excluded from every non-form dex (exactly the bug being fixed). +CREATE OR REPLACE FUNCTION pg_temp.set_default_form( + p_pokedex_number INTEGER, + p_pokemon TEXT, + p_form TEXT +) RETURNS void AS $$ +DECLARE + affected INTEGER; +BEGIN + UPDATE pokemon + SET "isDefaultForm" = TRUE + WHERE "pokedexNumber" = p_pokedex_number AND pokemon = p_pokemon AND form = p_form; + + GET DIAGNOSTICS affected = ROW_COUNT; + IF affected <> 1 THEN + RAISE EXCEPTION 'set_default_form: expected exactly 1 row for pokedexNumber=%, pokemon=%, form=%, got %', + p_pokedex_number, p_pokemon, p_form, affected; + END IF; +END; +$$ LANGUAGE plpgsql; + +-- Species that previously had no base-form row at all: +SELECT pg_temp.set_default_form(550, 'Basculin', 'Red-striped'); +SELECT pg_temp.set_default_form(641, 'Tornadus', 'Incarnate Form'); +SELECT pg_temp.set_default_form(642, 'Thundurus', 'Incarnate Form'); +SELECT pg_temp.set_default_form(645, 'Landorus', 'Incarnate Form'); +SELECT pg_temp.set_default_form(741, 'Oricorio', 'Baile (Red)'); +SELECT pg_temp.set_default_form(905, 'Enamorus', 'Incarnate Form'); + +-- A full sweep of species with 2+ form rows and none of them form IS NULL turned up the +-- same gap for the following species too (default form chosen per in-game convention: where +-- one form's spriteKey equals the bare pokedexNumber, that form is the game-internal +-- default; otherwise the most common/iconic form is used per the inline notes): + +-- spriteKey-matched defaults: +SELECT pg_temp.set_default_form(413, 'Wormadam', 'Leaf Cloak'); +SELECT pg_temp.set_default_form(479, 'Rotom', 'Lightbulb'); +SELECT pg_temp.set_default_form(492, 'Shaymin', 'Normal Form'); +SELECT pg_temp.set_default_form(710, 'Pumpkaboo', 'Medium'); +SELECT pg_temp.set_default_form(711, 'Gourgeist', 'Medium'); +SELECT pg_temp.set_default_form(718, 'Zygarde', '50%'); -- zygarde-50 is the canonical default form +SELECT pg_temp.set_default_form(720, 'Hoopa', 'Confined'); +SELECT pg_temp.set_default_form(745, 'Lycanroc', 'Midday'); +SELECT pg_temp.set_default_form(849, 'Toxtricity', 'Amped'); +SELECT pg_temp.set_default_form(892, 'Urshifu', 'Single'); +SELECT pg_temp.set_default_form(925, 'Maushold', 'Family of 4'); -- matches this dataset's spriteKey convention (924/925), even though SV's own internal form-0 is Family of Three +SELECT pg_temp.set_default_form(931, 'Squawkabilly', 'Green'); +SELECT pg_temp.set_default_form(978, 'Tatsugiri', 'Curly'); +SELECT pg_temp.set_default_form(982, 'Dudunsparce', '2-Segment'); +SELECT pg_temp.set_default_form(999, 'Gimmighoul', 'Box Form'); -- also missing a base row; caught by a full re-sweep grouped by (pokedexNumber, pokemon) rather than pokedexNumber alone, see uniq_pokemon_default_form below + +-- No form shares the bare-number spriteKey; default chosen as the in-game form-index-0 / +-- most common appearance instead: +SELECT pg_temp.set_default_form(412, 'Burmy', 'Leaf Cloak'); -- Plant Cloak, form index 0 +SELECT pg_temp.set_default_form(422, 'Shellos', 'West Sea'); -- form index 0 +SELECT pg_temp.set_default_form(423, 'Gastrodon', 'West Sea'); -- form index 0 +SELECT pg_temp.set_default_form(585, 'Deerling', 'Spring'); -- form index 0 +SELECT pg_temp.set_default_form(586, 'Sawsbuck', 'Spring'); -- form index 0 +SELECT pg_temp.set_default_form(669, 'Flabébé', 'Red'); -- default box-art colour +SELECT pg_temp.set_default_form(670, 'Floette', 'Red'); +SELECT pg_temp.set_default_form(671, 'Florges', 'Red'); +SELECT pg_temp.set_default_form(774, 'Minior', 'Red'); -- Meteor Form isn't tracked as catchable in this dataset; Red is the conventional default core colour (its spriteKey 10136 is PokeAPI's minior-red default variety) +SELECT pg_temp.set_default_form(666, 'Vivillon', 'Meadow (France-Alsace) [Not Ultra Sun compatible]'); -- most iconic/default pattern (PokeAPI's default variety); NOT this species' true form-index-0 (that's Icy Snow) + +-- Phony/Antique pairs share the same spriteKey (visually near-identical); Phony is the +-- common, non-trade-locked form: +SELECT pg_temp.set_default_form(854, 'Sinistea', 'Phony'); +SELECT pg_temp.set_default_form(855, 'Polteageist', 'Phony'); +SELECT pg_temp.set_default_form(1012, 'Poltchageist', 'Phony'); +SELECT pg_temp.set_default_form(1013, 'Sinistcha', 'Phony'); + +-- Alcremie: 63 cream/sweet combinations, no bare-number spriteKey; the simplest obtainable +-- combination (no Sweet used, default spin direction/time) is used as the default. +SELECT pg_temp.set_default_form(869, 'Alcremie', 'Vanilla Strawberry'); + +-- These three species already had a working, literal 'male' base-form row (matched by the +-- pre-existing form.eq.male clause in application code) and Unown already had a working +-- 'A' base-form row (matched by a pokemon.eq.Unown-specific clause). Flagging them here too +-- lets every base-form query filter collapse to a single isDefaultForm check instead of +-- special-casing these three species and Unown in five separate places. +SELECT pg_temp.set_default_form(267, 'Beautifly', 'male'); +SELECT pg_temp.set_default_form(316, 'Gulpin', 'male'); +SELECT pg_temp.set_default_form(317, 'Swalot', 'male'); +SELECT pg_temp.set_default_form(201, 'Unown', 'A'); + +DROP FUNCTION pg_temp.set_default_form(INTEGER, TEXT, TEXT); + +-- Enforce the invariant this migration exists to establish: every species has exactly one +-- default form. The partial unique index below only enforces AT MOST one; without this check +-- a future species whose forms are all named would silently regress to the original bug +-- (excluded from every non-form dex) with no error anywhere. +-- Grouped by species name, not (pokedexNumber, pokemon): at this point in the migration +-- chain two species are still split across a wrong pokedexNumber (Ursaluna's Bloodmoon form +-- sits under Basculegion's 902 until 20260913000001 corrects it), and a species is the unit +-- that needs exactly one default regardless of how its rows are numbered. +DO $$ +DECLARE + offenders TEXT; +BEGIN + SELECT string_agg(pokemon, ', ' ORDER BY pokemon) INTO offenders + FROM ( + SELECT pokemon FROM pokemon + GROUP BY pokemon + HAVING count(*) FILTER (WHERE "isDefaultForm") <> 1 + ) s; + + IF offenders IS NOT NULL THEN + RAISE EXCEPTION 'these species do not have exactly one default form: %', offenders; + END IF; +END $$; + +-- Add a uniqueness rule for the new flag, kept alongside (not replacing) the existing +-- uniq_pokemon_base_form index. Keyed on (pokedexNumber, pokemon) rather than pokedexNumber +-- alone: when this migration runs, the seed data still has pokedexNumber collisions between +-- unrelated species (Wyrdeer seeded at Gimmighoul's 999; Ursaluna's Bloodmoon form at +-- Basculegion's 902), so a pokedexNumber-only key could not flag a default for the colliding +-- species. The follow-up migration 20260913000001_fix_wrong_pokedex_numbers.sql corrects +-- those two numbers; the composite key is kept because it is the accurate invariant - one +-- default per species - and stays correct regardless of numbering. +CREATE UNIQUE INDEX IF NOT EXISTS uniq_pokemon_default_form + ON pokemon("pokedexNumber", pokemon) + WHERE "isDefaultForm"; + +-- Expose the new flag through pokedex_entries and use it (instead of form IS NULL) to sort +-- the default form first. Mirrors 20260125003000_add_pokedex_notes_to_view.sql, with +-- p."isDefaultForm" appended as the LAST selected column - Postgres requires +-- CREATE OR REPLACE VIEW to keep existing columns in the same name/order/type and only +-- allows new columns to be appended at the end - and the formSortBucket CASE updated to +-- check it (falling back to "form IS NULL" too, so a future INSERT that sets form to NULL +-- without also setting isDefaultForm still sorts correctly). +CREATE OR REPLACE VIEW pokedex_entries AS +SELECT + p.id, + p."pokedexNumber", + p.pokemon, + p.form, + p."spriteKey", + p."canGigantamax", + r.name AS "regionToCatchIn", + r."releaseOrder" AS "regionReleaseOrder", + COALESCE( + ARRAY_AGG(g."displayName" ORDER BY g."displayName") FILTER (WHERE g.id IS NOT NULL), + ARRAY[]::TEXT[] + ) AS "gamesToCatchIn", + p."regionToEvolveIn", + p."evolutionInformation", + p."catchInformation", + p."createdAt", + p."updatedAt", + CASE + WHEN p."isDefaultForm" OR p.form IS NULL OR lower(p.form) = 'male' THEN 0 + WHEN lower(p.form) = 'female' THEN 1 + WHEN lower(p.form) LIKE '%alolan%' + OR lower(p.form) LIKE '%galarian%' + OR lower(p.form) LIKE '%hisuian%' + OR lower(p.form) LIKE '%paldean%' + THEN 2 + ELSE 3 + END AS "formSortBucket", + CASE + WHEN lower(p.form) LIKE '%alolan%' + OR lower(p.form) LIKE '%galarian%' + OR lower(p.form) LIKE '%hisuian%' + OR lower(p.form) LIKE '%paldean%' + THEN r."releaseOrder" + ELSE 0 + END AS "formSortRegionOrder", + CASE + WHEN lower(p.form) LIKE 'female-%' THEN 1 + ELSE 0 + END AS "formSortRegionalSub", + COALESCE(lower(p.form), '') AS "formSortLabel", + CASE + WHEN p.pokemon = 'Unown' THEN + CASE + WHEN p.form = '?' THEN 26 + WHEN p.form = '!' THEN 27 + WHEN length(p.form) = 1 AND ascii(upper(p.form)) BETWEEN 65 AND 90 THEN ascii(upper(p.form)) - 65 + ELSE 28 + END + ELSE 0 + END AS "unownSortOrder", + p.notes, + p."isDefaultForm" +FROM pokemon p +JOIN regions r ON r.id = p."originRegionId" +LEFT JOIN pokemon_origin_games pog ON pog."pokemonId" = p.id +LEFT JOIN games g ON g.id = pog."gameId" +GROUP BY p.id, r.name, r."releaseOrder"; + +GRANT SELECT ON pokedex_entries TO anon, authenticated; + +-- game_pokedex_entry_details selects pe.* from pokedex_entries. Postgres expands a view's +-- "*" into a fixed column list AT THE TIME the view is (re)created, not dynamically per +-- query - so it does NOT pick up pokedex_entries' new trailing columns on its own (this is +-- why "notes", added to pokedex_entries by 20260125003000, was never actually visible +-- through this view either). Re-issuing the same definition here forces Postgres to +-- re-expand pe.* against the now-updated pokedex_entries, picking up both "notes" and +-- "isDefaultForm" as new trailing columns. +CREATE OR REPLACE VIEW game_pokedex_entry_details AS +SELECT + gpe."dexId", + gd."displayName" AS "dexDisplayName", + gd."sortOrder" AS "dexSortOrder", + gpe."dexNumber", + pe.* +FROM game_pokedex_entries gpe +JOIN game_dexes gd ON gd.id = gpe."dexId" +JOIN pokedex_entries pe ON pe.id = gpe."pokemonId"; + +GRANT SELECT ON game_pokedex_entry_details TO anon, authenticated; diff --git a/supabase/migrations/20260913000001_fix_wrong_pokedex_numbers.sql b/supabase/migrations/20260913000001_fix_wrong_pokedex_numbers.sql new file mode 100644 index 0000000..c6ac42a --- /dev/null +++ b/supabase/migrations/20260913000001_fix_wrong_pokedex_numbers.sql @@ -0,0 +1,53 @@ +-- Correct two mis-seeded national dex numbers, verified against PokeAPI +-- (pokemon-species ids: wyrdeer = 899, ursaluna = 901, gimmighoul = 999). +-- +-- Wyrdeer was seeded at 999 - Gimmighoul's number - and national dex 899 was absent from the +-- data entirely. Its spriteKey was likewise '999', so Wyrdeer rendered Gimmighoul's artwork +-- (static/sprites-small/home/999.webp); both fields are corrected here. +-- +-- Ursaluna's Bloodmoon form was seeded at 902, which is Basculegion's number. Only the +-- pokedexNumber is wrong - its spriteKey '10272' (ursaluna-bloodmoon) is already correct. +-- +-- Safe to renumber: pokedexNumber lives only on pokemon and the two views derived from it. +-- Dex membership (game_pokedex_entries) and user catch records reference pokemon.id, so no +-- dex contents or caught//uncaught state are affected. +-- +-- A full audit of all 1390 pokemon rows against PokeAPI (1025 species / 1351 varieties) +-- found no other incorrect dex numbers, no other spriteKey pointing at the wrong species, +-- and no canGigantamax mismatches. + +CREATE OR REPLACE FUNCTION pg_temp.fix_dex_number( + p_old_number INTEGER, + p_pokemon TEXT, + p_form TEXT, + p_new_number INTEGER, + p_new_sprite_key TEXT +) RETURNS void AS $$ +DECLARE + affected INTEGER; +BEGIN + -- Matching either the old or the new number keeps this re-runnable: a second run is a + -- no-op update of an already-corrected row rather than a hard failure, while 0 or 2+ + -- matches (a typo, or real corruption) still raise. + UPDATE pokemon + SET "pokedexNumber" = p_new_number, + "spriteKey" = COALESCE(p_new_sprite_key, "spriteKey") + WHERE "pokedexNumber" IN (p_old_number, p_new_number) + AND pokemon = p_pokemon + AND form IS NOT DISTINCT FROM p_form; + + GET DIAGNOSTICS affected = ROW_COUNT; + IF affected <> 1 THEN + RAISE EXCEPTION 'fix_dex_number: expected exactly 1 row for pokemon=%, form=%, pokedexNumber=%, got %', + p_pokemon, p_form, p_old_number, affected; + END IF; +END; +$$ LANGUAGE plpgsql; + +-- Wyrdeer: 999 -> 899, and its sprite from Gimmighoul's '999' to its own '899'. +SELECT pg_temp.fix_dex_number(999, 'Wyrdeer', NULL, 899, '899'); + +-- Ursaluna Bloodmoon: 902 -> 901 (spriteKey already correct, left untouched). +SELECT pg_temp.fix_dex_number(902, 'Ursaluna', 'Bloodmoon', 901, NULL); + +DROP FUNCTION pg_temp.fix_dex_number(INTEGER, TEXT, TEXT, INTEGER, TEXT);