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

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:
2026-09-24 17:36:18 +00:00
parent 438623d857
commit a10585a4d2
49 changed files with 8236 additions and 170 deletions

View File

@@ -1,10 +1,27 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const catalog = JSON.parse(readFileSync('data/pokemon-catalog.json', 'utf8')) as {
pokemon: { number: number; name: string; hp: number; type1: string }[];
games: { sourceId: number; name: string }[];
entries: [number, number, string][];
pokemon: {
number: number;
name: string;
hp: number;
type1: string;
evolvesFrom: number | null;
}[];
games: { sourceId: number; name: string; slug: string }[];
entries: [number, number, string, string?][];
evolutions: [number, number, number[], string][];
};
const places = JSON.parse(readFileSync('data/pokemon-locations.json', 'utf8')) as {
locations: { sourceId: number; name: string }[];
methods: string[];
conditions: string[];
encounters: [number, number, number, string | null, number, number, number, ...unknown[]][];
};
const game = (slug: string) => catalog.games.find((g) => g.slug === slug)!.sourceId;
const entry = (number: number, slug: string) =>
catalog.entries.find(([p, g]) => p === number && g === game(slug));
describe('Pokémon catalog snapshot', () => {
it('contains the national species catalog and the latest source versions', () => {
expect(catalog.pokemon).toHaveLength(1025);
@@ -12,7 +29,8 @@ describe('Pokémon catalog snapshot', () => {
expect(catalog.pokemon.find((p) => p.number === 25)).toMatchObject({
name: 'Pikachu',
type1: 'Electric',
hp: 35
hp: 35,
evolvesFrom: 172
});
expect(catalog.games.some((g) => g.name.includes('Z-A'))).toBe(true);
});
@@ -24,12 +42,62 @@ describe('Pokémon catalog snapshot', () => {
expect(new Set(catalog.entries.map(([p, g]) => `${p}/${g}`)).size).toBe(catalog.entries.length);
expect(
catalog.entries.every(
([p, g, a]) => pokemon.has(p) && games.has(g) && ['catchable', 'unknown'].includes(a)
([p, g, a]) =>
pokemon.has(p) &&
games.has(g) &&
['catchable', 'unknown', 'evolve', 'breed', 'event_only'].includes(a)
)
).toBe(true);
});
it('does not invent exclusive availability from missing encounters', () => {
expect(catalog.entries.some(([, , a]) => a === 'catchable')).toBe(true);
expect(catalog.entries.some(([, , a]) => a === 'unknown')).toBe(true);
expect(catalog.entries.some(([, , a]) => a === 'trade_only' || a === 'transfer_only')).toBe(
false
);
});
it('derives evolution, breeding and event availability per game', () => {
expect(entry(9, 'red')).toEqual([9, game('red'), 'evolve', 'Evolve Wartortle: Level 36+']);
expect(entry(172, 'gold')).toEqual([172, game('gold'), 'breed', 'Breed Pikachu']);
expect(entry(298, 'emerald')?.[3]).toBe('Breed Marill holding a Sea Incense');
expect(entry(151, 'firered')?.[2]).toBe('event_only');
// the evolution method depends on the game
expect(entry(462, 'platinum')?.[3]).toBe('Evolve Magneton: Level up at Mt. Coronet');
expect(entry(462, 'heartgold')?.[2]).toBe('unknown');
expect(entry(26, 'sun')?.[3]).toBe('Evolve Pikachu: Use a Thunder Stone in Alola');
// no breeding in Let's Go
expect(catalog.entries.some(([, g, a]) => g === game('lets-go-pikachu') && a === 'breed')).toBe(
false
);
});
it('keeps evolution methods per game for present species only', () => {
const present = new Set(catalog.entries.map(([p, g]) => `${p}/${g}`));
for (const [from, to, versions] of catalog.evolutions)
for (const version of versions) {
expect(present.has(`${from}/${version}`)).toBe(true);
expect(present.has(`${to}/${version}`)).toBe(true);
}
});
});
describe('Pokémon locations snapshot', () => {
it('references known species, games, locations, methods and conditions', () => {
const pokemon = new Set(catalog.pokemon.map((p) => p.number));
const games = new Set(catalog.games.map((g) => g.sourceId));
const locations = new Set(places.locations.map((l) => l.sourceId));
expect(places.locations.length).toBeGreaterThan(1000);
expect(places.encounters.length).toBeGreaterThan(60000);
for (const [p, g, l, , method, min, max, , , conditions] of places.encounters) {
expect(pokemon.has(p) && games.has(g) && locations.has(l)).toBe(true);
expect(places.methods[method]).toBeTruthy();
expect(min).toBeLessThanOrEqual(max);
for (const index of conditions as number[]) expect(places.conditions[index]).toBeTruthy();
}
});
it('marks every species/game pair with an encounter as catchable', () => {
const catchable = new Set(
catalog.entries.filter(([, , a]) => a === 'catchable').map(([p, g]) => `${p}/${g}`)
);
for (const [p, g] of places.encounters) expect(catchable.has(`${p}/${g}`)).toBe(true);
});
});

View File

@@ -46,7 +46,25 @@ export const relations = defineRelations(schema, (r) => ({
entries: r.many.pokemonGameEntries()
},
pokemon: {
entry: r.many.pokemonGameEntries()
entry: r.many.pokemonGameEntries(),
encounters: r.many.pokemonEncounters()
},
pokemonLocations: {
encounters: r.many.pokemonEncounters()
},
pokemonEncounters: {
pokemon: r.one.pokemon({
from: r.pokemonEncounters.pokemonId,
to: r.pokemon.id
}),
game: r.one.games({
from: r.pokemonEncounters.gameId,
to: r.games.id
}),
location: r.one.pokemonLocations({
from: r.pokemonEncounters.locationId,
to: r.pokemonLocations.id
})
},
pokemonGameEntries: {
game: r.one.games({
@@ -80,6 +98,28 @@ export const relations = defineRelations(schema, (r) => ({
})
},
// ── Game tracker ──────────────────────────────
trackerGames: {
items: r.many.trackerItems()
},
trackerItems: {
game: r.one.trackerGames({
from: r.trackerItems.gameId,
to: r.trackerGames.id
}),
progress: r.many.trackerProgress()
},
trackerProgress: {
item: r.one.trackerItems({
from: r.trackerProgress.itemId,
to: r.trackerItems.id
}),
user: r.one.user({
from: r.trackerProgress.userId,
to: r.user.id
})
},
// ── Animaldex ─────────────────────────────────
animals: {
sightings: r.many.sightings()

View File

@@ -1,10 +1,15 @@
import { index, integer, pgEnum, pgTable, text, unique } from 'drizzle-orm/pg-core';
import { boolean, index, integer, pgEnum, pgTable, text, unique } from 'drizzle-orm/pg-core';
import { availabilityValues } from '../../../availability';
import { user } from './auth';
import { id, timestamps } from './helpers';
export const availability = pgEnum('availability', availabilityValues);
// Where a catalog row came from. Seeding replaces `pokeapi` and `derived` rows
// but never touches `manual` ones, which are admin corrections.
export const catalogSources = ['pokeapi', 'derived', 'manual'] as const;
const source = () => text('source', { enum: catalogSources }).notNull().default('pokeapi');
export const pokemon = pgTable('pokemon', {
id: id(),
number: integer('number').notNull().unique(),
@@ -18,14 +23,23 @@ export const pokemon = pgTable('pokemon', {
specialAttack: integer('special_attack').notNull().default(0),
specialDefense: integer('special_defense').notNull().default(0),
speed: integer('speed').notNull().default(0),
// national numbers, so the chain survives reseeding without id lookups
evolvesFrom: integer('evolves_from'),
evolutionChain: integer('evolution_chain'),
isBaby: boolean('is_baby').notNull().default(false),
isLegendary: boolean('is_legendary').notNull().default(false),
isMythical: boolean('is_mythical').notNull().default(false),
...timestamps
});
export const games = pgTable('games', {
id: id(),
sourceId: integer('source_id').unique(),
slug: text('slug').unique(),
name: text('name').notNull(),
generation: integer('generation').notNull().default(1),
sortOrder: integer('sort_order').notNull().default(0),
breeding: boolean('breeding').notNull().default(true),
...timestamps
});
@@ -40,13 +54,81 @@ export const pokemonGameEntries = pgTable(
.notNull()
.references(() => games.id, { onDelete: 'cascade' }),
availability: availability('availability').notNull().default('catchable'),
// optional extra detail for the tooltip, e.g. "trade with a Blue version owner"
// how to obtain it, e.g. "Evolve Wartortle: Level 36+" or "Route 22, Viridian Forest"
note: text('note'),
source: source(),
...timestamps
},
(table) => [unique().on(table.pokemonId, table.gameId), index().on(table.gameId)]
);
export const pokemonLocations = pgTable('pokemon_locations', {
id: id(),
sourceId: integer('source_id').unique(),
slug: text('slug').notNull(),
name: text('name').notNull(),
region: text('region'),
description: text('description'),
// R2 object key of an admin-uploaded picture
imageKey: text('image_key'),
imageCredit: text('image_credit'),
source: source(),
...timestamps
});
export const pokemonEncounters = pgTable(
'pokemon_encounters',
{
id: id(),
pokemonId: integer('pokemon_id')
.notNull()
.references(() => pokemon.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => games.id, { onDelete: 'cascade' }),
locationId: integer('location_id')
.notNull()
.references(() => pokemonLocations.id, { onDelete: 'cascade' }),
area: text('area'),
method: text('method').notNull(),
minLevel: integer('min_level'),
maxLevel: integer('max_level'),
// summed encounter-slot percentage; null for gifts and folded raids
rarity: integer('rarity'),
form: text('form'),
conditions: text('conditions').array().notNull().default([]),
note: text('note'),
source: source(),
...timestamps
},
(table) => [index().on(table.pokemonId, table.gameId), index().on(table.locationId)]
);
// The method that applies in each game, e.g. Magneton → Magnezone needs Mt.
// Coronet in Platinum but a Thunder Stone in Sword.
export const pokemonEvolutions = pgTable(
'pokemon_evolutions',
{
id: id(),
fromPokemonId: integer('from_pokemon_id')
.notNull()
.references(() => pokemon.id, { onDelete: 'cascade' }),
toPokemonId: integer('to_pokemon_id')
.notNull()
.references(() => pokemon.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => games.id, { onDelete: 'cascade' }),
method: text('method').notNull(),
source: source(),
...timestamps
},
(table) => [
unique().on(table.fromPokemonId, table.toPokemonId, table.gameId, table.method),
index().on(table.gameId)
]
);
export const userEntries = pgTable(
'user_entries',
{
@@ -94,3 +176,6 @@ export type PokemonGameEntry = typeof pokemonGameEntries.$inferSelect;
export type UserEntry = typeof userEntries.$inferSelect;
export type UserGame = typeof userGames.$inferSelect;
export type Availability = (typeof availability.enumValues)[number];
export type PokemonLocation = typeof pokemonLocations.$inferSelect;
export type PokemonEncounter = typeof pokemonEncounters.$inferSelect;
export type PokemonEvolution = typeof pokemonEvolutions.$inferSelect;

View File

@@ -0,0 +1,61 @@
import { boolean, index, integer, jsonb, pgTable, text, unique } from 'drizzle-orm/pg-core';
import type { MasteryRules } from '../../../game-tracker/mastery';
import { user } from './auth';
import { id, timestamps } from './helpers';
// Checklist-style games (Call of Duty and future franchises). Pokémon keeps its
// own species × game model in catch-em-all.ts.
export const trackerGames = pgTable('tracker_games', {
id: id(),
slug: text('slug').notNull().unique(),
franchise: text('franchise').notNull(),
name: text('name').notNull(),
shortName: text('short_name').notNull(),
releaseYear: integer('release_year'),
sortOrder: integer('sort_order').notNull().default(0),
rules: jsonb('rules').$type<MasteryRules>().notNull(),
...timestamps
});
export const trackerItems = pgTable(
'tracker_items',
{
id: id(),
gameId: integer('game_id')
.notNull()
.references(() => trackerGames.id, { onDelete: 'cascade' }),
// stable catalog key, e.g. `weapon:m8a7` or `311210:the-true-gunslinger`
key: text('key').notNull(),
kind: text('kind', { enum: ['achievement', 'weapon'] }).notNull(),
// achievement mode or weapon class
category: text('category').notNull(),
name: text('name').notNull(),
description: text('description'),
icon: text('icon'),
postLaunch: boolean('post_launch').notNull().default(false),
sortOrder: integer('sort_order').notNull().default(0),
...timestamps
},
(table) => [unique().on(table.gameId, table.key), index().on(table.gameId)]
);
// One row per completed milestone: `done` for achievements, or a weapon
// milestone key such as `gold` or `maxLevel`. Derived tiers are never stored.
export const trackerProgress = pgTable(
'tracker_progress',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
itemId: integer('item_id')
.notNull()
.references(() => trackerItems.id, { onDelete: 'cascade' }),
milestone: text('milestone').notNull().default('done'),
...timestamps
},
(table) => [unique().on(table.userId, table.itemId, table.milestone), index().on(table.userId)]
);
export type TrackerGame = typeof trackerGames.$inferSelect;
export type TrackerItem = typeof trackerItems.$inferSelect;

View File

@@ -4,6 +4,7 @@ export * from './auth';
export * from './blog';
export * from './progress';
export * from './catch-em-all';
export * from './game-tracker';
export * from './animaldex';
export * from './lists';
export * from './money-tracker';

View File

@@ -0,0 +1,137 @@
import { error } from '@sveltejs/kit';
import { and, asc, eq, inArray } from 'drizzle-orm';
import { db } from '$lib/server/db';
import {
games,
pokemon,
pokemonEncounters,
pokemonEvolutions,
pokemonGameEntries,
pokemonLocations
} from '$lib/server/db/schema';
export type PokemonDetail = Awaited<ReturnType<typeof pokemonDetail>>;
// Everything the detail panel shows for one species, scoped to a game when given.
export async function pokemonDetail(number: number, gameId: number | null) {
const [species] = await db.select().from(pokemon).where(eq(pokemon.number, number));
if (!species) error(404, 'Unknown Pokémon');
const chain = await db
.select({
id: pokemon.id,
number: pokemon.number,
name: pokemon.name,
evolvesFrom: pokemon.evolvesFrom
})
.from(pokemon)
.where(eq(pokemon.evolutionChain, species.evolutionChain ?? -1))
.orderBy(asc(pokemon.number));
const members = chain.length ? chain : [species];
const entries = await db
.select({
id: pokemonGameEntries.id,
gameId: pokemonGameEntries.gameId,
game: games.name,
availability: pokemonGameEntries.availability,
note: pokemonGameEntries.note,
source: pokemonGameEntries.source
})
.from(pokemonGameEntries)
.innerJoin(games, eq(games.id, pokemonGameEntries.gameId))
.where(eq(pokemonGameEntries.pokemonId, species.id))
.orderBy(asc(games.generation), asc(games.id));
// which chain members exist in the selected game at all
const present = gameId
? await db
.select({ id: pokemonGameEntries.pokemonId })
.from(pokemonGameEntries)
.where(
and(
eq(pokemonGameEntries.gameId, gameId),
inArray(
pokemonGameEntries.pokemonId,
members.map((member) => member.id)
)
)
)
: [];
const presentIds = new Set(present.map((row) => row.id));
const evolutions = gameId
? await db
.select({
from: pokemonEvolutions.fromPokemonId,
to: pokemonEvolutions.toPokemonId,
method: pokemonEvolutions.method
})
.from(pokemonEvolutions)
.where(
and(
eq(pokemonEvolutions.gameId, gameId),
inArray(
pokemonEvolutions.toPokemonId,
members.map((member) => member.id)
)
)
)
: [];
const encounters = gameId
? await db
.select({
locationId: pokemonLocations.id,
location: pokemonLocations.name,
region: pokemonLocations.region,
hasPicture: pokemonLocations.imageKey,
area: pokemonEncounters.area,
method: pokemonEncounters.method,
minLevel: pokemonEncounters.minLevel,
maxLevel: pokemonEncounters.maxLevel,
rarity: pokemonEncounters.rarity,
form: pokemonEncounters.form,
conditions: pokemonEncounters.conditions,
note: pokemonEncounters.note
})
.from(pokemonEncounters)
.innerJoin(pokemonLocations, eq(pokemonLocations.id, pokemonEncounters.locationId))
.where(
and(eq(pokemonEncounters.pokemonId, species.id), eq(pokemonEncounters.gameId, gameId))
)
.orderBy(
asc(pokemonLocations.name),
asc(pokemonEncounters.area),
asc(pokemonEncounters.method),
asc(pokemonEncounters.minLevel)
)
: [];
const numbers = new Map(members.map((member) => [member.id, member.number]));
return {
pokemon: {
number: species.number,
name: species.name,
generation: species.generation,
type1: species.type1,
type2: species.type2,
isBaby: species.isBaby,
isLegendary: species.isLegendary,
isMythical: species.isMythical
},
chain: members.map(({ id, number, name, evolvesFrom }) => ({
number,
name,
evolvesFrom,
inGame: presentIds.has(id)
})),
evolutions: evolutions.map((evolution) => ({
from: numbers.get(evolution.from)!,
to: numbers.get(evolution.to)!,
method: evolution.method
})),
entry: entries.find((entry) => entry.gameId === gameId) ?? null,
games: entries.map(({ gameId, game, availability }) => ({ gameId, game, availability })),
encounters: encounters.map(({ hasPicture, ...encounter }) => ({
...encounter,
hasPicture: !!hasPicture
}))
};
}

View File

@@ -46,7 +46,7 @@ const EXT: Record<AllowedMimeType, string> = {
/** Upload through the site server so browsers do not need R2 CORS access. */
export async function uploadImage(
file: File,
prefix: 'sightings' | 'lists' = 'sightings'
prefix: 'sightings' | 'lists' | 'locations' = 'sightings'
): Promise<{ key: string; publicUrl: string }> {
if (!isAllowedMimeType(file.type)) throw new Error('Unsupported image type');
const mimeType = file.type;
@@ -73,6 +73,13 @@ export function listImageUrl(key: string): string | null {
return `${env.R2_PUBLIC_URL.replace(/\/$/, '')}/${key}`;
}
/** Public URL of an uploaded Pokémon location picture, or null for foreign keys. */
export function locationImageUrl(key: string | null): string | null {
if (!key || !/^locations\/[0-9a-f-]{36}\.(jpg|png|webp)$/.test(key) || !env.R2_PUBLIC_URL)
return null;
return `${env.R2_PUBLIC_URL.replace(/\/$/, '')}/${key}`;
}
export function listImageKey(url: string): string | null {
const base = env.R2_PUBLIC_URL?.replace(/\/$/, '');
if (!base || !url.startsWith(`${base}/`)) return null;