add game tracker with owned pokemon data and call of duty mastery
All checks were successful
Test and deploy staging / staging (push) Successful in 18s

Fold catchemall into /projects/game-tracker (old URL redirects) and add
Call of Duty tracking for Black Ops through WWII.

- Pokémon catalog is now owned: locations, encounters and per-game
  evolution methods are built from the pinned PokéAPI snapshot and seeded.
- Pokémon you can evolve or breed in a game count as obtainable; mythicals
  without another route are marked event-only.
- Detail dialog with evolution chain and where to find it, location pages
  with admin notes and pictures, and admin corrections that survive reseeds.
- Call of Duty weapons, milestones and Steam achievements per game, with
  derived Diamond / Dark Matter / Solar / Black Sky / Chrome tiers.
- New migration (db:migrate:game-tracker), seeds (db:seed, db:seed:cod) and
  deploy preflight checks for them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 17:36:18 +00:00
parent 438623d857
commit a10585a4d2
49 changed files with 8236 additions and 170 deletions

View File

@@ -46,7 +46,25 @@ export const relations = defineRelations(schema, (r) => ({
entries: r.many.pokemonGameEntries()
},
pokemon: {
entry: r.many.pokemonGameEntries()
entry: r.many.pokemonGameEntries(),
encounters: r.many.pokemonEncounters()
},
pokemonLocations: {
encounters: r.many.pokemonEncounters()
},
pokemonEncounters: {
pokemon: r.one.pokemon({
from: r.pokemonEncounters.pokemonId,
to: r.pokemon.id
}),
game: r.one.games({
from: r.pokemonEncounters.gameId,
to: r.games.id
}),
location: r.one.pokemonLocations({
from: r.pokemonEncounters.locationId,
to: r.pokemonLocations.id
})
},
pokemonGameEntries: {
game: r.one.games({
@@ -80,6 +98,28 @@ export const relations = defineRelations(schema, (r) => ({
})
},
// ── Game tracker ──────────────────────────────
trackerGames: {
items: r.many.trackerItems()
},
trackerItems: {
game: r.one.trackerGames({
from: r.trackerItems.gameId,
to: r.trackerGames.id
}),
progress: r.many.trackerProgress()
},
trackerProgress: {
item: r.one.trackerItems({
from: r.trackerProgress.itemId,
to: r.trackerItems.id
}),
user: r.one.user({
from: r.trackerProgress.userId,
to: r.user.id
})
},
// ── Animaldex ─────────────────────────────────
animals: {
sightings: r.many.sightings()

View File

@@ -1,10 +1,15 @@
import { index, integer, pgEnum, pgTable, text, unique } from 'drizzle-orm/pg-core';
import { boolean, index, integer, pgEnum, pgTable, text, unique } from 'drizzle-orm/pg-core';
import { availabilityValues } from '../../../availability';
import { user } from './auth';
import { id, timestamps } from './helpers';
export const availability = pgEnum('availability', availabilityValues);
// Where a catalog row came from. Seeding replaces `pokeapi` and `derived` rows
// but never touches `manual` ones, which are admin corrections.
export const catalogSources = ['pokeapi', 'derived', 'manual'] as const;
const source = () => text('source', { enum: catalogSources }).notNull().default('pokeapi');
export const pokemon = pgTable('pokemon', {
id: id(),
number: integer('number').notNull().unique(),
@@ -18,14 +23,23 @@ export const pokemon = pgTable('pokemon', {
specialAttack: integer('special_attack').notNull().default(0),
specialDefense: integer('special_defense').notNull().default(0),
speed: integer('speed').notNull().default(0),
// national numbers, so the chain survives reseeding without id lookups
evolvesFrom: integer('evolves_from'),
evolutionChain: integer('evolution_chain'),
isBaby: boolean('is_baby').notNull().default(false),
isLegendary: boolean('is_legendary').notNull().default(false),
isMythical: boolean('is_mythical').notNull().default(false),
...timestamps
});
export const games = pgTable('games', {
id: id(),
sourceId: integer('source_id').unique(),
slug: text('slug').unique(),
name: text('name').notNull(),
generation: integer('generation').notNull().default(1),
sortOrder: integer('sort_order').notNull().default(0),
breeding: boolean('breeding').notNull().default(true),
...timestamps
});
@@ -40,13 +54,81 @@ export const pokemonGameEntries = pgTable(
.notNull()
.references(() => games.id, { onDelete: 'cascade' }),
availability: availability('availability').notNull().default('catchable'),
// optional extra detail for the tooltip, e.g. "trade with a Blue version owner"
// how to obtain it, e.g. "Evolve Wartortle: Level 36+" or "Route 22, Viridian Forest"
note: text('note'),
source: source(),
...timestamps
},
(table) => [unique().on(table.pokemonId, table.gameId), index().on(table.gameId)]
);
export const pokemonLocations = pgTable('pokemon_locations', {
id: id(),
sourceId: integer('source_id').unique(),
slug: text('slug').notNull(),
name: text('name').notNull(),
region: text('region'),
description: text('description'),
// R2 object key of an admin-uploaded picture
imageKey: text('image_key'),
imageCredit: text('image_credit'),
source: source(),
...timestamps
});
export const pokemonEncounters = pgTable(
'pokemon_encounters',
{
id: id(),
pokemonId: integer('pokemon_id')
.notNull()
.references(() => pokemon.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => games.id, { onDelete: 'cascade' }),
locationId: integer('location_id')
.notNull()
.references(() => pokemonLocations.id, { onDelete: 'cascade' }),
area: text('area'),
method: text('method').notNull(),
minLevel: integer('min_level'),
maxLevel: integer('max_level'),
// summed encounter-slot percentage; null for gifts and folded raids
rarity: integer('rarity'),
form: text('form'),
conditions: text('conditions').array().notNull().default([]),
note: text('note'),
source: source(),
...timestamps
},
(table) => [index().on(table.pokemonId, table.gameId), index().on(table.locationId)]
);
// The method that applies in each game, e.g. Magneton → Magnezone needs Mt.
// Coronet in Platinum but a Thunder Stone in Sword.
export const pokemonEvolutions = pgTable(
'pokemon_evolutions',
{
id: id(),
fromPokemonId: integer('from_pokemon_id')
.notNull()
.references(() => pokemon.id, { onDelete: 'cascade' }),
toPokemonId: integer('to_pokemon_id')
.notNull()
.references(() => pokemon.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => games.id, { onDelete: 'cascade' }),
method: text('method').notNull(),
source: source(),
...timestamps
},
(table) => [
unique().on(table.fromPokemonId, table.toPokemonId, table.gameId, table.method),
index().on(table.gameId)
]
);
export const userEntries = pgTable(
'user_entries',
{
@@ -94,3 +176,6 @@ export type PokemonGameEntry = typeof pokemonGameEntries.$inferSelect;
export type UserEntry = typeof userEntries.$inferSelect;
export type UserGame = typeof userGames.$inferSelect;
export type Availability = (typeof availability.enumValues)[number];
export type PokemonLocation = typeof pokemonLocations.$inferSelect;
export type PokemonEncounter = typeof pokemonEncounters.$inferSelect;
export type PokemonEvolution = typeof pokemonEvolutions.$inferSelect;

View File

@@ -0,0 +1,61 @@
import { boolean, index, integer, jsonb, pgTable, text, unique } from 'drizzle-orm/pg-core';
import type { MasteryRules } from '../../../game-tracker/mastery';
import { user } from './auth';
import { id, timestamps } from './helpers';
// Checklist-style games (Call of Duty and future franchises). Pokémon keeps its
// own species × game model in catch-em-all.ts.
export const trackerGames = pgTable('tracker_games', {
id: id(),
slug: text('slug').notNull().unique(),
franchise: text('franchise').notNull(),
name: text('name').notNull(),
shortName: text('short_name').notNull(),
releaseYear: integer('release_year'),
sortOrder: integer('sort_order').notNull().default(0),
rules: jsonb('rules').$type<MasteryRules>().notNull(),
...timestamps
});
export const trackerItems = pgTable(
'tracker_items',
{
id: id(),
gameId: integer('game_id')
.notNull()
.references(() => trackerGames.id, { onDelete: 'cascade' }),
// stable catalog key, e.g. `weapon:m8a7` or `311210:the-true-gunslinger`
key: text('key').notNull(),
kind: text('kind', { enum: ['achievement', 'weapon'] }).notNull(),
// achievement mode or weapon class
category: text('category').notNull(),
name: text('name').notNull(),
description: text('description'),
icon: text('icon'),
postLaunch: boolean('post_launch').notNull().default(false),
sortOrder: integer('sort_order').notNull().default(0),
...timestamps
},
(table) => [unique().on(table.gameId, table.key), index().on(table.gameId)]
);
// One row per completed milestone: `done` for achievements, or a weapon
// milestone key such as `gold` or `maxLevel`. Derived tiers are never stored.
export const trackerProgress = pgTable(
'tracker_progress',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
itemId: integer('item_id')
.notNull()
.references(() => trackerItems.id, { onDelete: 'cascade' }),
milestone: text('milestone').notNull().default('done'),
...timestamps
},
(table) => [unique().on(table.userId, table.itemId, table.milestone), index().on(table.userId)]
);
export type TrackerGame = typeof trackerGames.$inferSelect;
export type TrackerItem = typeof trackerItems.$inferSelect;

View File

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