add game tracker with owned pokemon data and call of duty mastery
All checks were successful
Test and deploy staging / staging (push) Successful in 18s
All checks were successful
Test and deploy staging / staging (push) Successful in 18s
Fold catchemall into /projects/game-tracker (old URL redirects) and add Call of Duty tracking for Black Ops through WWII. - Pokémon catalog is now owned: locations, encounters and per-game evolution methods are built from the pinned PokéAPI snapshot and seeded. - Pokémon you can evolve or breed in a game count as obtainable; mythicals without another route are marked event-only. - Detail dialog with evolution chain and where to find it, location pages with admin notes and pictures, and admin corrections that survive reseeds. - Call of Duty weapons, milestones and Steam achievements per game, with derived Diamond / Dark Matter / Solar / Black Sky / Chrome tiers. - New migration (db:migrate:game-tracker), seeds (db:seed, db:seed:cod) and deploy preflight checks for them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,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'
|
||||
]);
|
||||
|
||||
225
src/lib/catch-em-all/PokemonDetails.svelte
Normal file
225
src/lib/catch-em-all/PokemonDetails.svelte
Normal 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}
|
||||
@@ -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,
|
||||
|
||||
@@ -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++;
|
||||
|
||||
183
src/lib/game-tracker/mastery.test.ts
Normal file
183
src/lib/game-tracker/mastery.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
149
src/lib/game-tracker/mastery.ts
Normal file
149
src/lib/game-tracker/mastery.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
61
src/lib/server/db/schema/game-tracker.ts
Normal file
61
src/lib/server/db/schema/game-tracker.ts
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
137
src/lib/server/pokemon-detail.ts
Normal file
137
src/lib/server/pokemon-detail.ts
Normal 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
|
||||
}))
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user