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

@@ -5,3 +5,6 @@ yarn.lock
# Generated pinned catalog
data/pokemon-catalog.json
# Generated Pokémon locations and encounters
data/pokemon-locations.json

5146
data/call-of-duty.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -18,11 +18,14 @@
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"db:seed": "bun scripts/seed-catalog.ts",
"db:seed:cod": "bun scripts/seed-cod-catalog.ts",
"db:migrate:auth": "bun scripts/migrate-auth-catalog.ts",
"db:migrate:foundation": "bun scripts/migrate-foundation.ts",
"db:migrate:money-tracker": "bun scripts/migrate-money-tracker.ts",
"db:migrate:game-tracker": "bun scripts/migrate-game-tracker.ts",
"auth:role": "bun scripts/set-admin.ts",
"catalog:build": "python3 scripts/build-pokemon-catalog.py",
"catalog:cod": "python3 scripts/build-cod-catalog.py && prettier --write data/call-of-duty.json",
"test:auth": "node --env-file=.env node_modules/@playwright/test/cli.js test --config playwright.auth.config.ts"
},
"devDependencies": {

View File

@@ -0,0 +1,49 @@
"""Refresh the achievement lists in data/call-of-duty.json from Steam Community.
Weapons, classes and mastery rules in that file are hand-maintained; this only
replaces each game's `achievements` array with the public global achievement
pages of its Steam apps. Keys are `<app id>:<slug>` so progress survives renames
of descriptions or icons.
"""
import html
import json
from pathlib import Path
import re
import urllib.request
TARGET = Path(__file__).resolve().parent.parent / 'data/call-of-duty.json'
ROW = re.compile(r'<div class="achieveRow.*?<img src="([^"]+)".*?<h3>(.*?)</h3>\s*<h5>(.*?)</h5>', re.S)
def slug(text):
return re.sub(r'[^a-z0-9]+', '-', text.lower()).strip('-')
def achievements(app):
request = urllib.request.Request(f"https://steamcommunity.com/stats/{app['id']}/achievements/?l=english",
headers={'User-Agent': 'stanrunge.dev game-tracker catalog builder'})
with urllib.request.urlopen(request, timeout=60) as response:
page = response.read().decode('utf-8')
rows, seen = [], {}
for icon, name, description in ROW.findall(page):
name, description = html.unescape(name).strip(), html.unescape(description).strip()
key = f"{app['id']}:{slug(name)}"
seen[key] = seen.get(key, 0) + 1
if seen[key] > 1:
key += f'-{seen[key]}'
rows.append(dict(key=key, category=app['label'], name=name, description=description or None, icon=icon))
if not rows:
raise RuntimeError(f"No achievements parsed for Steam app {app['id']}")
return rows
def main():
catalog = json.loads(TARGET.read_text())
for game in catalog['games']:
game['achievements'] = [row for app in game['steamApps'] for row in achievements(app)]
print(f"{game['shortName']}: {len(game['achievements'])} achievements")
TARGET.write_text(json.dumps(catalog, ensure_ascii=False, indent='\t') + '\n')
if __name__ == '__main__':
main()

View File

@@ -1,4 +1,15 @@
"""Build the species-level catalog from a pinned PokéAPI CSV snapshot.
"""Build the owned Pokémon catalog from a pinned PokéAPI CSV snapshot.
Writes two files:
data/pokemon-catalog.json species, games, per-game availability, evolutions
data/pokemon-locations.json locations and aggregated wild/gift/static encounters
Availability per species/game:
catchable documented encounter (wild, gift, static, in-game trade, ...)
evolve a pre-evolution is obtainable in that game and the method exists there
breed an obtainable relative can breed it in that game
event_only mythical Pokémon present in the game without another route
unknown present in the game's Pokédex/index data, route not confirmed
Run with --source-dir to reuse downloaded CSVs. Default downloads into a temporary
folder. Encounter absence is not evidence of trade/transfer/event exclusivity.
@@ -8,13 +19,52 @@ import concurrent.futures
import csv
import json
from pathlib import Path
import re
import tempfile
import urllib.request
REVISION = '575291cdb197a7e3a320297be276c9de4ef8401a'
TABLES = ['pokemon', 'pokemon_species', 'pokemon_species_names', 'pokemon_types',
'pokemon_stats', 'type_names', 'versions', 'version_names', 'version_groups',
'pokemon_dex_numbers', 'pokedex_version_groups', 'pokemon_game_indices', 'encounters']
'version_group_regions', 'regions', 'region_names', 'pokemon_dex_numbers',
'pokedex_version_groups', 'pokemon_game_indices', 'encounters', 'encounter_slots',
'encounter_methods', 'encounter_method_prose', 'encounter_condition_values',
'encounter_condition_value_prose', 'encounter_condition_value_map', 'locations',
'location_names', 'location_areas', 'location_area_prose', 'pokemon_evolution',
'evolution_trigger_prose', 'item_names', 'move_names', 'pokemon_egg_groups',
'pokemon_forms', 'pokemon_form_names']
# Version groups without a Day Care, Nursery or picnic eggs.
NO_BREEDING = {'red-blue', 'yellow', 'red-green-japan', 'blue-japan', 'colosseum', 'xd',
'lets-go-pikachu-lets-go-eevee', 'legends-arceus', 'legends-za',
'mega-dimension', 'champions'}
# Baby Pokémon that only hatch when a parent holds an incense; species number -> item.
INCENSE = {298: 'Sea Incense', 360: 'Lax Incense', 406: 'Rose Incense', 433: 'Pure Incense',
438: 'Rock Incense', 439: 'Odd Incense', 440: 'Luck Incense', 446: 'Full Incense',
458: 'Wave Incense'}
NO_EGGS = 15
METHOD_LABELS = {
'walk': 'Grass or cave', 'old-rod': 'Old Rod', 'good-rod': 'Good Rod', 'super-rod': 'Super Rod',
'surf': 'Surfing', 'rock-smash': 'Rock Smash', 'headbutt': 'Headbutt', 'dark-grass': 'Dark grass',
'grass-spots': 'Rustling grass', 'cave-spots': 'Dust cloud', 'bridge-spots': 'Bridge shadow',
'super-rod-spots': 'Super Rod (rippling water)', 'surf-spots': 'Surfing (rippling water)',
'yellow-flowers': 'Yellow flowers', 'purple-flowers': 'Purple flowers', 'red-flowers': 'Red flowers',
'rough-terrain': 'Rough terrain', 'gift': 'Gift', 'gift-egg': 'Gift egg', 'static': 'Static encounter',
'pokeflute': 'Poké Flute', 'headbutt-low': 'Headbutt (rare trees)', 'headbutt-normal': 'Headbutt (common trees)',
'headbutt-high': 'Headbutt (frequent trees)', 'squirt-bottle': 'Squirt Bottle', 'wailmer-pail': 'Wailmer Pail',
'seaweed': 'Diving in seaweed', 'roaming-grass': 'Roaming (grass)', 'roaming-water': 'Roaming (water)',
'devon-scope': 'Devon Scope', 'feebas-tile-fishing': 'Fishing on Feebas tiles', 'island-scan': 'Island Scan',
'sos': 'SOS battle', 'bubbling-spots': 'Fishing in bubbling spots', 'berry-trees': 'Berry tree',
'npc-trade': 'In-game trade', 'sos-from-bubbling-spot': 'SOS battle (bubbling spot)',
'overworld': 'Overworld', 'overworld-water': 'Overworld (water)', 'overworld-flying': 'Overworld (flying)',
'overworld-special': 'Overworld (special)', 'overworld-flying-special': 'Overworld (flying, special)',
'overworld-water-special': 'Overworld (water, special)', 'overworld-dirt': 'Overworld (dirt)',
'horde': 'Horde encounter', 'snag': 'Snag from a trainer', 'snag-rematch': 'Snag from a trainer (rematch)',
'pokespot': 'Poké Spot', 'hidden-grotto': 'Hidden Grotto', 'honey-tree': 'Honey tree',
'wanderer': 'Wanderer', 'wanderer-water': 'Wanderer (water)', 'chase-water': 'Chasing (water)',
'dynamax-adventure': 'Dynamax Adventure', 'max-raid': 'Max Raid Battle',
}
TIMES = {'day': 'during the day', 'night': 'at night', 'dusk': 'at dusk', 'full-moon': 'during a full moon'}
def build(directory):
@@ -22,12 +72,18 @@ def build(directory):
with (directory / f'{name}.csv').open() as f:
return list(csv.DictReader(f))
def english(name, key, field='name'):
return {int(r[key]): r[field] for r in rows(name) if r['local_language_id'] == '9'}
def number(value):
return int(value) if value not in ('', None) else None
species = rows('pokemon_species')
pokemon = rows('pokemon')
species_for = {int(p['id']): int(p['species_id']) for p in pokemon}
defaults = {int(p['species_id']): int(p['id']) for p in pokemon if p['is_default'] == '1'}
names = {int(p['pokemon_species_id']): p['name'] for p in rows('pokemon_species_names') if p['local_language_id'] == '9'}
type_names = {int(t['type_id']): t['name'] for t in rows('type_names') if t['local_language_id'] == '9'}
names = english('pokemon_species_names', 'pokemon_species_id')
type_names = english('type_names', 'type_id')
types = {}
for p in rows('pokemon_types'):
types.setdefault(int(p['pokemon_id']), {})[int(p['slot'])] = type_names[int(p['type_id'])]
@@ -35,42 +91,307 @@ def build(directory):
for p in rows('pokemon_stats'):
stats.setdefault(int(p['pokemon_id']), {})[int(p['stat_id'])] = int(p['base_stat'])
stat_fields = ['hp', 'attack', 'defense', 'specialAttack', 'specialDefense', 'speed']
egg_groups = {}
for p in rows('pokemon_egg_groups'):
egg_groups.setdefault(int(p['species_id']), set()).add(int(p['egg_group_id']))
evolves_from = {int(s['id']): number(s['evolves_from_species_id']) for s in species}
chain_of = {int(s['id']): int(s['evolution_chain_id']) for s in species}
mythical = {int(s['id']) for s in species if s['is_mythical'] == '1'}
mons = []
for s in species:
number = int(s['id'])
default = defaults[number]
mons.append(dict(number=number, name=names[number], generation=int(s['generation_id']),
n = int(s['id'])
default = defaults[n]
mons.append(dict(number=n, name=names[n], generation=int(s['generation_id']),
type1=types[default][1], type2=types[default].get(2),
**{name: stats[default][i + 1] for i, name in enumerate(stat_fields)}))
**{name: stats[default][i + 1] for i, name in enumerate(stat_fields)},
evolvesFrom=evolves_from[n], evolutionChain=chain_of[n],
isBaby=s['is_baby'] == '1', isLegendary=s['is_legendary'] == '1',
isMythical=n in mythical))
# ── Games ────────────────────────────────────────────────────────────
groups = {int(g['id']): g for g in rows('version_groups')}
group_order = {g: int(groups[g]['order']) for g in groups}
regions_for_group = {}
for r in rows('version_group_regions'):
regions_for_group.setdefault(int(r['version_group_id']), set()).add(int(r['region_id']))
region_names = english('region_names', 'region_id')
versions = rows('versions')
game_names = {int(v['version_id']): v['name'] for v in rows('version_names') if v['local_language_id'] == '9'}
games = [dict(sourceId=int(v['id']), name=game_names.get(int(v['id']), v['identifier'].replace('-', ' ').title()),
generation=int(groups[int(v['version_group_id'])]['generation_id'])) for v in versions]
for game, version in zip(games, versions):
if version['identifier'].endswith('-japan'):
game['name'] += ' (Japan)'
game_names = english('version_names', 'version_id')
games, version_group = [], {}
for v in versions:
vid, group = int(v['id']), int(v['version_group_id'])
version_group[vid] = group
name = game_names.get(vid, v['identifier'].replace('-', ' ').title())
if v['identifier'].endswith('-japan'):
name += ' (Japan)'
games.append(dict(sourceId=vid, name=name, slug=v['identifier'],
generation=int(groups[group]['generation_id']), sortOrder=group_order[group],
breeding=groups[group]['identifier'] not in NO_BREEDING))
versions_for_group = {}
for v in versions:
versions_for_group.setdefault(int(v['version_group_id']), []).append(int(v['id']))
# ── Presence (Pokédex membership and game indices) ───────────────────
groups_for_dex = {}
for d in rows('pokedex_version_groups'):
groups_for_dex.setdefault(int(d['pokedex_id']), []).append(int(d['version_group_id']))
entries = {}
# Membership and internal game indices establish presence, not how it is obtained.
present = set()
for p in rows('pokemon_dex_numbers'):
for group in groups_for_dex.get(int(p['pokedex_id']), []):
for version in versions_for_group.get(group, []):
entries[(int(p['species_id']), version)] = 'unknown'
present.add((int(p['species_id']), version))
for p in rows('pokemon_game_indices'):
if int(p['game_index']) > 0:
entries[(species_for[int(p['pokemon_id'])], int(p['version_id']))] = 'unknown'
for p in rows('encounters'):
entries[(species_for[int(p['pokemon_id'])], int(p['version_id']))] = 'catchable'
return dict(source={'repository': 'https://github.com/PokeAPI/pokeapi', 'revision': REVISION,
'scope': 'All species and game versions in this snapshot; default-form types/stats. Unknown means presence without confirmed encounter data. Missing pairs have no source record, not proven absence.'},
pokemon=mons, games=games, entries=[[p, g, availability] for (p, g), availability in sorted(entries.items())])
present.add((species_for[int(p['pokemon_id'])], int(p['version_id'])))
# ── Locations and encounters ─────────────────────────────────────────
location_names = english('location_names', 'location_id')
location_rows = rows('locations')
location_region = {int(l['id']): number(l['region_id']) for l in location_rows}
locations = [dict(sourceId=int(l['id']), slug=l['identifier'],
name=location_names.get(int(l['id']), l['identifier'].replace('-', ' ').title()),
region=region_names.get(number(l['region_id']) or 0))
for l in location_rows]
location_label = {l['sourceId']: l['name'] for l in locations}
area_prose = english('location_area_prose', 'location_area_id')
areas = {}
for a in rows('location_areas'):
location = int(a['location_id'])
label = None
if a['identifier']:
prose = area_prose.get(int(a['id']), '')
inner = re.fullmatch(re.escape(location_names.get(location, '')) + r' \((.+)\)', prose)
label = inner.group(1) if inner else ' '.join(
w.upper() if re.fullmatch(r'b?\d+f', w) else w for w in a['identifier'].split('-')).capitalize()
areas[int(a['id'])] = (location, label)
method_prose = english('encounter_method_prose', 'encounter_method_id')
method_label = {int(m['id']): METHOD_LABELS.get(m['identifier'], method_prose.get(int(m['id']), m['identifier']))
for m in rows('encounter_methods')}
slots = {int(s['id']): (int(s['encounter_method_id']), number(s['rarity'])) for s in rows('encounter_slots')}
condition_prose = english('encounter_condition_value_prose', 'encounter_condition_value_id')
species_ids = {s['identifier']: int(s['id']) for s in species}
condition_text = {}
for c in rows('encounter_condition_values'):
value, kind, identifier = int(c['id']), int(c['encounter_condition_id']), c['identifier']
if kind == 15: # in-game trade: the Pokémon the NPC wants
wanted = identifier.removeprefix('trade-')
condition_text[value] = f"In exchange for {names[species_ids[wanted]] if wanted in species_ids else wanted.replace('-', ' ')}"
elif kind == 16: # Game Corner prize
condition_text[value] = f"{identifier.removeprefix('coins-')} Game Corner coins"
elif c['is_default'] != '1':
condition_text[value] = condition_prose.get(value) or re.sub(
r'^(other|story-progress|item)-', '', identifier).replace('-', ' ').capitalize()
conditions = {}
for c in rows('encounter_condition_value_map'):
value = int(c['encounter_condition_value_id'])
if value in condition_text:
conditions.setdefault(int(c['encounter_id']), set()).add(condition_text[value])
form_names = {int(f['pokemon_form_id']): f['form_name'] for f in rows('pokemon_form_names')
if f['local_language_id'] == '9' and f['form_name']}
form_for = {int(f['pokemon_id']): form_names.get(int(f['id'])) for f in rows('pokemon_forms')
if f['is_default'] == '1' and int(f['pokemon_id']) not in defaults.values()}
grouped = {}
for e in rows('encounters'):
pid, version = int(e['pokemon_id']), int(e['version_id'])
location, area = areas[int(e['location_area_id'])]
method, rarity = slots[int(e['encounter_slot_id'])]
key = (species_for[pid], version, location, area, method_label[method], form_for.get(pid),
tuple(sorted(conditions.get(int(e['id']), ()))))
low, high, total = grouped.get(key, (999, 0, 0))
grouped[key] = (min(low, int(e['min_level'])), max(high, int(e['max_level'])), total + (rarity or 0))
# Max Raid dens repeat every species per den and star level; fold them per location.
raids = {}
for key in [k for k in grouped if k[4] == METHOD_LABELS['max-raid']]:
s, v, loc, area, method, form, conds = key
low, high, _ = grouped.pop(key)
raid = raids.setdefault((s, v, loc, form), dict(dens=set(), stars=set(), beams=set(), low=999, high=0))
if area:
raid['dens'].add(area.removeprefix('Max Den '))
raid['low'], raid['high'] = min(raid['low'], low), max(raid['high'], high)
for condition in conds:
star = re.search(r'difficulty of (\d) star', condition)
if star:
raid['stars'].add(int(star.group(1)))
elif 'pillar of light' in condition:
raid['beams'].add('Wild Area News' if 'News' in condition else 'purple' if 'purple' in condition else 'red')
for (s, v, loc, form), raid in raids.items():
stars = sorted(raid['stars'])
conds = [f'{stars[0]}★' + (f'–{stars[-1]}★' if len(stars) > 1 else '') + ' raids'] if stars else []
if raid['beams'] == {'purple'}:
conds.append('Rare (purple) beam dens only')
if 'Wild Area News' in raid['beams']:
conds.append('Wild Area News event dens')
dens = sorted(raid['dens'])
area = (('Max Den ' if len(dens) == 1 else 'Max Dens ') + ', '.join(dens)) if dens else None
grouped[(s, v, loc, area, METHOD_LABELS['max-raid'], form, tuple(conds))] = (raid['low'], raid['high'], 0)
encounters = [[s, v, loc, area, method, low, high, min(total, 100) or None, form, list(conds)]
for (s, v, loc, area, method, form, conds), (low, high, total) in sorted(
grouped.items(), key=lambda item: tuple('' if x is None else str(x) for x in item[0]))]
catchable, where = set(), {}
for s, v, loc, *_ in encounters:
catchable.add((s, v))
seen = where.setdefault((s, v), [])
if location_label[loc] not in seen:
seen.append(location_label[loc])
# ── Evolution methods per game ───────────────────────────────────────
items = english('item_names', 'item_id')
moves = english('move_names', 'move_id')
triggers = english('evolution_trigger_prose', 'evolution_trigger_id')
def describe(r):
trigger = int(r['evolution_trigger_id'])
level = number(r['minimum_level'])
if trigger == 1:
text = f'Level {level}+' if level else 'Level up'
elif trigger == 2:
text = 'Trade'
elif trigger == 3:
text = f"Use a {items[int(r['trigger_item_id'])]}"
else:
text = triggers[trigger]
parts = [text]
if r['held_item_id']:
parts.append(f"holding a {items[int(r['held_item_id'])]}")
if r['known_move_id']:
parts.append(f"knowing {moves[int(r['known_move_id'])]}")
if r['known_move_type_id']:
parts.append(f"knowing a {type_names[int(r['known_move_type_id'])]}-type move")
if r['minimum_happiness']:
parts.append('with high friendship')
if r['minimum_affection']:
parts.append('with high affection')
if r['minimum_beauty']:
parts.append('with high Beauty')
if r['time_of_day']:
parts.append(TIMES.get(r['time_of_day'], r['time_of_day']))
if r['near_special_rock'] == '1':
parts.append('near the special rock')
if r['location_id']:
parts.append(f"at {location_label[int(r['location_id'])]}")
if r['party_species_id']:
parts.append(f"with {names[int(r['party_species_id'])]} in the party")
if r['party_type_id']:
parts.append(f"with a {type_names[int(r['party_type_id'])]}-type Pokémon in the party")
if r['trade_species_id']:
parts.append(f"for a {names[int(r['trade_species_id'])]}")
if r['relative_physical_stats']:
parts.append({'1': 'if Attack > Defense', '0': 'if Attack = Defense', '-1': 'if Attack < Defense'}[r['relative_physical_stats']])
if r['gender_id']:
parts.append('(female)' if r['gender_id'] == '1' else '(male)')
if r['needs_overworld_rain'] == '1':
parts.append('while it is raining')
if r['turn_upside_down'] == '1':
parts.append('holding the console upside down')
if r['used_move_id']:
parts.append(f"after using {moves[int(r['used_move_id'])]} {r['minimum_move_count'] or ''} times".replace(' ', ' '))
if r['minimum_steps']:
parts.append(f"after walking {r['minimum_steps']} steps with it")
if r['minimum_damage_taken']:
parts.append(f"after losing {r['minimum_damage_taken']}+ HP without fainting")
if r['needs_multiplayer'] == '1':
parts.append('in Union Circle')
if r['nature_bitmask']:
parts.append('(form depends on nature)')
if r['percentage_chance']:
parts.append(f"({r['percentage_chance']}% chance)")
if r['region_id']:
parts.append(f"in {region_names[int(r['region_id'])]}")
return ' '.join(parts)
evolution_rows = {}
for r in rows('pokemon_evolution'):
evolution_rows.setdefault(int(r['evolved_species_id']), []).append(r)
# Methods are dated from Red/Blue; the earlier Japanese Red/Green/Blue share them.
first_order = min(group_order[int(r['version_group_id'])] for rs in evolution_rows.values() for r in rs)
def methods(target, version):
group = version_group[version]
regions = regions_for_group.get(group, set())
valid = [r for r in evolution_rows.get(target, [])
if group_order[int(r['version_group_id'])] <= max(group_order[group], first_order)
and (not r['region_id'] or int(r['region_id']) in regions)
and (not r['location_id'] or location_region[int(r['location_id'])] in regions)]
# A regional variant (e.g. Alolan Raichu in Alola) replaces the generic method.
regional = {r['required_pokemon_form_id'] for r in valid if r['region_id']}
valid = [r for r in valid if r['region_id'] or r['required_pokemon_form_id'] not in regional]
# Each form variant uses the method from the latest version group that defines it.
latest = {}
for r in valid:
key = (r['required_pokemon_form_id'], r['evolved_pokemon_form_id'])
latest[key] = max(latest.get(key, 0), group_order[int(r['version_group_id'])])
chosen = [describe(r) for r in valid
if group_order[int(r['version_group_id'])] == latest[(r['required_pokemon_form_id'], r['evolved_pokemon_form_id'])]]
return list(dict.fromkeys(chosen))
# ── Availability ─────────────────────────────────────────────────────
availability, notes = {}, {}
for key in present | catchable:
availability[key] = 'catchable' if key in catchable else 'unknown'
if key in catchable:
places = where[key]
notes[key] = ', '.join(places[:3]) + (f' (+{len(places) - 3} more)' if len(places) > 3 else '')
chain_members = {}
for n, chain in chain_of.items():
chain_members.setdefault(chain, []).append(n)
obtainable = {'catchable', 'evolve', 'breed'}
evolutions = {}
for game in games:
v = game['sourceId']
changed = True
while changed:
changed = False
for n in evolves_from:
key = (n, v)
if availability.get(key) != 'unknown':
continue
parent = evolves_from[n]
if parent and availability.get((parent, v)) in obtainable:
found = methods(n, v)
if found:
availability[key], notes[key] = 'evolve', f"Evolve {names[parent]}: {' or '.join(found)}"
changed = True
continue
if game['breeding'] and not parent:
parents = [m for m in chain_members[chain_of[n]] if m != n
and availability.get((m, v)) in obtainable
and NO_EGGS not in egg_groups.get(m, {NO_EGGS})]
if parents:
incense = f' holding a {INCENSE[n]}' if n in INCENSE else ''
availability[key], notes[key] = 'breed', f'Breed {names[parents[0]]}{incense}'
changed = True
for n in mythical:
if availability.get((n, v)) == 'unknown':
availability[(n, v)] = 'event_only'
notes[(n, v)] = 'Mythical: normally only through an event distribution or special unlock'
for n, parent in evolves_from.items():
if parent and (n, v) in availability and (parent, v) in availability:
for method in methods(n, v):
evolutions.setdefault((parent, n, method), []).append(v)
entries = []
for (n, v), value in sorted(availability.items()):
entries.append([n, v, value, notes[(n, v)]] if (n, v) in notes else [n, v, value])
source = {'repository': 'https://github.com/PokeAPI/pokeapi', 'revision': REVISION}
catalog = dict(
source=dict(source, scope='All species and game versions in this snapshot; default-form types/stats. '
'Evolve/breed/event availability is derived from evolution, egg group and mythical data. '
'Unknown means presence without a confirmed route. Missing pairs have no source record, not proven absence.'),
pokemon=mons, games=games, entries=entries,
evolutions=[[a, b, versions, method] for (a, b, method), versions in sorted(evolutions.items())])
# Methods and conditions repeat thousands of times; store them once and reference by index.
methods_used = sorted({e[4] for e in encounters})
conditions_used = sorted({c for e in encounters for c in e[9]})
for e in encounters:
e[4] = methods_used.index(e[4])
e[9] = [conditions_used.index(c) for c in e[9]]
location_catalog = dict(source=dict(source, scope='Locations and encounters aggregated per species, game, '
'area, method, form and non-default condition. Rarity is the summed slot '
'percentage. Encounter rows are [species, version, location, area, method index, '
'min level, max level, rarity, form, condition indexes].'),
locations=locations, methods=methods_used, conditions=conditions_used,
encounters=encounters)
return catalog, location_catalog
def main():
@@ -86,10 +407,16 @@ def main():
(directory / f'{table}.csv').write_bytes(response.read())
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
list(executor.map(download, TABLES))
catalog = build(directory)
target = Path(__file__).resolve().parent.parent / 'data/pokemon-catalog.json'
target.write_text(json.dumps(catalog, ensure_ascii=False, separators=(',', ':')) + '\n')
print(f"Built {len(catalog['pokemon'])} species, {len(catalog['games'])} versions, {len(catalog['entries'])} entries")
catalog, locations = build(directory)
data = Path(__file__).resolve().parent.parent / 'data'
for name, content in [('pokemon-catalog.json', catalog), ('pokemon-locations.json', locations)]:
(data / name).write_text(json.dumps(content, ensure_ascii=False, separators=(',', ':')) + '\n')
counts = {}
for entry in catalog['entries']:
counts[entry[2]] = counts.get(entry[2], 0) + 1
print(f"Built {len(catalog['pokemon'])} species, {len(catalog['games'])} versions, "
f"{len(catalog['entries'])} entries {counts}, {len(catalog['evolutions'])} evolution methods, "
f"{len(locations['locations'])} locations, {len(locations['encounters'])} encounters")
if __name__ == '__main__':

View File

@@ -40,6 +40,13 @@ try {
)) as money_tables,
(select count(*) from information_schema.columns
where table_name = 'money_utility_settings' and column_name = 'billing_start') as money_billing_start,
(select count(*) from information_schema.tables
where table_schema = 'public' and table_name in (
'pokemon_locations', 'pokemon_encounters', 'pokemon_evolutions',
'tracker_games', 'tracker_items', 'tracker_progress'
)) as game_tracker_tables,
(select count(*) from information_schema.columns
where table_name = 'pokemon_game_entries' and column_name = 'source') as entry_source,
(select count(*) from information_schema.columns
where (table_name = 'money_accounts' and column_name = 'kind')
or (table_name = 'money_recurring' and column_name in ('starts_on', 'ends_on'))
@@ -57,7 +64,9 @@ try {
Number(schema.animal_serial_ids) !== 3 ||
Number(schema.money_tables) !== 9 ||
!Number(schema.money_billing_start) ||
Number(schema.money_projection_columns) !== 5
Number(schema.money_projection_columns) !== 5 ||
Number(schema.game_tracker_tables) !== 6 ||
!Number(schema.entry_source)
) {
throw new Error(
'Required migrations or Animaldex ID conversion have not been applied to this database'
@@ -67,15 +76,21 @@ try {
select
(select count(*) from pokemon) as pokemon,
(select count(*) from games where source_id is not null) as games,
(select count(*) from pokemon_game_entries) as entries
(select count(*) from pokemon_game_entries) as entries,
(select count(*) from pokemon_encounters) as encounters,
(select count(*) from tracker_games where franchise = 'call-of-duty') as cod_games
`;
if (
Number(catalog.pokemon) < 1025 ||
Number(catalog.games) < 53 ||
Number(catalog.entries) < 27267
Number(catalog.entries) < 27267 ||
Number(catalog.encounters) < 60000
) {
throw new Error('Pokémon catalog has not been seeded in this database');
}
if (Number(catalog.cod_games) < 7) {
throw new Error('Call of Duty catalog has not been seeded in this database');
}
} finally {
await client.end();
}

View File

@@ -45,6 +45,6 @@ fi
curl --retry 12 --retry-delay 2 --retry-all-errors --fail --silent --show-error \
"$origin/login" >/dev/null
curl --retry 6 --retry-delay 2 --retry-all-errors --fail --silent --show-error \
"$origin/projects/catch-em-all" >/dev/null
"$origin/projects/game-tracker/pokemon" >/dev/null
trap - ERR
echo "Deployed $sha to $origin"

View File

@@ -0,0 +1,127 @@
-- Game tracker: owned Pokémon locations/encounters/evolutions and checklist games.
-- Additive and repeatable. Apply before seeding (`npm run db:seed`, `npm run db:seed:cod`).
ALTER TYPE availability ADD VALUE IF NOT EXISTS 'evolve';
ALTER TYPE availability ADD VALUE IF NOT EXISTS 'breed';
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS evolves_from integer;
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS evolution_chain integer;
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS is_baby boolean NOT NULL DEFAULT false;
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS is_legendary boolean NOT NULL DEFAULT false;
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS is_mythical boolean NOT NULL DEFAULT false;
ALTER TABLE games ADD COLUMN IF NOT EXISTS slug text;
ALTER TABLE games ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0;
ALTER TABLE games ADD COLUMN IF NOT EXISTS breeding boolean NOT NULL DEFAULT true;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'games_slug_key') THEN
ALTER TABLE games ADD CONSTRAINT games_slug_key UNIQUE (slug);
END IF;
END $$;
-- Seeds used to mark their own rows with a "PokéAPI:" note prefix; anything else
-- was hand-maintained and must survive reseeding.
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'pokemon_game_entries' AND column_name = 'source') THEN
ALTER TABLE pokemon_game_entries ADD COLUMN source text NOT NULL DEFAULT 'pokeapi';
UPDATE pokemon_game_entries SET source = 'manual' WHERE note IS NULL OR note NOT LIKE 'PokéAPI:%';
END IF;
END $$;
CREATE TABLE IF NOT EXISTS pokemon_locations (
id serial PRIMARY KEY,
source_id integer UNIQUE,
slug text NOT NULL,
name text NOT NULL,
region text,
description text,
image_key text,
image_credit text,
source text NOT NULL DEFAULT 'pokeapi',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS pokemon_encounters (
id serial PRIMARY KEY,
pokemon_id integer NOT NULL,
game_id integer NOT NULL,
location_id integer NOT NULL,
area text,
method text NOT NULL,
min_level integer,
max_level integer,
rarity integer,
form text,
conditions text[] NOT NULL DEFAULT '{}'::text[],
note text,
source text NOT NULL DEFAULT 'pokeapi',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pokemon_encounters_pokemon_id_pokemon_id_fkey FOREIGN KEY (pokemon_id) REFERENCES pokemon(id) ON DELETE CASCADE,
CONSTRAINT pokemon_encounters_game_id_games_id_fkey FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE,
CONSTRAINT pokemon_encounters_location_id_pokemon_locations_id_fkey FOREIGN KEY (location_id) REFERENCES pokemon_locations(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS pokemon_encounters_pokemon_id_game_id_index ON pokemon_encounters (pokemon_id, game_id);
CREATE INDEX IF NOT EXISTS pokemon_encounters_location_id_index ON pokemon_encounters (location_id);
CREATE TABLE IF NOT EXISTS pokemon_evolutions (
id serial PRIMARY KEY,
from_pokemon_id integer NOT NULL,
to_pokemon_id integer NOT NULL,
game_id integer NOT NULL,
method text NOT NULL,
source text NOT NULL DEFAULT 'pokeapi',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pokemon_evolutions_from_pokemon_id_to_pokemon_id_game_id_method_unique UNIQUE (from_pokemon_id, to_pokemon_id, game_id, method),
CONSTRAINT pokemon_evolutions_from_pokemon_id_pokemon_id_fkey FOREIGN KEY (from_pokemon_id) REFERENCES pokemon(id) ON DELETE CASCADE,
CONSTRAINT pokemon_evolutions_to_pokemon_id_pokemon_id_fkey FOREIGN KEY (to_pokemon_id) REFERENCES pokemon(id) ON DELETE CASCADE,
CONSTRAINT pokemon_evolutions_game_id_games_id_fkey FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS pokemon_evolutions_game_id_index ON pokemon_evolutions (game_id);
CREATE TABLE IF NOT EXISTS tracker_games (
id serial PRIMARY KEY,
slug text NOT NULL UNIQUE,
franchise text NOT NULL,
name text NOT NULL,
short_name text NOT NULL,
release_year integer,
sort_order integer NOT NULL DEFAULT 0,
rules jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS tracker_items (
id serial PRIMARY KEY,
game_id integer NOT NULL,
key text NOT NULL,
kind text NOT NULL,
category text NOT NULL,
name text NOT NULL,
description text,
icon text,
post_launch boolean NOT NULL DEFAULT false,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT tracker_items_game_id_key_unique UNIQUE (game_id, key),
CONSTRAINT tracker_items_game_id_tracker_games_id_fkey FOREIGN KEY (game_id) REFERENCES tracker_games(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS tracker_items_game_id_index ON tracker_items (game_id);
CREATE TABLE IF NOT EXISTS tracker_progress (
id serial PRIMARY KEY,
user_id text NOT NULL,
item_id integer NOT NULL,
milestone text NOT NULL DEFAULT 'done',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT tracker_progress_user_id_item_id_milestone_unique UNIQUE (user_id, item_id, milestone),
CONSTRAINT tracker_progress_user_id_user_id_fkey FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE CASCADE,
CONSTRAINT tracker_progress_item_id_tracker_items_id_fkey FOREIGN KEY (item_id) REFERENCES tracker_items(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS tracker_progress_user_id_index ON tracker_progress (user_id);

View File

@@ -0,0 +1,15 @@
import { readFile } from 'node:fs/promises';
import postgres from 'postgres';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const client = postgres(process.env.DB_URL, { max: 1 });
try {
await client.begin(async (tx) => {
await tx.unsafe(
await readFile(new URL('./game-tracker-migration.sql', import.meta.url), 'utf8')
);
});
console.log('Game tracker migration applied');
} finally {
await client.end();
}

View File

@@ -1,25 +1,67 @@
import { readFile } from 'node:fs/promises';
import { drizzle } from 'drizzle-orm/postgres-js';
import { and, eq, isNull, sql } from 'drizzle-orm';
import { and, eq, isNull, ne, sql } from 'drizzle-orm';
import postgres from 'postgres';
import { pokemon, games, pokemonGameEntries } from '../src/lib/server/db/schema/catch-em-all';
import {
pokemon,
games,
pokemonGameEntries,
pokemonLocations,
pokemonEncounters,
pokemonEvolutions
} from '../src/lib/server/db/schema/catch-em-all';
type Availability = 'catchable' | 'unknown' | 'evolve' | 'breed' | 'event_only';
type Catalog = {
source: { revision: string };
pokemon: (typeof pokemon.$inferInsert)[];
games: { sourceId: number; name: string; generation: number }[];
entries: [number, number, 'catchable' | 'unknown'][];
games: {
sourceId: number;
name: string;
slug: string;
generation: number;
sortOrder: number;
breeding: boolean;
}[];
entries: [number, number, Availability, string?][];
evolutions: [from: number, to: number, versions: number[], method: string][];
};
type Locations = {
locations: { sourceId: number; slug: string; name: string; region: string | null }[];
methods: string[];
conditions: string[];
encounters: [
number: number,
version: number,
location: number,
area: string | null,
method: number,
minLevel: number,
maxLevel: number,
rarity: number | null,
form: string | null,
conditions: number[]
][];
};
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const catalog: Catalog = JSON.parse(
await readFile(new URL('../data/pokemon-catalog.json', import.meta.url), 'utf8')
);
const read = async <T>(name: string): Promise<T> =>
JSON.parse(await readFile(new URL(`../data/${name}`, import.meta.url), 'utf8'));
const catalog = await read<Catalog>('pokemon-catalog.json');
const places = await read<Locations>('pokemon-locations.json');
const client = postgres(process.env.DB_URL, { max: 1 });
const db = drizzle({ client });
async function inBatches<T>(rows: T[], insert: (batch: T[]) => Promise<unknown>) {
for (let offset = 0; offset < rows.length; offset += 1000)
await insert(rows.slice(offset, offset + 1000));
}
try {
await db.transaction(async (tx) => {
const pokemonIds = new Map<number, number>();
const gameIds = new Map<number, number>();
const locationIds = new Map<number, number>();
for (const row of catalog.pokemon) {
const [saved] = await tx
.insert(pokemon)
@@ -47,38 +89,110 @@ try {
.returning();
gameIds.set(row.sourceId, saved.id);
}
for (let offset = 0; offset < catalog.entries.length; offset += 1000) {
const rows = catalog.entries
.slice(offset, offset + 1000)
.map(([number, version, availability]) => {
const pokemonId = pokemonIds.get(number),
gameId = gameIds.get(version);
if (!pokemonId || !gameId)
throw new Error(`Unknown catalog reference ${number}/${version}`);
return {
pokemonId,
gameId,
availability,
note:
availability === 'catchable'
? 'PokéAPI: documented encounter; conditions, location, or a specific form may be required.'
: 'PokéAPI: Pokédex or game-index record only; catch, evolution, gift, trade, transfer, and event availability is not confirmed.'
const lookup = (map: Map<number, number>, key: number, kind: string) => {
const id = map.get(key);
if (!id) throw new Error(`Unknown catalog ${kind} ${key}`);
return id;
};
});
await tx
await inBatches(catalog.entries, (batch) =>
tx
.insert(pokemonGameEntries)
.values(rows)
.values(
batch.map(([number, version, availability, note]) => ({
pokemonId: lookup(pokemonIds, number, 'Pokémon'),
gameId: lookup(gameIds, version, 'game'),
availability,
note: note ?? null,
source:
availability === 'catchable' || availability === 'unknown'
? ('pokeapi' as const)
: ('derived' as const)
}))
)
.onConflictDoUpdate({
target: [pokemonGameEntries.pokemonId, pokemonGameEntries.gameId],
set: { availability: sql`excluded.availability`, note: sql`excluded.note` },
// Preserve hand-maintained availability and notes.
setWhere: sql`${pokemonGameEntries.note} like 'PokéAPI:%'`
});
}
});
console.log(
`Seeded ${catalog.pokemon.length} species, ${catalog.games.length} versions, ${catalog.entries.length} entries from ${catalog.source.revision}`
set: {
availability: sql`excluded.availability`,
note: sql`excluded.note`,
source: sql`excluded.source`
},
// Admin corrections are never overwritten.
setWhere: ne(pokemonGameEntries.source, 'manual')
})
);
for (const row of places.locations) {
const [saved] = await tx
.insert(pokemonLocations)
.values(row)
.onConflictDoUpdate({
target: pokemonLocations.sourceId,
// descriptions and pictures are site-owned and kept
set: { slug: row.slug, name: row.name, region: row.region },
setWhere: ne(pokemonLocations.source, 'manual')
})
.returning();
locationIds.set(row.sourceId, saved?.id ?? 0);
}
for (const [sourceId, id] of locationIds) {
if (id) continue;
const [manual] = await tx
.select({ id: pokemonLocations.id })
.from(pokemonLocations)
.where(eq(pokemonLocations.sourceId, sourceId));
locationIds.set(sourceId, manual.id);
}
// Encounters and evolutions have no natural key; replace imported rows wholesale.
await tx.delete(pokemonEncounters).where(ne(pokemonEncounters.source, 'manual'));
await inBatches(places.encounters, (batch) =>
tx.insert(pokemonEncounters).values(
batch.map(
([
number,
version,
location,
area,
method,
minLevel,
maxLevel,
rarity,
form,
conditions
]) => ({
pokemonId: lookup(pokemonIds, number, 'Pokémon'),
gameId: lookup(gameIds, version, 'game'),
locationId: lookup(locationIds, location, 'location'),
area,
method: places.methods[method],
minLevel,
maxLevel,
rarity,
form,
conditions: conditions.map((index) => places.conditions[index])
})
)
)
);
await tx.delete(pokemonEvolutions).where(ne(pokemonEvolutions.source, 'manual'));
const evolutions = catalog.evolutions.flatMap(([from, to, versions, method]) =>
versions.map((version) => ({
fromPokemonId: lookup(pokemonIds, from, 'Pokémon'),
toPokemonId: lookup(pokemonIds, to, 'Pokémon'),
gameId: lookup(gameIds, version, 'game'),
method
}))
);
await inBatches(evolutions, (batch) =>
tx.insert(pokemonEvolutions).values(batch).onConflictDoNothing()
);
console.log(
`Seeded ${catalog.pokemon.length} species, ${catalog.games.length} versions, ${catalog.entries.length} entries, ` +
`${places.locations.length} locations, ${places.encounters.length} encounters and ` +
`${evolutions.length} evolution methods from ${catalog.source.revision}`
);
});
} finally {
await client.end();
}

148
scripts/seed-cod-catalog.ts Normal file
View File

@@ -0,0 +1,148 @@
import { readFile } from 'node:fs/promises';
import { drizzle } from 'drizzle-orm/postgres-js';
import { and, eq, inArray, notInArray, sql } from 'drizzle-orm';
import postgres from 'postgres';
import {
trackerGames,
trackerItems,
trackerProgress
} from '../src/lib/server/db/schema/game-tracker';
import type { MasteryRules } from '../src/lib/game-tracker/mastery';
type Catalog = {
games: (Omit<MasteryRules, 'classes'> & {
slug: string;
name: string;
shortName: string;
releaseYear: number;
classes: { name: string; weapons: string[]; postLaunch?: string[] }[];
achievements: {
key: string;
category: string;
name: string;
description: string | null;
icon: string;
}[];
})[];
};
// Keys are derived from weapon names, so renaming a weapon starts its progress over.
const weaponKey = (name: string) =>
`weapon:${name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')}`;
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const catalog: Catalog = JSON.parse(
await readFile(new URL('../data/call-of-duty.json', import.meta.url), 'utf8')
);
const client = postgres(process.env.DB_URL, { max: 1 });
const db = drizzle({ client });
try {
await db.transaction(async (tx) => {
for (const [order, game] of catalog.games.entries()) {
const rules: MasteryRules = {
milestones: game.milestones,
tiers: game.tiers,
classes: game.classes.map((c) => c.name),
steamApps: game.steamApps
};
const values = {
slug: game.slug,
franchise: 'call-of-duty',
name: game.name,
shortName: game.shortName,
releaseYear: game.releaseYear,
sortOrder: order,
rules
};
const [saved] = await tx
.insert(trackerGames)
.values(values)
.onConflictDoUpdate({ target: trackerGames.slug, set: values })
.returning();
const weapons = game.classes.flatMap((c) =>
[
...c.weapons.map((name) => ({ name, postLaunch: false })),
...(c.postLaunch ?? []).map((name) => ({ name, postLaunch: true }))
].map((weapon) => ({ ...weapon, category: c.name }))
);
const items = [
...weapons.map((weapon, index) => ({
gameId: saved.id,
key: weaponKey(weapon.name),
kind: 'weapon' as const,
category: weapon.category,
name: weapon.name,
description: null,
icon: null,
postLaunch: weapon.postLaunch,
sortOrder: index
})),
...game.achievements.map((achievement, index) => ({
gameId: saved.id,
key: achievement.key,
kind: 'achievement' as const,
category: achievement.category,
name: achievement.name,
description: achievement.description,
icon: achievement.icon,
postLaunch: false,
sortOrder: index
}))
];
await tx
.insert(trackerItems)
.values(items)
.onConflictDoUpdate({
target: [trackerItems.gameId, trackerItems.key],
set: {
kind: sql`excluded.kind`,
category: sql`excluded.category`,
name: sql`excluded.name`,
description: sql`excluded.description`,
icon: sql`excluded.icon`,
postLaunch: sql`excluded.post_launch`,
sortOrder: sql`excluded.sort_order`
}
});
// Items dropped from the catalog disappear unless someone has progress on them.
const stale = await tx
.select({ id: trackerItems.id, name: trackerItems.name })
.from(trackerItems)
.where(
and(
eq(trackerItems.gameId, saved.id),
notInArray(
trackerItems.key,
items.map((item) => item.key)
)
)
);
if (stale.length) {
const kept = await tx
.selectDistinct({ id: trackerProgress.itemId })
.from(trackerProgress)
.where(
inArray(
trackerProgress.itemId,
stale.map((item) => item.id)
)
);
const keep = new Set(kept.map((row) => row.id));
const remove = stale.filter((item) => !keep.has(item.id)).map((item) => item.id);
if (remove.length) await tx.delete(trackerItems).where(inArray(trackerItems.id, remove));
for (const item of stale.filter((item) => keep.has(item.id)))
console.warn(
`${game.shortName}: kept "${item.name}", which has progress but left the catalog`
);
}
console.log(
`${game.shortName}: ${weapons.length} weapons, ${game.achievements.length} achievements`
);
}
});
} finally {
await client.end();
}

View File

@@ -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'
]);

View 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}

View File

@@ -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,

View File

@@ -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++;

View 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);
}
}
});
});

View 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
};
}

View File

@@ -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
},
{

View File

@@ -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);
});
});

View File

@@ -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',

View File

@@ -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);
});
});

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';

View 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
}))
};
}

View File

@@ -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;

View File

@@ -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');
});

View File

@@ -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',

View File

@@ -4,7 +4,6 @@
import ExternalLink from 'lucide-svelte/icons/external-link';
const projects = [
{ name: 'catchemall', href: '/projects/catch-em-all', external: false },
{ name: 'animaldex', href: '/projects/animaldex', external: false },
{ name: 'derby lens', href: '/projects/derby', external: false },
{ name: 'cs prices', href: '/projects/cs-prices', external: false },

View File

@@ -1 +0,0 @@
export {};

View File

@@ -1,7 +0,0 @@
<script lang="ts">
import './layout.css';
import type { LayoutProps } from './$types';
let { children }: LayoutProps = $props();
</script>
<div class="p-4">{@render children()}</div>

View File

@@ -0,0 +1,7 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
// Catchemall moved into the game tracker; keep old links and bookmarks working.
export const GET: RequestHandler = ({ url }) => {
redirect(308, `/projects/game-tracker/pokemon${url.search}`);
};

View File

@@ -1 +0,0 @@
@import 'tailwindcss';

View File

@@ -0,0 +1,116 @@
import { and, asc, countDistinct, eq, inArray, sql } from 'drizzle-orm';
import { db } from '$lib/server/db';
import {
catchEmAllSettings,
pokemonGameEntries,
trackerGames,
trackerItems,
trackerProgress,
userEntries,
userGames
} from '$lib/server/db/schema';
import { summarizeGame } from '$lib/game-tracker/mastery';
import type { PageServerLoad } from './$types';
const obtainable = inArray(pokemonGameEntries.availability, ['catchable', 'evolve', 'breed']);
async function pokemonSummary(userId: string | undefined) {
let gameIds: number[] | null = null;
if (userId) {
const [settings] = await db
.select({ id: catchEmAllSettings.id })
.from(catchEmAllSettings)
.where(eq(catchEmAllSettings.userId, userId));
const selected = await db
.select({ id: userGames.gameId })
.from(userGames)
.where(eq(userGames.userId, userId));
if (settings || selected.length) gameIds = selected.map((row) => row.id);
}
const scope =
gameIds === null ? obtainable : and(obtainable, inArray(pokemonGameEntries.gameId, gameIds));
const [totals] = await db
.select({
entries: sql<number>`count(*)`.mapWith(Number),
species: countDistinct(pokemonGameEntries.pokemonId),
games: countDistinct(pokemonGameEntries.gameId)
})
.from(pokemonGameEntries)
.where(scope);
const [caught] = userId
? await db
.select({
entries: sql<number>`count(*)`.mapWith(Number),
species: countDistinct(pokemonGameEntries.pokemonId)
})
.from(userEntries)
.innerJoin(pokemonGameEntries, eq(pokemonGameEntries.id, userEntries.pokemonGameEntryId))
.where(and(eq(userEntries.userId, userId), scope))
: [{ entries: 0, species: 0 }];
return { totals, caught };
}
async function callOfDutySummary(userId: string | undefined) {
const [gamesList, items, progress] = await Promise.all([
db
.select()
.from(trackerGames)
.where(eq(trackerGames.franchise, 'call-of-duty'))
.orderBy(asc(trackerGames.sortOrder)),
db
.select({
id: trackerItems.id,
gameId: trackerItems.gameId,
kind: trackerItems.kind,
name: trackerItems.name,
category: trackerItems.category,
postLaunch: trackerItems.postLaunch
})
.from(trackerItems),
userId
? db
.select({ itemId: trackerProgress.itemId, milestone: trackerProgress.milestone })
.from(trackerProgress)
.where(eq(trackerProgress.userId, userId))
: Promise.resolve([])
]);
const done = new Set(progress.map((row) => `${row.itemId}:${row.milestone}`));
const byGame = Map.groupBy(items, (item) => item.gameId);
return gamesList.map((game) => {
const list = byGame.get(game.id) ?? [];
const summary = summarizeGame(
game.rules,
list.filter((item) => item.kind === 'weapon'),
list.filter((item) => item.kind === 'achievement').map((item) => item.id),
(id, milestone) => done.has(`${id}:${milestone}`)
);
return {
slug: game.slug,
name: game.shortName,
releaseYear: game.releaseYear,
achievements: summary.achievements,
milestones: summary.milestones.map(({ key, label, value, total }) => ({
key,
label,
value,
total
})),
tiers: summary.tiers.map(({ key, label, scope, value, total, achieved }) => ({
key,
label,
scope,
value,
total,
achieved
}))
};
});
}
export const load: PageServerLoad = async ({ locals }) => {
const [pokemon, callOfDuty] = await Promise.all([
pokemonSummary(locals.user?.id),
callOfDutySummary(locals.user?.id)
]);
return { pokemon, callOfDuty };
};

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import { resolve } from '$app/paths';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
function percent(value: number, total: number) {
return total > 0 ? Math.round((value / total) * 100) : 0;
}
</script>
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">game tracker</h1>
<p class="mt-2 text-[var(--site-muted)]">
completionist progress: every Pokémon in every game, and every achievement, weapon and camo in
Call of Duty.
</p>
{#if !data.user}<p class="mt-6">
<a
href={resolve('/login?returnTo=/projects/game-tracker')}
class="underline underline-offset-4">sign in</a
> to save your progress.
</p>{/if}
<section class="mt-10">
<h2 class="text-2xl font-semibold">Pokémon</h2>
<a
href={resolve('/projects/game-tracker/pokemon')}
class="mt-4 block rounded-lg border border-[var(--site-border)] p-5 hover:bg-[var(--site-hover)]"
>
<div class="flex flex-wrap items-baseline justify-between gap-3">
<p class="text-xl font-semibold">catchemall</p>
<p class="text-xl font-semibold tabular-nums">
{percent(data.pokemon.caught.entries, data.pokemon.totals.entries)}%
</p>
</div>
<div class="mt-3">
<ProgressBar
value={data.pokemon.caught.entries}
max={data.pokemon.totals.entries}
label="Pokémon caught"
small
/>
</div>
<p class="mt-2 text-[var(--site-muted)]">
{data.pokemon.caught.entries} of {data.pokemon.totals.entries} obtainable entries ·
{data.pokemon.caught.species} of {data.pokemon.totals.species} species · {data.pokemon
.totals.games} games
</p>
</a>
<a
href={resolve('/projects/game-tracker/pokemon/locations')}
class="mt-3 inline-block underline underline-offset-4">browse Pokémon locations</a
>
</section>
<section class="mt-12">
<h2 class="text-2xl font-semibold">Call of Duty</h2>
<ul class="mt-4 grid gap-4 md:grid-cols-2">
{#each data.callOfDuty as game (game.slug)}
<li>
<a
href={resolve('/projects/game-tracker/call-of-duty/[slug]', { slug: game.slug })}
class="block h-full rounded-lg border border-[var(--site-border)] p-5 hover:bg-[var(--site-hover)]"
>
<div class="flex flex-wrap items-baseline justify-between gap-3">
<p class="text-xl font-semibold">{game.name}</p>
<p class="text-[var(--site-muted)]">{game.releaseYear}</p>
</div>
<dl class="mt-4 grid gap-3">
{#each [{ key: 'achievements', label: 'achievements', ...game.achievements }, ...game.milestones] as row (row.key)}
<div>
<div class="flex justify-between gap-3 text-[var(--site-muted)]">
<dt>{row.label}</dt>
<dd class="tabular-nums">{row.value}/{row.total}</dd>
</div>
<div class="mt-1">
<ProgressBar value={row.value} max={row.total} label={row.label} small />
</div>
</div>
{/each}
</dl>
{#if game.tiers.length}
<p class="mt-4 text-[var(--site-muted)]">
{game.tiers
.map((tier) =>
tier.scope === 'class'
? `${tier.label} ${tier.value}/${tier.total} classes`
: `${tier.label} ${tier.achieved ? '✓' : `${percent(tier.value, tier.total)}%`}`
)
.join(' · ')}
</p>
{/if}
</a>
</li>
{:else}
<li class="text-[var(--site-muted)]">the Call of Duty catalog has not been loaded yet.</li>
{/each}
</ul>
</section>
</div>

View File

@@ -0,0 +1,99 @@
import { error, fail } from '@sveltejs/kit';
import { and, asc, eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { trackerGames, trackerItems, trackerProgress } from '$lib/server/db/schema';
import { appliesTo } from '$lib/game-tracker/mastery';
import type { Actions, PageServerLoad } from './$types';
async function findGame(slug: string) {
const [game] = await db
.select()
.from(trackerGames)
.where(and(eq(trackerGames.slug, slug), eq(trackerGames.franchise, 'call-of-duty')));
if (!game) error(404, 'Game not found');
return game;
}
export const load: PageServerLoad = async ({ params, locals }) => {
const game = await findGame(params.slug);
const items = await db
.select({
id: trackerItems.id,
kind: trackerItems.kind,
category: trackerItems.category,
name: trackerItems.name,
description: trackerItems.description,
icon: trackerItems.icon,
postLaunch: trackerItems.postLaunch
})
.from(trackerItems)
.where(eq(trackerItems.gameId, game.id))
.orderBy(asc(trackerItems.kind), asc(trackerItems.sortOrder));
const progress = locals.user
? await db
.select({ itemId: trackerProgress.itemId, milestone: trackerProgress.milestone })
.from(trackerProgress)
.innerJoin(trackerItems, eq(trackerItems.id, trackerProgress.itemId))
.where(and(eq(trackerProgress.userId, locals.user.id), eq(trackerItems.gameId, game.id)))
: [];
return {
game: { slug: game.slug, name: game.name, shortName: game.shortName, rules: game.rules },
items,
done: progress.map((row) => `${row.itemId}:${row.milestone}`),
seo: {
title: `${game.shortName} · Game Tracker`,
description: `Track ${game.name} achievements, weapon levels and camo mastery.`
}
};
};
export const actions = {
toggle: async ({ request, locals, params }) => {
if (!locals.user) return fail(401, { message: 'Log in to track your progress' });
const game = await findGame(params.slug);
const data = await request.formData();
const itemId = Number(data.get('itemId'));
const milestone = String(data.get('milestone') ?? '');
if (!Number.isSafeInteger(itemId) || itemId <= 0 || itemId > 2147483647)
return fail(400, { message: 'Invalid item' });
const [item] = await db
.select()
.from(trackerItems)
.where(and(eq(trackerItems.id, itemId), eq(trackerItems.gameId, game.id)));
if (!item) return fail(404, { message: 'Unknown item' });
const valid =
item.kind === 'achievement'
? milestone === 'done'
: game.rules.milestones.some(
(rule) =>
rule.key === milestone &&
appliesTo(rule, {
id: item.id,
name: item.name,
category: item.category,
postLaunch: item.postLaunch
})
);
if (!valid) return fail(400, { message: 'That milestone does not apply here' });
if (data.has('done') && data.get('done') !== 'on')
return fail(400, { message: 'Invalid state' });
if (data.get('done') === 'on') {
await db
.insert(trackerProgress)
.values({ userId: locals.user.id, itemId, milestone })
.onConflictDoNothing();
} else {
await db
.delete(trackerProgress)
.where(
and(
eq(trackerProgress.userId, locals.user.id),
eq(trackerProgress.itemId, itemId),
eq(trackerProgress.milestone, milestone)
)
);
}
return { saved: true };
}
} satisfies Actions;

View File

@@ -0,0 +1,284 @@
<script lang="ts">
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { deserialize } from '$app/forms';
import { SvelteSet } from 'svelte/reactivity';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import { appliesTo, summarizeGame } from '$lib/game-tracker/mastery';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
// rebuilt whenever the page data changes, so the server render already shows progress
const done = $derived(new SvelteSet(data.done));
const pending = new SvelteSet<string>();
let saveError = $state('');
// checkboxes stay disabled until hydration, so early clicks can't silently go unsaved
let hydrated = $state(false);
onMount(() => (hydrated = true));
const isDone = (id: number, milestone: string) => done.has(`${id}:${milestone}`);
async function toggle(itemId: number, milestone: string, checked: boolean) {
const key = `${itemId}:${milestone}`;
if (pending.has(key)) return;
pending.add(key);
saveError = '';
if (checked) done.add(key);
else done.delete(key);
const body = new FormData();
body.set('itemId', String(itemId));
body.set('milestone', milestone);
if (checked) body.set('done', 'on');
let failed: boolean;
try {
const response = await fetch('?/toggle', { method: 'POST', body });
failed = deserialize(await response.text()).type !== 'success';
} catch {
failed = true;
}
pending.delete(key);
if (failed) {
saveError = 'Could not save your progress. Please try again, or log in again.';
if (checked) done.delete(key);
else done.add(key);
}
}
const rules = $derived(data.game.rules);
const weapons = $derived(data.items.filter((item) => item.kind === 'weapon'));
const achievements = $derived(data.items.filter((item) => item.kind === 'achievement'));
const summary = $derived(
summarizeGame(
rules,
weapons,
achievements.map((item) => item.id),
isDone
)
);
type Bar = {
label: string;
description?: string;
value: number;
total: number;
achieved: boolean;
};
const bars = $derived<Bar[]>([
{ label: 'achievements', ...summary.achievements },
...summary.milestones,
...summary.tiers.map((tier) => ({
...tier,
label: tier.scope === 'class' ? `${tier.label} classes` : tier.label
}))
]);
const classes = $derived(
rules.classes
.map((name) => {
const list = weapons.filter((weapon) => weapon.category === name);
const milestones = rules.milestones.filter((milestone) =>
list.some((weapon) => appliesTo(milestone, weapon))
);
const tiers = rules.tiers
.filter((tier) => tier.scope === 'class')
.map((tier) => ({ tier, count: summary.mastery.classTiers.get(tier.key)?.get(name) }))
.filter((entry) => entry.count);
return { name, list, milestones, tiers };
})
.filter((entry) => entry.list.length)
);
const categories = $derived([...Map.groupBy(achievements, (item) => item.category)]);
const tabs = ['weapons', 'achievements'] as const;
let tab = $state<(typeof tabs)[number]>('weapons');
let hideDone = $state(false);
function percent(value: number, total: number) {
return total > 0 ? Math.round((value / total) * 100) : 0;
}
</script>
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<a
href={resolve('/projects/game-tracker')}
class="text-[var(--site-muted)] hover:text-[var(--site-text)]">← game tracker</a
>
<h1 class="mt-3 text-4xl font-semibold tracking-tight sm:text-5xl">{data.game.shortName}</h1>
<p class="mt-2 text-[var(--site-muted)]">
{weapons.length} weapons · {achievements.length} achievements
</p>
{#if !data.user}<p class="mt-6">
<a
href={resolve(`/login?returnTo=/projects/game-tracker/call-of-duty/${data.game.slug}`)}
class="underline underline-offset-4">sign in</a
> to save your progress.
</p>{/if}
{#if saveError}<p role="alert" class="mt-5 text-red-500">{saveError}</p>{/if}
<div
class="mt-8 grid gap-x-10 gap-y-7 border-y border-[var(--site-border)] py-6 sm:grid-cols-2 lg:grid-cols-3"
>
{#each bars as bar (bar.label)}
<div class="min-w-0" title={bar.description}>
<div class="flex flex-wrap items-baseline justify-between gap-2">
<p class="font-medium">{bar.label}</p>
<p class="text-xl font-semibold tabular-nums">
{bar.achieved ? '✓' : `${percent(bar.value, bar.total)}%`}
</p>
</div>
<div class="mt-3">
<ProgressBar value={bar.value} max={bar.total} label={bar.label} small />
</div>
<p class="mt-2 text-[var(--site-muted)]">{bar.value} of {bar.total}</p>
</div>
{/each}
</div>
<div class="mt-8 flex flex-wrap items-center justify-between gap-4">
<div role="tablist" class="flex gap-6 text-lg">
{#each tabs as name (name)}
<button
role="tab"
type="button"
aria-selected={tab === name}
onclick={() => (tab = name)}
class={tab === name
? 'underline underline-offset-8'
: 'text-[var(--site-muted)] hover:text-[var(--site-text)]'}>{name}</button
>
{/each}
</div>
<label class="flex cursor-pointer items-center gap-3">
<input type="checkbox" bind:checked={hideDone} class="h-5 w-5 accent-[var(--site-text)]" />
hide finished
</label>
</div>
{#if tab === 'weapons'}
{#if rules.tiers.length || rules.milestones.length}
<p class="mt-4 max-w-3xl text-[var(--site-muted)]">
{[...rules.milestones, ...rules.tiers]
.filter((rule) => rule.description)
.map((rule) => `${rule.label}: ${rule.description}`)
.join('. ')}. DLC weapons don’t count toward class or overall camos.
</p>
{/if}
{#each classes as group (group.name)}
{@const rows = group.list.filter(
(weapon) =>
!hideDone ||
group.milestones.some(
(milestone) => appliesTo(milestone, weapon) && !isDone(weapon.id, milestone.key)
)
)}
<section class="mt-10">
<div class="flex flex-wrap items-baseline justify-between gap-3">
<h2 class="text-2xl font-semibold">{group.name}</h2>
<p class="text-[var(--site-muted)]">
{#each group.tiers as { tier, count } (tier.key)}
<span class={count?.achieved ? 'ml-4 text-[var(--site-text)]' : 'ml-4'}
>{tier.label}
{count?.achieved ? '✓' : `${count?.value}/${count?.total}`}</span
>
{/each}
</p>
</div>
{#if rows.length}
<table class="mt-3 w-full border-collapse">
<thead>
<tr class="border-b border-[var(--site-border)] text-left text-[var(--site-muted)]">
<th scope="col" class="py-2 font-medium">weapon</th>
{#each group.milestones as milestone (milestone.key)}<th
scope="col"
class="w-20 py-2 text-center font-medium sm:w-28"
title={milestone.description}>{milestone.label}</th
>{/each}
</tr>
</thead>
<tbody>
{#each rows as weapon (weapon.id)}
{@const tiers = summary.mastery.weaponTiers(weapon)}
<tr class="border-b border-[var(--site-border)]">
<th scope="row" class="py-2 text-left font-normal">
{weapon.name}
{#if weapon.postLaunch}<span class="ml-1 text-sm text-[var(--site-muted)]"
>DLC</span
>{/if}
{#if tiers.length}<span class="block text-sm text-[var(--site-muted)]"
>{tiers.map((tier) => tier.label).join(' · ')}</span
>{/if}
</th>
{#each group.milestones as milestone (milestone.key)}
<td class="py-2 text-center">
{#if appliesTo(milestone, weapon)}
<input
type="checkbox"
class="h-5 w-5 cursor-pointer accent-[var(--site-text)]"
checked={isDone(weapon.id, milestone.key)}
disabled={!hydrated ||
!data.user ||
pending.has(`${weapon.id}:${milestone.key}`)}
aria-label={`${weapon.name}: ${milestone.label}`}
title={data.user
? milestone.description
: 'Sign in to save your progress'}
onchange={(event) =>
toggle(weapon.id, milestone.key, event.currentTarget.checked)}
/>
{:else}<span class="text-[var(--site-muted)]">—</span>{/if}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
{:else}
<p class="mt-3 text-[var(--site-muted)]">all done.</p>
{/if}
</section>
{/each}
{:else}
{#each categories as [category, list] (category)}
{@const rows = list.filter((item) => !hideDone || !isDone(item.id, 'done'))}
<section class="mt-10">
<div class="flex flex-wrap items-baseline justify-between gap-3">
<h2 class="text-2xl font-semibold">{category}</h2>
<p class="text-[var(--site-muted)]">
{list.filter((item) => isDone(item.id, 'done')).length} of {list.length}
</p>
</div>
<ul class="mt-3">
{#each rows as item (item.id)}
<li class="border-t border-[var(--site-border)]">
<label class="flex cursor-pointer items-center gap-4 py-3">
{#if item.icon}<img
src={item.icon}
alt=""
loading="lazy"
width="48"
height="48"
class="h-12 w-12 shrink-0 rounded"
class:opacity-40={!isDone(item.id, 'done')}
/>{/if}
<span class="min-w-0 flex-1">
<span class="block font-medium">{item.name}</span>
{#if item.description}<span class="block text-[var(--site-muted)]"
>{item.description}</span
>{/if}
</span>
<input
type="checkbox"
class="h-5 w-5 shrink-0 cursor-pointer accent-[var(--site-text)]"
checked={isDone(item.id, 'done')}
disabled={!hydrated || !data.user || pending.has(`${item.id}:done`)}
aria-label={`Unlocked ${item.name}`}
onchange={(event) => toggle(item.id, 'done', event.currentTarget.checked)}
/>
</label>
</li>
{:else}
<li class="border-t border-[var(--site-border)] py-3 text-[var(--site-muted)]">
all unlocked.
</li>
{/each}
</ul>
</section>
{/each}
{/if}
</div>

View File

@@ -1,36 +1,52 @@
import { db } from '$lib/server/db';
import {
games,
pokemon as pokemonTable,
pokemonGameEntries,
userEntries,
userGames,
catchEmAllSettings
} from '$lib/server/db/schema';
import { and, eq, inArray } from 'drizzle-orm';
import { and, asc, eq, inArray } from 'drizzle-orm';
import type { Actions, PageServerLoad } from './$types';
import { availabilityValues } from '$lib/availability';
import { availabilityValues, obtainableAvailability, type Availability } from '$lib/availability';
import { requireAdmin } from '$lib/server/authorization';
import { fail } from '@sveltejs/kit';
export type WireEntry = [id: number, gameId: number, availability: number, note?: string];
export const load: PageServerLoad = async ({ locals }) => {
const pokemonRows = await db.query.pokemon.findMany({
with: {
entry: true
},
orderBy: { number: 'asc' }
});
const pokemon = pokemonRows.map(({ entry, ...rest }) => ({
...rest,
entries: entry.map((e): WireEntry => {
const [pokemonRows, entryRows, gameRows] = await Promise.all([
db
.select({
id: pokemonTable.id,
number: pokemonTable.number,
name: pokemonTable.name
})
.from(pokemonTable)
.orderBy(asc(pokemonTable.number)),
db
.select({
id: pokemonGameEntries.id,
pokemonId: pokemonGameEntries.pokemonId,
gameId: pokemonGameEntries.gameId,
availability: pokemonGameEntries.availability,
note: pokemonGameEntries.note
})
.from(pokemonGameEntries),
db
.select({ id: games.id, name: games.name, generation: games.generation })
.from(games)
.orderBy(asc(games.generation), asc(games.id))
]);
const entries = Map.groupBy(entryRows, (entry) => entry.pokemonId);
const pokemon = pokemonRows.map((species) => ({
...species,
entries: (entries.get(species.id) ?? []).map((e): WireEntry => {
const code = availabilityValues.indexOf(e.availability);
return e.note === null ? [e.id, e.gameId, code] : [e.id, e.gameId, code, e.note];
})
}));
const gameRows = await db.query.games.findMany({
orderBy: { generation: 'asc', id: 'asc' }
});
let caughtIds: number[] = [];
let selectedGameIds: number[] = [];
let gamesConfigured = false;
@@ -62,24 +78,28 @@ export const load: PageServerLoad = async ({ locals }) => {
};
};
function entryId(data: FormData) {
const id = Number(data.get('entryId'));
return Number.isSafeInteger(id) && id > 0 && id <= 2147483647 ? id : null;
}
export const actions = {
togglePokemonCaught: async ({ request, locals }) => {
if (!locals.user) return fail(401, { message: 'Log in to track your progress' });
const data = await request.formData();
const entryId = Number(data.get('entryId'));
if (!Number.isSafeInteger(entryId) || entryId <= 0 || entryId > 2147483647)
return fail(400, { message: 'Invalid entry' });
const id = entryId(data);
if (!id) return fail(400, { message: 'Invalid entry' });
// only catchable entries can be toggled — the client disables these
// checkboxes but we can't trust that
// only obtainable entries can be toggled — the client disables the others
// but we can't trust that
const [entry] = await db
.select({ availability: pokemonGameEntries.availability })
.from(pokemonGameEntries)
.where(eq(pokemonGameEntries.id, entryId));
.where(eq(pokemonGameEntries.id, id));
if (!entry) return fail(404, { message: 'Unknown entry' });
if (entry.availability !== 'catchable') {
return fail(400, { message: 'This entry does not have confirmed encounter data' });
if (!obtainableAvailability.has(entry.availability)) {
return fail(400, { message: 'This Pokémon has no confirmed way to obtain it in this game' });
}
if (data.has('caught') && data.get('caught') !== 'on')
@@ -89,14 +109,12 @@ export const actions = {
if (caught) {
await db
.insert(userEntries)
.values({ pokemonGameEntryId: entryId, userId: locals.user.id })
.values({ pokemonGameEntryId: id, userId: locals.user.id })
.onConflictDoNothing();
} else {
await db
.delete(userEntries)
.where(
and(eq(userEntries.pokemonGameEntryId, entryId), eq(userEntries.userId, locals.user.id))
);
.where(and(eq(userEntries.pokemonGameEntryId, id), eq(userEntries.userId, locals.user.id)));
}
return { saved: true };
},
@@ -134,5 +152,33 @@ export const actions = {
}
});
return { saved: true };
},
// Admin correction of how a Pokémon is obtained in one game. Manual entries
// survive reseeding; "reset" hands the entry back to the catalog.
saveEntry: async (event) => {
requireAdmin(event);
const data = await event.request.formData();
const id = entryId(data);
if (!id) return fail(400, { message: 'Invalid entry' });
if (data.get('reset') === 'on') {
await db
.update(pokemonGameEntries)
.set({ source: 'pokeapi' })
.where(eq(pokemonGameEntries.id, id));
return { saved: true, message: 'The catalog will restore this entry on the next seed.' };
}
const availability = String(data.get('availability'));
if (!(availabilityValues as readonly string[]).includes(availability))
return fail(400, { message: 'Invalid availability' });
const note = String(data.get('note') ?? '').trim();
if (note.length > 500) return fail(400, { message: 'Keep the note under 500 characters' });
const [updated] = await db
.update(pokemonGameEntries)
.set({ availability: availability as Availability, note: note || null, source: 'manual' })
.where(eq(pokemonGameEntries.id, id))
.returning({ id: pokemonGameEntries.id });
if (!updated) return fail(404, { message: 'Unknown entry' });
return { saved: true };
}
} satisfies Actions;

View File

@@ -1,22 +1,30 @@
<script lang="ts">
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { deserialize, enhance } from '$app/forms';
import { availabilityValues, type Availability } from '$lib/availability';
import { invalidateAll } from '$app/navigation';
import {
availabilityLabels,
availabilityValues,
obtainableAvailability,
type Availability
} from '$lib/availability';
import { SvelteSet } from 'svelte/reactivity';
import type { PageProps } from './$types';
import type { Game } from '$lib/server/db/schema';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import PokemonDetails from '$lib/catch-em-all/PokemonDetails.svelte';
import { summarizeProgress } from '$lib/catch-em-all/progress';
type Entry = { id: number; availability: Availability; note: string | null };
type Game = { id: number; name: string };
let { data, form }: PageProps = $props();
const caught = new SvelteSet<number>();
// rebuilt whenever the page data changes, so the server render already shows progress
const caught = $derived(new SvelteSet(data.caughtIds));
const pending = new SvelteSet<number>();
let saveError = $state('');
$effect(() => {
caught.clear();
for (const id of data.caughtIds) caught.add(id);
});
// checkboxes stay disabled until hydration, so early clicks can't silently go unsaved
let hydrated = $state(false);
onMount(() => (hydrated = true));
async function toggleCaught(entryId: number, checked: boolean) {
if (pending.has(entryId)) return;
@@ -77,7 +85,7 @@
unit: 'Pokémon'
},
{
label: 'seen in every catchable game',
label: 'caught in every game it’s in',
value: progress.completedPokemon,
total: progress.catchablePokemon,
unit: 'Pokémon'
@@ -94,15 +102,17 @@
}
let search = $state('');
let showNonCatchable = $state(false);
let showUnobtainable = $state(false);
let pageNumber = $state(1);
const pageSize = 25;
const filteredPokemon = $derived(
data.pokemon.filter((pokemon) => {
const availability =
viewGameId === null ? undefined : entryMap.get(pokemon.id)?.get(viewGameId)?.availability;
if (
!showNonCatchable &&
!showUnobtainable &&
viewGameId !== null &&
entryMap.get(pokemon.id)?.get(viewGameId)?.availability !== 'catchable'
!(availability && obtainableAvailability.has(availability))
)
return false;
return `${pokemon.number} ${pokemon.name}`
@@ -131,20 +141,30 @@
}
function tooltip(entry: Entry | undefined, game: Game): string | null {
if (!entry) return `No catalog record for ${game.name}; availability is not confirmed`;
if (obtainableAvailability.has(entry.availability)) return null;
const reason = {
catchable: null,
trade_only: 'Only obtainable by trading in this game',
transfer_only: 'Only obtainable by transferring from another game',
event_only: 'Only obtainable through an event distribution',
unknown: 'Availability has not been confirmed'
}[entry.availability];
if (reason === null) return null;
}[entry.availability as 'trade_only' | 'transfer_only' | 'event_only' | 'unknown'];
return entry.note ? `${reason} (${entry.note})` : reason;
}
let detailsDialog: HTMLDialogElement;
let detailNumber = $state<number | null>(null);
function openDetails(number: number) {
detailNumber = number;
detailsDialog.showModal();
}
</script>
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<div class="flex flex-wrap items-end justify-between gap-5">
<a
href={resolve('/projects/game-tracker')}
class="text-[var(--site-muted)] hover:text-[var(--site-text)]">← game tracker</a
>
<div class="mt-3 flex flex-wrap items-end justify-between gap-5">
<div>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">catchemall</h1>
<p class="mt-2 text-[var(--site-muted)]">
@@ -161,7 +181,7 @@
{#if !data.user}<p class="mt-6">
<a
href={resolve('/login?returnTo=/projects/catch-em-all')}
href={resolve('/login?returnTo=/projects/game-tracker/pokemon')}
class="underline underline-offset-4">sign in</a
> to save your progress.
</p>{/if}
@@ -185,7 +205,7 @@
/>
</div>
<p class="mt-2 text-base text-[var(--site-muted)]">
{progress.caughtEntries} of {progress.catchableEntries} catchable entries caught
{progress.caughtEntries} of {progress.catchableEntries} obtainable entries caught
</p>
<div class="mt-8 grid gap-x-10 gap-y-7 lg:grid-cols-3">
{#each progressBars as item (item.label)}
@@ -205,7 +225,7 @@
{/each}
</div>
<p class="mt-6 text-base text-[var(--site-muted)]">
counts use your selected games and confirmed catchable entries.
counts use your selected games. Pokémon you can evolve or breed there count as obtainable.
</p>
</div>
@@ -232,18 +252,24 @@
</select>
</label>
</div>
<label class="mt-5 flex cursor-pointer items-center gap-3">
<div class="mt-5 flex flex-wrap items-center justify-between gap-4">
<label class="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
checked={showNonCatchable}
checked={showUnobtainable}
onchange={(event) => {
showNonCatchable = event.currentTarget.checked;
showUnobtainable = event.currentTarget.checked;
pageNumber = 1;
}}
class="h-5 w-5 accent-[var(--site-text)]"
/>
show non-catchable Pokémon
show Pokémon you can’t get in this game
</label>
<a
href={resolve('/projects/game-tracker/pokemon/locations')}
class="underline underline-offset-4">browse locations</a
>
</div>
{#if viewGame}
<div class="mt-8">
@@ -263,7 +289,11 @@
{@const reason = tooltip(entry, viewGame)}
<tr class="border-b border-[var(--site-border)]">
<th scope="row" class="py-2 text-left font-normal">
<div class="flex min-w-0 items-center gap-3">
<button
type="button"
onclick={() => openDetails(pokemon.number)}
class="flex w-full min-w-0 items-center gap-3 text-left hover:text-[var(--site-muted)]"
>
<img
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{pokemon.number}.png"
alt=""
@@ -274,9 +304,15 @@
/>
<span class="min-w-0"
><span class="mr-2 text-[var(--site-muted)]">#{pokemon.number}</span
>{pokemon.name}</span
>{pokemon.name}
{#if entry && reason === null}<span
class="block truncate text-sm text-[var(--site-muted)]"
>{availabilityLabels[entry.availability]}{entry.note
? ` · ${entry.note}`
: ''}</span
>{/if}</span
>
</div>
</button>
</th>
<td class="py-2 text-center">
{#if entry && reason === null}
@@ -284,7 +320,7 @@
type="checkbox"
class="h-5 w-5 cursor-pointer accent-[var(--site-text)]"
checked={caught.has(entry.id)}
disabled={!data.user || pending.has(entry.id)}
disabled={!hydrated || !data.user || pending.has(entry.id)}
aria-label={`Caught ${pokemon.name} in ${viewGame.name}`}
title={data.user ? (entry.note ?? undefined) : 'Sign in to save your progress'}
onchange={(event) => toggleCaught(entry.id, event.currentTarget.checked)}
@@ -335,13 +371,35 @@
<details class="mt-10 border-t border-[var(--site-border)] pt-5 text-[var(--site-muted)]">
<summary class="cursor-pointer">about availability</summary>
<p class="mt-3 max-w-3xl">
checkboxes represent confirmed encounters from PokéAPI. “?” means the Pokémon appears in a
game catalog but its acquisition method is unconfirmed. “—” means there is no source record.
Neither proves it is unobtainable.
the catalog is owned by this site and built from a pinned PokéAPI snapshot. Checkboxes appear
for Pokémon you can catch (wild, gift, static or in-game trade), evolve from something you can
get in that game, or breed. “·” marks event-only mythicals, “?” means the Pokémon is in the
game but no route is confirmed, and “—” means there is no record. Neither proves it is
unobtainable. Newer games (Brilliant Diamond and later) have no encounter data yet.
</p>
</details>
</div>
<dialog
bind:this={detailsDialog}
onclose={() => (detailNumber = null)}
class="m-auto max-h-[90vh] w-[calc(100%-2rem)] max-w-2xl overflow-y-auto rounded-xl border border-[var(--site-border)] bg-[var(--site-surface)] p-5 text-[var(--site-text)] backdrop:bg-black/70 sm:p-6"
>
<div class="flex justify-end">
<button type="button" onclick={() => detailsDialog.close()} class="text-[var(--site-muted)]"
>close</button
>
</div>
{#if detailNumber !== null}
<PokemonDetails
number={detailNumber}
gameId={viewGameId}
isAdmin={data.isAdmin}
onsaved={() => invalidateAll()}
/>
{/if}
</dialog>
<dialog
bind:this={rulesDialog}
class="m-auto w-[calc(100%-2rem)] max-w-2xl rounded-xl border border-[var(--site-border)] bg-[var(--site-surface)] p-5 text-[var(--site-text)] backdrop:bg-black/70 sm:p-6"

View File

@@ -0,0 +1,11 @@
import { error, json } from '@sveltejs/kit';
import { pokemonDetail } from '$lib/server/pokemon-detail';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url }) => {
const game = Number(url.searchParams.get('game'));
const gameId = Number.isSafeInteger(game) && game > 0 && game <= 2147483647 ? game : null;
const number = Number(params.number);
if (number > 100000) error(404, 'Unknown Pokémon');
return json(await pokemonDetail(number, gameId));
};

View File

@@ -0,0 +1,19 @@
import { asc, countDistinct, eq, sql } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { pokemonEncounters, pokemonLocations } from '$lib/server/db/schema';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => ({
locations: await db
.select({
id: pokemonLocations.id,
name: pokemonLocations.name,
region: pokemonLocations.region,
hasPicture: sql<boolean>`${pokemonLocations.imageKey} is not null`,
pokemon: countDistinct(pokemonEncounters.pokemonId)
})
.from(pokemonLocations)
.leftJoin(pokemonEncounters, eq(pokemonEncounters.locationId, pokemonLocations.id))
.groupBy(pokemonLocations.id)
.orderBy(asc(pokemonLocations.region), asc(pokemonLocations.name))
});

View File

@@ -0,0 +1,89 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let search = $state('');
let region = $state('');
let withPokemonOnly = $state(true);
const regions = $derived([...new Set(data.locations.map((l) => l.region ?? 'other'))]);
const grouped = $derived(
Map.groupBy(
data.locations.filter(
(location) =>
(!withPokemonOnly || location.pokemon > 0) &&
(!region || (location.region ?? 'other') === region) &&
location.name.toLowerCase().includes(search.trim().toLowerCase())
),
(location) => location.region ?? 'other'
)
);
</script>
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<a
href={resolve('/projects/game-tracker/pokemon')}
class="text-[var(--site-muted)] hover:text-[var(--site-text)]">← catchemall</a
>
<h1 class="mt-3 text-4xl font-semibold tracking-tight sm:text-5xl">locations</h1>
<p class="mt-2 text-[var(--site-muted)]">
{data.locations.length} places across every region. Open one to see which Pokémon live there.
</p>
<div class="mt-8 grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,16rem)]">
<label class="block"
>search
<input
type="search"
bind:value={search}
placeholder="route, city, cave…"
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-3"
/>
</label>
<label class="block"
>region
<select
bind:value={region}
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-3"
>
<option value="">all regions</option>
{#each regions as name (name)}<option value={name}>{name}</option>{/each}
</select>
</label>
</div>
<label class="mt-5 flex cursor-pointer items-center gap-3">
<input
type="checkbox"
bind:checked={withPokemonOnly}
class="h-5 w-5 accent-[var(--site-text)]"
/>
only places with recorded Pokémon
</label>
{#each [...grouped] as [name, locations] (name)}
<section class="mt-10">
<h2 class="text-2xl font-semibold">{name}</h2>
<ul class="mt-3 grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">
{#each locations as location (location.id)}
<li class="border-t border-[var(--site-border)]">
<a
href={resolve('/projects/game-tracker/pokemon/locations/[id=integer]', {
id: String(location.id)
})}
class="flex items-baseline justify-between gap-3 py-3 hover:text-[var(--site-muted)]"
>
<span>{location.name}</span>
<span class="shrink-0 text-sm text-[var(--site-muted)]"
>{location.pokemon ? `${location.pokemon} Pokémon` : ''}{location.hasPicture
? ' · picture'
: ''}</span
>
</a>
</li>
{/each}
</ul>
</section>
{:else}
<p class="mt-10 text-[var(--site-muted)]">no locations match.</p>
{/each}
</div>

View File

@@ -0,0 +1,101 @@
import { error, fail } from '@sveltejs/kit';
import { asc, eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { games, pokemon, pokemonEncounters, pokemonLocations } from '$lib/server/db/schema';
import { requireAdmin } from '$lib/server/authorization';
import { deleteObject, locationImageUrl, uploadImage } from '$lib/server/r2';
import type { Actions, PageServerLoad } from './$types';
const PICTURE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const MAX_PICTURE_BYTES = 5 * 1024 * 1024;
async function findLocation(id: string) {
const [location] = await db
.select()
.from(pokemonLocations)
.where(eq(pokemonLocations.id, Number(id)));
if (!location) error(404, 'Location not found');
return location;
}
export const load: PageServerLoad = async ({ params }) => {
const location = await findLocation(params.id);
const encounters = await db
.select({
gameId: games.id,
game: games.name,
number: pokemon.number,
name: pokemon.name,
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(games, eq(games.id, pokemonEncounters.gameId))
.innerJoin(pokemon, eq(pokemon.id, pokemonEncounters.pokemonId))
.where(eq(pokemonEncounters.locationId, location.id))
.orderBy(
asc(games.generation),
asc(games.id),
asc(pokemonEncounters.area),
asc(pokemonEncounters.method),
asc(pokemon.number)
);
return {
location: {
id: location.id,
name: location.name,
region: location.region,
description: location.description,
imageCredit: location.imageCredit,
imageUrl: locationImageUrl(location.imageKey)
},
encounters,
seo: {
title: `${location.name} · Pokémon locations`,
description: `Which Pokémon live in ${location.name}${location.region ? ` (${location.region})` : ''}, in which games, how to encounter them and at what levels.`
}
};
};
export const actions = {
save: async (event) => {
requireAdmin(event);
const location = await findLocation(event.params.id);
const data = await event.request.formData();
const description = String(data.get('description') ?? '').trim();
const imageCredit = String(data.get('imageCredit') ?? '').trim();
if (description.length > 5000 || imageCredit.length > 300)
return fail(400, { message: 'Description or credit is too long' });
let imageKey = location.imageKey;
const picture = data.get('picture');
if (picture instanceof File && picture.size > 0) {
if (!PICTURE_TYPES.includes(picture.type))
return fail(400, { message: 'Use a JPEG, PNG or WebP picture' });
if (picture.size > MAX_PICTURE_BYTES)
return fail(400, { message: 'Pictures can be at most 5 MB' });
try {
imageKey = (await uploadImage(picture, 'locations')).key;
} catch {
return fail(503, { message: 'Picture storage is not available right now' });
}
} else if (data.get('removePicture') === 'on') {
imageKey = null;
}
await db
.update(pokemonLocations)
.set({ description: description || null, imageCredit: imageCredit || null, imageKey })
.where(eq(pokemonLocations.id, location.id));
// Remove the replaced object only after the row no longer points at it.
if (location.imageKey && location.imageKey !== imageKey)
await deleteObject(location.imageKey).catch(() => undefined);
return { saved: true };
}
} satisfies Actions;

View File

@@ -0,0 +1,171 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { enhance } from '$app/forms';
import { page } from '$app/state';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
const games = $derived([
...new Map(data.encounters.map((encounter) => [encounter.gameId, encounter.game]))
]);
let gameId = $state<number | null>(null);
$effect(() => {
const requested = Number(page.url.searchParams.get('game'));
if (!games.some(([id]) => id === gameId))
gameId = games.find(([id]) => id === requested)?.[0] ?? games[0]?.[0] ?? null;
});
const rows = $derived(data.encounters.filter((encounter) => encounter.gameId === gameId));
const areas = $derived([...Map.groupBy(rows, (row) => row.area ?? '')]);
let saving = $state(false);
function levels(min: number | null, max: number | null) {
if (min === null) return '';
return min === max || max === null ? `Lv. ${min}` : `Lv. ${min}–${max}`;
}
</script>
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<a
href={resolve('/projects/game-tracker/pokemon/locations')}
class="text-[var(--site-muted)] hover:text-[var(--site-text)]">← locations</a
>
<h1 class="mt-3 text-4xl font-semibold tracking-tight sm:text-5xl">{data.location.name}</h1>
{#if data.location.region}<p class="mt-2 text-[var(--site-muted)]">{data.location.region}</p>{/if}
<div class="mt-8 grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<figure>
{#if data.location.imageUrl}
<img
src={data.location.imageUrl}
alt={data.location.name}
class="w-full rounded-lg border border-[var(--site-border)]"
/>
{#if data.location.imageCredit}<figcaption class="mt-2 text-sm text-[var(--site-muted)]">
{data.location.imageCredit}
</figcaption>{/if}
{:else}
<div
class="flex aspect-video items-center justify-center rounded-lg border border-dashed border-[var(--site-border)] text-[var(--site-muted)]"
>
no picture yet
</div>
{/if}
</figure>
<div>
{#if data.location.description}
<p class="whitespace-pre-line">{data.location.description}</p>
{:else}
<p class="text-[var(--site-muted)]">no notes about this place yet.</p>
{/if}
</div>
</div>
{#if data.isAdmin}
<form
method="POST"
action="?/save"
enctype="multipart/form-data"
class="mt-8 border-t border-[var(--site-border)] pt-6"
use:enhance={() => {
saving = true;
return async ({ update }) => {
await update({ reset: false });
saving = false;
};
}}
>
<h2 class="text-xl font-semibold">edit location</h2>
{#if form?.message}<p role="alert" class="mt-3 text-red-500">{form.message}</p>{/if}
<label class="mt-4 block"
>notes
<textarea
name="description"
rows="4"
maxlength="5000"
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
>{data.location.description ?? ''}</textarea
>
</label>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<label class="block"
>picture (JPEG, PNG or WebP, up to 5 MB)
<input
type="file"
name="picture"
accept="image/jpeg,image/png,image/webp"
class="mt-2 block w-full"
/>
</label>
<label class="block"
>picture credit
<input
name="imageCredit"
value={data.location.imageCredit ?? ''}
maxlength="300"
placeholder="e.g. my own screenshot"
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
/>
</label>
</div>
{#if data.location.imageUrl}<label class="mt-3 flex items-center gap-2"
><input type="checkbox" name="removePicture" /> remove the current picture</label
>{/if}
<button
disabled={saving}
class="mt-5 rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)] disabled:opacity-40"
>{saving ? 'saving…' : 'save'}</button
>
</form>
{/if}
<section class="mt-12">
<div class="flex flex-wrap items-end justify-between gap-4">
<h2 class="text-2xl font-semibold">Pokémon here</h2>
{#if games.length}
<label class="block"
>game
<select
bind:value={gameId}
class="mt-2 block rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
>
{#each games as [id, name] (id)}<option value={id}>{name}</option>{/each}
</select>
</label>
{/if}
</div>
{#each areas as [area, list] (area)}
{#if area}<h3 class="mt-6 font-semibold">{area}</h3>{/if}
<ul class="mt-2">
{#each list as encounter, index (index)}
<li class="flex items-center gap-3 border-t border-[var(--site-border)] py-2">
<img
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{encounter.number}.png"
alt=""
loading="lazy"
width="40"
height="40"
class="h-10 w-10 shrink-0"
/>
<span class="min-w-0">
<span class="mr-2 text-[var(--site-muted)]">#{encounter.number}</span>{encounter.name}
<span class="block text-sm 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>
</span>
</li>
{/each}
</ul>
{:else}
<p class="mt-4 text-[var(--site-muted)]">no Pokémon recorded here.</p>
{/each}
</section>
</div>

View File

@@ -11,7 +11,9 @@ const staticPaths = [
'/projects',
'/blog',
'/lists',
'/projects/catch-em-all',
'/projects/game-tracker',
'/projects/game-tracker/pokemon',
'/projects/game-tracker/pokemon/locations',
'/projects/animaldex',
'/projects/derby',
'/projects/spotify-beatmap-finder',

View File

@@ -68,12 +68,12 @@ test('register, persist progress, isolate users, enforce admin, revoke session',
page.on('pageerror', (error) => errors.push(error.message));
const email = `auth-test-${crypto.randomUUID()}@example.com`;
createdEmails.push(email);
await page.goto('/register?returnTo=/projects/catch-em-all');
await page.goto('/register?returnTo=/projects/game-tracker/pokemon');
await page.getByLabel('Name', { exact: true }).fill('Browser Trainer');
await page.getByRole('textbox', { name: 'Email', exact: true }).fill(email);
await page.getByLabel('Password', { exact: true }).fill(password);
await page.getByRole('button', { name: 'Create account', exact: true }).click();
await expect(page).toHaveURL(`${origin}/projects/catch-em-all`);
await expect(page).toHaveURL(`${origin}/projects/game-tracker/pokemon`);
const session = await (await page.request.get('/api/auth/get-session')).json();
createdUsers.push(session.user.id);
expect(session.user.role).toBe('user');
@@ -96,14 +96,16 @@ test('register, persist progress, isolate users, enforce admin, revoke session',
)[0]
).toEqual(caught);
const [game] = await sql`select id from games order by id limit 1`;
const selection = await action(page.request, '/projects/catch-em-all?/setGames', {
const selection = await action(page.request, '/projects/game-tracker/pokemon?/setGames', {
gameIds: String(game.id)
});
expect(selection.status()).toBe(200);
await page.reload();
await expect(page.locator('thead th')).toHaveCount(2);
// Explicitly selecting none must not revert to all games after a reload.
expect((await action(page.request, '/projects/catch-em-all?/setGames')).status()).toBe(200);
expect((await action(page.request, '/projects/game-tracker/pokemon?/setGames')).status()).toBe(
200
);
await page.reload();
await expect(page.locator('thead th')).toHaveCount(1);
@@ -112,7 +114,7 @@ test('register, persist progress, isolate users, enforce admin, revoke session',
const other = await register(bob, 'Other Trainer');
expect(
(
await action(bob, '/projects/catch-em-all?/togglePokemonCaught', {
await action(bob, '/projects/game-tracker/pokemon?/togglePokemonCaught', {
entryId: String(caught.pokemon_game_entry_id)
})
).status()
@@ -135,11 +137,15 @@ test('register, persist progress, isolate users, enforce admin, revoke session',
).status()
).toBe(403);
expect(
(await action(bob, '/projects/catch-em-all?/setGames', { gameIds: 'not-a-game' })).status()
(
await action(bob, '/projects/game-tracker/pokemon?/setGames', { gameIds: 'not-a-game' })
).status()
).toBe(400);
expect(
(
await action(bob, '/projects/catch-em-all?/togglePokemonCaught', { entryId: '999999999' })
await action(bob, '/projects/game-tracker/pokemon?/togglePokemonCaught', {
entryId: '999999999'
})
).status()
).toBe(404);
} finally {
@@ -204,12 +210,21 @@ test('anonymous/forged cookies, reserved identities, unsafe redirects, OAuth sta
expect((await action(page.request, '/lists?/newList')).status()).toBe(401);
expect(
(
await action(page.request, '/projects/catch-em-all?/togglePokemonCaught', {
await action(page.request, '/projects/game-tracker/pokemon?/togglePokemonCaught', {
entryId: '1',
caught: 'on'
})
).status()
).toBe(401);
expect(
(
await action(page.request, '/projects/game-tracker/call-of-duty/black-ops-2?/toggle', {
itemId: '1',
milestone: 'gold',
done: 'on'
})
).status()
).toBe(401);
expect(
(await page.request.post('/admin/login', { data: { password: 'anything' } })).status()
).toBe(410);