mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-16 10:32:10 +00:00
fix: preserve default forms and complete dex mappings
This commit is contained in:
@@ -1248,10 +1248,10 @@ pokedexNumber,pokemon,form,originRegionToCatchIn,originGamesToCatchIn,notes,spri
|
|||||||
896,Glastrier,,Galar,Sword/Shield,,896
|
896,Glastrier,,Galar,Sword/Shield,,896
|
||||||
897,Spectrier,,Galar,Sword/Shield,,897
|
897,Spectrier,,Galar,Sword/Shield,,897
|
||||||
898,Calyrex,,Galar,Sword/Shield,,898
|
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
|
900,Kleavor,,Kanto,Red/Yellow/LG: Pikachu,,900
|
||||||
901,Ursaluna,,Johto,Gold/Crystal/Heart Gold/Soul Silver,,901
|
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,,Hisui,Legends: Arceus,,902
|
||||||
902,Basculegion,Female,Hisui,Legends: Arceus,,902
|
902,Basculegion,Female,Hisui,Legends: Arceus,,902
|
||||||
903,Sneasler,,Hisui,Legends: Arceus,,903
|
903,Sneasler,,Hisui,Legends: Arceus,,903
|
||||||
|
|||||||
|
@@ -29,6 +29,7 @@ export interface PokedexEntryDB {
|
|||||||
form: string | null;
|
form: string | null;
|
||||||
spriteKey: string | null;
|
spriteKey: string | null;
|
||||||
canGigantamax: boolean;
|
canGigantamax: boolean;
|
||||||
|
isDefaultForm: boolean;
|
||||||
regionToCatchIn: string | null;
|
regionToCatchIn: string | null;
|
||||||
gamesToCatchIn: string[] | null;
|
gamesToCatchIn: string[] | null;
|
||||||
regionToEvolveIn: string | null;
|
regionToEvolveIn: string | null;
|
||||||
|
|||||||
@@ -60,8 +60,9 @@ class CombinedDataRepository {
|
|||||||
let query = this.supabase.from('pokedex_entries').select('*');
|
let query = this.supabase.from('pokedex_entries').select('*');
|
||||||
|
|
||||||
if (!enableForms) {
|
if (!enableForms) {
|
||||||
// Filter to base forms: NULL or 'male' (gendered species), plus Unown "A".
|
// Filter to base forms only. Gendered species (form='male') and Unown ('A') are
|
||||||
query = query.or('form.is.null,form.eq.male,and(pokemon.eq.Unown,form.eq.A)');
|
// also flagged isDefaultForm in the pokemon table, so this single check covers them.
|
||||||
|
query = query.eq('isDefaultForm', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (region) {
|
if (region) {
|
||||||
@@ -88,8 +89,9 @@ class CombinedDataRepository {
|
|||||||
let query = this.supabase.from('game_pokedex_entry_details').select('*').in('dexId', dexScopes);
|
let query = this.supabase.from('game_pokedex_entry_details').select('*').in('dexId', dexScopes);
|
||||||
|
|
||||||
if (!enableForms) {
|
if (!enableForms) {
|
||||||
// Filter to base forms: NULL or 'male' (gendered species), plus Unown "A".
|
// Filter to base forms only. Gendered species (form='male') and Unown ('A') are
|
||||||
query = query.or('form.is.null,form.eq.male,and(pokemon.eq.Unown,form.eq.A)');
|
// also flagged isDefaultForm in the pokemon table, so this single check covers them.
|
||||||
|
query = query.eq('isDefaultForm', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (region) {
|
if (region) {
|
||||||
@@ -109,8 +111,12 @@ class CombinedDataRepository {
|
|||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all non-base form entries for a game from pokedex_entries, excluding already-seen IDs.
|
// Fetch all named-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.
|
// 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(
|
private async fetchFormsForGame(
|
||||||
game: string,
|
game: string,
|
||||||
region: string,
|
region: string,
|
||||||
@@ -470,8 +476,9 @@ class CombinedDataRepository {
|
|||||||
|
|
||||||
// Apply same filters as in findCombinedData
|
// Apply same filters as in findCombinedData
|
||||||
if (!enableForms) {
|
if (!enableForms) {
|
||||||
// Filter to base forms: NULL or 'male' (gendered species), plus Unown "A".
|
// Filter to base forms only. Gendered species (form='male') and Unown ('A') are
|
||||||
query = query.or('form.is.null,form.eq.male,and(pokemon.eq.Unown,form.eq.A)');
|
// also flagged isDefaultForm in the pokemon table, so this single check covers them.
|
||||||
|
query = query.eq('isDefaultForm', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (region) {
|
if (region) {
|
||||||
|
|||||||
@@ -2,6 +2,44 @@ import type { Pokedex } from '$lib/models/Pokedex';
|
|||||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
|
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
|
* Calculate expected pokedex entries based on pokedex configuration
|
||||||
* Applies the same filtering logic as CombinedDataRepository
|
* Applies the same filtering logic as CombinedDataRepository
|
||||||
@@ -14,54 +52,51 @@ export async function calculateExpectedEntries(
|
|||||||
const dexScopes = await resolveDexScopes(supabase, pokedex);
|
const dexScopes = await resolveDexScopes(supabase, pokedex);
|
||||||
|
|
||||||
if (dexScopes.length > 0) {
|
if (dexScopes.length > 0) {
|
||||||
const { data: dexEntries, error: dexError } = await supabase
|
const dexEntries = await fetchAllRows<{ pokemonId: number }>(
|
||||||
.from('game_pokedex_entries')
|
(from, to) =>
|
||||||
.select('pokemonId')
|
supabase
|
||||||
.in('dexId', dexScopes);
|
.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) {
|
const uniqueIds = Array.from(new Set(dexEntries.map((row) => row.pokemonId)));
|
||||||
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) || []));
|
|
||||||
if (uniqueIds.length === 0) return [];
|
if (uniqueIds.length === 0) return [];
|
||||||
|
|
||||||
if (!pokedex.isFormDex) {
|
if (!pokedex.isFormDex) {
|
||||||
const { data: entries, error: entryError } = await supabase
|
const entries: { id: number; isDefaultForm: boolean }[] = [];
|
||||||
.from('pokedex_entries')
|
|
||||||
.select('id, pokemon, form')
|
|
||||||
.in('id', uniqueIds);
|
|
||||||
|
|
||||||
if (entryError) {
|
for (const ids of chunk(uniqueIds, ID_CHUNK_SIZE)) {
|
||||||
console.error('Error filtering base forms for dex scopes:', entryError);
|
const { data, error } = await supabase
|
||||||
throw new Error(`Failed to filter base forms: ${entryError.message}`);
|
.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 (
|
// Gendered species (form='male') and Unown ('A') are also flagged isDefaultForm
|
||||||
entries
|
// in the pokemon table, so this single check covers them.
|
||||||
?.filter(
|
return entries.filter((entry) => entry.isDefaultForm).map((entry) => entry.id);
|
||||||
(entry) =>
|
|
||||||
entry.form === null ||
|
|
||||||
entry.form === 'male' ||
|
|
||||||
(entry.pokemon === 'Unown' && entry.form === 'A')
|
|
||||||
)
|
|
||||||
.map((entry) => entry.id) || []
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return uniqueIds;
|
return uniqueIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
let query = supabase.from('pokedex_entries').select('id');
|
// 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.
|
||||||
// Apply form filter: if isFormDex is false, only include base forms
|
let region: string | null = null;
|
||||||
// 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
|
|
||||||
if (pokedex.gameScope) {
|
if (pokedex.gameScope) {
|
||||||
// Determine region from gameScope using games table.
|
// Determine region from gameScope using games table.
|
||||||
// We keep games.region as a denormalized convenience column.
|
// We keep games.region as a denormalized convenience column.
|
||||||
@@ -78,24 +113,37 @@ export async function calculateExpectedEntries(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (regionData?.region) {
|
region = regionData?.region ?? null;
|
||||||
query = query.eq('regionToCatchIn', regionData.region);
|
}
|
||||||
|
|
||||||
|
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
|
// Apply region filter: if gameScope is specified, filter by region
|
||||||
if (pokedex.gameScope) {
|
if (region) {
|
||||||
query = query.contains('gamesToCatchIn', [pokedex.gameScope]);
|
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) {
|
return query;
|
||||||
console.error('Error calculating expected entries:', error);
|
};
|
||||||
throw new Error(`Failed to calculate expected entries: ${error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user