Merge pull request #92 from jcreek/91-forms-only-work-when-all-games-is-selected

91 forms only work when all games is selected
This commit is contained in:
Josh Creek
2026-06-16 15:49:07 +01:00
committed by GitHub
5 changed files with 8069 additions and 10 deletions
+7717
View File
File diff suppressed because it is too large Load Diff
+117 -10
View File
@@ -3,6 +3,17 @@ import { type CatchRecord, type CatchRecordDB } from '$lib/models/CatchRecord';
import { type CombinedData } from '$lib/models/CombinedData';
import type { SupabaseClient } from '@supabase/supabase-js';
// Raw row from game_pokedex_entry_details includes extra ordering columns not in PokedexEntryDB.
type RawDexEntry = PokedexEntryDB & {
dexNumber: number;
dexSortOrder: number;
formSortBucket: number;
formSortRegionOrder: number;
formSortRegionalSub: number;
formSortLabel: string;
unownSortOrder: number;
};
class CombinedDataRepository {
private static readonly MAX_ROWS_PER_REQUEST = 1000;
@@ -98,14 +109,55 @@ 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.
private async fetchFormsForGame(
game: string,
region: string,
excludeIds: Set<number>
): Promise<PokedexEntryDB[]> {
const allForms: PokedexEntryDB[] = [];
let start = 0;
const maxRows = CombinedDataRepository.MAX_ROWS_PER_REQUEST;
while (true) {
const end = start + maxRows - 1;
let query = this.supabase.from('pokedex_entries').select('*').not('form', 'is', null);
if (game) {
query = query.contains('gamesToCatchIn', [game]);
}
if (region) {
query = query.eq('regionToCatchIn', region);
}
const { data, error } = await query.order('id', { ascending: true }).range(start, end);
if (error) {
console.error('Error fetching forms for game:', error);
return [];
}
if (!data || data.length === 0) break;
allForms.push(...(data as PokedexEntryDB[]).filter((e) => !excludeIds.has(e.id)));
if (data.length < maxRows) break;
start = end + 1;
}
return allForms;
}
private async fetchAllDexEntries(
dexScopes: string[],
enableForms: boolean,
region: string
region: string,
game: string = ''
): Promise<PokedexEntryDB[]> {
if (dexScopes.length === 0) return [];
const entries: PokedexEntryDB[] = [];
const entries: RawDexEntry[] = [];
let start = 0;
const maxRows = CombinedDataRepository.MAX_ROWS_PER_REQUEST;
@@ -125,7 +177,7 @@ class CombinedDataRepository {
break;
}
entries.push(...data);
entries.push(...(data as RawDexEntry[]));
if (data.length < maxRows) {
break;
@@ -134,7 +186,63 @@ class CombinedDataRepository {
start = end + 1;
}
return entries;
// When forms are enabled, game_pokedex_entries only has base forms, so supplement from pokedex_entries.
if (!enableForms || !game) {
return entries as PokedexEntryDB[];
}
// Build lookup: pokedexNumber → dex position (from the raw game dex data)
const dexInfoByPokedexNumber = new Map<number, { dexNumber: number; dexSortOrder: number }>();
for (const entry of entries) {
if (!dexInfoByPokedexNumber.has(entry.pokedexNumber)) {
dexInfoByPokedexNumber.set(entry.pokedexNumber, {
dexNumber: entry.dexNumber ?? 0,
dexSortOrder: entry.dexSortOrder ?? 0
});
}
}
// Fetch forms not already covered by game_pokedex_entries
const existingIds = new Set(entries.map((e) => e.id));
const formEntries = await this.fetchFormsForGame(game, region, existingIds);
if (formEntries.length === 0) {
return entries as PokedexEntryDB[];
}
// Merge and sort: dex entries first (they are the canonical base form for the regional dex),
// then supplemented forms. Within each group, sort by form fields.
type TaggedEntry = RawDexEntry & { _isDexEntry: boolean };
const merged: TaggedEntry[] = [
...entries.map((e) => ({ ...e, _isDexEntry: true })),
...(formEntries as RawDexEntry[]).map((e) => ({ ...e, _isDexEntry: false }))
];
merged.sort((a, b) => {
const aDex = dexInfoByPokedexNumber.get(a.pokedexNumber) ?? {
dexNumber: 9999,
dexSortOrder: 9999
};
const bDex = dexInfoByPokedexNumber.get(b.pokedexNumber) ?? {
dexNumber: 9999,
dexSortOrder: 9999
};
if (aDex.dexSortOrder !== bDex.dexSortOrder) return aDex.dexSortOrder - bDex.dexSortOrder;
if (aDex.dexNumber !== bDex.dexNumber) return aDex.dexNumber - bDex.dexNumber;
if ((a.unownSortOrder ?? 0) !== (b.unownSortOrder ?? 0))
return (a.unownSortOrder ?? 0) - (b.unownSortOrder ?? 0);
// Dex entries (canonical regional base form) sort before supplemented forms.
if (a._isDexEntry !== b._isDexEntry) return a._isDexEntry ? -1 : 1;
if ((a.formSortBucket ?? 0) !== (b.formSortBucket ?? 0))
return (a.formSortBucket ?? 0) - (b.formSortBucket ?? 0);
if ((a.formSortRegionOrder ?? 0) !== (b.formSortRegionOrder ?? 0))
return (a.formSortRegionOrder ?? 0) - (b.formSortRegionOrder ?? 0);
if ((a.formSortRegionalSub ?? 0) !== (b.formSortRegionalSub ?? 0))
return (a.formSortRegionalSub ?? 0) - (b.formSortRegionalSub ?? 0);
return (a.formSortLabel ?? '').localeCompare(b.formSortLabel ?? '');
});
return merged as PokedexEntryDB[];
}
private dedupeEntries(entries: PokedexEntryDB[]): PokedexEntryDB[] {
@@ -264,7 +372,7 @@ class CombinedDataRepository {
): Promise<CombinedData[]> {
const entries =
dexScopes.length > 0
? this.dedupeEntries(await this.fetchAllDexEntries(dexScopes, enableForms, region))
? this.dedupeEntries(await this.fetchAllDexEntries(dexScopes, enableForms, region, game))
: await this.fetchAllEntries(enableForms, region, game);
if (!entries || entries.length === 0) {
@@ -310,10 +418,9 @@ class CombinedDataRepository {
const to = from + limit - 1;
const entries =
dexScopes.length > 0
? this.dedupeEntries(await this.fetchAllDexEntries(dexScopes, enableForms, region)).slice(
from,
to + 1
)
? this.dedupeEntries(
await this.fetchAllDexEntries(dexScopes, enableForms, region, game)
).slice(from, to + 1)
: await this.fetchEntriesByRange(from, to, enableForms, region, game);
if (!entries || entries.length === 0) {
@@ -354,7 +461,7 @@ class CombinedDataRepository {
): Promise<number> {
if (dexScopes.length > 0) {
const entries = this.dedupeEntries(
await this.fetchAllDexEntries(dexScopes, enableForms, region)
await this.fetchAllDexEntries(dexScopes, enableForms, region, game)
);
return entries.length;
}
@@ -0,0 +1,22 @@
-- Fix missing female form entries in pokemon_origin_games.
--
-- Female Sneasel was absent from all Gen 2 games despite the base form being present.
-- Female Indeedee was absent from Sword despite the base form being present.
-- Both were identified by comparing pokemon_origin_games coverage between base forms
-- and their female counterparts.
INSERT INTO pokemon_origin_games ("pokemonId", "gameId")
SELECT p.id, g.id
FROM pokemon p
CROSS JOIN games g
WHERE p.pokemon = 'Sneasel' AND p.form = 'Female'
AND g."displayName" IN ('Gold', 'Silver', 'Crystal', 'Heart Gold', 'Soul Silver')
ON CONFLICT DO NOTHING;
INSERT INTO pokemon_origin_games ("pokemonId", "gameId")
SELECT p.id, g.id
FROM pokemon p
CROSS JOIN games g
WHERE p.pokemon = 'Indeedee' AND p.form = 'Female'
AND g."displayName" = 'Sword'
ON CONFLICT DO NOTHING;
@@ -0,0 +1,17 @@
-- Add Scarlet and Violet to all Vivillon pattern forms.
--
-- All 18 pattern forms are obtainable in Scarlet/Violet via the Pokémon Portal
-- Vivillon Points mechanic. The seed data only recorded X/Y (the origin games),
-- which was wrong given the goal is to show all games where a form is catchable.
--
-- Fancy (Event) already has Scarlet/Violet.
-- Pokéball (Event) was X/Y only and is not included here.
INSERT INTO pokemon_origin_games ("pokemonId", "gameId")
SELECT p.id, g.id
FROM pokemon p
CROSS JOIN games g
WHERE p.pokemon = 'Vivillon'
AND p.form NOT IN ('Fancy (Event)', 'Pokeball (Event)')
AND g."displayName" IN ('Scarlet', 'Violet')
ON CONFLICT DO NOTHING;
@@ -0,0 +1,196 @@
-- Fix incorrect and missing game assignments for forms.
--
-- The original seed data recorded only the first game a form appeared in, but
-- the goal is to show all games where a form is actively obtainable. This
-- migration corrects wrong entries and adds missing ones based on in-game
-- mechanics (form-change items, events, wild encounters) verified via Bulbapedia.
--
-- Magearna Original Color: confirmed Home-only reward — no change needed.
-- ============================================================
-- REMOVALS (incorrect existing assignments)
-- ============================================================
-- Rotom appliance forms: introduced in Platinum (Secret Key event).
-- Diamond/Pearl only have base Rotom; the Secret Room mechanic was Platinum-only.
DELETE FROM pokemon_origin_games
WHERE "pokemonId" IN (
SELECT id FROM pokemon WHERE pokemon = 'Rotom'
AND form IN ('Fan', 'Fridge', 'Lightbulb', 'Mower', 'Oven', 'Washer')
)
AND "gameId" IN (SELECT id FROM games WHERE "displayName" IN ('Diamond', 'Pearl'));
-- Deoxys: in Gen 3 each form was version-exclusive with no form-change mechanic.
-- Ruby/Sapphire only ever had Normal form. Emerald only had Speed form.
-- Attack form wrong games: Ruby, Sapphire, Emerald, Alpha Sapphire, Omega Ruby.
-- (OR/AS has no Deoxys meteorite mechanic — only Normal form is catchable there.)
DELETE FROM pokemon_origin_games
WHERE "pokemonId" IN (SELECT id FROM pokemon WHERE pokemon = 'Deoxys' AND form = 'Attack Form')
AND "gameId" IN (SELECT id FROM games WHERE "displayName" IN ('Ruby', 'Sapphire', 'Emerald', 'Alpha Sapphire', 'Omega Ruby'));
-- Defense form wrong games: Ruby, Sapphire, Emerald, Alpha Sapphire, Omega Ruby.
DELETE FROM pokemon_origin_games
WHERE "pokemonId" IN (SELECT id FROM pokemon WHERE pokemon = 'Deoxys' AND form = 'Defense Form')
AND "gameId" IN (SELECT id FROM games WHERE "displayName" IN ('Ruby', 'Sapphire', 'Emerald', 'Alpha Sapphire', 'Omega Ruby'));
-- Speed form wrong games: Ruby, Sapphire, Alpha Sapphire, Omega Ruby.
-- (Emerald is correct — Speed form originated there.)
DELETE FROM pokemon_origin_games
WHERE "pokemonId" IN (SELECT id FROM pokemon WHERE pokemon = 'Deoxys' AND form = 'Speed Form')
AND "gameId" IN (SELECT id FROM games WHERE "displayName" IN ('Ruby', 'Sapphire', 'Alpha Sapphire', 'Omega Ruby'));
-- Shaymin Sky Form: Gracidea was not in Diamond/Pearl (Platinum-first).
-- Legends: Arceus has Shaymin only in Land Form — no Gracidea mechanic.
DELETE FROM pokemon_origin_games
WHERE "pokemonId" IN (SELECT id FROM pokemon WHERE pokemon = 'Shaymin' AND form = 'Sky Form')
AND "gameId" IN (SELECT id FROM games WHERE "displayName" IN ('Diamond', 'Pearl', 'Legends: Arceus'));
-- Lycanroc Dusk: required the special Own Tempo Rockruff distributed only in
-- Ultra Sun/Ultra Moon. Not available in the original Sun/Moon.
DELETE FROM pokemon_origin_games
WHERE "pokemonId" IN (SELECT id FROM pokemon WHERE pokemon = 'Lycanroc' AND form = 'Dusk')
AND "gameId" IN (SELECT id FROM games WHERE "displayName" IN ('Sun', 'Moon'));
-- Hoopa Unbound: Prison Bottle was an ORAS-exclusive event item.
-- X/Y had no way to obtain the Prison Bottle; Unbound form was ORAS-only.
DELETE FROM pokemon_origin_games
WHERE "pokemonId" IN (SELECT id FROM pokemon WHERE pokemon = 'Hoopa' AND form = 'Unbound')
AND "gameId" IN (SELECT id FROM games WHERE "displayName" IN ('X', 'Y'));
-- ============================================================
-- ADDITIONS (missing game assignments)
-- ============================================================
-- Rotom appliance forms: form-change mechanic available in each generation:
-- Gen 4: Platinum (Rotom's Room), HG/SS (Silph Co. Rotom's Room)
-- Gen 5: B/W + B2/W2 (Castelia City)
-- Gen 6: X/Y and OR/AS (box method)
-- Gen 7: S/M and US/UM (box method)
-- Gen 8: Sw/Sh, BD/SP (Rotom Catalog key item)
-- Gen 9: Sc/Vi (Rotom Catalog, purchasable at Porto Marinada Market)
INSERT INTO pokemon_origin_games ("pokemonId", "gameId")
SELECT p.id, g.id
FROM pokemon p
CROSS JOIN games g
WHERE p.pokemon = 'Rotom'
AND p.form IN ('Fan', 'Fridge', 'Lightbulb', 'Mower', 'Oven', 'Washer')
AND g."displayName" IN (
'Platinum', 'Heart Gold', 'Soul Silver',
'Black', 'White', 'Black 2', 'White 2',
'X', 'Y', 'Omega Ruby', 'Alpha Sapphire',
'Sun', 'Moon', 'Ultra Sun', 'Ultra Moon',
'Sword', 'Shield', 'Brilliant Diamond', 'Shining Pearl',
'Scarlet', 'Violet'
)
ON CONFLICT DO NOTHING;
-- Deoxys Attack Form: FireRed-exclusive in Gen 3; meteorite form changes
-- available in Veilstone City (D/P/Pt/BD/SP), Route 3 (HG/SS),
-- Nacrene Museum (B/W/B2/W2), and via meteorites in X/Y.
INSERT INTO pokemon_origin_games ("pokemonId", "gameId")
SELECT p.id, g.id
FROM pokemon p
CROSS JOIN games g
WHERE p.pokemon = 'Deoxys' AND p.form = 'Attack Form'
AND g."displayName" IN (
'FireRed',
'Diamond', 'Pearl', 'Platinum',
'Heart Gold', 'Soul Silver',
'Black', 'White', 'Black 2', 'White 2',
'X', 'Y',
'Brilliant Diamond', 'Shining Pearl'
)
ON CONFLICT DO NOTHING;
-- Deoxys Defense Form: LeafGreen-exclusive in Gen 3; same meteorite mechanic above.
INSERT INTO pokemon_origin_games ("pokemonId", "gameId")
SELECT p.id, g.id
FROM pokemon p
CROSS JOIN games g
WHERE p.pokemon = 'Deoxys' AND p.form = 'Defense Form'
AND g."displayName" IN (
'LeafGreen',
'Diamond', 'Pearl', 'Platinum',
'Heart Gold', 'Soul Silver',
'Black', 'White', 'Black 2', 'White 2',
'X', 'Y',
'Brilliant Diamond', 'Shining Pearl'
)
ON CONFLICT DO NOTHING;
-- Deoxys Speed Form: Emerald-exclusive in Gen 3 (already in DB); same meteorite mechanic.
INSERT INTO pokemon_origin_games ("pokemonId", "gameId")
SELECT p.id, g.id
FROM pokemon p
CROSS JOIN games g
WHERE p.pokemon = 'Deoxys' AND p.form = 'Speed Form'
AND g."displayName" IN (
'Diamond', 'Pearl', 'Platinum',
'Heart Gold', 'Soul Silver',
'Black', 'White', 'Black 2', 'White 2',
'X', 'Y',
'Brilliant Diamond', 'Shining Pearl'
)
ON CONFLICT DO NOTHING;
-- Shaymin Land Form: missing HG/SS (Oak's Letter event) and Sc/Vi (Mystery Gift).
INSERT INTO pokemon_origin_games ("pokemonId", "gameId")
SELECT p.id, g.id
FROM pokemon p
CROSS JOIN games g
WHERE p.pokemon = 'Shaymin' AND (p.form = 'Normal Form' OR p.form IS NULL)
AND g."displayName" IN ('Heart Gold', 'Soul Silver', 'Scarlet', 'Violet')
ON CONFLICT DO NOTHING;
-- Shaymin Sky Form: Gracidea obtainable in Platinum (Floaroma Town), HG/SS
-- (Goldenrod City), X/Y (Snowbelle City), and Sc/Vi (bundled with Mystery Gift).
-- Diamond/Pearl and Legends: Arceus removed above.
INSERT INTO pokemon_origin_games ("pokemonId", "gameId")
SELECT p.id, g.id
FROM pokemon p
CROSS JOIN games g
WHERE p.pokemon = 'Shaymin' AND p.form = 'Sky Form'
AND g."displayName" IN ('Platinum', 'Heart Gold', 'Soul Silver', 'X', 'Y', 'Scarlet', 'Violet')
ON CONFLICT DO NOTHING;
-- Hoopa Confined: distributed via Mystery Gift for all Gen 6 games (X/Y + OR/AS).
INSERT INTO pokemon_origin_games ("pokemonId", "gameId")
SELECT p.id, g.id
FROM pokemon p
CROSS JOIN games g
WHERE p.pokemon = 'Hoopa' AND p.form = 'Confined'
AND g."displayName" IN ('Omega Ruby', 'Alpha Sapphire')
ON CONFLICT DO NOTHING;
-- Hoopa Unbound: Prison Bottle was an ORAS-exclusive event; X/Y removed above.
INSERT INTO pokemon_origin_games ("pokemonId", "gameId")
SELECT p.id, g.id
FROM pokemon p
CROSS JOIN games g
WHERE p.pokemon = 'Hoopa' AND p.form = 'Unbound'
AND g."displayName" IN ('Omega Ruby', 'Alpha Sapphire')
ON CONFLICT DO NOTHING;
-- Flabébé/Floette/Florges flower colors: all five colors obtainable in OR/AS
-- (wild encounters confirmed) and US/UM (some via wild, others via SOS/breeding).
-- Not in S/M (Flabébé is absent from base Sun/Moon).
INSERT INTO pokemon_origin_games ("pokemonId", "gameId")
SELECT p.id, g.id
FROM pokemon p
CROSS JOIN games g
WHERE p.pokemon IN ('Flabébé', 'Floette', 'Florges')
AND p.form IN ('Red', 'Orange', 'Yellow', 'Blue', 'White')
AND g."displayName" IN ('Omega Ruby', 'Alpha Sapphire', 'Ultra Sun', 'Ultra Moon')
ON CONFLICT DO NOTHING;
-- Furfrou trims: grooming salons confirmed in X/Y (Lumiose City),
-- OR/AS (Slateport City), and S/M + US/UM (Malie City / Hau'oli City).
-- Go entries kept as-is.
INSERT INTO pokemon_origin_games ("pokemonId", "gameId")
SELECT p.id, g.id
FROM pokemon p
CROSS JOIN games g
WHERE p.pokemon = 'Furfrou'
AND p.form IN ('Dandy', 'Debutante', 'Diamond', 'Heart', 'Kabuki', 'La Reine', 'Matron', 'Pharaoh', 'Star')
AND g."displayName" IN ('X', 'Y', 'Omega Ruby', 'Alpha Sapphire', 'Sun', 'Moon', 'Ultra Sun', 'Ultra Moon')
ON CONFLICT DO NOTHING;