add game tracker with owned pokemon data and call of duty mastery
All checks were successful
Test and deploy staging / staging (push) Successful in 18s
All checks were successful
Test and deploy staging / staging (push) Successful in 18s
Fold catchemall into /projects/game-tracker (old URL redirects) and add Call of Duty tracking for Black Ops through WWII. - Pokémon catalog is now owned: locations, encounters and per-game evolution methods are built from the pinned PokéAPI snapshot and seeded. - Pokémon you can evolve or breed in a game count as obtainable; mythicals without another route are marked event-only. - Detail dialog with evolution chain and where to find it, location pages with admin notes and pictures, and admin corrections that survive reseeds. - Call of Duty weapons, milestones and Steam achievements per game, with derived Diamond / Dark Matter / Solar / Black Sky / Chrome tiers. - New migration (db:migrate:game-tracker), seeds (db:seed, db:seed:cod) and deploy preflight checks for them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
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
1
data/pokemon-locations.json
Normal file
1
data/pokemon-locations.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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": {
|
||||
|
||||
49
scripts/build-cod-catalog.py
Normal file
49
scripts/build-cod-catalog.py
Normal 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()
|
||||
@@ -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__':
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
127
scripts/game-tracker-migration.sql
Normal file
127
scripts/game-tracker-migration.sql
Normal 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);
|
||||
15
scripts/migrate-game-tracker.ts
Normal file
15
scripts/migrate-game-tracker.ts
Normal 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();
|
||||
}
|
||||
@@ -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.'
|
||||
};
|
||||
});
|
||||
await tx
|
||||
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 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:%'`
|
||||
});
|
||||
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}`
|
||||
);
|
||||
});
|
||||
console.log(
|
||||
`Seeded ${catalog.pokemon.length} species, ${catalog.games.length} versions, ${catalog.entries.length} entries from ${catalog.source.revision}`
|
||||
);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
|
||||
148
scripts/seed-cod-catalog.ts
Normal file
148
scripts/seed-cod-catalog.ts
Normal 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();
|
||||
}
|
||||
@@ -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'
|
||||
]);
|
||||
|
||||
225
src/lib/catch-em-all/PokemonDetails.svelte
Normal file
225
src/lib/catch-em-all/PokemonDetails.svelte
Normal 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}
|
||||
@@ -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,
|
||||
|
||||
@@ -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++;
|
||||
|
||||
183
src/lib/game-tracker/mastery.test.ts
Normal file
183
src/lib/game-tracker/mastery.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
149
src/lib/game-tracker/mastery.ts
Normal file
149
src/lib/game-tracker/mastery.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,7 +46,25 @@ export const relations = defineRelations(schema, (r) => ({
|
||||
entries: r.many.pokemonGameEntries()
|
||||
},
|
||||
pokemon: {
|
||||
entry: r.many.pokemonGameEntries()
|
||||
entry: r.many.pokemonGameEntries(),
|
||||
encounters: r.many.pokemonEncounters()
|
||||
},
|
||||
pokemonLocations: {
|
||||
encounters: r.many.pokemonEncounters()
|
||||
},
|
||||
pokemonEncounters: {
|
||||
pokemon: r.one.pokemon({
|
||||
from: r.pokemonEncounters.pokemonId,
|
||||
to: r.pokemon.id
|
||||
}),
|
||||
game: r.one.games({
|
||||
from: r.pokemonEncounters.gameId,
|
||||
to: r.games.id
|
||||
}),
|
||||
location: r.one.pokemonLocations({
|
||||
from: r.pokemonEncounters.locationId,
|
||||
to: r.pokemonLocations.id
|
||||
})
|
||||
},
|
||||
pokemonGameEntries: {
|
||||
game: r.one.games({
|
||||
@@ -80,6 +98,28 @@ export const relations = defineRelations(schema, (r) => ({
|
||||
})
|
||||
},
|
||||
|
||||
// ── Game tracker ──────────────────────────────
|
||||
trackerGames: {
|
||||
items: r.many.trackerItems()
|
||||
},
|
||||
trackerItems: {
|
||||
game: r.one.trackerGames({
|
||||
from: r.trackerItems.gameId,
|
||||
to: r.trackerGames.id
|
||||
}),
|
||||
progress: r.many.trackerProgress()
|
||||
},
|
||||
trackerProgress: {
|
||||
item: r.one.trackerItems({
|
||||
from: r.trackerProgress.itemId,
|
||||
to: r.trackerItems.id
|
||||
}),
|
||||
user: r.one.user({
|
||||
from: r.trackerProgress.userId,
|
||||
to: r.user.id
|
||||
})
|
||||
},
|
||||
|
||||
// ── Animaldex ─────────────────────────────────
|
||||
animals: {
|
||||
sightings: r.many.sightings()
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { index, integer, pgEnum, pgTable, text, unique } from 'drizzle-orm/pg-core';
|
||||
import { boolean, index, integer, pgEnum, pgTable, text, unique } from 'drizzle-orm/pg-core';
|
||||
import { availabilityValues } from '../../../availability';
|
||||
import { user } from './auth';
|
||||
import { id, timestamps } from './helpers';
|
||||
|
||||
export const availability = pgEnum('availability', availabilityValues);
|
||||
|
||||
// Where a catalog row came from. Seeding replaces `pokeapi` and `derived` rows
|
||||
// but never touches `manual` ones, which are admin corrections.
|
||||
export const catalogSources = ['pokeapi', 'derived', 'manual'] as const;
|
||||
const source = () => text('source', { enum: catalogSources }).notNull().default('pokeapi');
|
||||
|
||||
export const pokemon = pgTable('pokemon', {
|
||||
id: id(),
|
||||
number: integer('number').notNull().unique(),
|
||||
@@ -18,14 +23,23 @@ export const pokemon = pgTable('pokemon', {
|
||||
specialAttack: integer('special_attack').notNull().default(0),
|
||||
specialDefense: integer('special_defense').notNull().default(0),
|
||||
speed: integer('speed').notNull().default(0),
|
||||
// national numbers, so the chain survives reseeding without id lookups
|
||||
evolvesFrom: integer('evolves_from'),
|
||||
evolutionChain: integer('evolution_chain'),
|
||||
isBaby: boolean('is_baby').notNull().default(false),
|
||||
isLegendary: boolean('is_legendary').notNull().default(false),
|
||||
isMythical: boolean('is_mythical').notNull().default(false),
|
||||
...timestamps
|
||||
});
|
||||
|
||||
export const games = pgTable('games', {
|
||||
id: id(),
|
||||
sourceId: integer('source_id').unique(),
|
||||
slug: text('slug').unique(),
|
||||
name: text('name').notNull(),
|
||||
generation: integer('generation').notNull().default(1),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
breeding: boolean('breeding').notNull().default(true),
|
||||
...timestamps
|
||||
});
|
||||
|
||||
@@ -40,13 +54,81 @@ export const pokemonGameEntries = pgTable(
|
||||
.notNull()
|
||||
.references(() => games.id, { onDelete: 'cascade' }),
|
||||
availability: availability('availability').notNull().default('catchable'),
|
||||
// optional extra detail for the tooltip, e.g. "trade with a Blue version owner"
|
||||
// how to obtain it, e.g. "Evolve Wartortle: Level 36+" or "Route 22, Viridian Forest"
|
||||
note: text('note'),
|
||||
source: source(),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [unique().on(table.pokemonId, table.gameId), index().on(table.gameId)]
|
||||
);
|
||||
|
||||
export const pokemonLocations = pgTable('pokemon_locations', {
|
||||
id: id(),
|
||||
sourceId: integer('source_id').unique(),
|
||||
slug: text('slug').notNull(),
|
||||
name: text('name').notNull(),
|
||||
region: text('region'),
|
||||
description: text('description'),
|
||||
// R2 object key of an admin-uploaded picture
|
||||
imageKey: text('image_key'),
|
||||
imageCredit: text('image_credit'),
|
||||
source: source(),
|
||||
...timestamps
|
||||
});
|
||||
|
||||
export const pokemonEncounters = pgTable(
|
||||
'pokemon_encounters',
|
||||
{
|
||||
id: id(),
|
||||
pokemonId: integer('pokemon_id')
|
||||
.notNull()
|
||||
.references(() => pokemon.id, { onDelete: 'cascade' }),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => games.id, { onDelete: 'cascade' }),
|
||||
locationId: integer('location_id')
|
||||
.notNull()
|
||||
.references(() => pokemonLocations.id, { onDelete: 'cascade' }),
|
||||
area: text('area'),
|
||||
method: text('method').notNull(),
|
||||
minLevel: integer('min_level'),
|
||||
maxLevel: integer('max_level'),
|
||||
// summed encounter-slot percentage; null for gifts and folded raids
|
||||
rarity: integer('rarity'),
|
||||
form: text('form'),
|
||||
conditions: text('conditions').array().notNull().default([]),
|
||||
note: text('note'),
|
||||
source: source(),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [index().on(table.pokemonId, table.gameId), index().on(table.locationId)]
|
||||
);
|
||||
|
||||
// The method that applies in each game, e.g. Magneton → Magnezone needs Mt.
|
||||
// Coronet in Platinum but a Thunder Stone in Sword.
|
||||
export const pokemonEvolutions = pgTable(
|
||||
'pokemon_evolutions',
|
||||
{
|
||||
id: id(),
|
||||
fromPokemonId: integer('from_pokemon_id')
|
||||
.notNull()
|
||||
.references(() => pokemon.id, { onDelete: 'cascade' }),
|
||||
toPokemonId: integer('to_pokemon_id')
|
||||
.notNull()
|
||||
.references(() => pokemon.id, { onDelete: 'cascade' }),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => games.id, { onDelete: 'cascade' }),
|
||||
method: text('method').notNull(),
|
||||
source: source(),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [
|
||||
unique().on(table.fromPokemonId, table.toPokemonId, table.gameId, table.method),
|
||||
index().on(table.gameId)
|
||||
]
|
||||
);
|
||||
|
||||
export const userEntries = pgTable(
|
||||
'user_entries',
|
||||
{
|
||||
@@ -94,3 +176,6 @@ export type PokemonGameEntry = typeof pokemonGameEntries.$inferSelect;
|
||||
export type UserEntry = typeof userEntries.$inferSelect;
|
||||
export type UserGame = typeof userGames.$inferSelect;
|
||||
export type Availability = (typeof availability.enumValues)[number];
|
||||
export type PokemonLocation = typeof pokemonLocations.$inferSelect;
|
||||
export type PokemonEncounter = typeof pokemonEncounters.$inferSelect;
|
||||
export type PokemonEvolution = typeof pokemonEvolutions.$inferSelect;
|
||||
|
||||
61
src/lib/server/db/schema/game-tracker.ts
Normal file
61
src/lib/server/db/schema/game-tracker.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { boolean, index, integer, jsonb, pgTable, text, unique } from 'drizzle-orm/pg-core';
|
||||
import type { MasteryRules } from '../../../game-tracker/mastery';
|
||||
import { user } from './auth';
|
||||
import { id, timestamps } from './helpers';
|
||||
|
||||
// Checklist-style games (Call of Duty and future franchises). Pokémon keeps its
|
||||
// own species × game model in catch-em-all.ts.
|
||||
export const trackerGames = pgTable('tracker_games', {
|
||||
id: id(),
|
||||
slug: text('slug').notNull().unique(),
|
||||
franchise: text('franchise').notNull(),
|
||||
name: text('name').notNull(),
|
||||
shortName: text('short_name').notNull(),
|
||||
releaseYear: integer('release_year'),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
rules: jsonb('rules').$type<MasteryRules>().notNull(),
|
||||
...timestamps
|
||||
});
|
||||
|
||||
export const trackerItems = pgTable(
|
||||
'tracker_items',
|
||||
{
|
||||
id: id(),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => trackerGames.id, { onDelete: 'cascade' }),
|
||||
// stable catalog key, e.g. `weapon:m8a7` or `311210:the-true-gunslinger`
|
||||
key: text('key').notNull(),
|
||||
kind: text('kind', { enum: ['achievement', 'weapon'] }).notNull(),
|
||||
// achievement mode or weapon class
|
||||
category: text('category').notNull(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
icon: text('icon'),
|
||||
postLaunch: boolean('post_launch').notNull().default(false),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [unique().on(table.gameId, table.key), index().on(table.gameId)]
|
||||
);
|
||||
|
||||
// One row per completed milestone: `done` for achievements, or a weapon
|
||||
// milestone key such as `gold` or `maxLevel`. Derived tiers are never stored.
|
||||
export const trackerProgress = pgTable(
|
||||
'tracker_progress',
|
||||
{
|
||||
id: id(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
itemId: integer('item_id')
|
||||
.notNull()
|
||||
.references(() => trackerItems.id, { onDelete: 'cascade' }),
|
||||
milestone: text('milestone').notNull().default('done'),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [unique().on(table.userId, table.itemId, table.milestone), index().on(table.userId)]
|
||||
);
|
||||
|
||||
export type TrackerGame = typeof trackerGames.$inferSelect;
|
||||
export type TrackerItem = typeof trackerItems.$inferSelect;
|
||||
@@ -4,6 +4,7 @@ export * from './auth';
|
||||
export * from './blog';
|
||||
export * from './progress';
|
||||
export * from './catch-em-all';
|
||||
export * from './game-tracker';
|
||||
export * from './animaldex';
|
||||
export * from './lists';
|
||||
export * from './money-tracker';
|
||||
|
||||
137
src/lib/server/pokemon-detail.ts
Normal file
137
src/lib/server/pokemon-detail.ts
Normal 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
|
||||
}))
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export {};
|
||||
@@ -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>
|
||||
7
src/routes/projects/catch-em-all/+server.ts
Normal file
7
src/routes/projects/catch-em-all/+server.ts
Normal 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}`);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
@import 'tailwindcss';
|
||||
116
src/routes/projects/game-tracker/+page.server.ts
Normal file
116
src/routes/projects/game-tracker/+page.server.ts
Normal 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 };
|
||||
};
|
||||
101
src/routes/projects/game-tracker/+page.svelte
Normal file
101
src/routes/projects/game-tracker/+page.svelte
Normal 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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showNonCatchable}
|
||||
onchange={(event) => {
|
||||
showNonCatchable = event.currentTarget.checked;
|
||||
pageNumber = 1;
|
||||
}}
|
||||
class="h-5 w-5 accent-[var(--site-text)]"
|
||||
/>
|
||||
show non-catchable Pokémon
|
||||
</label>
|
||||
<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={showUnobtainable}
|
||||
onchange={(event) => {
|
||||
showUnobtainable = event.currentTarget.checked;
|
||||
pageNumber = 1;
|
||||
}}
|
||||
class="h-5 w-5 accent-[var(--site-text)]"
|
||||
/>
|
||||
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"
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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))
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user