add game tracker with owned pokemon data and call of duty mastery
All checks were successful
Test and deploy staging / staging (push) Successful in 18s

Fold catchemall into /projects/game-tracker (old URL redirects) and add
Call of Duty tracking for Black Ops through WWII.

- Pokémon catalog is now owned: locations, encounters and per-game
  evolution methods are built from the pinned PokéAPI snapshot and seeded.
- Pokémon you can evolve or breed in a game count as obtainable; mythicals
  without another route are marked event-only.
- Detail dialog with evolution chain and where to find it, location pages
  with admin notes and pictures, and admin corrections that survive reseeds.
- Call of Duty weapons, milestones and Steam achievements per game, with
  derived Diamond / Dark Matter / Solar / Black Sky / Chrome tiers.
- New migration (db:migrate:game-tracker), seeds (db:seed, db:seed:cod) and
  deploy preflight checks for them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 17:36:18 +00:00
parent 438623d857
commit a10585a4d2
49 changed files with 8236 additions and 170 deletions

View File

@@ -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__':