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,5 +1,6 @@
// How a Pokémon can be obtained in a game. Missing rows mean no source record;
// they do not prove absence. Unknown rows establish presence, not availability.
// New values go at the end: the client receives availability as an index.
// Shared between the db schema and the client, which receives availability as
// an index into this array to keep the page payload small.
export const availabilityValues = [
@@ -7,7 +8,26 @@ export const availabilityValues = [
'trade_only',
'transfer_only',
'event_only',
'unknown'
'unknown',
'evolve',
'breed'
] as const;
export type Availability = (typeof availabilityValues)[number];
export const availabilityLabels: Record<Availability, string> = {
catchable: 'catch it',
trade_only: 'trade only',
transfer_only: 'transfer only',
event_only: 'event only',
unknown: 'not confirmed',
evolve: 'evolve it',
breed: 'breed it'
};
// Obtainable inside the game itself, so it counts toward progress and can be ticked.
export const obtainableAvailability: ReadonlySet<Availability> = new Set([
'catchable',
'evolve',
'breed'
]);

View File

@@ -0,0 +1,225 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { enhance } from '$app/forms';
import {
availabilityLabels,
availabilityValues,
obtainableAvailability
} from '$lib/availability';
import type { PokemonDetail } from '$lib/server/pokemon-detail';
let {
number,
gameId,
isAdmin = false,
onsaved
}: { number: number; gameId: number | null; isAdmin?: boolean; onsaved?: () => void } = $props();
let detail = $state<PokemonDetail | null>(null);
let loadError = $state('');
let formMessage = $state('');
let version = $state(0);
$effect(() => {
const controller = new AbortController();
const url = `/projects/game-tracker/pokemon/api/${number}${gameId ? `?game=${gameId}` : ''}`;
void version;
loadError = '';
fetch(url, { signal: controller.signal })
.then((response) => (response.ok ? response.json() : Promise.reject(response.status)))
.then((body: PokemonDetail) => (detail = body))
.catch((reason) => {
if (!controller.signal.aborted) loadError = `Could not load details (${reason}).`;
});
return () => controller.abort();
});
const current = $derived(detail?.pokemon.number === number ? detail : null);
const steps = $derived(
current
? current.chain
.map((member) => ({
member,
from: current.chain.find((other) => other.number === member.evolvesFrom)
}))
// without a game, show the whole chain; with one, only steps that exist in it
.filter(({ member, from }) => from && (!gameId || (member.inGame && from.inGame)))
.map(({ member, from }) => {
const methods = current.evolutions
.filter((evolution) => evolution.to === member.number)
.map((evolution) => evolution.method);
return { from: from?.name ?? '?', to: member.name, number: member.number, methods };
})
: []
);
const obtainableGames = $derived(
current?.games.filter((game) => obtainableAvailability.has(game.availability)) ?? []
);
const otherGames = $derived(
current?.games.filter((game) => !obtainableAvailability.has(game.availability)) ?? []
);
function levels(min: number | null, max: number | null) {
if (min === null) return '';
return min === max || max === null ? `Lv. ${min}` : `Lv. ${min}–${max}`;
}
</script>
{#if loadError}
<p role="alert" class="text-red-500">{loadError}</p>
{:else if !current}
<p class="text-[var(--site-muted)]">loading…</p>
{:else}
{@const species = current.pokemon}
<div class="flex flex-wrap items-center gap-5">
<img
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/{species.number}.png"
alt=""
width="120"
height="120"
class="h-28 w-28 shrink-0"
/>
<div class="min-w-0">
<p class="text-[var(--site-muted)]">#{species.number} · generation {species.generation}</p>
<h2 class="text-3xl font-semibold">{species.name}</h2>
<p class="mt-1 text-[var(--site-muted)]">
{species.type1}{species.type2 ? ` / ${species.type2}` : ''}
{#if species.isMythical}· mythical{:else if species.isLegendary}· legendary{/if}
{#if species.isBaby}· baby{/if}
</p>
</div>
</div>
{#if current.entry}
<section class="mt-6">
<h3 class="font-semibold">in {current.entry.game}</h3>
<p class="mt-1">
<span class="font-medium">{availabilityLabels[current.entry.availability]}</span>
{#if current.entry.note}<span class="text-[var(--site-muted)]">
· {current.entry.note}</span
>{/if}
</p>
{#if current.entry.source === 'manual'}<p class="mt-1 text-sm text-[var(--site-muted)]">
corrected by hand
</p>{/if}
</section>
{:else if gameId}
<p class="mt-6 text-[var(--site-muted)]">no catalog record for this game.</p>
{/if}
{#if steps.length}
<section class="mt-6">
<h3 class="font-semibold">evolution</h3>
<ul class="mt-2">
{#each steps as step (step.number)}
<li class="border-t border-[var(--site-border)] py-2">
<span class:font-semibold={step.number === species.number}>{step.from} → {step.to}</span
>
<span class="block text-[var(--site-muted)]">
{step.methods.length ? step.methods.join(' or ') : 'not possible in this game'}
</span>
</li>
{/each}
</ul>
</section>
{/if}
{#if current.encounters.length}
<section class="mt-6">
<h3 class="font-semibold">where to find it</h3>
<ul class="mt-2">
{#each current.encounters as encounter, index (index)}
<li class="border-t border-[var(--site-border)] py-2">
<a
href={resolve('/projects/game-tracker/pokemon/locations/[id=integer]', {
id: String(encounter.locationId)
})}
class="underline underline-offset-4">{encounter.location}</a
>{#if encounter.area}<span class="text-[var(--site-muted)]">
· {encounter.area}</span
>{/if}
<span class="block text-[var(--site-muted)]">
{[
encounter.method,
levels(encounter.minLevel, encounter.maxLevel),
encounter.rarity ? `${encounter.rarity}%` : '',
encounter.form ?? '',
...encounter.conditions,
encounter.note ?? ''
]
.filter(Boolean)
.join(' · ')}
</span>
</li>
{/each}
</ul>
</section>
{:else if current.entry?.availability === 'catchable'}
<p class="mt-6 text-[var(--site-muted)]">no encounter details recorded.</p>
{/if}
<section class="mt-6">
<h3 class="font-semibold">other games</h3>
<p class="mt-1">
{obtainableGames.length
? `obtainable in ${obtainableGames.map((game) => game.game).join(', ')}`
: 'not obtainable in any game on record'}
</p>
{#if otherGames.length}<p class="mt-1 text-[var(--site-muted)]">
also present in {otherGames.map((game) => game.game).join(', ')}
</p>{/if}
</section>
{#if isAdmin && current.entry}
<form
method="POST"
action="?/saveEntry"
class="mt-8 border-t border-[var(--site-border)] pt-5"
use:enhance={() => {
formMessage = '';
return async ({ result, update }) => {
await update({ reset: false });
if (result.type === 'success') {
formMessage = 'saved';
version++;
onsaved?.();
} else if (result.type === 'failure')
formMessage = String(result.data?.message ?? 'Could not save');
};
}}
>
<h3 class="font-semibold">correct this entry</h3>
<input type="hidden" name="entryId" value={current.entry.id} />
<div class="mt-3 grid gap-3 sm:grid-cols-[12rem_minmax(0,1fr)]">
<label class="block"
>availability
<select
name="availability"
value={current.entry.availability}
class="mt-1 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
>
{#each availabilityValues as value (value)}<option {value}
>{availabilityLabels[value]}</option
>{/each}
</select>
</label>
<label class="block"
>note
<input
name="note"
value={current.entry.note ?? ''}
maxlength="500"
class="mt-1 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
/>
</label>
</div>
<label class="mt-3 flex items-center gap-2 text-[var(--site-muted)]"
><input type="checkbox" name="reset" /> let the catalog manage this entry again</label
>
<div class="mt-4 flex items-center gap-4">
<button class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)]"
>save</button
>
{#if formMessage}<p role="status" class="text-[var(--site-muted)]">{formMessage}</p>{/if}
</div>
</form>
{/if}
{/if}

View File

@@ -4,6 +4,8 @@ import { summarizeProgress } from './progress';
const catchable = availabilityValues.indexOf('catchable');
const transferOnly = availabilityValues.indexOf('transfer_only');
const evolve = availabilityValues.indexOf('evolve');
const breed = availabilityValues.indexOf('breed');
const pokemon = [
{
id: 1,
@@ -23,6 +25,20 @@ const pokemon = [
] as const;
describe('Catchemall progress', () => {
it('counts Pokémon obtained by evolving or breeding as catchable', () => {
const chain = [
{ id: 7, entries: [[71, 1, catchable]] },
{ id: 9, entries: [[91, 1, evolve]] },
{ id: 172, entries: [[1721, 1, breed]] },
{ id: 151, entries: [[1511, 1, availabilityValues.indexOf('event_only')]] }
] as const;
expect(summarizeProgress(chain, new Set([1]), new Set([91]))).toMatchObject({
catchableEntries: 3,
caughtEntries: 1,
catchablePokemon: 3
});
});
it('counts unique Pokémon and completed games only from selected catchable entries', () => {
expect(summarizeProgress(pokemon, new Set([1, 2, 3]), new Set([11, 12, 31]))).toEqual({
catchableEntries: 3,

View File

@@ -1,4 +1,4 @@
import { availabilityValues } from '$lib/availability';
import { availabilityValues, obtainableAvailability } from '$lib/availability';
type PokemonWithEntries = {
id: number;
@@ -21,7 +21,10 @@ export function summarizeProgress(
let speciesEntries = 0;
let speciesCaught = 0;
for (const [entryId, gameId, availability] of species.entries) {
if (!selectedGameIds.has(gameId) || availabilityValues[availability] !== 'catchable')
if (
!selectedGameIds.has(gameId) ||
!obtainableAvailability.has(availabilityValues[availability])
)
continue;
speciesEntries++;

View File

@@ -0,0 +1,183 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { computeMastery, summarizeGame, type MasteryRules, type Weapon } from './mastery';
const rules: MasteryRules = {
steamApps: [],
classes: ['Rifles', 'Pistols', 'Melee'],
milestones: [
{ key: 'maxLevel', label: 'max level', classes: ['Rifles', 'Pistols'] },
{ key: 'gold', label: 'Gold' }
],
tiers: [
{ key: 'diamond', label: 'Diamond', scope: 'class', requires: 'gold' },
{ key: 'darkMatter', label: 'Dark Matter', scope: 'all', requires: 'diamond' }
]
};
const weapons: Weapon[] = [
{ id: 1, name: 'A', category: 'Rifles', postLaunch: false },
{ id: 2, name: 'B', category: 'Rifles', postLaunch: false },
{ id: 3, name: 'DLC', category: 'Rifles', postLaunch: true },
{ id: 4, name: 'P', category: 'Pistols', postLaunch: false },
{ id: 5, name: 'Knife', category: 'Melee', postLaunch: false }
];
const doneWith = (pairs: [number, string][]) => {
const set = new Set(pairs.map(([id, key]) => `${id}:${key}`));
return (id: number, key: string) => set.has(`${id}:${key}`);
};
const labels = (tiers: { key: string }[]) => tiers.map((tier) => tier.key);
describe('camo mastery', () => {
it('awards a class tier once every base weapon in the class has the milestone', () => {
const mastery = computeMastery(
rules,
weapons,
doneWith([
[1, 'gold'],
[2, 'gold']
])
);
expect(mastery.classTiers.get('diamond')?.get('Rifles')).toEqual({
value: 2,
total: 2,
achieved: true
});
expect(mastery.classTiers.get('diamond')?.get('Pistols')?.achieved).toBe(false);
expect(labels(mastery.weaponTiers(weapons[0]))).toEqual(['diamond']);
// post-launch weapons don't gate the tier and only earn it with their own Gold
expect(labels(mastery.weaponTiers(weapons[2]))).toEqual([]);
expect(mastery.allTiers.get('darkMatter')).toEqual({ value: 1, total: 3, achieved: false });
});
it('derives the overall tier from every class tier', () => {
const mastery = computeMastery(
rules,
weapons,
doneWith([
[1, 'gold'],
[2, 'gold'],
[3, 'gold'],
[4, 'gold'],
[5, 'gold']
])
);
expect(mastery.allTiers.get('darkMatter')?.achieved).toBe(true);
expect(labels(mastery.weaponTiers(weapons[2]))).toEqual(['diamond', 'darkMatter']);
expect(labels(mastery.weaponTiers(weapons[4]))).toEqual(['diamond', 'darkMatter']);
});
it('supports classes that depend on other classes and overall tiers on a subset', () => {
const custom: MasteryRules = {
...rules,
tiers: [
{
key: 'diamond',
label: 'Diamond',
scope: 'class',
requires: 'gold',
classRequirements: { Melee: ['Pistols', 'Melee'] }
},
{
key: 'solar',
label: 'Solar',
scope: 'all',
requires: 'diamond',
classes: ['Rifles', 'Melee']
}
]
};
const mastery = computeMastery(
custom,
weapons,
doneWith([
[1, 'gold'],
[2, 'gold'],
[5, 'gold']
])
);
expect(mastery.classTiers.get('diamond')?.get('Melee')).toEqual({
value: 1,
total: 2,
achieved: false
});
expect(mastery.allTiers.get('solar')).toEqual({ value: 1, total: 2, achieved: false });
});
it('summarizes milestones only for the weapons they apply to', () => {
const summary = summarizeGame(
rules,
weapons,
[10, 11],
doneWith([
[10, 'done'],
[1, 'maxLevel']
])
);
expect(summary.achievements).toEqual({ value: 1, total: 2, achieved: false });
expect(summary.milestones.map(({ key, value, total }) => [key, value, total])).toEqual([
['maxLevel', 1, 4],
['gold', 0, 5]
]);
expect(summary.tiers.map(({ key, total }) => [key, total])).toEqual([
['diamond', 3],
['darkMatter', 3]
]);
});
});
describe('Call of Duty catalog', () => {
const catalog = JSON.parse(readFileSync('data/call-of-duty.json', 'utf8')) as {
games: {
slug: string;
milestones: MasteryRules['milestones'];
tiers: MasteryRules['tiers'];
classes: { name: string; weapons: string[]; postLaunch?: string[] }[];
achievements: { key: string; name: string }[];
}[];
};
it('covers the tracked games with unique weapons and achievements', () => {
expect(catalog.games.map((game) => game.slug)).toEqual([
'black-ops',
'black-ops-2',
'ghosts',
'advanced-warfare',
'black-ops-3',
'infinite-warfare',
'wwii'
]);
for (const game of catalog.games) {
const names = game.classes.flatMap((c) => [...c.weapons, ...(c.postLaunch ?? [])]);
const perClass = game.classes.map((c) => c.name);
expect(new Set(perClass).size).toBe(perClass.length);
for (const c of game.classes)
expect(new Set([...c.weapons, ...(c.postLaunch ?? [])]).size).toBe(
c.weapons.length + (c.postLaunch?.length ?? 0)
);
expect(names.length).toBeGreaterThan(20);
// weapon progress is keyed by the slug of its name (scripts/seed-cod-catalog.ts)
const keys = names.map((name) => name.toLowerCase().replace(/[^a-z0-9]+/g, '-'));
expect(new Set(keys).size, game.slug).toBe(names.length);
expect(game.achievements.length).toBeGreaterThan(30);
expect(new Set(game.achievements.map((a) => a.key)).size).toBe(game.achievements.length);
}
});
it('only references known classes and milestones in mastery rules', () => {
for (const game of catalog.games) {
const classes = new Set(game.classes.map((c) => c.name));
const milestones = new Set(game.milestones.map((m) => m.key));
const tiers = new Set(game.tiers.map((t) => t.key));
for (const milestone of game.milestones)
for (const name of milestone.classes ?? []) expect(classes, name).toContain(name);
for (const tier of game.tiers) {
expect(milestones.has(tier.requires) || tiers.has(tier.requires), tier.key).toBe(true);
for (const name of [
...(tier.classes ?? []),
...Object.values(tier.classRequirements ?? {}).flat()
])
expect(classes, name).toContain(name);
}
}
});
});

View File

@@ -0,0 +1,149 @@
// Camo mastery for checklist games. Users tick per-weapon milestones (max level,
// Gold, …); class tiers (Diamond) and overall tiers (Dark Matter) are derived.
// Post-launch weapons never gate a tier. They earn an achieved tier once their
// own base milestone is done, as DLC weapons do in Black Ops III and WWII.
export type Milestone = {
key: string;
label: string;
description?: string;
// limit to these weapon classes; every class when omitted
classes?: string[];
exceptWeapons?: string[];
postLaunchOnly?: boolean;
};
export type Tier = {
key: string;
label: string;
scope: 'class' | 'all';
// a milestone key, or for `all` tiers also a class tier key
requires: string;
description?: string;
classes?: string[];
// class tiers only: the classes whose weapons a class depends on
classRequirements?: Record<string, string[]>;
};
export type MasteryRules = {
milestones: Milestone[];
tiers: Tier[];
classes: string[];
steamApps: { id: number; label: string }[];
};
export type Weapon = { id: number; name: string; category: string; postLaunch: boolean };
export type Count = { value: number; total: number; achieved: boolean };
export function appliesTo(milestone: Milestone, weapon: Weapon): boolean {
if (milestone.postLaunchOnly && !weapon.postLaunch) return false;
if (milestone.classes && !milestone.classes.includes(weapon.category)) return false;
return !milestone.exceptWeapons?.includes(weapon.name);
}
function count(value: number, total: number): Count {
return { value, total, achieved: total > 0 && value === total };
}
export function computeMastery(
rules: MasteryRules,
weapons: readonly Weapon[],
isDone: (weaponId: number, milestone: string) => boolean
) {
const milestones = new Map(rules.milestones.map((milestone) => [milestone.key, milestone]));
const tiers = new Map(rules.tiers.map((tier) => [tier.key, tier]));
const counted = (key: string, classes: readonly string[]) => {
const milestone = milestones.get(key);
return weapons.filter(
(weapon) =>
milestone &&
!weapon.postLaunch &&
classes.includes(weapon.category) &&
appliesTo(milestone, weapon)
);
};
const done = (list: readonly Weapon[], key: string) =>
list.filter((weapon) => isDone(weapon.id, key)).length;
const classTiers = new Map<string, Map<string, Count>>();
for (const tier of rules.tiers) {
if (tier.scope !== 'class') continue;
const perClass = new Map<string, Count>();
for (const name of tier.classes ?? rules.classes) {
const members = counted(tier.requires, tier.classRequirements?.[name] ?? [name]);
if (members.length) perClass.set(name, count(done(members, tier.requires), members.length));
}
classTiers.set(tier.key, perClass);
}
const allTiers = new Map<string, Count>();
for (const tier of rules.tiers) {
if (tier.scope !== 'all') continue;
const perClass = classTiers.get(tier.requires);
if (perClass) {
const classes = [...perClass].filter(
([name]) => !tier.classes || tier.classes.includes(name)
);
allTiers.set(
tier.key,
count(classes.filter(([, value]) => value.achieved).length, classes.length)
);
} else {
const members = counted(tier.requires, tier.classes ?? rules.classes);
allTiers.set(tier.key, count(done(members, tier.requires), members.length));
}
}
// the per-weapon milestone at the bottom of a tier chain, e.g. darkMatter → diamond → gold
const baseMilestone = (key: string): string => {
const tier = tiers.get(key);
return tier ? baseMilestone(tier.requires) : key;
};
function weaponTiers(weapon: Weapon): Tier[] {
return rules.tiers.filter((tier) => {
const base = baseMilestone(tier.key);
const milestone = milestones.get(base);
if (!milestone || !appliesTo(milestone, weapon)) return false;
const achieved =
tier.scope === 'class'
? classTiers.get(tier.key)?.get(weapon.category)?.achieved
: allTiers.get(tier.key)?.achieved &&
(!tier.classes || tier.classes.includes(weapon.category));
return !!achieved && (!weapon.postLaunch || isDone(weapon.id, base));
});
}
return { classTiers, allTiers, weaponTiers };
}
export function summarizeGame(
rules: MasteryRules,
weapons: readonly Weapon[],
achievementIds: readonly number[],
isDone: (itemId: number, milestone: string) => boolean
) {
const mastery = computeMastery(rules, weapons, isDone);
return {
achievements: count(
achievementIds.filter((id) => isDone(id, 'done')).length,
achievementIds.length
),
milestones: rules.milestones.map((milestone) => {
const list = weapons.filter((weapon) => appliesTo(milestone, weapon));
return {
...milestone,
...count(list.filter((weapon) => isDone(weapon.id, milestone.key)).length, list.length)
};
}),
tiers: rules.tiers.map((tier) => {
if (tier.scope === 'all') return { ...tier, ...mastery.allTiers.get(tier.key)! };
const perClass = [...(mastery.classTiers.get(tier.key)?.values() ?? [])];
return {
...tier,
...count(perClass.filter((value) => value.achieved).length, perClass.length)
};
}),
mastery
};
}

View File

@@ -6,9 +6,9 @@ export const projects = [
external: true
},
{
name: 'catchemall',
subtitle: 'catch 1025 pokemon across 53 games',
href: '/projects/catch-em-all',
name: 'game tracker',
subtitle: 'every pokemon in every game, and call of duty camo mastery',
href: '/projects/game-tracker',
external: false
},
{

View File

@@ -49,6 +49,6 @@ describe('public page metadata', () => {
}
}).indexable
).toBe(false);
expect(getSeo('/projects/catch-em-all', {}, 'STAGING').indexable).toBe(false);
expect(getSeo('/projects/game-tracker/pokemon', {}, 'STAGING').indexable).toBe(false);
});
});

View File

@@ -31,13 +31,24 @@ const publicPages: Record<string, { title: string; description: string }> = {
},
'/projects': {
title: 'Projects',
description: 'Software projects by Stan Runge, including Catchemall, Animaldex and Derby Lens.'
description:
'Software projects by Stan Runge, including Game Tracker, Animaldex and Derby Lens.'
},
'/blog': { title: 'Blog', description: 'Articles and notes by Stan Runge.' },
'/lists': { title: 'Lists', description: 'Lists collected and maintained by Stan Runge.' },
'/projects/catch-em-all': {
'/projects/game-tracker': {
title: 'Game Tracker',
description:
'Completionist progress for every Pokémon in every game and Call of Duty achievements, weapons and camos.'
},
'/projects/game-tracker/pokemon': {
title: 'Catchemall',
description: 'Track which Pokémon you have caught across 53 games and 1,025 Pokémon.'
description:
'Track which Pokémon you have caught across 53 games, with where to find them and how to evolve or breed them.'
},
'/projects/game-tracker/pokemon/locations': {
title: 'Pokémon locations',
description: 'Every Pokémon location by region, with the Pokémon you can find there.'
},
'/projects/animaldex': {
title: 'Animaldex',

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;

View File

@@ -4,7 +4,9 @@ import { formatTitle, getTitle, siteEnvironment } from './title';
describe('site titles', () => {
it('uses one suffix and an environment prefix', () => {
expect(getTitle('/', 'DEV')).toBe('(DEV) Home | Stan Runge');
expect(getTitle('/projects/catch-em-all', 'STAGING')).toBe('(STAGING) Catchemall | Stan Runge');
expect(getTitle('/projects/game-tracker/pokemon', 'STAGING')).toBe(
'(STAGING) Catchemall | Stan Runge'
);
expect(formatTitle('A blog post')).toBe('A blog post | Stan Runge');
});

View File

@@ -22,7 +22,8 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
'/login': 'Sign in',
'/register': 'Register',
'/account': 'Account',
'/projects/catch-em-all': 'Catchemall',
'/projects/game-tracker/pokemon': 'Catchemall',
'/projects/game-tracker/pokemon/locations': 'Pokémon locations',
'/projects/animaldex': 'Animaldex',
'/projects/animaldex/register': 'Log a sighting',
'/projects/animaldex/admin': 'Animaldex admin',