veekun_pokedex/pokedex/util/movesets.py

529 lines
22 KiB
Python
Raw Normal View History

2011-04-25 22:58:38 +00:00
#! /usr/bin/env python
# Encoding: UTF-8
import sys
import argparse
2011-04-26 09:53:48 +00:00
import itertools
2011-04-25 22:58:38 +00:00
from collections import defaultdict
from sqlalchemy.orm import aliased
2011-04-25 22:58:38 +00:00
from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.sql.expression import not_, and_, or_
from pokedex.db import connect, tables, util
from pokedex.util import querytimer
from pokedex.util.astar import a_star
class IllegalMoveCombination(ValueError): pass
class TooManyMoves(IllegalMoveCombination): pass
class NoMoves(IllegalMoveCombination): pass
class MovesNotLearnable(IllegalMoveCombination): pass
class NoParent(IllegalMoveCombination): pass
class TargetExcluded(IllegalMoveCombination): pass
2011-04-26 09:53:48 +00:00
def powerset(iterable):
# recipe from: http://docs.python.org/library/itertools.html
"powerset([1,2,3]) --> () (1,) (2,) (3,) (1,2) (1,3) (2,3) (1,2,3)"
s = list(iterable)
return itertools.chain.from_iterable(itertools.combinations(s, r)
for r in range(len(s)+1))
2011-04-25 22:58:38 +00:00
class MovesetSearch(object):
def __init__(self, session, pokemon, version, moves, level=100, costs=None,
exclude_versions=(), exclude_pokemon=(), debug=False):
self.generator = self.error = None
if not moves:
self.error = NoMoves('No moves specified.')
return
elif len(moves) > 4:
self.error = NoMoves('Too many moves specified.')
return
self.debug = debug
self.session = session
self.sketch = util.get(session, tables.Move, identifier=u'sketch').id
self.no_eggs_group = util.get(session, tables.EggGroup,
identifier=u'no-eggs').id
self.ditto_group = util.get(session, tables.EggGroup,
identifier=u'ditto').id
2011-04-25 22:58:38 +00:00
if costs is None:
self.costs = default_costs
else:
self.costs = costs
self.excluded_families = frozenset(p.evolution_chain_id
for p in exclude_pokemon)
if pokemon:
self.goal_evolution_chain = pokemon.evolution_chain_id
if self.goal_evolution_chain in self.excluded_families:
self.error = TargetExcluded('The target pokemon was excluded.')
return
else:
self.goal_evolution_chain = None
if debug:
print 'Specified moves:', [move.id for move in moves]
self.goal_moves = frozenset(move.id for move in moves)
self.goal_version_group = version.version_group_id
self.load_pokemon()
2011-04-25 22:58:38 +00:00
# Fill self.generation_id_by_version_group
self.load_version_groups(version.version_group_id,
[v.version_group_id for v in exclude_versions])
self.pokemon_moves = defaultdict( # key: pokemon
lambda: defaultdict( # key: move
lambda: defaultdict( # key: version_group
lambda: defaultdict( # key: method
list)))) # list of (level, cost)
self.movepools = defaultdict(dict) # evo chain -> move -> best cost
self.learnpools = defaultdict(set) # evo chain -> move, w/o egg moves
2011-04-25 22:58:38 +00:00
easy_moves, non_egg_moves = self.load_pokemon_moves(
self.goal_evolution_chain, 'family')
hard_moves = self.goal_moves - easy_moves
2011-04-26 09:53:48 +00:00
self.egg_moves = self.goal_moves - non_egg_moves
2011-04-25 22:58:38 +00:00
if hard_moves:
# Have to breed!
self.load_pokemon_moves(self.goal_evolution_chain, 'others')
2011-04-26 00:14:32 +00:00
self.construct_breed_graph()
2011-04-25 22:58:38 +00:00
def load_version_groups(self, version, excluded):
"""Load generation_id_by_version_group
"""
2011-04-25 22:58:38 +00:00
query = self.session.query(tables.VersionGroup.id,
tables.VersionGroup.generation_id)
query = query.join(tables.Version.version_group)
if excluded:
query = query.filter(not_(tables.VersionGroup.id.in_(excluded)))
self.generation_id_by_version_group = dict(query)
def expand(v2):
for v1 in self.generation_id_by_version_group:
if self.trade_cost(v1, v2):
yield 0, None, v1
def is_goal(v):
return True
goal = self.goal_version_group
filtered_map = {goal: self.generation_id_by_version_group[goal]}
for result in a_star(self.goal_version_group, expand, is_goal):
for cost, transition, version in result:
filtered_map[version] = (
self.generation_id_by_version_group[version])
self.generation_id_by_version_group = filtered_map
if self.debug:
print 'Excluded version groups:', excluded
print 'Trade cost table:'
print '%03s' % '',
for g1 in sorted(self.generation_id_by_version_group):
print '%03s' % g1,
print
for g1 in sorted(self.generation_id_by_version_group):
print '%03s' % g1,
for g2 in sorted(self.generation_id_by_version_group):
print '%03s' % (self.trade_cost(g1, g2) or '---'),
print
def load_pokemon_moves(self, evolution_chain, selection):
2011-04-26 00:14:32 +00:00
"""Load pokemon_moves, movepools, learnpools, smeargle_families
2011-04-25 22:58:38 +00:00
`selection`:
'family' for loading only pokemon in evolution_chain
'others' for loading only pokemon NOT in evolution_chain
Returns: (easy_moves, non_egg_moves)
If `selection` == 'family':
easy_moves is a set of moves that are easier to obtain than by
breeding
non_egg_moves is a set of moves that don't require breeding
Otherwise, these are empty sets.
"""
if self.debug:
print 'Loading moves, c%s %s' % (evolution_chain, selection)
query = self.session.query(
tables.PokemonMove.pokemon_id,
tables.PokemonMove.move_id,
tables.PokemonMove.version_group_id,
tables.PokemonMoveMethod.identifier,
tables.PokemonMove.level,
tables.Pokemon.evolution_chain_id,
)
query = query.join(tables.PokemonMove.pokemon)
query = query.filter(tables.PokemonMoveMethod.id ==
tables.PokemonMove.pokemon_move_method_id)
query = query.filter(tables.PokemonMove.version_group_id.in_(
set(self.generation_id_by_version_group)))
query = query.filter(or_(
tables.PokemonMove.level > 100, # XXX: Chaff?
tables.PokemonMove.move_id.in_(self.goal_moves),
tables.PokemonMove.move_id == self.sketch,
tables.PokemonMove.move_id.in_(
self.evolution_moves.values()),
2011-04-25 22:58:38 +00:00
))
if self.excluded_families:
query = query.filter(not_(tables.Pokemon.evolution_chain_id.in_(
self.excluded_families)))
if evolution_chain:
if selection == 'family':
query = query.filter(tables.Pokemon.evolution_chain_id == (
evolution_chain))
elif selection == 'others':
query = query.filter(tables.Pokemon.evolution_chain_id != (
evolution_chain))
query = query.order_by(tables.PokemonMove.level)
easy_moves = set()
non_egg_moves = set()
2011-04-26 00:14:32 +00:00
self.smeargle_families = set()
2011-04-25 22:58:38 +00:00
for pokemon, move, vg, method, level, chain in query:
if move in self.goal_moves or move == self.sketch:
2011-04-25 22:58:38 +00:00
cost = self.learn_cost(method, vg)
self.movepools[chain][move] = min(
self.movepools[chain].get(move, cost), cost)
if method != 'egg':
self.learnpools[chain].add(move)
non_egg_moves.add(move)
if cost < self.costs['breed']:
easy_moves.add(move)
else:
cost = -1
2011-04-26 00:14:32 +00:00
if move == self.sketch:
self.smeargle_families.add(self.evolution_chains[pokemon])
2011-04-25 22:58:38 +00:00
self.pokemon_moves[pokemon][move][vg][method].append((level, cost))
if self.debug and selection == 'family':
print 'Easy moves:', sorted(easy_moves)
print 'Non-egg moves:', sorted(non_egg_moves)
2011-04-26 00:14:32 +00:00
if self.debug:
print 'Smeargle families:', sorted(self.smeargle_families)
2011-04-25 22:58:38 +00:00
return easy_moves, non_egg_moves
def learn_cost(self, method, version_group):
"""Return cost of learning a move by method (identifier) in ver. group
"""
if method == 'level-up':
return self.costs['level-up']
gen = self.generation_id_by_version_group[version_group]
if method == 'machine' and gen < 5:
return self.costs['machine-once']
elif method == 'tutor' and gen == 3:
return self.costs['tutor-once']
elif method == 'egg':
return self.costs['breed']
else:
return self.costs[method]
def trade_cost(self, version_group_from, version_group_to, *thing_generations):
"""Return cost of trading between versions, None if impossibble
`thing_generations` should be the generation IDs of the pokemon and
moves being traded.
"""
# XXX: this ignores HM transfer restrictions
gen_from = self.generation_id_by_version_group[version_group_from]
gen_to = self.generation_id_by_version_group[version_group_to]
if gen_from == gen_to:
return self.costs['trade']
elif any(gen > gen_to for gen in thing_generations):
return None
elif gen_from in (1, 2):
if gen_to in (1, 2):
return self.costs['trade']
else:
return None
elif gen_to in (1, 2):
return None
elif gen_from > gen_to:
return None
elif gen_from < gen_to - 1:
return None
else:
return self.costs['trade'] + self.costs['transfer']
def load_pokemon(self):
"""Load pokemon breed groups and evolutions
2011-04-26 00:14:32 +00:00
self.egg_groups: maps evolution chains to their sorted egg groups
(wil contain empty tuple for no-eggs or ditto)
self.evolution_chains: maps pokemon to their evolution chains
self.pokemon_by_evolution_chain: maps evolution chains to their pokemon
self.unbreedable: set of unbreedable pokemon
self.evolution_parents: maps pokemon to their pre-evolved form
self.evolutions: maps pokemon to lists of (trigger, move, level, child)
self.evolution_moves: maps evolution_chains to their evolution moves
"""
eg1 = tables.PokemonEggGroup
eg2 = aliased(tables.PokemonEggGroup)
query = self.session.query(
tables.Pokemon.id,
tables.Pokemon.evolution_chain_id,
tables.Pokemon.evolves_from_pokemon_id,
eg1.egg_group_id,
eg2.egg_group_id,
)
query = query.join((eg1, eg1.pokemon_id == tables.Pokemon.id))
query = query.outerjoin((eg2, and_(
eg2.pokemon_id == tables.Pokemon.id,
eg1.egg_group_id < eg2.egg_group_id,
)))
bad_groups = (self.no_eggs_group, self.ditto_group)
unbreedable = set()
self.evolution_parents = dict()
self.egg_groups = defaultdict(tuple)
self.evolution_chains = dict()
self.pokemon_by_evolution_chain = defaultdict(set)
for pokemon, evolution_chain, parent, g1, g2 in query:
if g1 in bad_groups:
unbreedable.add(pokemon)
else:
2011-04-26 00:14:32 +00:00
new_groups = (g1, g2) if g2 else (g1, )
if len(self.egg_groups.get(evolution_chain, ())) <= len(new_groups):
self.egg_groups[evolution_chain] = new_groups
self.evolution_chains[pokemon] = evolution_chain
self.pokemon_by_evolution_chain[evolution_chain].add(pokemon)
if parent:
self.evolution_parents[pokemon] = parent
self.unbreedable = frozenset(unbreedable)
self.evolutions = defaultdict(set)
self.evolution_moves = dict()
query = self.session.query(
tables.PokemonEvolution.evolved_pokemon_id,
tables.EvolutionTrigger.identifier,
tables.PokemonEvolution.known_move_id,
tables.PokemonEvolution.minimum_level,
)
query = query.join(tables.PokemonEvolution.trigger)
for child, trigger, move, level in query:
self.evolutions[self.evolution_parents[child]].add(
(trigger, move, level, child))
if move:
self.evolution_moves[self.evolution_chains[child]] = move
if self.debug:
print 'Loaded %s pokemon: %s evo; %s families: %s breedable' % (
len(self.evolution_chains),
len(self.pokemon_by_evolution_chain),
len(self.egg_groups),
len(self.evolutions),
)
print 'Evolution moves: %s' % self.evolution_moves
2011-04-26 00:14:32 +00:00
def construct_breed_graph(self):
2011-04-26 09:53:48 +00:00
"""Fills breeds_required
breeds_required[egg_group][moveset] = minimum number of breeds needed
from a pokemon in this group with this moveset to the goal pokemon
with the goal moveset.
The score cannot get lower by learning new moves, only by breeding.
If missing, breeding or raising the pokemon won't do any good.
For pokemon in the target family, breeds_required doesn't apply.
"""
# Part I. Determining what moves can be passed/learned
2011-04-26 00:14:32 +00:00
# eg1_movepools[egg_group_id] = set of moves passable by pkmn in that group
eg1_movepools = defaultdict(set)
# eg2_movepools[b_g_id1, b_g_id2] = ditto for pkmn in *both* groups
eg2_movepools = defaultdict(set)
# non_egg_pools = as eg1_movepools but for *learnable* moves
learn_pools = defaultdict(set)
goal_egg_groups = self.egg_groups[self.goal_evolution_chain]
all_groups = set()
for family, groups in self.egg_groups.iteritems():
if not groups:
continue
if family == self.goal_evolution_chain:
continue
elif family in self.smeargle_families:
pool = self.goal_moves
else:
pool = self.movepools[family]
pool = set(pool) & self.goal_moves
learnpool = self.learnpools[family] & pool
for group in groups:
eg1_movepools[group].update(pool)
learn_pools[group].update(learnpool)
all_groups.add(group)
if len(groups) >= 2:
eg2_movepools[groups].update(pool)
if self.debug:
for group in sorted(all_groups):
print "%2s can pass: %s" % (group, sorted(eg1_movepools[group]))
if learn_pools[group] != eg1_movepools[group]:
print " but learn: %s" % sorted(learn_pools[group])
for g2 in sorted(all_groups):
for g1 in sorted(all_groups):
if eg2_movepools[g1, g2]:
print " %2s/%2s pass: %s" % (g1, g2, sorted(eg2_movepools[g1, g2]))
print 'Goal groups:', goal_egg_groups
2011-04-26 09:53:48 +00:00
# Part II. Determining which moves are worthwhile to pass
# We want *all* paths, not just shortest ones, so use DFS.
breeds_required = defaultdict(dict)
def handle(group, moves, path):
"""
group: the group of the parent
moves: moves the parent should pass down
path: previously visited groups - to prevent cycles
"""
if not moves:
# No more moves needed to pass down: success!
return True
if breeds_required[group].get(moves, 999) <= len(path):
# Already done
return True
success = False
# Breed some more
path = path + (group, )
for new_group in all_groups.difference(path):
new_groups = tuple(sorted([group, new_group]))
# Can we pass down all the requested moves?
if moves.issubset(eg1_movepools[new_group]):
# Learn some of the moves: they don't have to be passed to us
for learned in powerset(moves & learn_pools[new_group]):
new_moves = moves.difference(learned)
local_success = handle(new_group, new_moves, path)
# If this chain eventually ended up being successful,
# it means that it is useful to pass this moveset
# to this group.
if local_success:
breeds_required[group][moves] = min(breeds_required[group].get(moves, 999), len(path) - 1)
success = True
return success
for group in goal_egg_groups:
handle(group, self.goal_moves, ())
for moves in powerset(self.goal_moves.difference(self.egg_moves)):
if moves:
breeds_required[group][frozenset(moves) | self.egg_moves] = 1
self.breeds_required = breeds_required
if self.debug:
for group, movesetlist in breeds_required.items():
print 'From egg group', group
for moveset, cost in movesetlist.items():
print " %s breeds with %s" % (cost, sorted(moveset))
2011-04-25 22:58:38 +00:00
default_costs = {
# Costs for learning a move in verious ways
'level-up': 20, # The normal way
'machine': 40, # Machines are slightly inconvenient.
'machine-once': 2000, # before gen. 5, TMs only work once. Avoid.
'tutor': 60, # Tutors are slightly more inconvenient than TMs can't carry them around
'tutor-once': 2100, # gen III: tutors only work once (well except Emerald frontier ones)
'sketch': 10, # Quite cheap. (Doesn't include learning Sketch itself)
# Gimmick moves we need to use this method to learn the move anyway,
# so make a big-ish dent in the score if missing
'stadium-surfing-pikachu': 100,
'light-ball-egg': 100, # …
# Ugh... I don't know?
'colosseum-purification': 100,
'xd-shadow': 100,
'xd-purification': 100,
'form-change': 100,
# Other actions.
# Breeding should cost more than 3 times than a lv-up/machine/tutor move.
'evolution': 100, # We have to do this anyway, usually.
'evolution-delayed': 50, # *in addition* to evolution. Who wants to mash B on every level.
'breed': 400, # Breeding's a pain.
'trade': 200, # Trading's a pain, but not as much as breeding.
'transfer': 200, # *in addition* to trade. For one-way cross-generation transfers
'delete': 300, # Deleting a move. (Not needed unless deleting an evolution move.)
'relearn': 150, # Also a pain, though not as big as breeding.
'per-level': 1, # Prefer less grinding. This is for all lv-ups but the final “grow”
}
def main(argv):
parser = argparse.ArgumentParser(description=
'Find out if the specified moveset is valid, and provide a suggestion '
'on how to obtain it.')
parser.add_argument('pokemon', metavar='POKEMON', type=unicode,
help='Pokemon to check the moveset for')
parser.add_argument('move', metavar='MOVE', type=unicode, nargs='*',
help='Moves in the moveset')
parser.add_argument('-l', '--level', metavar='LV', type=int, default=100,
help='Level of the pokemon')
parser.add_argument('-v', '--version', metavar='VER', type=unicode,
default='black',
help='Version to search in.')
parser.add_argument('-V', '--exclude-version', metavar='VER', type=unicode,
action='append', default=[],
help='Versions to exclude (along with their '
'counterparts, if any, e.g. `black` will also exclude White).')
parser.add_argument('-P', '--exclude-pokemon', metavar='PKM', type=unicode,
action='append', default=[],
help='Pokemon to exclude (along with their families, e.g. `pichu` '
'will also exclude Pikachu and Raichu).')
parser.add_argument('-d', '--debug', action='append_const', const=1,
default=[],
help='Output timing and debugging information (can be specified more '
'than once).')
args = parser.parse_args(argv)
args.debug = len(args.debug)
if args.debug:
print 'Connecting'
session = connect(engine_args={'echo': args.debug > 1})
if args.debug:
print 'Parsing arguments'
def _get_list(table, idents, name):
result = []
for ident in idents:
try:
result.append(util.get(session, table, identifier=ident))
except NoResultFound:
print>>sys.stderr, ('%s %s not found. Please use '
'the identifier.' % (name, ident))
return 2
return result
pokemon = _get_list(tables.Pokemon, [args.pokemon], 'Pokemon')[0]
moves = _get_list(tables.Move, args.move, 'Move')
version = _get_list(tables.Version, [args.version], 'Version')[0]
excl_versions = _get_list(tables.Version, args.exclude_version, 'Version')
excl_pokemon = _get_list(tables.Pokemon, args.exclude_pokemon, 'Pokemon')
if args.debug:
print 'Starting search'
search = MovesetSearch(session, pokemon, version, moves, args.level,
exclude_versions=excl_versions, exclude_pokemon=excl_pokemon,
debug=args.debug)
if search.error:
print 'Error:', search.error
return 1
if args.debug:
print 'Done'
2011-04-25 22:58:38 +00:00
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))