Expand website and game trackers
Some checks failed
Test and deploy staging / staging (push) Failing after 5m29s

This commit is contained in:
2026-09-25 22:43:04 +00:00
parent f6b34150d4
commit c2ba8febb4
4076 changed files with 97237 additions and 1453 deletions

View File

@@ -0,0 +1,91 @@
"""Import attributable Call of Duty Wiki table snapshots into a factual challenge catalog.
Input is one or more rendered, line-numbered source snapshots per game, exported
by the research tool. Ambiguous or incomplete rows are skipped. No thresholds
are invented. Keys depend on category + name, never page line numbers.
Usage: python3 scripts/build-cod-challenges.py --snapshots /tmp/cod-wiki
The checked-in JSON is the deployable snapshot; this command does not need DB access.
"""
import argparse
import json
from pathlib import Path
import re
TITLES={'black-ops':'Call_of_Duty:_Black_Ops','black-ops-2':'Call_of_Duty:_Black_Ops_II','ghosts':'Operations_(challenges)/Call_of_Duty:_Ghosts','advanced-warfare':'Call_of_Duty:_Advanced_Warfare','black-ops-3':'Call_of_Duty:_Black_Ops_III','infinite-warfare':'Call_of_Duty:_Infinite_Warfare','wwii':'Call_of_Duty:_WWII'}
VERBS=r'(?:Get|Earn|Kill|Win|Complete|Destroy|Perform|Play|Achieve|Reach|Use|Capture|Defend|Survive|Assist|Shoot|Recover|Hack|Call in|Obtain|Unlock|Steal|Equip|Finish|Plant|Defuse|Deny|Pick up|Revive|Take|Absorb|Damage|Land|Hold|Score|Purchase|Make|Eliminate|Return|Collect|Prevent|Detonate|End|Resupply)'
def slug(s):return re.sub(r'[^a-z0-9]+','-',s.lower()).strip('-')
def clean(s):return re.sub(r'\s+',' ',s).strip().replace('[]','')
def lines_for(directory,game):
lines={}
for p in directory.glob(game+'*.txt'):
if p.name.split('--')[0].split('.txt')[0]!=game:continue
raw=p.read_text();raw=re.sub(r'cite[^†]+†([^†]+)(?:†[^]+)?',r'\1',raw)
for m in re.finditer(r'L(\d+):\s*(.*?)(?=L\d+:|$)',raw,re.S):
number=int(m[1]);text=m[2].split('-------------------------')[0].strip()
if number not in lines or len(text)>len(lines[number]):lines[number]=text
return sorted(lines.items())
def parse(directory,game):
rows=lines_for(directory,game);heads={};items={};previous='';previous_line=0
source='https://callofduty.fandom.com/wiki/'+('' if game=='ghosts' else 'Challenges/')+TITLES[game]
for number,line in rows:
line=line.replace('\xa0',' ')
heading=re.match(r'^(#{2,6})\s+(.+)',line)
if heading:
level=len(heading[1]);heads={k:v for k,v in heads.items() if k<level};heads[level]=clean(heading[2]);previous='';continue
if game=='ghosts':
# The source omits many tier thresholds. Only standalone, explicit operations
# are imported; unknown '#' counts are deliberately left out.
bullet=re.match(rf'^\s*\*\s+(.+?):\s*((?:{VERBS}|Fire|Force|Crush|Have|Be|Successfully|Infect)\b.+)$',line)
if bullet and heads and '#' not in bullet[2] and any('Operations' in h for h in heads.values()) and not any('References' in h or 'Trivia' in h for h in heads.values()):
name=clean(bullet[1]);requirement=clean(bullet[2]);category=' / '.join(heads[k] for k in sorted(heads))
key=slug(category)+':'+slug(name)
items[key]=dict(key=key,name=name,category=category,requirement=requirement,reward=None,target=None,icon=None,sourceUrl=source+'#'+heads[max(heads)].replace(' ','_'))
continue
if not heads or not line or line.startswith(('*','Note:','Challenge','Name ','Description','XP Reward','Image','This ','The ','After ','All ','I:','II:','III:','IV:','V:','VI:')):continue
if any(re.search(r'Contents|Trivia|References|Gallery|Navigation|Explore|Follow|Community|Advertise',h) for h in heads.values()):continue
# BO2 campaign objectives lack stable named table rows; exclude those.
if game=='black-ops-2' and number<705:continue
if game=='black-ops' and number<490:continue
match=re.match(rf'^(.{{2,100}}?)\s+({VERBS}\b.+)$',line)
if match:
name,rest=match.groups()
if re.match(rf'^{VERBS}\b',name) or 'Challenge How' in name:continue
elif game=='advanced-warfare' and previous and number-previous_line<5 and re.match(rf'^{VERBS}\b',line):
name,rest=previous,line
else:
if game=='advanced-warfare' and len(line)<70 and not re.search(r'\d|XP|Playercard|Emblem|Reticle',line):previous=clean(line);previous_line=number
continue
fields=re.split(r'\s{2,}',rest)
requirement=clean(fields[0]);rewards=fields[1:]
if len(fields)==1:
match_xp=re.search(r'\s+(\d[\d,]*)(?:\s*XP)?$',requirement)
if match_xp:rewards=[match_xp[1]+' XP'];requirement=requirement[:match_xp.start()]
reward=clean(' · '.join(r for r in rewards if r not in ['None','Image'] and not re.fullmatch(r'\d+',r)))
reward=re.sub(r'Image:\s*','',reward).replace('Image ','').strip(' ·')
if not reward and any('Image' in r for r in rewards):reward='Calling card (see source)'
if not requirement or len(requirement)>1000 or re.search(r'\bL\d+:',requirement):continue
# Numeric progress only when the requirement starts with an explicit count.
count=re.match(r'^(?:Get|Earn|Kill|Win|Destroy|Play|Capture|Complete|Absorb)\s+(\d[\d,]*)\s+',requirement)
target=int(count[1].replace(',','')) if count else None
category=' / '.join(heads[k] for k in sorted(heads))
if game=='black-ops':category='Multiplayer / '+category
name=clean(name).strip('* ')
if re.search(r'Image:|\bXP\b',name):continue # merged table cells, not a valid challenge name
key=slug(category)+':'+slug(name)
if key in items:continue
items[key]=dict(key=key,name=name,category=category,requirement=requirement,reward=reward or None,target=target,icon=None,sourceUrl=source+'#'+heads[max(heads)].replace(' ','_'))
return list(items.values())
def main():
parser=argparse.ArgumentParser();parser.add_argument('--snapshots',type=Path,required=True);args=parser.parse_args()
games={game:parse(args.snapshots,game) for game in TITLES}
result=dict(source=dict(name='Call of Duty Wiki contributors',license='CC BY-SA',licenseUrl='https://www.fandom.com/licensing',attribution='https://callofduty.fandom.com/wiki/Call_of_Duty_Wiki:Copyrights',retrieved='2026-09-25',scope='Named challenge rows with explicit requirements. Incomplete and ambiguous source rows are excluded. Source coverage varies; these are not complete game inventories. Numeric targets are recorded only when explicit at the start of a requirement.'),games=games)
Path('data/cod-challenges.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n')
print({game:len(rows) for game,rows in games.items()})
if __name__=='__main__':main()

View File

@@ -0,0 +1,85 @@
"""Build the factual region atlas and authored overview maps.
Run from the repository root. Coordinate sources are cached in /tmp/pokemon-atlas.
Later regions are explicitly schematic and do not imply exact route geometry.
"""
import argparse, hashlib, json, re, urllib.request
from pathlib import Path
parser=argparse.ArgumentParser()
parser.add_argument('--cache',type=Path,default=Path('/tmp/pokemon-atlas'))
args=parser.parse_args();args.cache.mkdir(parents=True,exist_ok=True)
sources={
'pokeemerald-region.json':'https://raw.githubusercontent.com/pret/pokeemerald/master/src/data/region_map/region_map_sections.json',
'pokefirered-region.json':'https://raw.githubusercontent.com/pret/pokefirered/master/src/data/region_map/region_map_sections.json',
'platinum-map.json':'https://raw.githubusercontent.com/pret/pokeplatinum/main/res/town_map/town_map_data.json',
'johto-landmarks.asm':'https://raw.githubusercontent.com/pret/pokecrystal/master/data/maps/landmarks.asm'
}
checksums={'pokeemerald-region.json': '3a5f8c07c85c17f05cec06d049305962e9a9a1d2bd6eae935f3887866a4ad491', 'pokefirered-region.json': '82e456cbe86a57ebc4bd0d88d6e000e83e28340895ac038079c38d415bc6de5f', 'platinum-map.json': 'b5f6792e7993afa57df3d5b86c0af57488d23642576c4a224ba401bd762ba6b8', 'johto-landmarks.asm': 'bf61258379901daf794555b1a8f73bcf634d50ef3ca6e067f88f599b9df9816f'}
for filename,url in sources.items():
path=args.cache/filename
if not path.exists():
with urllib.request.urlopen(url,timeout=60) as response: path.write_bytes(response.read())
if hashlib.sha256(path.read_bytes()).hexdigest()!=checksums[filename]:
raise ValueError(f'{filename}: upstream coordinates changed; review the map and update its checksum before rebuilding')
from pathlib import Path
locations=json.load(open('data/pokemon-locations.json'))['locations']
def slug(s):return re.sub(r'[^a-z0-9]+','-',s.lower()).strip('-')
def location(key,region):
key=key.replace('eternia','eterna').replace('mount-coronet','mt-coronet').replace('tin-tower','bell-tower')
keys=[key,f'{region}-{key}',key.replace('route-','sea-route-'),f'{region}-{key.replace("route-","sea-route-")}']
return next((p for p in locations if p['slug'] in keys and (p['region'] or '').lower()==region),None)
def node(key,x,y,region,cells=None):
p=location(key,region)
return {'slug':p['slug'] if p else key,'name':p['name'] if p else key.replace('-',' ').title(),'x':x,'y':y,'kind':'town' if re.search('town|city|village|area$|league',key) else 'route' if 'route' in key else 'landmark','cells':cells or []}
regions={}
for region,file in [('hoenn',args.cache/'pokeemerald-region.json'),('kanto',args.cache/'pokefirered-region.json')]:
d=json.load(open(file))['map_sections'];nodes=[]
for p in d:
if 'x' not in p:continue
key=slug(p['name']);x=p['x'];y=p['y'];w=p['width'];h=p['height']
if region=='hoenn' and key=='none':break
if region=='kanto' and key=='one-island':break
nodes.append(node(key,x+w/2,y+h/2,region,[[x,y,w,h]]))
regions[region]={'name':region.title(),'width':32,'height':20,'nodes':nodes,'source':f'https://github.com/pret/poke{ "emerald" if region=="hoenn" else "firered" }/blob/master/src/data/region_map/region_map_sections.json','precision':'Game map coordinates'}
# Platinum town map blocks preserve branching paths and geographic placement.
d=json.load(open(args.cache/'platinum-map.json'));groups={}
for b in d['blocks']:
key=re.sub(r'_(north|south)$','',b['area'] or '').replace('_','-')
if not key:continue
groups.setdefault(key,[]).append([b['x'],b['z'],1,1])
if b.get('landmark') and b['landmark'].replace('_','-')!=key:
land=b['landmark'].replace('_','-')
if location(land,'sinnoh'):groups.setdefault(land,[]).append([b['x'],b['z'],1,1])
nodes=[node(k,sum(c[0]+.5 for c in cells)/len(cells),sum(c[1]+.5 for c in cells)/len(cells),'sinnoh',cells) for k,cells in groups.items()]
regions['sinnoh']={'name':'Sinnoh','width':32,'height':30,'nodes':nodes,'source':'https://github.com/pret/pokeplatinum/blob/main/res/town_map/town_map_data.json','precision':'Platinum town map coordinates'}
s=(args.cache/'johto-landmarks.asm').read_text();nodes=[]
for x,y,key in re.findall(r'landmark\s+(\d+),\s*(\d+),\s*(\w+)Name',s):
if key=='PalletTown':break
name=re.sub(r'([a-z])([A-Z])',r'\1-\2',key);name=re.sub(r'([A-Za-z])(\d)',r'\1-\2',name)
nodes.append(node(name.lower(),int(x)/5,int(y)/5,'johto'))
regions['johto']={'name':'Johto','width':32,'height':30,'nodes':nodes,'source':'https://github.com/pret/pokecrystal/blob/master/data/maps/landmarks.asm','precision':'Crystal landmark coordinates'}
# Later-region overview points: deliberately identified as schematic rather than exact route maps.
landmarks={
'unova':'Nuvema Town,23,26;Accumula Town,23,23;Striaton City,26,20;Nacrene City,21,20;Castelia City,16,23;Nimbasa City,16,16;Driftveil City,9,17;Mistralton City,7,10;Icirrus City,11,5;Opelucid City,20,5;Lacunosa Town,26,6;Undella Town,28,12;Black City,26,17;White Forest,28,18;Aspertia City,4,26;Floccesy Town,7,25;Virbank City,10,25;Humilau City,29,4;Pokemon League,28,1',
'kalos':'Vaniville Town,23,25;Aquacorde Town,23,22;Santalune City,24,19;Lumiose City,16,12;Camphrier Town,12,19;Ambrette Town,5,24;Cyllage City,4,18;Geosenge Town,3,12;Shalour City,6,8;Coumarine City,11,6;Laverre City,17,7;Dendemille Town,24,8;Anistar City,28,12;Couriway Town,28,20;Snowbelle City,27,25;Pokemon Village,24,28;Pokemon League,30,17',
'alola':'Iki Town,6,6;Hauoli City,3,9;Melemele Sea,6,12;Heahea City,22,10;Paniola Town,24,7;Royal Avenue,26,11;Konikoni City,21,13;Malie City,26,20;Tapu Village,22,23;Po Town,21,17;Mount Lanakila,21,21;Seafolk Village,5,23;Poni Wilds,4,20;Vast Poni Canyon,7,17;Aether Paradise,14,15',
'galar':'Postwick,15,27;Wedgehurst,15,24;Wild Area,15,20;Motostoke,15,17;Turffield,8,14;Hulbury,23,14;Hammerlocke,15,10;Stow on Side,6,10;Ballonlea,7,5;Circhester,25,8;Spikemuth,26,17;Wyndon,15,3;Isle of Armor,29,23;Crown Tundra,7,28',
'hisui':'Jubilife Village,6,15;Obsidian Fieldlands,9,22;Crimson Mirelands,19,22;Cobalt Coastlands,26,15;Coronet Highlands,16,13;Alabaster Icelands,16,5;Temple of Sinnoh,16,10;Prelude Beach,4,19',
'paldea':'Cabo Poco,17,28;Los Platos,17,24;Mesagoza,17,21;Cortondo,11,22;Artazon,24,22;Levincia,28,15;Cascarrafa,9,16;Porto Marinada,4,11;Medali,12,10;Montenevera,20,6;Alfornada,5,27;Glaseado Mountain,21,9;Casseroya Lake,9,6;Area Zero,17,15;Pokemon League,14,19',
'orre':'Phenac City,21,21;Pyrite Town,12,20;Agate Village,11,6;Mt Battle,22,4;Realgam Tower,19,15;The Under,10,23;Outskirt Stand,26,25;Snagem Hideout,27,15;Cipher Lab,27,8;Gateon Port,5,15;Pokemon HQ Lab,5,22;Rock Poke Spot,16,18;Oasis Poke Spot,23,11;Cave Poke Spot,16,10;Citadark Isle,4,28',
'kitakami':'Mossui Town,16,23;Loyalty Plaza,11,24;Apple Hills,9,21;Kitakami Hall,22,17;Oni Mountain,16,10;Crystal Pool,16,9;Timeless Woods,26,6;Fellhorn Gorge,26,14;Paradise Barrens,6,8;Revelers Road,20,21',
'blueberry':'Central Plaza,16,15;Savanna Biome,8,22;Coastal Biome,24,22;Canyon Biome,8,8;Polar Biome,24,8'
}
for region,raw in landmarks.items():
ns=[]
for point in raw.split(';'):
name,x,y=point.split(',');n=node(slug(name),int(x),int(y),region);n['name']=name.replace('Pokemon','Pokémon');ns.append(n)
regions[region]={'name':{'blueberry':'Blueberry Academy'}.get(region,region.title()),'width':32,'height':30,'nodes':ns,'source':f'https://bulbapedia.bulbagarden.net/wiki/{region.title() if region!="blueberry" else "Terarium"}','precision':'Schematic landmark overview; use the location list for routes'}
# Multiple blocks can describe one route or cave; retain one selectable marker.
for region in regions.values():
merged={}
for point in region['nodes']:
if point['slug'] in merged: merged[point['slug']]['cells'].extend(point['cells'])
else: merged[point['slug']]=point
region['nodes']=list(merged.values())
Path('data/pokemon-atlas.json').write_text(json.dumps({'source':'Coordinates adapted from pret decompilations; later-region landmark overviews are authored schematics. No game or wiki prose is reproduced.','regions':regions},ensure_ascii=False,indent=2)+'\n')
print({k:len(v['nodes']) for k,v in regions.items()})

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Build image-space map markers and assemble the three SV in-game maps.
Requires Pillow and beautifulsoup4. Cached source pages/tiles: /tmp/game-map-art.
The map artwork is from the games; source image-map coordinates are adapted from
Serebii with attribution. Hand-placed landmarks are explicitly approximate.
"""
from pathlib import Path
import argparse
import json
import re
import urllib.request
from PIL import Image
from bs4 import BeautifulSoup
ROOT=Path(__file__).resolve().parents[1]
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--cache',type=Path,default=Path('/tmp/game-map-art'))
args=parser.parse_args();args.cache.mkdir(parents=True,exist_ok=True)
def fetch(url,name):
p=args.cache/name
if not p.exists():p.write_bytes(urllib.request.urlopen(url,timeout=45).read())
return p
def slug(s):return re.sub(r'[^a-z0-9]+','-',s.lower().replace('é','e')).strip('-')
original=json.loads((ROOT/'data/pokemon-atlas.json').read_text())['regions']
locations=json.loads((ROOT/'data/pokemon-locations.json').read_text())['locations']
def node(name,x,y,region):
key=slug(name);aliases={'mount-coronet':'mt-coronet','pokemon-league':'sinnoh-pokemon-league','lake-of-rage':'lake-of-rage','mt-battle':'mt-battle','pokemon-hq-lab':'pokemon-hq-lab'}
key=aliases.get(key,key)
found=next((p for p in locations if slug(p['name'])==slug(name) and (p['region'] or '').lower()==region),None)
if not found:found=next((p for p in locations if p['slug'] in [key,region+'-'+key] and (p['region'] or '').lower()==region),None)
return dict(slug=found['slug'] if found else key,name=found['name'] if found else name,x=round(x,3),y=round(y,3),kind='town' if re.search(r'Town|City|Village|Area|League',name) else 'route' if 'Route' in name else 'landmark',cells=[])
maps={}
for region in original:
p=ROOT/f'static/game-art/maps/{region}.webp'
if region in ['paldea','kitakami','blueberry']:
remote='terarium' if region=='blueberry' else region
canvas=Image.new('RGB',(2048,2048))
for x in range(8):
for y in range(8):
tile=fetch(f'https://www.serebii.net/pokearth/{remote}/map/tile_2-{x}-{y}.png',f'{remote}-tile-{x}-{y}.png')
canvas.paste(Image.open(tile),(x*256,y*256))
canvas.save(p,format='WEBP',quality=90,method=6)
im=Image.open(p);w,h=im.size
source='https://www.serebii.net/pokearth/'+region+'/'
if region=='blueberry':source='https://www.serebii.net/pokearth/terarium/'
if region=='orre':source='https://pokemonrevolution.net/forum/topic/147410-orre-region/'
nodes=[]
if region in ['sinnoh','hoenn','kanto','johto','unova','kalos']:
if region in ['kalos','unova']:page=fetch('https://www.serebii.net/pokearth/','pokearth.html')
else:page=fetch(source,region+'.html')
soup=BeautifulSoup(page.read_bytes(),'html.parser')
area_map=soup.find('map',attrs={'name':re.compile('^'+region+'$',re.I)})
if area_map:
for a in area_map.find_all('area'):
values=list(map(float,re.findall(r'[\d.]+',a.get('coords',''))))
if len(values)<4:continue
xs=values[::2];ys=values[1::2]
# Bounding-box centre remains inside rectangular routes and town markers.
x=(min(xs)+max(xs))/2;y=(min(ys)+max(ys))/2
name=a.get('title','').strip()
if name and 0<=x<=w and 0<=y<=h:nodes.append(node(name,x,y,region))
nodes=list({n['slug']:n for n in nodes}.values())
maps[region]=dict(name=original[region]['name'],image=f'/game-art/maps/{region}.webp',width=w,height=h,nodes=nodes,source=source,credit='Nintendo / The Pokémon Company; map imagery and location coordinates via Serebii' if region!='orre' else 'Nintendo / Genius Sonority; Pokémon XD screenshot via Pokémon Revolution forum',precision='Regional map · location markers aligned to the source image' if nodes else 'Game map · approximate landmark markers',pixelated=w<500)
# Reviewed landmark centres as percentages of the image, not the old schematic.
manual={
'paldea':'Cabo Poco,52,93;Los Platos,51,85;Mesagoza,49,78;Cortondo,31,77;Artazon,69,78;Levincia,83,58;Cascarrafa,30,58;Porto Marinada,14,48;Medali,35,40;Montenevera,51,27;Alfornada,15,89;Glaseado Mountain,55,31;Casseroya Lake,25,28;Area Zero,48,61;Pokemon League,45,72',
'kitakami':'Mossui Town,56,77;Loyalty Plaza,43,80;Apple Hills,38,75;Kitakami Hall,67,58;Oni Mountain,49,49;Crystal Pool,48,50;Timeless Woods,75,28;Fellhorn Gorge,81,46;Paradise Barrens,27,29;Revelers Road,64,70',
'blueberry':'Central Plaza,50,50;Savanna Biome,72,70;Coastal Biome,72,30;Canyon Biome,30,70;Polar Biome,30,30',
'hisui':'Jubilife Village,31,55;Obsidian Fieldlands,27,70;Crimson Mirelands,70,68;Cobalt Coastlands,86,35;Coronet Highlands,52,43;Alabaster Icelands,37.5,18;Temple of Sinnoh,36,10;Prelude Beach,26,61',
'galar':'Postwick,27.5,68.5;Wedgehurst,27.5,63.5;Wild Area,26.1,54.2;Motostoke,25.7,46.3;Turffield,12.4,40.9;Hulbury,38.5,40.8;Hammerlocke,26.2,34.5;Stow on Side,12.6,31.3;Ballonlea,10.4,24.3;Circhester,43.3,26.5;Spikemuth,47.4,34.7;Wyndon,26.2,9.4;Isle of Armor,76.9,35.2;Crown Tundra,26.1,84.5',
'alola':'Iki Town,14.5,40.4;Hauoli City,12,48.3;Melemele Sea,18.8,55;Heahea City,30.8,32;Paniola Town,34.3,25.8;Royal Avenue,37.8,32.5;Konikoni City,26.3,28.8;Malie City,91.3,62.9;Tapu Village,78.5,73.3;Po Town,79,55;Mount Lanakila,82.8,64.6;Seafolk Village,56.8,37.9;Poni Wilds,57.8,33.3;Vast Poni Canyon,67,19.6;Aether Paradise,38.3,61.7',
'orre':'Phenac City,73,61.2;Pyrite Town,56,44;Agate Village,39,30;Mt Battle,54,19.8;Realgam Tower,59,55.6;Outskirt Stand,87.8,61.7;Snagem Hideout,84,36;Cipher Lab,73.4,27.7;Gateon Port,12.8,31.9;Pokemon HQ Lab,19.5,19.6;Rock Poke Spot,86.7,48.5;Oasis Poke Spot,57.5,31.9;Cave Poke Spot,38.3,47;Citadark Isle,31,57.7;Orre Colosseum,22.6,46.5'
}
for region,places in manual.items():
m=maps[region]
m['nodes']=[node(name,float(x)*m['width']/100,float(y)*m['height']/100,region) for name,x,y in (p.split(',') for p in places.split(';'))]
(ROOT/'data/pokemon-map-art.json').write_text(json.dumps(maps,ensure_ascii=False,indent=2)+'\n')
print({r:len(m['nodes']) for r,m in maps.items()})

View File

@@ -0,0 +1,119 @@
"""Reproducible factual PokéAPI import: regional dexes, learnsets and species biology.
Uses the same pinned revision as the encounter catalog. Reuses cached CSV files.
Never infers encounter absence from missing data.
"""
import argparse
import concurrent.futures
import csv
import json
from pathlib import Path
import urllib.request
REVISION = '575291cdb197a7e3a320297be276c9de4ef8401a'
TABLES = ['pokemon', 'pokemon_species', 'pokemon_abilities', 'ability_names', 'pokemon_egg_groups',
'egg_group_prose', 'growth_rates', 'pokemon_stats', 'pokemon_dex_numbers', 'pokedex_prose',
'pokedexes', 'pokedex_version_groups', 'versions', 'version_group_regions', 'regions',
'moves', 'move_names', 'type_names', 'move_damage_class_prose', 'move_targets',
'pokemon_moves', 'pokemon_move_method_prose']
def build(directory):
def rows(name):
with (directory / f'{name}.csv').open() as f:
yield from csv.DictReader(f)
def names(table, key):
return {int(r[key]): r['name'] for r in rows(table) if r['local_language_id'] == '9'}
def integer(v):
return int(v) if v else None
defaults = {int(r['id']): r for r in rows('pokemon') if r['is_default'] == '1'}
species_for = {pid: int(r['species_id']) for pid, r in defaults.items()}
abilities, eggs, evs = {}, {}, {}
ability_names = names('ability_names', 'ability_id')
egg_names = names('egg_group_prose', 'egg_group_id')
growth = {int(r['id']): r['identifier'].replace('-', ' ') for r in rows('growth_rates')}
for r in rows('pokemon_abilities'):
if int(r['pokemon_id']) in species_for:
abilities.setdefault(species_for[int(r['pokemon_id'])], []).append(dict(name=ability_names[int(r['ability_id'])], hidden=r['is_hidden'] == '1'))
for r in rows('pokemon_egg_groups'):
eggs.setdefault(int(r['species_id']), []).append(egg_names[int(r['egg_group_id'])])
for r in rows('pokemon_stats'):
pid = int(r['pokemon_id'])
if pid in species_for:
evs.setdefault(species_for[pid], [0]*6)[int(r['stat_id'])-1] = int(r['effort'])
by_species = {int(r['species_id']): r for r in defaults.values()}
facts = []
for s in rows('pokemon_species'):
number = int(s['id'])
p = by_species[number]
facts.append(dict(number=number, facts=dict(height=int(p['height']), weight=int(p['weight']),
baseExperience=integer(p['base_experience']), captureRate=int(s['capture_rate']),
baseHappiness=integer(s['base_happiness']), growthRate=growth[int(s['growth_rate_id'])],
genderRate=int(s['gender_rate']), hatchCounter=integer(s['hatch_counter']),
abilities=abilities.get(number, []), eggGroups=eggs.get(number, []), evYield=evs.get(number, []))))
versions = list(rows('versions'))
dex_names = names('pokedex_prose', 'pokedex_id')
dex_slugs = {int(r['id']): r['identifier'] for r in rows('pokedexes')}
dex_groups = {}
for r in rows('pokedex_version_groups'):
dex_groups.setdefault(int(r['pokedex_id']), []).append(int(r['version_group_id']))
dexes = []
for r in rows('pokemon_dex_numbers'):
dex = int(r['pokedex_id'])
if dex == 1:
continue
for v in versions:
if int(v['version_group_id']) in dex_groups.get(dex, []):
dexes.append([int(r['species_id']), int(v['id']), dex_slugs[dex], dex_names[dex], int(r['pokedex_number'])])
regions = {int(r['id']): r['identifier'] for r in rows('regions')}
group_regions = {}
for r in rows('version_group_regions'):
group_regions.setdefault(int(r['version_group_id']), []).append(regions[int(r['region_id'])])
games = [dict(sourceId=int(v['id']), versionGroup=int(v['version_group_id']), regions=group_regions.get(int(v['version_group_id']), [])) for v in versions]
move_names = names('move_names', 'move_id')
type_names = names('type_names', 'type_id')
damage = names('move_damage_class_prose', 'move_damage_class_id')
targets = {int(r['id']): r['identifier'].replace('-', ' ') for r in rows('move_targets')}
moves = [dict(id=int(r['id']), name=move_names.get(int(r['id']), r['identifier']), type=type_names[int(r['type_id'])],
category=damage[int(r['damage_class_id'])], power=integer(r['power']), accuracy=integer(r['accuracy']),
pp=integer(r['pp']), priority=int(r['priority']), target=targets[int(r['target_id'])]) for r in rows('moves')]
method_names = names('pokemon_move_method_prose', 'pokemon_move_method_id')
learnsets = {}
for r in rows('pokemon_moves'):
pid = int(r['pokemon_id'])
if pid not in species_for:
continue
key = (species_for[pid], int(r['version_group_id']))
learnsets.setdefault(key, set()).add((int(r['move_id']), method_names[int(r['pokemon_move_method_id'])], int(r['level'])))
return dict(source=dict(repository='https://github.com/PokeAPI/pokeapi', revision=REVISION,
scope='Default forms; regional dex numbers, species biology and version-specific learnsets. Move battle values are current snapshot values, not historical per-generation values.'),
facts=facts, dexes=dexes, games=games, moves=moves,
learnsets=[[species, group, sorted(entries)] for (species, group), entries in sorted(learnsets.items())])
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--cache', type=Path, default=Path('/tmp/pokemon-research-' + REVISION))
args = parser.parse_args()
args.cache.mkdir(parents=True, exist_ok=True)
def download(table):
path = args.cache / f'{table}.csv'
if path.exists():
return
with urllib.request.urlopen(f'https://raw.githubusercontent.com/PokeAPI/pokeapi/{REVISION}/data/v2/csv/{table}.csv', timeout=120) as response:
content = response.read()
path.write_bytes(content)
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
list(executor.map(download, TABLES))
result = build(args.cache)
target = Path(__file__).resolve().parent.parent / 'data/pokemon-research.json'
target.write_text(json.dumps(result, ensure_ascii=False, separators=(',', ':')) + '\n')
print({k: len(v) for k, v in result.items() if isinstance(v, list)})
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""Check picture coverage, decodability, dimensions, and manifest integrity offline."""
import json
from pathlib import Path
from PIL import Image
ROOT=Path(__file__).resolve().parents[1]
manifest=json.loads((ROOT/'data/game-images.json').read_text())
paths={r['path'] for r in manifest}
assert len(paths)==len(manifest), 'Duplicate image paths'
for game in json.loads((ROOT/'data/pokemon-catalog.json').read_text())['games']:
assert f'/game-logos/pokemon/{game["slug"]}.webp' in paths, game['slug']
for game in json.loads((ROOT/'data/call-of-duty.json').read_text())['games']:
assert f'/game-logos/cod/{game["slug"]}.webp' in paths, game['slug']
for kind in ['sprite','shiny','artwork']:
for number in range(1,1026):assert f'/game-art/pokemon/{kind}/{number}.webp' in paths
for slug in ['pokemon','call-of-duty','osu']:assert f'/game-logos/{slug}.webp' in paths
for row in json.loads((ROOT/'data/cod-art.json').read_text()):
assert row['path'] in paths, (row['game'],row['name'])
for path in json.loads((ROOT/'data/cod-achievement-art.json').read_text()).values():
assert path in paths, path
for region,m in json.loads((ROOT/'data/pokemon-map-art.json').read_text()).items():
paths.add(m['image'])
for point in m['nodes']:
assert 0 <= point['x'] <= m['width'] and 0 <= point['y'] <= m['height'], (region,point)
for_size=Image.open(ROOT/'static'/m['image'].lstrip('/'))
assert for_size.size==(m['width'],m['height']),region
for path in paths:
with Image.open(ROOT/'static'/path.lstrip('/')) as im:
assert im.format=='WEBP',path
im.load()
assert min(im.size)>0,path
print(f'{len(paths):,} local WebP images checked: all game pictures, 1,025 × 3 Pokémon images, 13 maps, and downloaded COD artwork.')

View File

@@ -45,6 +45,12 @@ try {
'pokemon_locations', 'pokemon_encounters', 'pokemon_evolutions',
'tracker_games', 'tracker_items', 'tracker_progress'
)) as game_tracker_tables,
(select count(*) from information_schema.tables where table_schema='public' and table_name in (
'pokemon_research','pokemon_dex_numbers','pokemon_moves','pokemon_learnsets','pokemon_game_research',
'pokemon_journeys','pokemon_catches','pokemon_milestones','tracker_ranks','tracker_weapon_ranks',
'tracker_challenges','tracker_challenge_progress','osu_entries','pokemon_seen'
)) as tracker_journal_tables,
(select count(*) from information_schema.columns where table_name='pokemon_journeys' and column_name='completed') as tracker_completion_column,
(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
@@ -66,6 +72,8 @@ try {
!Number(schema.money_billing_start) ||
Number(schema.money_projection_columns) !== 5 ||
Number(schema.game_tracker_tables) !== 6 ||
Number(schema.tracker_journal_tables) !== 14 ||
!Number(schema.tracker_completion_column) ||
!Number(schema.entry_source)
) {
throw new Error(
@@ -78,7 +86,12 @@ try {
(select count(*) from games where source_id is not null) as games,
(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
(select count(*) from tracker_games where franchise = 'call-of-duty') as cod_games,
(select count(*) from pokemon_research) as pokemon_research,
(select count(*) from pokemon_moves) as pokemon_moves,
(select count(*) from pokemon_learnsets) as pokemon_learnsets,
(select count(*) from pokemon_dex_numbers) as pokemon_dex_numbers,
(select count(*) from tracker_challenges) as tracker_challenges
`;
if (
Number(catalog.pokemon) < 1025 ||
@@ -88,6 +101,16 @@ try {
) {
throw new Error('Pokémon catalog has not been seeded in this database');
}
if (
Number(catalog.pokemon_research) < 1025 ||
Number(catalog.pokemon_moves) < 937 ||
Number(catalog.pokemon_learnsets) < 11000 ||
Number(catalog.pokemon_dex_numbers) < 16000 ||
Number(catalog.tracker_challenges) < 3600
)
throw new Error(
'Run db:migrate:tracker-journal, db:seed:research and db:seed:challenges before deploying the tracker expansion'
);
if (Number(catalog.cod_games) < 7) {
throw new Error('Call of Duty catalog has not been seeded in this database');
}

View File

@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Download the reviewed artwork manifest, preserving aspect ratio, as local WebP.
Requires Pillow. Existing valid files are reused; failed downloads never replace files.
"""
import argparse
from concurrent.futures import ThreadPoolExecutor
import io
import json
from pathlib import Path
import time
import urllib.request
from PIL import Image, ImageOps
ROOT = Path(__file__).resolve().parents[1]
def download(row):
target = ROOT / 'static' / row['path'].lstrip('/')
if target.exists():
with Image.open(target) as img:
if img.format == 'WEBP':
return row['id'], None
for attempt in range(3):
try:
request = urllib.request.Request(row['url'], headers={'User-Agent': 'PersonalGameJournal/1.0 (image catalog)'})
with urllib.request.urlopen(request, timeout=45) as response:
content = response.read()
with Image.open(io.BytesIO(content)) as image:
image = ImageOps.exif_transpose(image).convert('RGBA')
image.thumbnail((row.get('maxSize', 800),) * 2, Image.Resampling.LANCZOS)
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix('.tmp')
image.save(temporary, format='WEBP', lossless=row.get('lossless', False), quality=88, method=6)
temporary.replace(target)
return row['id'], None
except Exception as error:
if attempt == 2:
return row['id'], str(error)
time.sleep(attempt + 1)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--filter', default='')
parser.add_argument('--workers', type=int, default=6)
args = parser.parse_args()
rows = json.loads((ROOT / 'data/game-images.json').read_text())
rows = [r for r in rows if args.filter in r['id']]
failed = []
with ThreadPoolExecutor(max_workers=args.workers) as pool:
for index, (name, error) in enumerate(pool.map(download, rows), 1):
if error:
failed.append((name, error))
print('FAILED', name, error, flush=True)
elif index % 100 == 0 or len(rows) < 100:
print(f'{index}/{len(rows)} {name}', flush=True)
print(f'{len(rows) - len(failed)}/{len(rows)} images ready', flush=True)
if failed:
raise SystemExit(1)
if __name__ == '__main__':
main()

View File

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

23
scripts/seed-cod-art.ts Normal file
View File

@@ -0,0 +1,23 @@
import postgres from 'postgres';
import artwork from '../data/cod-art.json';
import achievementArt from '../data/cod-achievement-art.json';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const sql = postgres(process.env.DB_URL, { max: 1 });
try {
await sql.begin(async (tx) => {
for (const image of artwork) {
const rows =
await tx`update tracker_items i set icon=${image.path} from tracker_games g where i.game_id=g.id and g.slug=${image.game} and i.key=${image.key} returning i.id`;
if (rows.length !== 1) throw new Error(`Unknown artwork target: ${image.game}/${image.key}`);
}
for (const [source, path] of Object.entries(achievementArt)) {
await tx`update tracker_items set icon=${path} where kind='achievement' and icon=${source}`;
}
});
console.log(
`Seeded ${artwork.length} weapon pictures and ${Object.keys(achievementArt).length} achievement icons; progress preserved.`
);
} finally {
await sql.end();
}

View File

@@ -1,3 +1,6 @@
import achievementArt from '../data/cod-achievement-art.json';
const achievementImages: Record<string, string> = achievementArt;
import artwork from '../data/cod-art.json';
import { readFile } from 'node:fs/promises';
import { drizzle } from 'drizzle-orm/postgres-js';
import { and, eq, inArray, notInArray, sql } from 'drizzle-orm';
@@ -46,7 +49,8 @@ try {
milestones: game.milestones,
tiers: game.tiers,
classes: game.classes.map((c) => c.name),
steamApps: game.steamApps
steamApps: game.steamApps,
rankCaps: game.rankCaps
};
const values = {
slug: game.slug,
@@ -76,7 +80,9 @@ try {
category: weapon.category,
name: weapon.name,
description: null,
icon: null,
icon:
artwork.find((a) => a.game === game.slug && a.key === weaponKey(weapon.name))?.path ??
null,
postLaunch: weapon.postLaunch,
sortOrder: index
})),
@@ -87,7 +93,7 @@ try {
category: achievement.category,
name: achievement.name,
description: achievement.description,
icon: achievement.icon,
icon: achievementImages[achievement.icon] ?? achievement.icon,
postLaunch: false,
sortOrder: index
}))

View File

@@ -0,0 +1,41 @@
import { readFile } from 'node:fs/promises';
import postgres from 'postgres';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const catalog = JSON.parse(
await readFile(new URL('../data/cod-challenges.json', import.meta.url), 'utf8')
);
const client = postgres(process.env.DB_URL, { max: 1 });
try {
await client.begin(async (tx) => {
for (const [slug, rows] of Object.entries(catalog.games) as [
string,
{
key: string;
name: string;
category: string;
requirement: string;
reward: string | null;
icon: string | null;
target: number | null;
sourceUrl: string;
}[]
][]) {
const [game] =
await tx`select id from tracker_games where slug=${slug} and franchise='call-of-duty'`;
if (!game) throw new Error(`Seed Call of Duty catalog first: ${slug}`);
for (let offset = 0; offset < rows.length; offset += 500) {
const batch = rows
.slice(offset, offset + 500)
.map(({ sourceUrl, ...r }) => ({ ...r, game_id: game.id, source_url: sourceUrl }));
await tx`insert into tracker_challenges ${tx(batch)} on conflict (game_id,key) do update set name=excluded.name,category=excluded.category,requirement=excluded.requirement,reward=excluded.reward,icon=excluded.icon,target=excluded.target,source_url=excluded.source_url`;
}
// Retire removed source definitions only when nobody has saved progress.
if (rows.length)
await tx`delete from tracker_challenges c where c.game_id=${game.id} and c.key not in ${tx(rows.map((r) => r.key))} and not exists (select 1 from tracker_challenge_progress p where p.challenge_id=c.id)`;
console.log(`${slug}: ${rows.length} challenge rows`);
}
});
} finally {
await client.end();
}

View File

@@ -0,0 +1,65 @@
import { readFile } from 'node:fs/promises';
import postgres from 'postgres';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const catalog = JSON.parse(
await readFile(new URL('../data/pokemon-research.json', import.meta.url), 'utf8')
);
const client = postgres(process.env.DB_URL, { max: 1 });
try {
await client.begin(async (tx) => {
const species = new Map(
(await tx`select id, number from pokemon`).map((r) => [r.number, r.id])
);
const games = new Map(
(await tx`select id, source_id from games`).map((r) => [r.source_id, r.id])
);
const speciesId = (n: number) => {
const id = species.get(n);
if (!id) throw new Error(`Seed species ${n} first`);
return id;
};
const gameId = (n: number) => {
const id = games.get(n);
if (!id) throw new Error(`Seed game ${n} first`);
return id;
};
for (const row of catalog.facts)
await tx`insert into pokemon_research (pokemon_id,facts) values (${speciesId(row.number)},${tx.json(row.facts)}) on conflict (pokemon_id) do update set facts=excluded.facts`;
for (const row of catalog.games) {
if (!games.has(row.sourceId)) continue;
await tx`insert into pokemon_game_research (game_id,version_group,regions) values (${gameId(row.sourceId)},${row.versionGroup},${row.regions}) on conflict (game_id) do update set version_group=excluded.version_group,regions=excluded.regions`;
}
for (let offset = 0; offset < catalog.dexes.length; offset += 1000) {
const batch = catalog.dexes
.slice(offset, offset + 1000)
.filter((r: number[]) => games.has(r[1]))
.map(([n, v, d, name, number]: [number, number, string, string, number]) => ({
pokemon_id: speciesId(n),
game_id: gameId(v),
dex: d,
dex_name: name,
number
}));
if (batch.length)
await tx`insert into pokemon_dex_numbers ${tx(batch)} on conflict (pokemon_id,game_id,dex) do update set number=excluded.number,dex_name=excluded.dex_name`;
}
for (const row of catalog.moves)
await tx`insert into pokemon_moves ${tx(row)} on conflict (id) do update set name=excluded.name,type=excluded.type,category=excluded.category,power=excluded.power,accuracy=excluded.accuracy,pp=excluded.pp,priority=excluded.priority,target=excluded.target`;
for (let offset = 0; offset < catalog.learnsets.length; offset += 500) {
const batch = catalog.learnsets
.slice(offset, offset + 500)
.map(([n, version_group, moves]: [number, number, never]) => ({
pokemon_id: speciesId(n),
version_group,
moves: tx.json(moves)
}));
await tx`insert into pokemon_learnsets ${tx(batch)} on conflict (pokemon_id,version_group) do update set moves=excluded.moves`;
}
console.log(
`Seeded ${catalog.facts.length} profiles, ${catalog.dexes.length} dex entries, ${catalog.moves.length} moves and ${catalog.learnsets.length} versioned learnsets`
);
});
} finally {
await client.end();
}

View File

@@ -0,0 +1,71 @@
-- Additive, repeatable journal and factual catalog expansion. Existing progress is preserved.
CREATE TABLE IF NOT EXISTS pokemon_seen (
id serial PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
game_id integer NOT NULL REFERENCES games(id) ON DELETE CASCADE,
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(user_id, game_id, pokemon_id)
);
CREATE INDEX IF NOT EXISTS pokemon_seen_user_idx ON pokemon_seen(user_id);
CREATE TABLE IF NOT EXISTS pokemon_research (
pokemon_id integer PRIMARY KEY REFERENCES pokemon(id) ON DELETE CASCADE, facts jsonb NOT NULL
);
CREATE TABLE IF NOT EXISTS pokemon_dex_numbers (
id serial PRIMARY KEY, pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE, game_id integer NOT NULL REFERENCES games(id) ON DELETE CASCADE, dex text NOT NULL, dex_name text NOT NULL, number integer NOT NULL, UNIQUE(pokemon_id,game_id,dex)
);
CREATE TABLE IF NOT EXISTS pokemon_moves (
id integer PRIMARY KEY, name text NOT NULL, type text NOT NULL, category text NOT NULL, power integer, accuracy integer, pp integer, priority integer NOT NULL, target text NOT NULL
);
CREATE TABLE IF NOT EXISTS pokemon_learnsets (
id serial PRIMARY KEY, pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE, version_group integer NOT NULL, moves jsonb NOT NULL, UNIQUE(pokemon_id,version_group)
);
CREATE TABLE IF NOT EXISTS pokemon_game_research (
game_id integer PRIMARY KEY REFERENCES games(id) ON DELETE CASCADE, version_group integer NOT NULL, regions text[] NOT NULL
);
CREATE TABLE IF NOT EXISTS pokemon_journeys (
id serial PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, game_id integer NOT NULL REFERENCES games(id) ON DELETE CASCADE, stage text NOT NULL DEFAULT 'story' CHECK(stage IN ('story','postgame','collection')), location text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(user_id,game_id)
);
CREATE TABLE IF NOT EXISTS pokemon_catches (
id serial PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, game_id integer NOT NULL REFERENCES games(id) ON DELETE CASCADE, pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE, nickname text NOT NULL DEFAULT '', level integer NOT NULL DEFAULT 1 CHECK(level BETWEEN 1 AND 100), moves text[] NOT NULL DEFAULT '{}' CHECK(cardinality(moves)<=4), party_slot integer CHECK(party_slot BETWEEN 1 AND 6), shiny boolean NOT NULL DEFAULT false, nature text, ability text, ball text, location text, obtained text NOT NULL DEFAULT 'caught', stage text NOT NULL DEFAULT 'story' CHECK(stage IN ('story','postgame','collection')), notes text NOT NULL DEFAULT '', caught_on date, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(user_id,game_id,party_slot)
);
CREATE TABLE IF NOT EXISTS pokemon_milestones (
id serial PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, game_id integer NOT NULL REFERENCES games(id) ON DELETE CASCADE, key text NOT NULL, title text NOT NULL, category text NOT NULL, stage text NOT NULL DEFAULT 'story', done boolean NOT NULL DEFAULT false, notes text NOT NULL DEFAULT '', created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(user_id,game_id,key)
);
CREATE TABLE IF NOT EXISTS tracker_ranks (
id serial PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, game_id integer NOT NULL REFERENCES tracker_games(id) ON DELETE CASCADE, level integer NOT NULL DEFAULT 1 CHECK(level BETWEEN 1 AND 10000), prestige integer NOT NULL DEFAULT 0 CHECK(prestige BETWEEN 0 AND 1000), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(user_id,game_id)
);
CREATE TABLE IF NOT EXISTS tracker_weapon_ranks (
id serial PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, item_id integer NOT NULL REFERENCES tracker_items(id) ON DELETE CASCADE, level integer NOT NULL DEFAULT 1 CHECK(level BETWEEN 1 AND 10000), prestige integer NOT NULL DEFAULT 0 CHECK(prestige BETWEEN 0 AND 1000), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(user_id,item_id)
);
CREATE TABLE IF NOT EXISTS tracker_challenges (
id serial PRIMARY KEY, game_id integer NOT NULL REFERENCES tracker_games(id) ON DELETE CASCADE, key text NOT NULL, name text NOT NULL, category text NOT NULL, requirement text NOT NULL, reward text, icon text, target integer, source_url text NOT NULL, UNIQUE(game_id,key)
);
CREATE TABLE IF NOT EXISTS tracker_challenge_progress (
id serial PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, challenge_id integer NOT NULL REFERENCES tracker_challenges(id) ON DELETE CASCADE, current integer NOT NULL DEFAULT 0 CHECK(current >= 0), done boolean NOT NULL DEFAULT false, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(user_id,challenge_id)
);
CREATE TABLE IF NOT EXISTS osu_entries (
id serial PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, kind text NOT NULL CHECK(kind IN ('score','goal','training')), title text NOT NULL, mode text NOT NULL DEFAULT 'osu', beatmap_url text, score_url text, goal text NOT NULL DEFAULT 'pass', skill text NOT NULL DEFAULT 'stamina', bpm integer, duration integer, accuracy text, combo integer, misses integer, pp text, mods text NOT NULL DEFAULT 'NM', rate text NOT NULL DEFAULT '1.00', achieved boolean NOT NULL DEFAULT false, played_on date NOT NULL, notes text NOT NULL DEFAULT '', created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS pokemon_dex_game_idx ON pokemon_dex_numbers(game_id);
CREATE INDEX IF NOT EXISTS pokemon_catches_owner_idx ON pokemon_catches(user_id,game_id,pokemon_id);
CREATE INDEX IF NOT EXISTS osu_entries_owner_idx ON osu_entries(user_id,kind);
ALTER TABLE pokemon_journeys ADD COLUMN IF NOT EXISTS completed boolean NOT NULL DEFAULT false;
-- Historical catches were necessarily seen; repeatable and non-destructive.
INSERT INTO pokemon_seen (user_id, game_id, pokemon_id)
SELECT u.user_id, e.game_id, e.pokemon_id FROM user_entries u
JOIN pokemon_game_entries e ON e.id = u.pokemon_game_entry_id
ON CONFLICT DO NOTHING;