Expand website and game trackers
Some checks failed
Test and deploy staging / staging (push) Failing after 5m29s

This commit is contained in:
2026-09-25 22:43:04 +00:00
parent f6b34150d4
commit c2ba8febb4
4076 changed files with 97237 additions and 1453 deletions

View File

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

View File

@@ -0,0 +1,299 @@
import {
boolean,
check,
date,
index,
integer,
jsonb,
pgTable,
text,
unique
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { user } from './auth';
import { games, pokemon } from './catch-em-all';
import { trackerGames, trackerItems } from './game-tracker';
import { id, timestamps } from './helpers';
export const pokemonResearch = pgTable('pokemon_research', {
pokemonId: integer('pokemon_id')
.primaryKey()
.references(() => pokemon.id, { onDelete: 'cascade' }),
facts: jsonb('facts')
.$type<{
height: number;
weight: number;
baseExperience: number | null;
captureRate: number;
baseHappiness: number | null;
growthRate: string;
genderRate: number;
hatchCounter: number | null;
eggGroups: string[];
abilities: { name: string; hidden: boolean }[];
evYield: number[];
}>()
.notNull()
});
export const pokemonDexNumbers = pgTable(
'pokemon_dex_numbers',
{
id: id(),
pokemonId: integer('pokemon_id')
.notNull()
.references(() => pokemon.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => games.id, { onDelete: 'cascade' }),
dex: text('dex').notNull(),
dexName: text('dex_name').notNull(),
number: integer('number').notNull()
},
(t) => [unique().on(t.pokemonId, t.gameId, t.dex), index().on(t.gameId)]
);
export const pokemonMoves = pgTable('pokemon_moves', {
id: integer('id').primaryKey(),
name: text('name').notNull(),
type: text('type').notNull(),
category: text('category').notNull(),
power: integer('power'),
accuracy: integer('accuracy'),
pp: integer('pp'),
priority: integer('priority').notNull(),
target: text('target').notNull()
});
export const pokemonLearnsets = pgTable(
'pokemon_learnsets',
{
id: id(),
pokemonId: integer('pokemon_id')
.notNull()
.references(() => pokemon.id, { onDelete: 'cascade' }),
versionGroup: integer('version_group').notNull(),
moves: jsonb('moves').$type<[moveId: number, method: string, level: number][]>().notNull()
},
(t) => [unique().on(t.pokemonId, t.versionGroup)]
);
export const pokemonGameResearch = pgTable('pokemon_game_research', {
gameId: integer('game_id')
.primaryKey()
.references(() => games.id, { onDelete: 'cascade' }),
versionGroup: integer('version_group').notNull(),
regions: text('regions').array().notNull()
});
export const pokemonJourneys = pgTable(
'pokemon_journeys',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => games.id, { onDelete: 'cascade' }),
stage: text('stage', { enum: ['story', 'postgame', 'collection'] })
.notNull()
.default('story'),
location: text('location'),
completed: boolean('completed').notNull().default(false),
...timestamps
},
(t) => [unique().on(t.userId, t.gameId)]
);
export const pokemonSeen = pgTable(
'pokemon_seen',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => games.id, { onDelete: 'cascade' }),
pokemonId: integer('pokemon_id')
.notNull()
.references(() => pokemon.id, { onDelete: 'cascade' }),
...timestamps
},
(t) => [unique().on(t.userId, t.gameId, t.pokemonId), index().on(t.userId)]
);
export const pokemonCatches = pgTable(
'pokemon_catches',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => games.id, { onDelete: 'cascade' }),
pokemonId: integer('pokemon_id')
.notNull()
.references(() => pokemon.id, { onDelete: 'cascade' }),
nickname: text('nickname').notNull().default(''),
level: integer('level').notNull().default(1),
moves: text('moves').array().notNull().default([]),
partySlot: integer('party_slot'),
shiny: boolean('shiny').notNull().default(false),
nature: text('nature'),
ability: text('ability'),
ball: text('ball'),
location: text('location'),
obtained: text('obtained').notNull().default('caught'),
stage: text('stage', { enum: ['story', 'postgame', 'collection'] })
.notNull()
.default('story'),
notes: text('notes').notNull().default(''),
caughtOn: date('caught_on'),
...timestamps
},
(t) => [
index().on(t.userId, t.gameId, t.pokemonId),
unique().on(t.userId, t.gameId, t.partySlot),
check('pokemon_catch_level', sql`${t.level} between 1 and 100`),
check('pokemon_party_slot', sql`${t.partySlot} between 1 and 6`),
check('pokemon_four_moves', sql`cardinality(${t.moves}) <= 4`)
]
);
export const pokemonMilestones = pgTable(
'pokemon_milestones',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => games.id, { onDelete: 'cascade' }),
key: text('key').notNull(),
title: text('title').notNull(),
category: text('category').notNull(),
stage: text('stage').notNull().default('story'),
done: boolean('done').notNull().default(false),
notes: text('notes').notNull().default(''),
...timestamps
},
(t) => [unique().on(t.userId, t.gameId, t.key)]
);
export const trackerRanks = pgTable(
'tracker_ranks',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => trackerGames.id, { onDelete: 'cascade' }),
level: integer('level').notNull().default(1),
prestige: integer('prestige').notNull().default(0),
...timestamps
},
(t) => [
unique().on(t.userId, t.gameId),
check(
'tracker_rank_range',
sql`${t.level} between 1 and 10000 and ${t.prestige} between 0 and 1000`
)
]
);
export const trackerWeaponRanks = pgTable(
'tracker_weapon_ranks',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
itemId: integer('item_id')
.notNull()
.references(() => trackerItems.id, { onDelete: 'cascade' }),
level: integer('level').notNull().default(1),
prestige: integer('prestige').notNull().default(0),
...timestamps
},
(t) => [
unique().on(t.userId, t.itemId),
check(
'tracker_weapon_rank_range',
sql`${t.level} between 1 and 10000 and ${t.prestige} between 0 and 1000`
)
]
);
export const trackerChallenges = pgTable(
'tracker_challenges',
{
id: id(),
gameId: integer('game_id')
.notNull()
.references(() => trackerGames.id, { onDelete: 'cascade' }),
key: text('key').notNull(),
name: text('name').notNull(),
category: text('category').notNull(),
requirement: text('requirement').notNull(),
reward: text('reward'),
icon: text('icon'),
target: integer('target'),
sourceUrl: text('source_url').notNull()
},
(t) => [unique().on(t.gameId, t.key)]
);
export const trackerChallengeProgress = pgTable(
'tracker_challenge_progress',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
challengeId: integer('challenge_id')
.notNull()
.references(() => trackerChallenges.id, { onDelete: 'cascade' }),
current: integer('current').notNull().default(0),
done: boolean('done').notNull().default(false),
...timestamps
},
(t) => [unique().on(t.userId, t.challengeId)]
);
export const osuEntries = pgTable(
'osu_entries',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
kind: text('kind', { enum: ['score', 'goal', 'training'] }).notNull(),
title: text('title').notNull(),
mode: text('mode').notNull().default('osu'),
beatmapUrl: text('beatmap_url'),
scoreUrl: text('score_url'),
goal: text('goal').notNull().default('pass'),
skill: text('skill').notNull().default('stamina'),
bpm: integer('bpm'),
duration: integer('duration'),
accuracy: text('accuracy'),
combo: integer('combo'),
misses: integer('misses'),
pp: text('pp'),
mods: text('mods').notNull().default('NM'),
rate: text('rate').notNull().default('1.00'),
achieved: boolean('achieved').notNull().default(false),
playedOn: date('played_on').notNull(),
notes: text('notes').notNull().default(''),
...timestamps
},
(t) => [index().on(t.userId, t.kind)]
);
export type PokemonCatch = typeof pokemonCatches.$inferSelect;

View File

@@ -1,5 +1,6 @@
import { error } from '@sveltejs/kit';
import { and, asc, eq, inArray } from 'drizzle-orm';
import { pokemonResearch, pokemonGameResearch, pokemonLearnsets, pokemonMoves } from './db/schema';
import { db } from '$lib/server/db';
import {
games,
@@ -104,8 +105,50 @@ export async function pokemonDetail(number: number, gameId: number | null) {
)
: [];
const [research] = await db
.select()
.from(pokemonResearch)
.where(eq(pokemonResearch.pokemonId, species.id));
const [gameResearch] = gameId
? await db.select().from(pokemonGameResearch).where(eq(pokemonGameResearch.gameId, gameId))
: [];
const [learnset] = gameResearch
? await db
.select()
.from(pokemonLearnsets)
.where(
and(
eq(pokemonLearnsets.pokemonId, species.id),
eq(pokemonLearnsets.versionGroup, gameResearch.versionGroup)
)
)
: [];
const moveIds = [...new Set((learnset?.moves ?? []).map((m) => m[0]))];
const moves = moveIds.length
? await db.select().from(pokemonMoves).where(inArray(pokemonMoves.id, moveIds))
: [];
const moveMap = new Map(moves.map((m) => [m.id, m]));
const numbers = new Map(members.map((member) => [member.id, member.number]));
return {
facts: research?.facts ?? null,
stats: [
{ name: 'HP', value: species.hp },
{ name: 'Attack', value: species.attack },
{ name: 'Defense', value: species.defense },
{ name: 'Sp. Atk', value: species.specialAttack },
{ name: 'Sp. Def', value: species.specialDefense },
{ name: 'Speed', value: species.speed }
],
learnset: (learnset?.moves ?? [])
.flatMap(([id, method, level]) => {
const move = moveMap.get(id);
return move ? [{ ...move, method, level }] : [];
})
.sort(
(a, b) =>
a.method.localeCompare(b.method) || a.level - b.level || a.name.localeCompare(b.name)
),
pokemon: {
number: species.number,
name: species.name,

View File

@@ -0,0 +1,278 @@
import { and, asc, eq, sql } from 'drizzle-orm';
import { fail } from '@sveltejs/kit';
import type { Actions } from '@sveltejs/kit';
import { db } from './db';
import {
games,
pokemon,
pokemonCatches,
pokemonJourneys,
pokemonMilestones,
pokemonSeen,
pokemonGameEntries,
userEntries
} from './db/schema';
import { boundedInteger, isStage, obtainedMethods, validDate } from '$lib/game-tracker/journal';
// Serialize mutations for one user's game, including competing party assignments.
async function lockGame(
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
userId: string,
gameId: number
) {
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${userId}), ${gameId})`);
}
export const pokemonJournalActions = {
setSeen: async ({ request, locals }) => {
if (!locals.user) return fail(401, { message: 'Sign in to record a sighting.' });
const form = await request.formData();
const gameId = boundedInteger(form.get('gameId'), 1, 2147483647);
const pokemonId = boundedInteger(form.get('pokemonId'), 1, 2147483647);
const seen = form.get('seen');
if (!gameId || !pokemonId || !['on', 'off'].includes(String(seen)))
return fail(400, { message: 'Invalid sighting.' });
const [entry] = await db
.select({ id: pokemonGameEntries.id })
.from(pokemonGameEntries)
.where(
and(eq(pokemonGameEntries.gameId, gameId), eq(pokemonGameEntries.pokemonId, pokemonId))
);
if (!entry) return fail(404, { message: 'Species not found in this game.' });
return db.transaction(async (tx) => {
await lockGame(tx, locals.user!.id, gameId);
if (seen === 'on')
await tx
.insert(pokemonSeen)
.values({ userId: locals.user!.id, gameId, pokemonId })
.onConflictDoNothing();
else {
const [caught] = await tx
.select({ id: userEntries.id })
.from(userEntries)
.where(
and(
eq(userEntries.userId, locals.user!.id),
eq(userEntries.pokemonGameEntryId, entry.id)
)
);
if (caught) return fail(400, { message: 'Caught Pokémon also count as seen.' });
await tx
.delete(pokemonSeen)
.where(
and(
eq(pokemonSeen.userId, locals.user!.id),
eq(pokemonSeen.gameId, gameId),
eq(pokemonSeen.pokemonId, pokemonId)
)
);
}
return { saved: true };
});
},
setCompleted: async ({ request, locals }) => {
if (!locals.user) return fail(401, { message: 'Sign in to record a completed game.' });
const form = await request.formData();
const gameId = boundedInteger(form.get('gameId'), 1, 2147483647);
if (!gameId || !['on', 'off'].includes(String(form.get('completed'))))
return fail(400, { message: 'Invalid completion state.' });
if (!(await db.select({ id: games.id }).from(games).where(eq(games.id, gameId))).length)
return fail(404, { message: 'Game not found.' });
const completed = form.get('completed') === 'on';
await db
.insert(pokemonJourneys)
.values({ userId: locals.user.id, gameId, completed })
.onConflictDoUpdate({
target: [pokemonJourneys.userId, pokemonJourneys.gameId],
set: { completed, updatedAt: new Date() }
});
return { saved: true };
},
saveCatch: async ({ request, locals }) => {
if (!locals.user) return fail(401, { message: 'Sign in to register a catch.' });
const data = await request.formData();
const gameId = boundedInteger(data.get('gameId'), 1, 2147483647);
const pokemonId = boundedInteger(data.get('pokemonId'), 1, 2147483647);
const id = data.get('id') ? boundedInteger(data.get('id'), 1, 2147483647) : null;
const level = boundedInteger(data.get('level'), 1, 100);
const partySlot = data.get('partySlot') ? boundedInteger(data.get('partySlot'), 1, 6) : null;
const text = (key: string) => String(data.get(key) ?? '').trim();
const stage = text('stage');
const obtained = text('obtained');
const moves = data
.getAll('moves')
.map((v) => String(v).trim())
.filter(Boolean);
if (
!gameId ||
!pokemonId ||
!level ||
(data.get('id') && !id) ||
(data.get('partySlot') && !partySlot) ||
!isStage(stage) ||
!(obtainedMethods as readonly string[]).includes(obtained) ||
moves.length > 4 ||
moves.some((m) => m.length > 60) ||
new Set(moves).size !== moves.length ||
text('nickname').length > 50 ||
text('notes').length > 2000 ||
['location', 'nature', 'ability', 'ball'].some((k) => text(k).length > 150) ||
(text('caughtOn') && !validDate(text('caughtOn')))
)
return fail(400, {
message:
'Check the catch details: level 1–100, up to four different moves and a valid date.'
});
const [entry] = await db
.select()
.from(pokemonGameEntries)
.where(
and(eq(pokemonGameEntries.gameId, gameId), eq(pokemonGameEntries.pokemonId, pokemonId))
);
if (!entry) return fail(400, { message: 'This species has no catalog entry for this game.' });
const userId = locals.user.id;
const values = {
userId,
gameId,
pokemonId,
level,
partySlot,
stage,
obtained,
moves,
nickname: text('nickname'),
notes: text('notes'),
shiny: data.get('shiny') === 'on',
location: text('location') || null,
nature: text('nature') || null,
ability: text('ability') || null,
ball: text('ball') || null,
caughtOn: text('caughtOn') || null
};
return db.transaction(async (tx) => {
await lockGame(tx, userId, gameId);
if (id) {
const [owned] = await tx
.select({ id: pokemonCatches.id })
.from(pokemonCatches)
.where(
and(
eq(pokemonCatches.id, id),
eq(pokemonCatches.userId, userId),
eq(pokemonCatches.gameId, gameId),
eq(pokemonCatches.pokemonId, pokemonId)
)
);
if (!owned) return fail(404, { message: 'Catch not found.' });
}
if (partySlot)
await tx
.update(pokemonCatches)
.set({ partySlot: null })
.where(
and(
eq(pokemonCatches.userId, userId),
eq(pokemonCatches.gameId, gameId),
eq(pokemonCatches.partySlot, partySlot)
)
);
if (id)
await tx
.update(pokemonCatches)
.set(values)
.where(and(eq(pokemonCatches.id, id), eq(pokemonCatches.userId, userId)));
else await tx.insert(pokemonCatches).values(values);
await tx
.insert(userEntries)
.values({ userId, pokemonGameEntryId: entry.id })
.onConflictDoNothing();
await tx.insert(pokemonSeen).values({ userId, gameId, pokemonId }).onConflictDoNothing();
return { saved: true };
});
},
deleteCatch: async ({ request, locals }) => {
if (!locals.user) return fail(401, { message: 'Sign in to edit your catches.' });
const data = await request.formData();
const id = boundedInteger(data.get('id'), 1, 2147483647);
if (!id) return fail(400, { message: 'Invalid catch.' });
const [owned] = await db
.select()
.from(pokemonCatches)
.where(and(eq(pokemonCatches.id, id), eq(pokemonCatches.userId, locals.user.id)));
if (!owned) return fail(404, { message: 'Catch not found.' });
await db.transaction(async (tx) => {
await lockGame(tx, locals.user!.id, owned.gameId);
await tx
.delete(pokemonCatches)
.where(and(eq(pokemonCatches.id, id), eq(pokemonCatches.userId, locals.user!.id)));
// The historical caught checkmark deliberately survives deleting a journal record.
});
return { saved: true };
},
saveJourney: async ({ request, locals }) => {
if (!locals.user) return fail(401, { message: 'Sign in to save your adventure.' });
const data = await request.formData();
const gameId = boundedInteger(data.get('gameId'), 1, 2147483647);
const stage = String(data.get('stage') ?? '');
const location = String(data.get('location') ?? '').trim();
if (!gameId || !isStage(stage) || location.length > 150)
return fail(400, { message: 'Invalid journey.' });
if (!(await db.select({ id: games.id }).from(games).where(eq(games.id, gameId))).length)
return fail(404, { message: 'Game not found.' });
await db
.insert(pokemonJourneys)
.values({ userId: locals.user.id, gameId, stage, location: location || null })
.onConflictDoUpdate({
target: [pokemonJourneys.userId, pokemonJourneys.gameId],
set: { stage, location: location || null, updatedAt: new Date() }
});
return { saved: true };
},
saveMilestone: async ({ request, locals }) => {
if (!locals.user) return fail(401, { message: 'Sign in to track milestones.' });
const data = await request.formData();
const gameId = boundedInteger(data.get('gameId'), 1, 2147483647);
const text = (k: string) => String(data.get(k) ?? '').trim();
const key = text('key') || `custom:${crypto.randomUUID()}`;
if (
!gameId ||
!isStage(text('stage')) ||
!text('title') ||
text('title').length > 200 ||
text('notes').length > 2000 ||
key.length > 200 ||
!['badge', 'league', 'boss', 'event', 'collection'].includes(text('category'))
)
return fail(400, { message: 'Check the milestone details.' });
if (!(await db.select({ id: games.id }).from(games).where(eq(games.id, gameId))).length)
return fail(404, { message: 'Game not found.' });
const values = {
userId: locals.user.id,
gameId,
key,
title: text('title'),
category: text('category'),
stage: text('stage'),
notes: text('notes'),
done: data.get('done') === 'on'
};
await db
.insert(pokemonMilestones)
.values(values)
.onConflictDoUpdate({
target: [pokemonMilestones.userId, pokemonMilestones.gameId, pokemonMilestones.key],
set: values
});
return { saved: true };
}
} satisfies Actions;
export async function catchesFor(userId: string | undefined, gameId: number) {
if (!userId) return [];
return db
.select({ catch: pokemonCatches, number: pokemon.number, name: pokemon.name })
.from(pokemonCatches)
.innerJoin(pokemon, eq(pokemon.id, pokemonCatches.pokemonId))
.where(and(eq(pokemonCatches.userId, userId), eq(pokemonCatches.gameId, gameId)))
.orderBy(asc(pokemon.number), asc(pokemonCatches.id));
}

View File

@@ -0,0 +1,95 @@
import { eq, count } from 'drizzle-orm';
import { db } from './db';
import { pokemonProgress } from '$lib/game-tracker/progress';
import {
games,
pokemon,
pokemonGameEntries,
pokemonGameResearch,
pokemonDexNumbers,
pokemonJourneys,
pokemonMilestones,
pokemonSeen,
userEntries,
userGames,
catchEmAllSettings
} from './db/schema';
export async function loadPokemonProgress(userId?: string) {
const [
gameRows,
entries,
research,
dex,
caught,
seen,
journeys,
milestones,
selected,
settings,
totals
] = await Promise.all([
db.select({ id: games.id, name: games.name, slug: games.slug }).from(games),
db
.select({
id: pokemonGameEntries.id,
gameId: pokemonGameEntries.gameId,
pokemonId: pokemonGameEntries.pokemonId,
availability: pokemonGameEntries.availability
})
.from(pokemonGameEntries),
db
.select({ gameId: pokemonGameResearch.gameId, regions: pokemonGameResearch.regions })
.from(pokemonGameResearch),
db
.select({ gameId: pokemonDexNumbers.gameId, pokemonId: pokemonDexNumbers.pokemonId })
.from(pokemonDexNumbers),
userId
? db
.select({ id: userEntries.pokemonGameEntryId })
.from(userEntries)
.where(eq(userEntries.userId, userId))
: [],
userId
? db
.select({ gameId: pokemonSeen.gameId, pokemonId: pokemonSeen.pokemonId })
.from(pokemonSeen)
.where(eq(pokemonSeen.userId, userId))
: [],
userId
? db
.select({ gameId: pokemonJourneys.gameId, completed: pokemonJourneys.completed })
.from(pokemonJourneys)
.where(eq(pokemonJourneys.userId, userId))
: [],
userId ? db.select().from(pokemonMilestones).where(eq(pokemonMilestones.userId, userId)) : [],
userId
? db.select({ gameId: userGames.gameId }).from(userGames).where(eq(userGames.userId, userId))
: [],
userId
? db
.select({ id: catchEmAllSettings.id })
.from(catchEmAllSettings)
.where(eq(catchEmAllSettings.userId, userId))
: [],
db.select({ count: count() }).from(pokemon)
]);
const configured = settings.length > 0 || selected.length > 0;
const ids = new Set(selected.map((g) => g.gameId));
const regions = new Map(research.map((r) => [r.gameId, r.regions]));
return {
...pokemonProgress({
games: gameRows
.filter((g) => !configured || ids.has(g.id))
.map((g) => ({ ...g, regions: regions.get(g.id) ?? [] })),
entries,
dex,
caughtIds: caught.map((c) => c.id),
seen,
journeys,
milestones,
speciesTotal: totals[0].count
}),
configured
};
}