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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user