Files
personal-website/scripts/seed-catalog.ts
Stan Runge a10585a4d2
All checks were successful
Test and deploy staging / staging (push) Successful in 18s
add game tracker with owned pokemon data and call of duty mastery
Fold catchemall into /projects/game-tracker (old URL redirects) and add
Call of Duty tracking for Black Ops through WWII.

- Pokémon catalog is now owned: locations, encounters and per-game
  evolution methods are built from the pinned PokéAPI snapshot and seeded.
- Pokémon you can evolve or breed in a game count as obtainable; mythicals
  without another route are marked event-only.
- Detail dialog with evolution chain and where to find it, location pages
  with admin notes and pictures, and admin corrections that survive reseeds.
- Call of Duty weapons, milestones and Steam achievements per game, with
  derived Diamond / Dark Matter / Solar / Black Sky / Chrome tiers.
- New migration (db:migrate:game-tracker), seeds (db:seed, db:seed:cod) and
  deploy preflight checks for them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:36:18 +00:00

199 lines
6.1 KiB
TypeScript

import { readFile } from 'node:fs/promises';
import { drizzle } from 'drizzle-orm/postgres-js';
import { and, eq, isNull, ne, sql } from 'drizzle-orm';
import postgres from 'postgres';
import {
pokemon,
games,
pokemonGameEntries,
pokemonLocations,
pokemonEncounters,
pokemonEvolutions
} from '../src/lib/server/db/schema/catch-em-all';
type Availability = 'catchable' | 'unknown' | 'evolve' | 'breed' | 'event_only';
type Catalog = {
source: { revision: string };
pokemon: (typeof pokemon.$inferInsert)[];
games: {
sourceId: number;
name: string;
slug: string;
generation: number;
sortOrder: number;
breeding: boolean;
}[];
entries: [number, number, Availability, string?][];
evolutions: [from: number, to: number, versions: number[], method: string][];
};
type Locations = {
locations: { sourceId: number; slug: string; name: string; region: string | null }[];
methods: string[];
conditions: string[];
encounters: [
number: number,
version: number,
location: number,
area: string | null,
method: number,
minLevel: number,
maxLevel: number,
rarity: number | null,
form: string | null,
conditions: number[]
][];
};
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const read = async <T>(name: string): Promise<T> =>
JSON.parse(await readFile(new URL(`../data/${name}`, import.meta.url), 'utf8'));
const catalog = await read<Catalog>('pokemon-catalog.json');
const places = await read<Locations>('pokemon-locations.json');
const client = postgres(process.env.DB_URL, { max: 1 });
const db = drizzle({ client });
async function inBatches<T>(rows: T[], insert: (batch: T[]) => Promise<unknown>) {
for (let offset = 0; offset < rows.length; offset += 1000)
await insert(rows.slice(offset, offset + 1000));
}
try {
await db.transaction(async (tx) => {
const pokemonIds = new Map<number, number>();
const gameIds = new Map<number, number>();
const locationIds = new Map<number, number>();
for (const row of catalog.pokemon) {
const [saved] = await tx
.insert(pokemon)
.values(row)
.onConflictDoUpdate({ target: pokemon.number, set: row })
.returning();
pokemonIds.set(saved.number, saved.id);
}
for (const row of catalog.games) {
// Adopt older rows by exact name to preserve existing progress and game IDs.
const [imported] = await tx.select().from(games).where(eq(games.sourceId, row.sourceId));
if (!imported) {
const legacy = await tx
.select()
.from(games)
.where(and(eq(games.name, row.name), isNull(games.sourceId)));
if (legacy.length > 1) throw new Error(`Ambiguous legacy game: ${row.name}`);
if (legacy[0])
await tx.update(games).set({ sourceId: row.sourceId }).where(eq(games.id, legacy[0].id));
}
const [saved] = await tx
.insert(games)
.values(row)
.onConflictDoUpdate({ target: games.sourceId, set: row })
.returning();
gameIds.set(row.sourceId, saved.id);
}
const lookup = (map: Map<number, number>, key: number, kind: string) => {
const id = map.get(key);
if (!id) throw new Error(`Unknown catalog ${kind} ${key}`);
return id;
};
await inBatches(catalog.entries, (batch) =>
tx
.insert(pokemonGameEntries)
.values(
batch.map(([number, version, availability, note]) => ({
pokemonId: lookup(pokemonIds, number, 'Pokémon'),
gameId: lookup(gameIds, version, 'game'),
availability,
note: note ?? null,
source:
availability === 'catchable' || availability === 'unknown'
? ('pokeapi' as const)
: ('derived' as const)
}))
)
.onConflictDoUpdate({
target: [pokemonGameEntries.pokemonId, pokemonGameEntries.gameId],
set: {
availability: sql`excluded.availability`,
note: sql`excluded.note`,
source: sql`excluded.source`
},
// Admin corrections are never overwritten.
setWhere: ne(pokemonGameEntries.source, 'manual')
})
);
for (const row of places.locations) {
const [saved] = await tx
.insert(pokemonLocations)
.values(row)
.onConflictDoUpdate({
target: pokemonLocations.sourceId,
// descriptions and pictures are site-owned and kept
set: { slug: row.slug, name: row.name, region: row.region },
setWhere: ne(pokemonLocations.source, 'manual')
})
.returning();
locationIds.set(row.sourceId, saved?.id ?? 0);
}
for (const [sourceId, id] of locationIds) {
if (id) continue;
const [manual] = await tx
.select({ id: pokemonLocations.id })
.from(pokemonLocations)
.where(eq(pokemonLocations.sourceId, sourceId));
locationIds.set(sourceId, manual.id);
}
// Encounters and evolutions have no natural key; replace imported rows wholesale.
await tx.delete(pokemonEncounters).where(ne(pokemonEncounters.source, 'manual'));
await inBatches(places.encounters, (batch) =>
tx.insert(pokemonEncounters).values(
batch.map(
([
number,
version,
location,
area,
method,
minLevel,
maxLevel,
rarity,
form,
conditions
]) => ({
pokemonId: lookup(pokemonIds, number, 'Pokémon'),
gameId: lookup(gameIds, version, 'game'),
locationId: lookup(locationIds, location, 'location'),
area,
method: places.methods[method],
minLevel,
maxLevel,
rarity,
form,
conditions: conditions.map((index) => places.conditions[index])
})
)
)
);
await tx.delete(pokemonEvolutions).where(ne(pokemonEvolutions.source, 'manual'));
const evolutions = catalog.evolutions.flatMap(([from, to, versions, method]) =>
versions.map((version) => ({
fromPokemonId: lookup(pokemonIds, from, 'Pokémon'),
toPokemonId: lookup(pokemonIds, to, 'Pokémon'),
gameId: lookup(gameIds, version, 'game'),
method
}))
);
await inBatches(evolutions, (batch) =>
tx.insert(pokemonEvolutions).values(batch).onConflictDoNothing()
);
console.log(
`Seeded ${catalog.pokemon.length} species, ${catalog.games.length} versions, ${catalog.entries.length} entries, ` +
`${places.locations.length} locations, ${places.encounters.length} encounters and ` +
`${evolutions.length} evolution methods from ${catalog.source.revision}`
);
});
} finally {
await client.end();
}