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:
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();
|
||||
}
|
||||
Reference in New Issue
Block a user