So for fun I've been playing Endless Sky. It's a nicely made re-implementation of the old EV Nova (which appears to be still available, although I'm not sure it would run in a current Mac OS).
Part of the early grind of Endless Sky, as it was for EV Nova, is to haul commodities from star to star, buying low and selling high, until you accumulate enough credits to buy a better ship. Each star has prices for ten different commodity types, and one of the first questions the player asks is, what route has the highest profit? (In EV Nova, there was one fabulously profitable one-hop route, the only problem being that you were almost guaranteed to be attacked by pirates every time through it.)
Endless Sky's galaxy is defined in a simple text file that is part of the game package (here's the source). I looked at it and thought, hmmm. The galaxy is an undirected graph; the commodity prices are properties of the nodes of the graph. This looks like a job for Python! So one morning I sat down in and in less than two hours, I had code to read and store the galaxy as a graph, and it worked first time! What follows is a slightly upgraded version, so it has about 2:30 invested in it.
import sys
try:
filename = sys.argv[1]
map_file = open( filename, 'rt', encoding='utf-8', errors='ignore' )
except:
print('usage: mapper <file path to the map file>')
exit(-1)
# map trade names to indexes in a system trade list
trade_indices = {
"Clothing" : 0,
"Electronics" : 1,
"Equipment" : 2,
"Food" : 3,
"Heavy Metals" : 4,
"Industrial" : 5,
"Luxury Goods" : 6,
"Medical" : 7,
"Metal" : 8,
"Plastic" : 9
}
trade_names = { d : n for n, d in trade_indices.items() }
# The galaxy is a dict of { 'system-name' : System } It is an undirected
# graph in which the edges are system-names in each system's links:
# for neighbor_name in Galaxy['some-name'].links:
# neighbor_system = Galaxy[neighbor_name]
Galaxy = dict()
class System( object ):
def __init__( self ):
self.trade = [0] * 10 # prices of 10 commodity types here
self.links = set() # names of connected systems
self.habitable = False # can trade here?
import regex
# The following matches to a pattern of either
# verb namestring [nnn]
# or
# verb "name string with spaces" [nnn]
rx_verb = regex.compile( '''^\s*(\w+)\s+((\w+)|['"]([\w\s]+)['"])(\s*\d+)?\s*$''', flags=regex.IGNORECASE )
# If the current line is "verb name [nnn]" return (verb, namestring, [nnn|None]).
# Otherwise return (None, None, None)
def match_line( line ):
match = rx_verb.match( line )
verb = None
namestring = None
number = None
if match is not None :
verb = match.group(1)
namestring = match.group(3) if match.group(4) is None else match.group(4)
number = match.group(5)
return (verb, namestring, number)
# Read the map, build the Galaxy
in_system = False
for line in map_file :
( verb, name_string, number ) = match_line( line )
if not in_system :
if verb == 'system' :
# Beginning a system block. Create a system and file it.
new_system = System()
Galaxy[name_string] = new_system
in_system = True
habitable = False
gov_not_Hai = True
continue
# Process "link", "trade", "government" and "object" statements
if verb == 'link' :
# new_system links to name_string. Note the map has reciprocal links,
# we do not need to add a reverse link. In fact we couldn't, because
# name_string may not be in the galaxy yet.
new_system.links.add( name_string )
continue
if verb == 'trade' :
# new system trade line found, store the price in the system list
commodity_index = trade_indices[ name_string ]
commodity_price = int( number )
new_system.trade[ commodity_index ] = commodity_price
continue
# Check "object name" statements. The regex only matches to "object
# name", not to the more common "object" statements. If a system has an
# object with a name, it is habitable. Except for Algiebra, which has
# a named moon but cannot be used for trade.
if verb == 'object' :
habitable = 'Watcher' != name_string #anywhere but Algiebra
continue
# Look for government starting with Hai, because those systems are not
# accessible until later in the game (this could be conditioned on a
# command-line option)
if verb == 'government' :
gov_not_Hai = not name_string.startswith( 'Hai' )
# if the current line is blank, we are at the end of this system.
if 0 == len(line.strip()) :
new_system.habitable = habitable and gov_not_Hai
in_system = False
continue
# Note the official map *ALWAYS* has a blank line at the end of a
# system block i.e. preceding a 'system' statement. If the map file
# is mis-edited to not have such a blank, this code will merge the
# trade etc. lines from the following system to the previous one.
# All map lines processed, the Galaxy is built.
With the galaxy as a graph, it took another hour to brute-force the best trade route of one, two, three or four hops. No longer route because the starter ship and the typical freighter can't go more than four hops without landing.
# Return the set of all system names that are n hops out from the given
# system.
def n_hop_targets( system, n ):
if n == 1 :
return set( system.links )
all_targets = set()
for target in system.links :
all_targets |= n_hop_targets( Galaxy[ target ], n-1 )
return all_targets
# Compare the trade price lists from two systems. Return the index
# of the most profitable commodity and the profit margin.
def compare_prices( buy_list, sell_list ):
profits = [ buy-sell for buy, sell in zip( buy_list, sell_list ) ]
p = max( profits )
return profits.index(p), p
# Find the most profitable trade route between a given system and any of a
# set of systems it can reach at some number of hops. Input is the system
# itself, and set of target names.
# return the system name and (outbound buy, profit, inbound buy, profit)
def best_trade_route( system, target_set ):
home_line = system.trade
out_p = in_p = 0 # outbound and inbound profits
out_c = in_c = None # outbound, inbound indexes
where = None # target system
for dest_name in target_set :
dest_sys = Galaxy[ dest_name ]
if dest_sys.habitable :
dest_line = dest_sys.trade
d_c, d_p = compare_prices( home_line, dest_line )
h_c, h_p = compare_prices( dest_line, home_line )
if (out_p + in_p) < (d_p + h_p) :
out_p, out_c = d_p, d_c
in_p, in_c = h_p, h_c
where = dest_name
return (where, out_c, out_p, in_c, in_p )
def best_n_hop(n):
best_name = None # name of winning start point
best_targ = None # name of its trade partner
best_info = (0, 0, 0, 0)
best_rt = 0 # round-trip profit
pdict = dict()
for name, system in Galaxy.items() :
if system.habitable :
where, out_c, out_p, in_c, in_p = best_trade_route(
system,
n_hop_targets( system, n )
)
if (out_p + in_p) > best_rt :
best_name = name
best_targ = where
best_info = ( out_c, out_p, in_c, in_p )
best_rt = out_p + in_p
pdict[best_rt] = (best_name,best_targ,best_info)
print( '\n\nBest', n, 'hop trade route is' )
print( best_name, 'to', best_targ )
print( 'outbound take', trade_names[ best_info[0] ], 'earning', best_info[1] )
print( 'inbound bring', trade_names[ best_info[2] ], 'earning', best_info[3] )
print( 'round-trip profit is', best_rt )
best_n_hop(1)
best_n_hop(2)
best_n_hop(3)
best_n_hop(4)
So before lunch I knew that I should trade Metals and Luxury Goods between Alphard and Delta Velorum. I took a certain amount of pleasure in having all that work quickly and easily.
My pleasure was rather lessened when I googled "Endless Sky best trade route" and found a forum post with exactly that route, found by some non-programmer weeks ago.
Oh, well.