add game tracker with owned pokemon data and call of duty mastery
All checks were successful
Test and deploy staging / staging (push) Successful in 18s
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>
This commit is contained in:
@@ -1,25 +1,67 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||
import { and, eq, isNull, ne, sql } from 'drizzle-orm';
|
||||
import postgres from 'postgres';
|
||||
import { pokemon, games, pokemonGameEntries } from '../src/lib/server/db/schema/catch-em-all';
|
||||
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; generation: number }[];
|
||||
entries: [number, number, 'catchable' | 'unknown'][];
|
||||
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 catalog: Catalog = JSON.parse(
|
||||
await readFile(new URL('../data/pokemon-catalog.json', import.meta.url), 'utf8')
|
||||
);
|
||||
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)
|
||||
@@ -47,38 +89,110 @@ try {
|
||||
.returning();
|
||||
gameIds.set(row.sourceId, saved.id);
|
||||
}
|
||||
for (let offset = 0; offset < catalog.entries.length; offset += 1000) {
|
||||
const rows = catalog.entries
|
||||
.slice(offset, offset + 1000)
|
||||
.map(([number, version, availability]) => {
|
||||
const pokemonId = pokemonIds.get(number),
|
||||
gameId = gameIds.get(version);
|
||||
if (!pokemonId || !gameId)
|
||||
throw new Error(`Unknown catalog reference ${number}/${version}`);
|
||||
return {
|
||||
pokemonId,
|
||||
gameId,
|
||||
availability,
|
||||
note:
|
||||
availability === 'catchable'
|
||||
? 'PokéAPI: documented encounter; conditions, location, or a specific form may be required.'
|
||||
: 'PokéAPI: Pokédex or game-index record only; catch, evolution, gift, trade, transfer, and event availability is not confirmed.'
|
||||
};
|
||||
});
|
||||
await tx
|
||||
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(rows)
|
||||
.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` },
|
||||
// Preserve hand-maintained availability and notes.
|
||||
setWhere: sql`${pokemonGameEntries.note} like 'PokéAPI:%'`
|
||||
});
|
||||
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}`
|
||||
);
|
||||
});
|
||||
console.log(
|
||||
`Seeded ${catalog.pokemon.length} species, ${catalog.games.length} versions, ${catalog.entries.length} entries from ${catalog.source.revision}`
|
||||
);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user