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>
424 lines
24 KiB
Python
424 lines
24 KiB
Python
"""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.
|
||
"""
|
||
import argparse
|
||
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',
|
||
'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):
|
||
def rows(name):
|
||
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 = 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'])]
|
||
stats = {}
|
||
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:
|
||
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)},
|
||
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 = 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']))
|
||
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, []):
|
||
present.add((int(p['species_id']), version))
|
||
for p in rows('pokemon_game_indices'):
|
||
if int(p['game_index']) > 0:
|
||
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():
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument('--source-dir', type=Path)
|
||
args = parser.parse_args()
|
||
with tempfile.TemporaryDirectory() as temporary:
|
||
directory = args.source_dir or Path(temporary)
|
||
if not args.source_dir:
|
||
def download(table):
|
||
url = f'https://raw.githubusercontent.com/PokeAPI/pokeapi/{REVISION}/data/v2/csv/{table}.csv'
|
||
with urllib.request.urlopen(url, timeout=60) as response:
|
||
(directory / f'{table}.csv').write_bytes(response.read())
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
|
||
list(executor.map(download, TABLES))
|
||
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__':
|
||
main()
|