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',

View File

@@ -4,7 +4,6 @@
import ExternalLink from 'lucide-svelte/icons/external-link';
const projects = [
{ name: 'catchemall', href: '/projects/catch-em-all', external: false },
{ name: 'animaldex', href: '/projects/animaldex', external: false },
{ name: 'derby lens', href: '/projects/derby', external: false },
{ name: 'cs prices', href: '/projects/cs-prices', external: false },

View File

@@ -1 +0,0 @@
export {};

View File

@@ -1,7 +0,0 @@
<script lang="ts">
import './layout.css';
import type { LayoutProps } from './$types';
let { children }: LayoutProps = $props();
</script>
<div class="p-4">{@render children()}</div>

View File

@@ -0,0 +1,7 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
// Catchemall moved into the game tracker; keep old links and bookmarks working.
export const GET: RequestHandler = ({ url }) => {
redirect(308, `/projects/game-tracker/pokemon${url.search}`);
};

View File

@@ -1 +0,0 @@
@import 'tailwindcss';

View File

@@ -0,0 +1,116 @@
import { and, asc, countDistinct, eq, inArray, sql } from 'drizzle-orm';
import { db } from '$lib/server/db';
import {
catchEmAllSettings,
pokemonGameEntries,
trackerGames,
trackerItems,
trackerProgress,
userEntries,
userGames
} from '$lib/server/db/schema';
import { summarizeGame } from '$lib/game-tracker/mastery';
import type { PageServerLoad } from './$types';
const obtainable = inArray(pokemonGameEntries.availability, ['catchable', 'evolve', 'breed']);
async function pokemonSummary(userId: string | undefined) {
let gameIds: number[] | null = null;
if (userId) {
const [settings] = await db
.select({ id: catchEmAllSettings.id })
.from(catchEmAllSettings)
.where(eq(catchEmAllSettings.userId, userId));
const selected = await db
.select({ id: userGames.gameId })
.from(userGames)
.where(eq(userGames.userId, userId));
if (settings || selected.length) gameIds = selected.map((row) => row.id);
}
const scope =
gameIds === null ? obtainable : and(obtainable, inArray(pokemonGameEntries.gameId, gameIds));
const [totals] = await db
.select({
entries: sql<number>`count(*)`.mapWith(Number),
species: countDistinct(pokemonGameEntries.pokemonId),
games: countDistinct(pokemonGameEntries.gameId)
})
.from(pokemonGameEntries)
.where(scope);
const [caught] = userId
? await db
.select({
entries: sql<number>`count(*)`.mapWith(Number),
species: countDistinct(pokemonGameEntries.pokemonId)
})
.from(userEntries)
.innerJoin(pokemonGameEntries, eq(pokemonGameEntries.id, userEntries.pokemonGameEntryId))
.where(and(eq(userEntries.userId, userId), scope))
: [{ entries: 0, species: 0 }];
return { totals, caught };
}
async function callOfDutySummary(userId: string | undefined) {
const [gamesList, items, progress] = await Promise.all([
db
.select()
.from(trackerGames)
.where(eq(trackerGames.franchise, 'call-of-duty'))
.orderBy(asc(trackerGames.sortOrder)),
db
.select({
id: trackerItems.id,
gameId: trackerItems.gameId,
kind: trackerItems.kind,
name: trackerItems.name,
category: trackerItems.category,
postLaunch: trackerItems.postLaunch
})
.from(trackerItems),
userId
? db
.select({ itemId: trackerProgress.itemId, milestone: trackerProgress.milestone })
.from(trackerProgress)
.where(eq(trackerProgress.userId, userId))
: Promise.resolve([])
]);
const done = new Set(progress.map((row) => `${row.itemId}:${row.milestone}`));
const byGame = Map.groupBy(items, (item) => item.gameId);
return gamesList.map((game) => {
const list = byGame.get(game.id) ?? [];
const summary = summarizeGame(
game.rules,
list.filter((item) => item.kind === 'weapon'),
list.filter((item) => item.kind === 'achievement').map((item) => item.id),
(id, milestone) => done.has(`${id}:${milestone}`)
);
return {
slug: game.slug,
name: game.shortName,
releaseYear: game.releaseYear,
achievements: summary.achievements,
milestones: summary.milestones.map(({ key, label, value, total }) => ({
key,
label,
value,
total
})),
tiers: summary.tiers.map(({ key, label, scope, value, total, achieved }) => ({
key,
label,
scope,
value,
total,
achieved
}))
};
});
}
export const load: PageServerLoad = async ({ locals }) => {
const [pokemon, callOfDuty] = await Promise.all([
pokemonSummary(locals.user?.id),
callOfDutySummary(locals.user?.id)
]);
return { pokemon, callOfDuty };
};

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import { resolve } from '$app/paths';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
function percent(value: number, total: number) {
return total > 0 ? Math.round((value / total) * 100) : 0;
}
</script>
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">game tracker</h1>
<p class="mt-2 text-[var(--site-muted)]">
completionist progress: every Pokémon in every game, and every achievement, weapon and camo in
Call of Duty.
</p>
{#if !data.user}<p class="mt-6">
<a
href={resolve('/login?returnTo=/projects/game-tracker')}
class="underline underline-offset-4">sign in</a
> to save your progress.
</p>{/if}
<section class="mt-10">
<h2 class="text-2xl font-semibold">Pokémon</h2>
<a
href={resolve('/projects/game-tracker/pokemon')}
class="mt-4 block rounded-lg border border-[var(--site-border)] p-5 hover:bg-[var(--site-hover)]"
>
<div class="flex flex-wrap items-baseline justify-between gap-3">
<p class="text-xl font-semibold">catchemall</p>
<p class="text-xl font-semibold tabular-nums">
{percent(data.pokemon.caught.entries, data.pokemon.totals.entries)}%
</p>
</div>
<div class="mt-3">
<ProgressBar
value={data.pokemon.caught.entries}
max={data.pokemon.totals.entries}
label="Pokémon caught"
small
/>
</div>
<p class="mt-2 text-[var(--site-muted)]">
{data.pokemon.caught.entries} of {data.pokemon.totals.entries} obtainable entries ·
{data.pokemon.caught.species} of {data.pokemon.totals.species} species · {data.pokemon
.totals.games} games
</p>
</a>
<a
href={resolve('/projects/game-tracker/pokemon/locations')}
class="mt-3 inline-block underline underline-offset-4">browse Pokémon locations</a
>
</section>
<section class="mt-12">
<h2 class="text-2xl font-semibold">Call of Duty</h2>
<ul class="mt-4 grid gap-4 md:grid-cols-2">
{#each data.callOfDuty as game (game.slug)}
<li>
<a
href={resolve('/projects/game-tracker/call-of-duty/[slug]', { slug: game.slug })}
class="block h-full rounded-lg border border-[var(--site-border)] p-5 hover:bg-[var(--site-hover)]"
>
<div class="flex flex-wrap items-baseline justify-between gap-3">
<p class="text-xl font-semibold">{game.name}</p>
<p class="text-[var(--site-muted)]">{game.releaseYear}</p>
</div>
<dl class="mt-4 grid gap-3">
{#each [{ key: 'achievements', label: 'achievements', ...game.achievements }, ...game.milestones] as row (row.key)}
<div>
<div class="flex justify-between gap-3 text-[var(--site-muted)]">
<dt>{row.label}</dt>
<dd class="tabular-nums">{row.value}/{row.total}</dd>
</div>
<div class="mt-1">
<ProgressBar value={row.value} max={row.total} label={row.label} small />
</div>
</div>
{/each}
</dl>
{#if game.tiers.length}
<p class="mt-4 text-[var(--site-muted)]">
{game.tiers
.map((tier) =>
tier.scope === 'class'
? `${tier.label} ${tier.value}/${tier.total} classes`
: `${tier.label} ${tier.achieved ? '✓' : `${percent(tier.value, tier.total)}%`}`
)
.join(' · ')}
</p>
{/if}
</a>
</li>
{:else}
<li class="text-[var(--site-muted)]">the Call of Duty catalog has not been loaded yet.</li>
{/each}
</ul>
</section>
</div>

View File

@@ -0,0 +1,99 @@
import { error, fail } from '@sveltejs/kit';
import { and, asc, eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { trackerGames, trackerItems, trackerProgress } from '$lib/server/db/schema';
import { appliesTo } from '$lib/game-tracker/mastery';
import type { Actions, PageServerLoad } from './$types';
async function findGame(slug: string) {
const [game] = await db
.select()
.from(trackerGames)
.where(and(eq(trackerGames.slug, slug), eq(trackerGames.franchise, 'call-of-duty')));
if (!game) error(404, 'Game not found');
return game;
}
export const load: PageServerLoad = async ({ params, locals }) => {
const game = await findGame(params.slug);
const items = await db
.select({
id: trackerItems.id,
kind: trackerItems.kind,
category: trackerItems.category,
name: trackerItems.name,
description: trackerItems.description,
icon: trackerItems.icon,
postLaunch: trackerItems.postLaunch
})
.from(trackerItems)
.where(eq(trackerItems.gameId, game.id))
.orderBy(asc(trackerItems.kind), asc(trackerItems.sortOrder));
const progress = locals.user
? await db
.select({ itemId: trackerProgress.itemId, milestone: trackerProgress.milestone })
.from(trackerProgress)
.innerJoin(trackerItems, eq(trackerItems.id, trackerProgress.itemId))
.where(and(eq(trackerProgress.userId, locals.user.id), eq(trackerItems.gameId, game.id)))
: [];
return {
game: { slug: game.slug, name: game.name, shortName: game.shortName, rules: game.rules },
items,
done: progress.map((row) => `${row.itemId}:${row.milestone}`),
seo: {
title: `${game.shortName} · Game Tracker`,
description: `Track ${game.name} achievements, weapon levels and camo mastery.`
}
};
};
export const actions = {
toggle: async ({ request, locals, params }) => {
if (!locals.user) return fail(401, { message: 'Log in to track your progress' });
const game = await findGame(params.slug);
const data = await request.formData();
const itemId = Number(data.get('itemId'));
const milestone = String(data.get('milestone') ?? '');
if (!Number.isSafeInteger(itemId) || itemId <= 0 || itemId > 2147483647)
return fail(400, { message: 'Invalid item' });
const [item] = await db
.select()
.from(trackerItems)
.where(and(eq(trackerItems.id, itemId), eq(trackerItems.gameId, game.id)));
if (!item) return fail(404, { message: 'Unknown item' });
const valid =
item.kind === 'achievement'
? milestone === 'done'
: game.rules.milestones.some(
(rule) =>
rule.key === milestone &&
appliesTo(rule, {
id: item.id,
name: item.name,
category: item.category,
postLaunch: item.postLaunch
})
);
if (!valid) return fail(400, { message: 'That milestone does not apply here' });
if (data.has('done') && data.get('done') !== 'on')
return fail(400, { message: 'Invalid state' });
if (data.get('done') === 'on') {
await db
.insert(trackerProgress)
.values({ userId: locals.user.id, itemId, milestone })
.onConflictDoNothing();
} else {
await db
.delete(trackerProgress)
.where(
and(
eq(trackerProgress.userId, locals.user.id),
eq(trackerProgress.itemId, itemId),
eq(trackerProgress.milestone, milestone)
)
);
}
return { saved: true };
}
} satisfies Actions;

View File

@@ -0,0 +1,284 @@
<script lang="ts">
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { deserialize } from '$app/forms';
import { SvelteSet } from 'svelte/reactivity';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import { appliesTo, summarizeGame } from '$lib/game-tracker/mastery';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
// rebuilt whenever the page data changes, so the server render already shows progress
const done = $derived(new SvelteSet(data.done));
const pending = new SvelteSet<string>();
let saveError = $state('');
// checkboxes stay disabled until hydration, so early clicks can't silently go unsaved
let hydrated = $state(false);
onMount(() => (hydrated = true));
const isDone = (id: number, milestone: string) => done.has(`${id}:${milestone}`);
async function toggle(itemId: number, milestone: string, checked: boolean) {
const key = `${itemId}:${milestone}`;
if (pending.has(key)) return;
pending.add(key);
saveError = '';
if (checked) done.add(key);
else done.delete(key);
const body = new FormData();
body.set('itemId', String(itemId));
body.set('milestone', milestone);
if (checked) body.set('done', 'on');
let failed: boolean;
try {
const response = await fetch('?/toggle', { method: 'POST', body });
failed = deserialize(await response.text()).type !== 'success';
} catch {
failed = true;
}
pending.delete(key);
if (failed) {
saveError = 'Could not save your progress. Please try again, or log in again.';
if (checked) done.delete(key);
else done.add(key);
}
}
const rules = $derived(data.game.rules);
const weapons = $derived(data.items.filter((item) => item.kind === 'weapon'));
const achievements = $derived(data.items.filter((item) => item.kind === 'achievement'));
const summary = $derived(
summarizeGame(
rules,
weapons,
achievements.map((item) => item.id),
isDone
)
);
type Bar = {
label: string;
description?: string;
value: number;
total: number;
achieved: boolean;
};
const bars = $derived<Bar[]>([
{ label: 'achievements', ...summary.achievements },
...summary.milestones,
...summary.tiers.map((tier) => ({
...tier,
label: tier.scope === 'class' ? `${tier.label} classes` : tier.label
}))
]);
const classes = $derived(
rules.classes
.map((name) => {
const list = weapons.filter((weapon) => weapon.category === name);
const milestones = rules.milestones.filter((milestone) =>
list.some((weapon) => appliesTo(milestone, weapon))
);
const tiers = rules.tiers
.filter((tier) => tier.scope === 'class')
.map((tier) => ({ tier, count: summary.mastery.classTiers.get(tier.key)?.get(name) }))
.filter((entry) => entry.count);
return { name, list, milestones, tiers };
})
.filter((entry) => entry.list.length)
);
const categories = $derived([...Map.groupBy(achievements, (item) => item.category)]);
const tabs = ['weapons', 'achievements'] as const;
let tab = $state<(typeof tabs)[number]>('weapons');
let hideDone = $state(false);
function percent(value: number, total: number) {
return total > 0 ? Math.round((value / total) * 100) : 0;
}
</script>
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<a
href={resolve('/projects/game-tracker')}
class="text-[var(--site-muted)] hover:text-[var(--site-text)]">← game tracker</a
>
<h1 class="mt-3 text-4xl font-semibold tracking-tight sm:text-5xl">{data.game.shortName}</h1>
<p class="mt-2 text-[var(--site-muted)]">
{weapons.length} weapons · {achievements.length} achievements
</p>
{#if !data.user}<p class="mt-6">
<a
href={resolve(`/login?returnTo=/projects/game-tracker/call-of-duty/${data.game.slug}`)}
class="underline underline-offset-4">sign in</a
> to save your progress.
</p>{/if}
{#if saveError}<p role="alert" class="mt-5 text-red-500">{saveError}</p>{/if}
<div
class="mt-8 grid gap-x-10 gap-y-7 border-y border-[var(--site-border)] py-6 sm:grid-cols-2 lg:grid-cols-3"
>
{#each bars as bar (bar.label)}
<div class="min-w-0" title={bar.description}>
<div class="flex flex-wrap items-baseline justify-between gap-2">
<p class="font-medium">{bar.label}</p>
<p class="text-xl font-semibold tabular-nums">
{bar.achieved ? '✓' : `${percent(bar.value, bar.total)}%`}
</p>
</div>
<div class="mt-3">
<ProgressBar value={bar.value} max={bar.total} label={bar.label} small />
</div>
<p class="mt-2 text-[var(--site-muted)]">{bar.value} of {bar.total}</p>
</div>
{/each}
</div>
<div class="mt-8 flex flex-wrap items-center justify-between gap-4">
<div role="tablist" class="flex gap-6 text-lg">
{#each tabs as name (name)}
<button
role="tab"
type="button"
aria-selected={tab === name}
onclick={() => (tab = name)}
class={tab === name
? 'underline underline-offset-8'
: 'text-[var(--site-muted)] hover:text-[var(--site-text)]'}>{name}</button
>
{/each}
</div>
<label class="flex cursor-pointer items-center gap-3">
<input type="checkbox" bind:checked={hideDone} class="h-5 w-5 accent-[var(--site-text)]" />
hide finished
</label>
</div>
{#if tab === 'weapons'}
{#if rules.tiers.length || rules.milestones.length}
<p class="mt-4 max-w-3xl text-[var(--site-muted)]">
{[...rules.milestones, ...rules.tiers]
.filter((rule) => rule.description)
.map((rule) => `${rule.label}: ${rule.description}`)
.join('. ')}. DLC weapons don’t count toward class or overall camos.
</p>
{/if}
{#each classes as group (group.name)}
{@const rows = group.list.filter(
(weapon) =>
!hideDone ||
group.milestones.some(
(milestone) => appliesTo(milestone, weapon) && !isDone(weapon.id, milestone.key)
)
)}
<section class="mt-10">
<div class="flex flex-wrap items-baseline justify-between gap-3">
<h2 class="text-2xl font-semibold">{group.name}</h2>
<p class="text-[var(--site-muted)]">
{#each group.tiers as { tier, count } (tier.key)}
<span class={count?.achieved ? 'ml-4 text-[var(--site-text)]' : 'ml-4'}
>{tier.label}
{count?.achieved ? '✓' : `${count?.value}/${count?.total}`}</span
>
{/each}
</p>
</div>
{#if rows.length}
<table class="mt-3 w-full border-collapse">
<thead>
<tr class="border-b border-[var(--site-border)] text-left text-[var(--site-muted)]">
<th scope="col" class="py-2 font-medium">weapon</th>
{#each group.milestones as milestone (milestone.key)}<th
scope="col"
class="w-20 py-2 text-center font-medium sm:w-28"
title={milestone.description}>{milestone.label}</th
>{/each}
</tr>
</thead>
<tbody>
{#each rows as weapon (weapon.id)}
{@const tiers = summary.mastery.weaponTiers(weapon)}
<tr class="border-b border-[var(--site-border)]">
<th scope="row" class="py-2 text-left font-normal">
{weapon.name}
{#if weapon.postLaunch}<span class="ml-1 text-sm text-[var(--site-muted)]"
>DLC</span
>{/if}
{#if tiers.length}<span class="block text-sm text-[var(--site-muted)]"
>{tiers.map((tier) => tier.label).join(' · ')}</span
>{/if}
</th>
{#each group.milestones as milestone (milestone.key)}
<td class="py-2 text-center">
{#if appliesTo(milestone, weapon)}
<input
type="checkbox"
class="h-5 w-5 cursor-pointer accent-[var(--site-text)]"
checked={isDone(weapon.id, milestone.key)}
disabled={!hydrated ||
!data.user ||
pending.has(`${weapon.id}:${milestone.key}`)}
aria-label={`${weapon.name}: ${milestone.label}`}
title={data.user
? milestone.description
: 'Sign in to save your progress'}
onchange={(event) =>
toggle(weapon.id, milestone.key, event.currentTarget.checked)}
/>
{:else}<span class="text-[var(--site-muted)]">—</span>{/if}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
{:else}
<p class="mt-3 text-[var(--site-muted)]">all done.</p>
{/if}
</section>
{/each}
{:else}
{#each categories as [category, list] (category)}
{@const rows = list.filter((item) => !hideDone || !isDone(item.id, 'done'))}
<section class="mt-10">
<div class="flex flex-wrap items-baseline justify-between gap-3">
<h2 class="text-2xl font-semibold">{category}</h2>
<p class="text-[var(--site-muted)]">
{list.filter((item) => isDone(item.id, 'done')).length} of {list.length}
</p>
</div>
<ul class="mt-3">
{#each rows as item (item.id)}
<li class="border-t border-[var(--site-border)]">
<label class="flex cursor-pointer items-center gap-4 py-3">
{#if item.icon}<img
src={item.icon}
alt=""
loading="lazy"
width="48"
height="48"
class="h-12 w-12 shrink-0 rounded"
class:opacity-40={!isDone(item.id, 'done')}
/>{/if}
<span class="min-w-0 flex-1">
<span class="block font-medium">{item.name}</span>
{#if item.description}<span class="block text-[var(--site-muted)]"
>{item.description}</span
>{/if}
</span>
<input
type="checkbox"
class="h-5 w-5 shrink-0 cursor-pointer accent-[var(--site-text)]"
checked={isDone(item.id, 'done')}
disabled={!hydrated || !data.user || pending.has(`${item.id}:done`)}
aria-label={`Unlocked ${item.name}`}
onchange={(event) => toggle(item.id, 'done', event.currentTarget.checked)}
/>
</label>
</li>
{:else}
<li class="border-t border-[var(--site-border)] py-3 text-[var(--site-muted)]">
all unlocked.
</li>
{/each}
</ul>
</section>
{/each}
{/if}
</div>

View File

@@ -1,36 +1,52 @@
import { db } from '$lib/server/db';
import {
games,
pokemon as pokemonTable,
pokemonGameEntries,
userEntries,
userGames,
catchEmAllSettings
} from '$lib/server/db/schema';
import { and, eq, inArray } from 'drizzle-orm';
import { and, asc, eq, inArray } from 'drizzle-orm';
import type { Actions, PageServerLoad } from './$types';
import { availabilityValues } from '$lib/availability';
import { availabilityValues, obtainableAvailability, type Availability } from '$lib/availability';
import { requireAdmin } from '$lib/server/authorization';
import { fail } from '@sveltejs/kit';
export type WireEntry = [id: number, gameId: number, availability: number, note?: string];
export const load: PageServerLoad = async ({ locals }) => {
const pokemonRows = await db.query.pokemon.findMany({
with: {
entry: true
},
orderBy: { number: 'asc' }
});
const pokemon = pokemonRows.map(({ entry, ...rest }) => ({
...rest,
entries: entry.map((e): WireEntry => {
const [pokemonRows, entryRows, gameRows] = await Promise.all([
db
.select({
id: pokemonTable.id,
number: pokemonTable.number,
name: pokemonTable.name
})
.from(pokemonTable)
.orderBy(asc(pokemonTable.number)),
db
.select({
id: pokemonGameEntries.id,
pokemonId: pokemonGameEntries.pokemonId,
gameId: pokemonGameEntries.gameId,
availability: pokemonGameEntries.availability,
note: pokemonGameEntries.note
})
.from(pokemonGameEntries),
db
.select({ id: games.id, name: games.name, generation: games.generation })
.from(games)
.orderBy(asc(games.generation), asc(games.id))
]);
const entries = Map.groupBy(entryRows, (entry) => entry.pokemonId);
const pokemon = pokemonRows.map((species) => ({
...species,
entries: (entries.get(species.id) ?? []).map((e): WireEntry => {
const code = availabilityValues.indexOf(e.availability);
return e.note === null ? [e.id, e.gameId, code] : [e.id, e.gameId, code, e.note];
})
}));
const gameRows = await db.query.games.findMany({
orderBy: { generation: 'asc', id: 'asc' }
});
let caughtIds: number[] = [];
let selectedGameIds: number[] = [];
let gamesConfigured = false;
@@ -62,24 +78,28 @@ export const load: PageServerLoad = async ({ locals }) => {
};
};
function entryId(data: FormData) {
const id = Number(data.get('entryId'));
return Number.isSafeInteger(id) && id > 0 && id <= 2147483647 ? id : null;
}
export const actions = {
togglePokemonCaught: async ({ request, locals }) => {
if (!locals.user) return fail(401, { message: 'Log in to track your progress' });
const data = await request.formData();
const entryId = Number(data.get('entryId'));
if (!Number.isSafeInteger(entryId) || entryId <= 0 || entryId > 2147483647)
return fail(400, { message: 'Invalid entry' });
const id = entryId(data);
if (!id) return fail(400, { message: 'Invalid entry' });
// only catchable entries can be toggled — the client disables these
// checkboxes but we can't trust that
// only obtainable entries can be toggled — the client disables the others
// but we can't trust that
const [entry] = await db
.select({ availability: pokemonGameEntries.availability })
.from(pokemonGameEntries)
.where(eq(pokemonGameEntries.id, entryId));
.where(eq(pokemonGameEntries.id, id));
if (!entry) return fail(404, { message: 'Unknown entry' });
if (entry.availability !== 'catchable') {
return fail(400, { message: 'This entry does not have confirmed encounter data' });
if (!obtainableAvailability.has(entry.availability)) {
return fail(400, { message: 'This Pokémon has no confirmed way to obtain it in this game' });
}
if (data.has('caught') && data.get('caught') !== 'on')
@@ -89,14 +109,12 @@ export const actions = {
if (caught) {
await db
.insert(userEntries)
.values({ pokemonGameEntryId: entryId, userId: locals.user.id })
.values({ pokemonGameEntryId: id, userId: locals.user.id })
.onConflictDoNothing();
} else {
await db
.delete(userEntries)
.where(
and(eq(userEntries.pokemonGameEntryId, entryId), eq(userEntries.userId, locals.user.id))
);
.where(and(eq(userEntries.pokemonGameEntryId, id), eq(userEntries.userId, locals.user.id)));
}
return { saved: true };
},
@@ -134,5 +152,33 @@ export const actions = {
}
});
return { saved: true };
},
// Admin correction of how a Pokémon is obtained in one game. Manual entries
// survive reseeding; "reset" hands the entry back to the catalog.
saveEntry: async (event) => {
requireAdmin(event);
const data = await event.request.formData();
const id = entryId(data);
if (!id) return fail(400, { message: 'Invalid entry' });
if (data.get('reset') === 'on') {
await db
.update(pokemonGameEntries)
.set({ source: 'pokeapi' })
.where(eq(pokemonGameEntries.id, id));
return { saved: true, message: 'The catalog will restore this entry on the next seed.' };
}
const availability = String(data.get('availability'));
if (!(availabilityValues as readonly string[]).includes(availability))
return fail(400, { message: 'Invalid availability' });
const note = String(data.get('note') ?? '').trim();
if (note.length > 500) return fail(400, { message: 'Keep the note under 500 characters' });
const [updated] = await db
.update(pokemonGameEntries)
.set({ availability: availability as Availability, note: note || null, source: 'manual' })
.where(eq(pokemonGameEntries.id, id))
.returning({ id: pokemonGameEntries.id });
if (!updated) return fail(404, { message: 'Unknown entry' });
return { saved: true };
}
} satisfies Actions;

View File

@@ -1,22 +1,30 @@
<script lang="ts">
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { deserialize, enhance } from '$app/forms';
import { availabilityValues, type Availability } from '$lib/availability';
import { invalidateAll } from '$app/navigation';
import {
availabilityLabels,
availabilityValues,
obtainableAvailability,
type Availability
} from '$lib/availability';
import { SvelteSet } from 'svelte/reactivity';
import type { PageProps } from './$types';
import type { Game } from '$lib/server/db/schema';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import PokemonDetails from '$lib/catch-em-all/PokemonDetails.svelte';
import { summarizeProgress } from '$lib/catch-em-all/progress';
type Entry = { id: number; availability: Availability; note: string | null };
type Game = { id: number; name: string };
let { data, form }: PageProps = $props();
const caught = new SvelteSet<number>();
// rebuilt whenever the page data changes, so the server render already shows progress
const caught = $derived(new SvelteSet(data.caughtIds));
const pending = new SvelteSet<number>();
let saveError = $state('');
$effect(() => {
caught.clear();
for (const id of data.caughtIds) caught.add(id);
});
// checkboxes stay disabled until hydration, so early clicks can't silently go unsaved
let hydrated = $state(false);
onMount(() => (hydrated = true));
async function toggleCaught(entryId: number, checked: boolean) {
if (pending.has(entryId)) return;
@@ -77,7 +85,7 @@
unit: 'Pokémon'
},
{
label: 'seen in every catchable game',
label: 'caught in every game it’s in',
value: progress.completedPokemon,
total: progress.catchablePokemon,
unit: 'Pokémon'
@@ -94,15 +102,17 @@
}
let search = $state('');
let showNonCatchable = $state(false);
let showUnobtainable = $state(false);
let pageNumber = $state(1);
const pageSize = 25;
const filteredPokemon = $derived(
data.pokemon.filter((pokemon) => {
const availability =
viewGameId === null ? undefined : entryMap.get(pokemon.id)?.get(viewGameId)?.availability;
if (
!showNonCatchable &&
!showUnobtainable &&
viewGameId !== null &&
entryMap.get(pokemon.id)?.get(viewGameId)?.availability !== 'catchable'
!(availability && obtainableAvailability.has(availability))
)
return false;
return `${pokemon.number} ${pokemon.name}`
@@ -131,20 +141,30 @@
}
function tooltip(entry: Entry | undefined, game: Game): string | null {
if (!entry) return `No catalog record for ${game.name}; availability is not confirmed`;
if (obtainableAvailability.has(entry.availability)) return null;
const reason = {
catchable: null,
trade_only: 'Only obtainable by trading in this game',
transfer_only: 'Only obtainable by transferring from another game',
event_only: 'Only obtainable through an event distribution',
unknown: 'Availability has not been confirmed'
}[entry.availability];
if (reason === null) return null;
}[entry.availability as 'trade_only' | 'transfer_only' | 'event_only' | 'unknown'];
return entry.note ? `${reason} (${entry.note})` : reason;
}
let detailsDialog: HTMLDialogElement;
let detailNumber = $state<number | null>(null);
function openDetails(number: number) {
detailNumber = number;
detailsDialog.showModal();
}
</script>
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<div class="flex flex-wrap items-end justify-between gap-5">
<a
href={resolve('/projects/game-tracker')}
class="text-[var(--site-muted)] hover:text-[var(--site-text)]">← game tracker</a
>
<div class="mt-3 flex flex-wrap items-end justify-between gap-5">
<div>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">catchemall</h1>
<p class="mt-2 text-[var(--site-muted)]">
@@ -161,7 +181,7 @@
{#if !data.user}<p class="mt-6">
<a
href={resolve('/login?returnTo=/projects/catch-em-all')}
href={resolve('/login?returnTo=/projects/game-tracker/pokemon')}
class="underline underline-offset-4">sign in</a
> to save your progress.
</p>{/if}
@@ -185,7 +205,7 @@
/>
</div>
<p class="mt-2 text-base text-[var(--site-muted)]">
{progress.caughtEntries} of {progress.catchableEntries} catchable entries caught
{progress.caughtEntries} of {progress.catchableEntries} obtainable entries caught
</p>
<div class="mt-8 grid gap-x-10 gap-y-7 lg:grid-cols-3">
{#each progressBars as item (item.label)}
@@ -205,7 +225,7 @@
{/each}
</div>
<p class="mt-6 text-base text-[var(--site-muted)]">
counts use your selected games and confirmed catchable entries.
counts use your selected games. Pokémon you can evolve or breed there count as obtainable.
</p>
</div>
@@ -232,18 +252,24 @@
</select>
</label>
</div>
<label class="mt-5 flex cursor-pointer items-center gap-3">
<input
type="checkbox"
checked={showNonCatchable}
onchange={(event) => {
showNonCatchable = event.currentTarget.checked;
pageNumber = 1;
}}
class="h-5 w-5 accent-[var(--site-text)]"
/>
show non-catchable Pokémon
</label>
<div class="mt-5 flex flex-wrap items-center justify-between gap-4">
<label class="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
checked={showUnobtainable}
onchange={(event) => {
showUnobtainable = event.currentTarget.checked;
pageNumber = 1;
}}
class="h-5 w-5 accent-[var(--site-text)]"
/>
show Pokémon you can’t get in this game
</label>
<a
href={resolve('/projects/game-tracker/pokemon/locations')}
class="underline underline-offset-4">browse locations</a
>
</div>
{#if viewGame}
<div class="mt-8">
@@ -263,7 +289,11 @@
{@const reason = tooltip(entry, viewGame)}
<tr class="border-b border-[var(--site-border)]">
<th scope="row" class="py-2 text-left font-normal">
<div class="flex min-w-0 items-center gap-3">
<button
type="button"
onclick={() => openDetails(pokemon.number)}
class="flex w-full min-w-0 items-center gap-3 text-left hover:text-[var(--site-muted)]"
>
<img
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{pokemon.number}.png"
alt=""
@@ -274,9 +304,15 @@
/>
<span class="min-w-0"
><span class="mr-2 text-[var(--site-muted)]">#{pokemon.number}</span
>{pokemon.name}</span
>{pokemon.name}
{#if entry && reason === null}<span
class="block truncate text-sm text-[var(--site-muted)]"
>{availabilityLabels[entry.availability]}{entry.note
? ` · ${entry.note}`
: ''}</span
>{/if}</span
>
</div>
</button>
</th>
<td class="py-2 text-center">
{#if entry && reason === null}
@@ -284,7 +320,7 @@
type="checkbox"
class="h-5 w-5 cursor-pointer accent-[var(--site-text)]"
checked={caught.has(entry.id)}
disabled={!data.user || pending.has(entry.id)}
disabled={!hydrated || !data.user || pending.has(entry.id)}
aria-label={`Caught ${pokemon.name} in ${viewGame.name}`}
title={data.user ? (entry.note ?? undefined) : 'Sign in to save your progress'}
onchange={(event) => toggleCaught(entry.id, event.currentTarget.checked)}
@@ -335,13 +371,35 @@
<details class="mt-10 border-t border-[var(--site-border)] pt-5 text-[var(--site-muted)]">
<summary class="cursor-pointer">about availability</summary>
<p class="mt-3 max-w-3xl">
checkboxes represent confirmed encounters from PokéAPI. “?” means the Pokémon appears in a
game catalog but its acquisition method is unconfirmed. “—” means there is no source record.
Neither proves it is unobtainable.
the catalog is owned by this site and built from a pinned PokéAPI snapshot. Checkboxes appear
for Pokémon you can catch (wild, gift, static or in-game trade), evolve from something you can
get in that game, or breed. “·” marks event-only mythicals, “?” means the Pokémon is in the
game but no route is confirmed, and “—” means there is no record. Neither proves it is
unobtainable. Newer games (Brilliant Diamond and later) have no encounter data yet.
</p>
</details>
</div>
<dialog
bind:this={detailsDialog}
onclose={() => (detailNumber = null)}
class="m-auto max-h-[90vh] w-[calc(100%-2rem)] max-w-2xl overflow-y-auto rounded-xl border border-[var(--site-border)] bg-[var(--site-surface)] p-5 text-[var(--site-text)] backdrop:bg-black/70 sm:p-6"
>
<div class="flex justify-end">
<button type="button" onclick={() => detailsDialog.close()} class="text-[var(--site-muted)]"
>close</button
>
</div>
{#if detailNumber !== null}
<PokemonDetails
number={detailNumber}
gameId={viewGameId}
isAdmin={data.isAdmin}
onsaved={() => invalidateAll()}
/>
{/if}
</dialog>
<dialog
bind:this={rulesDialog}
class="m-auto w-[calc(100%-2rem)] max-w-2xl rounded-xl border border-[var(--site-border)] bg-[var(--site-surface)] p-5 text-[var(--site-text)] backdrop:bg-black/70 sm:p-6"

View File

@@ -0,0 +1,11 @@
import { error, json } from '@sveltejs/kit';
import { pokemonDetail } from '$lib/server/pokemon-detail';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url }) => {
const game = Number(url.searchParams.get('game'));
const gameId = Number.isSafeInteger(game) && game > 0 && game <= 2147483647 ? game : null;
const number = Number(params.number);
if (number > 100000) error(404, 'Unknown Pokémon');
return json(await pokemonDetail(number, gameId));
};

View File

@@ -0,0 +1,19 @@
import { asc, countDistinct, eq, sql } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { pokemonEncounters, pokemonLocations } from '$lib/server/db/schema';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => ({
locations: await db
.select({
id: pokemonLocations.id,
name: pokemonLocations.name,
region: pokemonLocations.region,
hasPicture: sql<boolean>`${pokemonLocations.imageKey} is not null`,
pokemon: countDistinct(pokemonEncounters.pokemonId)
})
.from(pokemonLocations)
.leftJoin(pokemonEncounters, eq(pokemonEncounters.locationId, pokemonLocations.id))
.groupBy(pokemonLocations.id)
.orderBy(asc(pokemonLocations.region), asc(pokemonLocations.name))
});

View File

@@ -0,0 +1,89 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let search = $state('');
let region = $state('');
let withPokemonOnly = $state(true);
const regions = $derived([...new Set(data.locations.map((l) => l.region ?? 'other'))]);
const grouped = $derived(
Map.groupBy(
data.locations.filter(
(location) =>
(!withPokemonOnly || location.pokemon > 0) &&
(!region || (location.region ?? 'other') === region) &&
location.name.toLowerCase().includes(search.trim().toLowerCase())
),
(location) => location.region ?? 'other'
)
);
</script>
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<a
href={resolve('/projects/game-tracker/pokemon')}
class="text-[var(--site-muted)] hover:text-[var(--site-text)]">← catchemall</a
>
<h1 class="mt-3 text-4xl font-semibold tracking-tight sm:text-5xl">locations</h1>
<p class="mt-2 text-[var(--site-muted)]">
{data.locations.length} places across every region. Open one to see which Pokémon live there.
</p>
<div class="mt-8 grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,16rem)]">
<label class="block"
>search
<input
type="search"
bind:value={search}
placeholder="route, city, cave…"
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-3"
/>
</label>
<label class="block"
>region
<select
bind:value={region}
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-3"
>
<option value="">all regions</option>
{#each regions as name (name)}<option value={name}>{name}</option>{/each}
</select>
</label>
</div>
<label class="mt-5 flex cursor-pointer items-center gap-3">
<input
type="checkbox"
bind:checked={withPokemonOnly}
class="h-5 w-5 accent-[var(--site-text)]"
/>
only places with recorded Pokémon
</label>
{#each [...grouped] as [name, locations] (name)}
<section class="mt-10">
<h2 class="text-2xl font-semibold">{name}</h2>
<ul class="mt-3 grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">
{#each locations as location (location.id)}
<li class="border-t border-[var(--site-border)]">
<a
href={resolve('/projects/game-tracker/pokemon/locations/[id=integer]', {
id: String(location.id)
})}
class="flex items-baseline justify-between gap-3 py-3 hover:text-[var(--site-muted)]"
>
<span>{location.name}</span>
<span class="shrink-0 text-sm text-[var(--site-muted)]"
>{location.pokemon ? `${location.pokemon} Pokémon` : ''}{location.hasPicture
? ' · picture'
: ''}</span
>
</a>
</li>
{/each}
</ul>
</section>
{:else}
<p class="mt-10 text-[var(--site-muted)]">no locations match.</p>
{/each}
</div>

View File

@@ -0,0 +1,101 @@
import { error, fail } from '@sveltejs/kit';
import { asc, eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { games, pokemon, pokemonEncounters, pokemonLocations } from '$lib/server/db/schema';
import { requireAdmin } from '$lib/server/authorization';
import { deleteObject, locationImageUrl, uploadImage } from '$lib/server/r2';
import type { Actions, PageServerLoad } from './$types';
const PICTURE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const MAX_PICTURE_BYTES = 5 * 1024 * 1024;
async function findLocation(id: string) {
const [location] = await db
.select()
.from(pokemonLocations)
.where(eq(pokemonLocations.id, Number(id)));
if (!location) error(404, 'Location not found');
return location;
}
export const load: PageServerLoad = async ({ params }) => {
const location = await findLocation(params.id);
const encounters = await db
.select({
gameId: games.id,
game: games.name,
number: pokemon.number,
name: pokemon.name,
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(games, eq(games.id, pokemonEncounters.gameId))
.innerJoin(pokemon, eq(pokemon.id, pokemonEncounters.pokemonId))
.where(eq(pokemonEncounters.locationId, location.id))
.orderBy(
asc(games.generation),
asc(games.id),
asc(pokemonEncounters.area),
asc(pokemonEncounters.method),
asc(pokemon.number)
);
return {
location: {
id: location.id,
name: location.name,
region: location.region,
description: location.description,
imageCredit: location.imageCredit,
imageUrl: locationImageUrl(location.imageKey)
},
encounters,
seo: {
title: `${location.name} · Pokémon locations`,
description: `Which Pokémon live in ${location.name}${location.region ? ` (${location.region})` : ''}, in which games, how to encounter them and at what levels.`
}
};
};
export const actions = {
save: async (event) => {
requireAdmin(event);
const location = await findLocation(event.params.id);
const data = await event.request.formData();
const description = String(data.get('description') ?? '').trim();
const imageCredit = String(data.get('imageCredit') ?? '').trim();
if (description.length > 5000 || imageCredit.length > 300)
return fail(400, { message: 'Description or credit is too long' });
let imageKey = location.imageKey;
const picture = data.get('picture');
if (picture instanceof File && picture.size > 0) {
if (!PICTURE_TYPES.includes(picture.type))
return fail(400, { message: 'Use a JPEG, PNG or WebP picture' });
if (picture.size > MAX_PICTURE_BYTES)
return fail(400, { message: 'Pictures can be at most 5 MB' });
try {
imageKey = (await uploadImage(picture, 'locations')).key;
} catch {
return fail(503, { message: 'Picture storage is not available right now' });
}
} else if (data.get('removePicture') === 'on') {
imageKey = null;
}
await db
.update(pokemonLocations)
.set({ description: description || null, imageCredit: imageCredit || null, imageKey })
.where(eq(pokemonLocations.id, location.id));
// Remove the replaced object only after the row no longer points at it.
if (location.imageKey && location.imageKey !== imageKey)
await deleteObject(location.imageKey).catch(() => undefined);
return { saved: true };
}
} satisfies Actions;

View File

@@ -0,0 +1,171 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { enhance } from '$app/forms';
import { page } from '$app/state';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
const games = $derived([
...new Map(data.encounters.map((encounter) => [encounter.gameId, encounter.game]))
]);
let gameId = $state<number | null>(null);
$effect(() => {
const requested = Number(page.url.searchParams.get('game'));
if (!games.some(([id]) => id === gameId))
gameId = games.find(([id]) => id === requested)?.[0] ?? games[0]?.[0] ?? null;
});
const rows = $derived(data.encounters.filter((encounter) => encounter.gameId === gameId));
const areas = $derived([...Map.groupBy(rows, (row) => row.area ?? '')]);
let saving = $state(false);
function levels(min: number | null, max: number | null) {
if (min === null) return '';
return min === max || max === null ? `Lv. ${min}` : `Lv. ${min}–${max}`;
}
</script>
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<a
href={resolve('/projects/game-tracker/pokemon/locations')}
class="text-[var(--site-muted)] hover:text-[var(--site-text)]">← locations</a
>
<h1 class="mt-3 text-4xl font-semibold tracking-tight sm:text-5xl">{data.location.name}</h1>
{#if data.location.region}<p class="mt-2 text-[var(--site-muted)]">{data.location.region}</p>{/if}
<div class="mt-8 grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<figure>
{#if data.location.imageUrl}
<img
src={data.location.imageUrl}
alt={data.location.name}
class="w-full rounded-lg border border-[var(--site-border)]"
/>
{#if data.location.imageCredit}<figcaption class="mt-2 text-sm text-[var(--site-muted)]">
{data.location.imageCredit}
</figcaption>{/if}
{:else}
<div
class="flex aspect-video items-center justify-center rounded-lg border border-dashed border-[var(--site-border)] text-[var(--site-muted)]"
>
no picture yet
</div>
{/if}
</figure>
<div>
{#if data.location.description}
<p class="whitespace-pre-line">{data.location.description}</p>
{:else}
<p class="text-[var(--site-muted)]">no notes about this place yet.</p>
{/if}
</div>
</div>
{#if data.isAdmin}
<form
method="POST"
action="?/save"
enctype="multipart/form-data"
class="mt-8 border-t border-[var(--site-border)] pt-6"
use:enhance={() => {
saving = true;
return async ({ update }) => {
await update({ reset: false });
saving = false;
};
}}
>
<h2 class="text-xl font-semibold">edit location</h2>
{#if form?.message}<p role="alert" class="mt-3 text-red-500">{form.message}</p>{/if}
<label class="mt-4 block"
>notes
<textarea
name="description"
rows="4"
maxlength="5000"
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
>{data.location.description ?? ''}</textarea
>
</label>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<label class="block"
>picture (JPEG, PNG or WebP, up to 5 MB)
<input
type="file"
name="picture"
accept="image/jpeg,image/png,image/webp"
class="mt-2 block w-full"
/>
</label>
<label class="block"
>picture credit
<input
name="imageCredit"
value={data.location.imageCredit ?? ''}
maxlength="300"
placeholder="e.g. my own screenshot"
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
/>
</label>
</div>
{#if data.location.imageUrl}<label class="mt-3 flex items-center gap-2"
><input type="checkbox" name="removePicture" /> remove the current picture</label
>{/if}
<button
disabled={saving}
class="mt-5 rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)] disabled:opacity-40"
>{saving ? 'saving…' : 'save'}</button
>
</form>
{/if}
<section class="mt-12">
<div class="flex flex-wrap items-end justify-between gap-4">
<h2 class="text-2xl font-semibold">Pokémon here</h2>
{#if games.length}
<label class="block"
>game
<select
bind:value={gameId}
class="mt-2 block rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
>
{#each games as [id, name] (id)}<option value={id}>{name}</option>{/each}
</select>
</label>
{/if}
</div>
{#each areas as [area, list] (area)}
{#if area}<h3 class="mt-6 font-semibold">{area}</h3>{/if}
<ul class="mt-2">
{#each list as encounter, index (index)}
<li class="flex items-center gap-3 border-t border-[var(--site-border)] py-2">
<img
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{encounter.number}.png"
alt=""
loading="lazy"
width="40"
height="40"
class="h-10 w-10 shrink-0"
/>
<span class="min-w-0">
<span class="mr-2 text-[var(--site-muted)]">#{encounter.number}</span>{encounter.name}
<span class="block text-sm 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>
</span>
</li>
{/each}
</ul>
{:else}
<p class="mt-4 text-[var(--site-muted)]">no Pokémon recorded here.</p>
{/each}
</section>
</div>

View File

@@ -11,7 +11,9 @@ const staticPaths = [
'/projects',
'/blog',
'/lists',
'/projects/catch-em-all',
'/projects/game-tracker',
'/projects/game-tracker/pokemon',
'/projects/game-tracker/pokemon/locations',
'/projects/animaldex',
'/projects/derby',
'/projects/spotify-beatmap-finder',