All checks were successful
Test and deploy staging / staging (push) Successful in 18s
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>
199 lines
6.1 KiB
TypeScript
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();
|
|
}
|