Expand website and game trackers
Some checks failed
Test and deploy staging / staging (push) Failing after 5m29s
Some checks failed
Test and deploy staging / staging (push) Failing after 5m29s
This commit is contained in:
@@ -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';
|
||||
|
||||
299
src/lib/server/db/schema/tracker-journal.ts
Normal file
299
src/lib/server/db/schema/tracker-journal.ts
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
278
src/lib/server/pokemon-journal.ts
Normal file
278
src/lib/server/pokemon-journal.ts
Normal 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));
|
||||
}
|
||||
95
src/lib/server/tracker-progress.ts
Normal file
95
src/lib/server/tracker-progress.ts
Normal 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user