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:
@@ -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__':
|
||||
|
||||
Reference in New Issue
Block a user