diff --git a/PyRoute/Calculation/RouteCalculation.py b/PyRoute/Calculation/RouteCalculation.py index 5035bedfa..2a95aa12b 100644 --- a/PyRoute/Calculation/RouteCalculation.py +++ b/PyRoute/Calculation/RouteCalculation.py @@ -212,3 +212,21 @@ def unilateral_filter(self, star): @return: Whether the star can be unilaterally filtered """ return False + + def is_sector_trade_balanced(self): + pass + + def is_sector_pass_balanced(self): + pass + + def is_allegiance_trade_balanced(self): + pass + + def is_allegiance_pass_balanced(self): + pass + + def multilateral_balance_trade(self): + pass + + def multilateral_balance_pass(self): + pass diff --git a/PyRoute/Calculation/TradeCalculation.py b/PyRoute/Calculation/TradeCalculation.py index 941458daa..1240c21bf 100644 --- a/PyRoute/Calculation/TradeCalculation.py +++ b/PyRoute/Calculation/TradeCalculation.py @@ -199,7 +199,7 @@ def get_trade_between(self, star, target): "This route from " + str(star) + " to " + str(target) + " has already been processed in reverse" try: - rawroute, diag = astar_path_indexes(self.galaxy.stars, star.index, target.index, self.galaxy.heuristic_distance_indexes) + rawroute, _ = astar_path_indexes(self.galaxy.stars, star.index, target.index, self.galaxy.heuristic_distance_indexes) except nx.NetworkXNoPath: return diff --git a/PyRoute/Calculation/TradeMPCalculation.py b/PyRoute/Calculation/TradeMPCalculation.py index fcda4e0b4..ba785c1ca 100644 --- a/PyRoute/Calculation/TradeMPCalculation.py +++ b/PyRoute/Calculation/TradeMPCalculation.py @@ -49,7 +49,7 @@ def intrasector_process(working_queue, processed_queue): continue try: - rawroute, diag = astar_path_indexes(tradeCalculation.galaxy.stars, star.index, neighbor.index, + rawroute, _ = astar_path_indexes(tradeCalculation.galaxy.stars, star.index, neighbor.index, tradeCalculation.galaxy.heuristic_distance_indexes) except nx.NetworkXNoPath: continue @@ -82,7 +82,7 @@ def long_route_process(working_queue, processed_queue): break try: - route, diag = astar_path_indexes(tradeCalculation.galaxy.stars, star, neighbor, + route, _ = astar_path_indexes(tradeCalculation.galaxy.stars, star, neighbor, tradeCalculation.galaxy.heuristic_distance_indexes) except nx.NetworkXNoPath: continue @@ -153,7 +153,7 @@ def calculate_routes(self): # This is the multiprocess method, which contains all the logic for using multi-process in the parent (core) process # When this is completed, all the child process should be completed. - def start_mp_services (self): + def start_mp_services(self): global tradeCalculation tradeCalculation = self @@ -281,7 +281,7 @@ def get_trade_between(self, star, target): f"This route from {star} to {target} has already been processed in reverse" try: - rawroute, diag = astar_path_indexes(self.galaxy.stars, star.index, target.index, self.galaxy.heuristic_distance_indexes) + rawroute, _ = astar_path_indexes(self.galaxy.stars, star.index, target.index, self.galaxy.heuristic_distance_indexes) except nx.NetworkXNoPath: return diff --git a/PyRoute/DeltaDebug/DeltaReduce.py b/PyRoute/DeltaDebug/DeltaReduce.py index 31841505a..30467472c 100644 --- a/PyRoute/DeltaDebug/DeltaReduce.py +++ b/PyRoute/DeltaDebug/DeltaReduce.py @@ -54,7 +54,7 @@ def is_initial_state_interesting(self): sectors = self.sectors args = self.args - interesting, msg, _ = self._check_interesting(args, sectors) + interesting, _, _ = self._check_interesting(args, sectors) if not interesting: raise AssertionError("Original input not interesting - aborting") diff --git a/PyRoute/DeltaPasses/AllegianceReducer.py b/PyRoute/DeltaPasses/AllegianceReducer.py index d7f91e624..e18298497 100644 --- a/PyRoute/DeltaPasses/AllegianceReducer.py +++ b/PyRoute/DeltaPasses/AllegianceReducer.py @@ -42,6 +42,10 @@ def run(self, singleton_only=False): continue temp_sectors = best_sectors.allegiance_subset(raw_lines) + temp_lines = len(temp_sectors.lines) + if 0 == temp_lines: + # nothing to do, move on + continue interesting, msg, _ = self.reducer._check_interesting(self.reducer.args, temp_sectors) # We've found a chunk of input and have _demonstrated_ its irrelevance, diff --git a/PyRoute/StatCalculation.py b/PyRoute/StatCalculation.py index 03d01d165..17d6b5e7a 100644 --- a/PyRoute/StatCalculation.py +++ b/PyRoute/StatCalculation.py @@ -228,7 +228,6 @@ def add_alg_stats(self, area, star, alg): self.max_tl(algStats, star) def add_pop_to_sophont(self, stats, star): - total_pct = 100 default_soph = 'Huma' home = None @@ -236,11 +235,15 @@ def add_pop_to_sophont(self, stats, star): soph_code = sophont[0:4] soph_pct = sophont[4:] + if 4 == len(sophont): + soph_code = sophont[0:3] + soph_pct = sophont[3:] + if soph_pct == 'A': default_soph = soph_code continue - soph_pct = 100.0 if soph_pct == 'W' else 0.0 if soph_pct in ['X', 'A'] else \ + soph_pct = 100.0 if soph_pct == 'W' else 0.0 if soph_pct in ['X', 'A', '?'] else \ 5.0 if soph_pct == '0' else 10.0 * int(soph_pct) if any([soph for soph in star.tradeCode.homeworld if soph.startswith(soph_code)]): diff --git a/PyRoute/TradeCodes.py b/PyRoute/TradeCodes.py index 857b337c7..763c7e143 100644 --- a/PyRoute/TradeCodes.py +++ b/PyRoute/TradeCodes.py @@ -3,7 +3,7 @@ @author: tjoneslo """ - +import itertools import re import logging import sys @@ -16,16 +16,57 @@ class TradeCodes(object): pcodes = ['As', 'De', 'Ga', 'Fl', 'He', 'Ic', 'Oc', 'Po', 'Va', 'Wa'] dcodes = ['Cp', 'Cx', 'Cs', 'Mr', 'Da', 'Di', 'Pz', 'An', 'Ab', 'Fo', 'Px', 'Re', 'Rs', 'Sa', 'Tz', 'Lk', - 'RsA', 'RsB', 'RsG', 'RsD', 'RsE', 'RsZ', 'RsT', 'RsI', 'RsK', + 'RsA', 'RsB', 'RsG', 'RsD', 'RsE', 'RsZ', 'RsT', 'RsI', 'RsK', 'RsO', 'Fr', 'Co', 'Tp', 'Ho', 'Tr', 'Tu', 'Cm', 'Tw'] ex_codes = {'As', 'Fl', 'Ic', 'De', 'Na', 'Va', 'Wa', 'He', 'Oc'} research = {'RsA': '\u0391', 'RsB': '\u0392', 'RsG': '\u0393', - 'RsD': '\u0394', 'RdE': '\u0395', 'RsZ': '\u0396', + 'RsD': '\u0394', 'RdE': '\u0395', 'RsO': '\u03A9', 'RsZ': '\u0396', 'RsT': '\u0398', 'RsI': '\u0399', 'RsK': '\u039A'} pcolor = {'As': '#8E9397', 'De': '#d17533', 'Fl': '#e37dff', 'He': '#ff6f0c', 'Ic': '#A5F2F3', 'Oc': '#0094ED', 'Po': '#6a986a', 'Va': '#c9c9c9', 'Wa': '#4abef4'} ext_codes = {'Lt', 'Ht', 'Lg', 'Hg'} + weird_codes = {'{Anomaly}', '{Fuel}', '{Ringworld}', '{Rosette}'} + allowed_residual_codes = {'Ag', 'Ba', 'Bo', 'Cl', 'Cw', 'Cy', 'Dw', 'Ex', 'Fr', 'Ga', 'Hi', 'In', 'Lo', 'N1', 'Na', + 'Ni', 'o', 'Pa', 'Ph', 'Pi', 'Po', 'Pr', 'Ri', 'Rn', 'Rv', 's', 'Sp', 'St', 'Tn', 'Za'} + # Whether any of these pairs are _permitted_ to occur together is _irrelevant_. They _do_ occur together in the + # TravellerMap raw data. + ok_pairs = { + ('Ag', 'Bo'), ('Ag', 'Cw'), ('Ag', 'Cy'), ('Ag', 'Dw'), ('Ag', 'Fl'), ('Ag', 'Ga'), ('Ag', 'Hi'), ('Ag', 'In'), + ('Ag', 'Lo'), ('Ag', 'N1'), ('Ag', 'Ni'), ('Ag', 'Pi'), ('Ag', 'Po'), ('Ag', 'Pr'), ('Ag', 'Ri'), ('Ag', 'St'), + ('Ag', 'Tn'), ('Ag', 'Va'), ('Ag', 'Wa'), ('As', 'Ba'), ('As', 'Bo'), ('As', 'Cy'), ('As', 'Hi'), ('As', 'Ic'), + ('As', 'In'), ('As', 'Lo'), ('As', 'Na'), ('As', 'Ni'), ('As', 'Ph'), ('As', 'Pi'), ('As', 'Po'), ('As', 'Va'), + ('Ba', 'Bo'), ('Ba', 'De'), ('Ba', 'Fl'), ('Ba', 'Ga'), ('Ba', 'He'), ('Ba', 'Ic'), ('Ba', 'Lo'), ('Ba', 'Na'), + ('Ba', 'Ni'), ('Ba', 'Oc'), ('Ba', 'Po'), ('Ba', 'Va'), ('Ba', 'Wa'), ('Ba', 'o'), ('Bo', 'De'), ('Bo', 'Fl'), + ('Bo', 'Ga'), ('Bo', 'He'), ('Bo', 'Hi'), ('Bo', 'Ic'), ('Bo', 'In'), ('Bo', 'Lo'), ('Bo', 'Na'), ('Bo', 'Ni'), + ('Bo', 'Oc'), ('Bo', 'Pa'), ('Bo', 'Ph'), ('Bo', 'Pi'), ('Bo', 'Po'), ('Bo', 'Ri'), ('Bo', 'Va'), ('Cw', 'Ni'), + ('Cw', 'Ri'), ('Cy', 'De'), ('Cy', 'Fl'), ('Cy', 'Ga'), ('Cy', 'He'), ('Cy', 'Hi'), ('Cy', 'Ic'), ('Cy', 'In'), + ('Cy', 'Lo'), ('Cy', 'Na'), ('Cy', 'Ni'), ('Cy', 'Oc'), ('Cy', 'Pa'), ('Cy', 'Ph'), ('Cy', 'Pi'), ('Cy', 'Po'), + ('Cy', 'Pr'), ('Cy', 'Ri'), ('Cy', 'Va'), ('Cy', 'Wa'), ('De', 'Fl'), ('De', 'He'), ('De', 'Hi'), ('De', 'Ic'), + ('De', 'In'), ('De', 'Lo'), ('De', 'Na'), ('De', 'Ni'), ('De', 'Ph'), ('De', 'Pi'), ('De', 'Po'), ('De', 'Pr'), + ('De', 'Ri'), ('De', 'Va'), ('Dw', 'Hi'), ('Dw', 'Lo'), ('Dw', 'Na'), ('Dw', 'Ni'), ('Dw', 'Po'), ('Ex', 'Lo'), + ('Ex', 'Ni'), ('Ex', 'Pr'), ('Fl', 'He'), ('Fl', 'Hi'), ('Fl', 'In'), ('Fl', 'Lo'), ('Fl', 'Na'), ('Fl', 'Ni'), + ('Fl', 'Oc'), ('Fl', 'Ph'), ('Fl', 'Pr'), ('Fl', 'Ri'), ('Fl', 'Rv'), ('Fl', 'Wa'), ('Ga', 'Hi'), ('Ga', 'Lo'), + ('Ga', 'Ni'), ('Ga', 'Pa'), ('Ga', 'Ph'), ('Ga', 'Pr'), ('Ga', 'Ri'), ('He', 'Hi'), ('He', 'In'), ('He', 'Lo'), + ('He', 'Na'), ('He', 'Ni'), ('He', 'Ph'), ('He', 'Pi'), ('He', 'Po'), ('Hi', 'Ic'), ('Hi', 'In'), ('Hi', 'Lo'), + ('Hi', 'Na'), ('Hi', 'Ni'), ('Hi', 'Oc'), ('Hi', 'Po'), ('Hi', 'Pr'), ('Hi', 'Ri'), ('Hi', 'Rn'), ('Hi', 'Sp'), + ('Hi', 'St'), ('Hi', 'Tn'), ('Hi', 'Va'), ('Hi', 'Wa'), ('Hi', 'Za'), ('Hi', 's'), ('Ic', 'In'), ('Ic', 'Lo'), + ('Ic', 'Na'), ('Ic', 'Ni'), ('Ic', 'Ph'), ('Ic', 'Pi'), ('Ic', 'Po'), ('Ic', 'Rn'), ('Ic', 'Va'), ('Ic', 'Wa'), + ('In', 'Lo'), ('In', 'Na'), ('In', 'Oc'), ('In', 'Po'), ('In', 'Ri'), ('In', 'Rn'), ('In', 'Sp'), ('In', 'Va'), + ('In', 'Wa'), ('Lo', 'Na'), ('Lo', 'Ni'), ('Lo', 'Oc'), ('Lo', 'Po'), ('Lo', 'Pr'), ('Lo', 'Ri'), ('Lo', 'Rv'), + ('Lo', 'St'), ('Lo', 'Tn'), ('Lo', 'Va'), ('Lo', 'Wa'), ('Na', 'Ni'), ('Na', 'Ph'), ('Na', 'Pi'), ('Na', 'Po'), + ('Na', 'Ri'), ('Na', 'Va'), ('Na', 'Wa'), ('Ni', 'Oc'), ('Ni', 'Pa'), ('Ni', 'Po'), ('Ni', 'Pr'), ('Ni', 'Ri'), + ('Ni', 'Rv'), ('Ni', 'St'), ('Ni', 'Tn'), ('Ni', 'Va'), ('Ni', 'Wa'), ('Oc', 'Ph'), ('Oc', 'Pi'), ('Oc', 'Pr'), + ('Oc', 'Ri'), ('Oc', 'Wa'), ('Pa', 'Ph'), ('Pa', 'Pi'), ('Pa', 'Ri'), ('Ph', 'Pi'), ('Ph', 'Po'), ('Ph', 'Ri'), + ('Ph', 'Va'), ('Ph', 'Wa'), ('Pi', 'Po'), ('Pi', 'Va'), ('Pi', 'Wa'), ('Po', 'Pr'), ('Po', 'Va'), ('Po', 'Wa'), + ('Pr', 'Wa'), ('Pr', 'Za'), ('Ri', 'Tn'), ('Ri', 'Wa'), ('Rn', 'Va'), ('St', 'Tn'), ('Va', 'Wa') + } + + # Search regexen + search = re.compile(r'\w{,2}\(([^)]{,4})[^)]*\)(\d|W|\?)?') + search_major = re.compile(r'\w{,2}\[([^)]{,4})[^)]*\](\d|W|\?)?') + sophont = re.compile(r"[A-Za-z\'!]{1}[\w\'!]{2,4}(\d|W|\?)") + dieback = re.compile(r"[Di]*\([^)]+\)\d?") __slots__ = '__dict__', 'codeset', 'pcode', 'dcode', 'xcode' @@ -34,36 +75,14 @@ def __init__(self, initial_codes): Constructor """ self.logger = logging.getLogger('PyRoute.TradeCodes') - self.codes = initial_codes.split() + self.codes, initial_codes = self._preprocess_initial_codes(initial_codes) self.pcode = set(TradeCodes.pcodes) & set(self.codes) self.dcode = set(TradeCodes.dcodes) & set(self.codes) self.xcode = TradeCodes.ext_codes & set(self.codes) self.owned = [code for code in self.codes if code.startswith('O:') or code.startswith('C:')] - self.homeworld_list = [] - self.sophont_list = [] - homeworlds_found = [] - - self.sophont_list = [code for code in self.codes if re.match(r"[\w\']{4}(\d|W)", code, re.U)] - - for homeworld in re.findall(r"[Di]*\([^)]+\)\d?", initial_codes, re.U): - full_name = re.sub(r'\(([^)]+)\)\d?', r'\1', homeworld) - homeworlds_found.append(homeworld) - match = re.match(r'\w{,2}\(([^)]{,4})[^)]*\)(\d|W)?', homeworld) - if match is None: - self.logger.error("Unable to process %s", initial_codes) - sys.exit(1) - if full_name.startswith("Di"): - sophont = "{code: <4}{pop}".format(code=match.group(1), pop='X') - else: - sophont = "{code: <4}{pop}".format(code=match.group(1), pop=match.group(2) if match.group(2) else 'W') - - sophont = sophont.replace("'", "X") - sophont = sophont.replace("!", "X") - sophont = sophont.replace(" ", "X") - self.sophont_list.append(sophont) - self.homeworld_list.append(sophont) + homeworlds_found = self._process_sophonts_and_homeworlds(initial_codes) self.codeset = set(self.codes) - self.dcode - set(self.owned) - set(self.sophont_list)\ - set(homeworlds_found) - self.xcode @@ -80,6 +99,162 @@ def __init__(self, initial_codes): self.dcode = list(self.dcode) + self.colony + self.owner self.dcode = sorted(self.dcode) + self.trim_ill_formed_residual_codes() + + def _process_sophonts_and_homeworlds(self, initial_codes): + self.homeworld_list = [] + self.sophont_list = [] + homeworlds_found = [] + self.sophont_list = [code for code in self.codes if TradeCodes.sophont.match(code)] + # Trim out overly-long values from sophont_list and return them to codeset later on + self.sophont_list = [code for code in self.sophont_list if 5 >= len(code)] + homeworld_matches = TradeCodes.dieback.findall(initial_codes) + # bolt on direct [homeworld] candidates + homeworld_major = [item for item in self.codes if item.startswith('[') and 1 == item.count('[') and 1 == item.count(']')] + deadworlds = [item for item in self.codes if 5 == len(item) and 'X' == item[4]] + for homeworld in homeworld_matches: + self._process_homeworld(homeworld, homeworlds_found, initial_codes) + for homeworld in homeworld_major: + self._process_major_race_homeworld(homeworld, homeworlds_found) + for deadworld in deadworlds: + self._process_deadworld(deadworld, homeworlds_found) + return homeworlds_found + + def _preprocess_initial_codes(self, initial_codes): + raw_codes = initial_codes.split() + # look for successive codes that should be part of the same name (eg "(Carte" , then "Blanche)", becoming + # "(Carte Blanche)", and bolt them together + num_codes = len(raw_codes) + codes = [] + for i in range(0, num_codes): + raw = raw_codes[i] + # Filter duplicates + if raw in codes: + continue + if '' == raw: + continue + if ')' == raw: + continue + if raw.startswith('Di('): + codes.append(raw) + continue + if 7 < len(raw) and '(' == raw[0] and ')' == raw[-2]: # Let older-style sophont codes through + codes.append(raw) + continue + if 7 < len(raw) and '[' == raw[0] and ']' == raw[-2]: # Let older-style sophont codes through + codes.append(raw) + continue + if 2 == len(raw) and ('W' == raw[1] or raw[1].isdigit()): + if 'C' == raw[0]: # Compact chirper population + pop = raw[1] + codes.append('Chir' + pop) + continue + if 'D' == raw[0]: # Compact Droyne population + pop = raw[1] + codes.append('Droy' + pop) + continue + if not raw.startswith('(') and '(' in raw and raw.endswith(')'): + continue + if not raw.endswith(')') and ')' in raw and raw.startswith('(') and 7 > len(raw): + continue + if not raw.startswith('(') and not raw.endswith(')') and '(' in raw and ')' in raw: + continue + if 7 == len(raw) and '(' == raw[0] and ')' == raw[5]: # Let preprocessed sophont codes through + codes.append(raw) + continue + if not raw.startswith('(') and not raw.startswith('['): # this isn't a sophont code + codes.append(raw) + continue + if raw.endswith(')') or raw.endswith(']'): # this _is_ a sophont code + if raw.startswith('[') and raw.endswith(']'): + raw = self._trim_overlong_homeworld_code(raw) # trim overlong _major_ race homeworld + elif raw.startswith('(') and raw.endswith(')'): + raw = self._trim_overlong_homeworld_code(raw) # trim overlong _minor_ race homeworld + codes.append(raw) + continue + if i < num_codes - 1: + next = raw_codes[i + 1] + if next.endswith(')') or next.endswith(']'): + combo = raw + ' ' + next + codes.append(combo) + raw_codes[i + 1] = '' + continue + + codes = sorted(codes) + + initial_codes = ' '.join(codes) + + return codes, initial_codes + + def _trim_overlong_homeworld_code(self, raw): + # We're assuming raw is a (homeworld) code - not handling pop codes at the moment + # If the homeworld string itself exceeds 35 characters in length, it will jam up against the left side of + # the importance code in the starline, which both looks ugly and causes re-parsing havoc. + left_bracket = raw[0] + right_bracket = ']' if '[' == left_bracket else ')' + + trim = raw[1:-1] + if 35 < len(trim): + trim = trim[0:35] + + return left_bracket + trim + right_bracket + + def _process_homeworld(self, homeworld, homeworlds_found, initial_codes): + full_name = re.sub(r'\(([^)]+)\)\d?', r'\1', homeworld) + + homeworlds_found.append(homeworld) + match = TradeCodes.search.match(homeworld) + if match is None: # try again with major-raceversion + match = TradeCodes.search_major.match(homeworld) + if match is None: + self.logger.error("Unable to process %s", initial_codes) + sys.exit(1) + if full_name.startswith("Di"): + code = match.group(1) + pop = 'X' + else: + code = match.group(1) + pop = match.group(2) if match.group(2) else 'W' + if homeworld + '?' in initial_codes: + homeworld += "?" + pop = '0' + elif homeworld + 'X' in initial_codes: + pop = 'X' + homeworld += str(pop) + elif homeworld + str(pop) in initial_codes: + homeworld += str(pop) + self._process_sophont_homeworld(code, pop, full_name=full_name) + + def _process_major_race_homeworld(self, homeworld, homeworlds_found): + """ + Homeworld processing using codes like [Solomani] - can make more assumptions than in the original case + + @type homeworld: string + @type homeworlds_found: list + """ + full_name = re.sub(r'\[([^)]+)\][\d|W]?', r'\1', homeworld) + pop = 'W' if ']' == homeworld[-1] else homeworld[-1] + homeworlds_found.append(homeworld) + code = full_name[0:4] + self._process_sophont_homeworld(code, pop, full_name=full_name) + + def _process_deadworld(self, deadworld, homeworlds_found): + full_name = re.sub(r'\(([^)]+)\)\d?', r'\1', deadworld) + homeworlds_found.append(deadworld) + code = full_name[0:4] + pop = 'X' + self._process_sophont_homeworld(code, pop, full_name=full_name) + + def _process_sophont_homeworld(self, code, pop, full_name=None): + sophont = "{code: <4}{pop}".format(code=code, pop=pop) + sophont = sophont.replace("'", "X") + sophont = sophont.replace("!", "X") + sophont = sophont.replace(" ", "X") + self.sophont_list.append(sophont) + self.homeworld_list.append(full_name) + + return sophont + def __str__(self): return " ".join(sorted(self.codes)) @@ -89,6 +264,16 @@ def __getstate__(self): del state['ownedBy'] return state + def __deepcopy__(self, memodict={}): + state = self.__dict__.copy() + + foo = TradeCodes('') + for key in state: + item = state[key] + setattr(foo, key, item) + + return foo + def planet_codes(self): return " ".join(self.codeset) @@ -202,11 +387,11 @@ def colonies(self, sector_name): @property def homeworld(self): - return self.homeworld_list + return sorted(self.homeworld_list) @property def sophonts(self): - return self.sophont_list + return sorted(self.sophont_list) @property def rich(self): @@ -304,3 +489,113 @@ def pcode_color(self): @property def low_per_capita_gwp(self): return self.extreme or self.poor or self.nonindustrial or self.low + + def is_well_formed(self): + msg = "" + for code in self.codeset: + if not self._check_residual_code_well_formed(code): + msg = "Residual code " + str(code) + " not in allowed residual list" + return False, msg + + # Check no duplicate codes + codes_set = set(self.codes) + if len(self.codes) != len(codes_set): + msg = "At least one trade code duplicated" + return False, msg + + research_stations = [code for code in self.codes if code.startswith('Rs')] + if 1 < len(research_stations): + msg = "At most one research station allowed" + return False, msg + + result, msg = self._check_code_pairs_allowed() + + # now check that sophont list is well-formed + bad_sophonts = [code for code in self.sophont_list if 5 < len(code)] + if 0 < len(bad_sophonts): + msg = "Sophont codes must be no more than 5 chars each - got at least " + bad_sophonts[0] + return False, msg + + # explicitly exclude multiple W-pop (ie, 95+%) sophonts + big_sophs = [code for code in self.sophont_list if code.endswith('W')] + if 1 < len(big_sophs): + sophs = ' '.join(big_sophs) + msg = "Can have at most one W-pop sophont. Have " + sophs + return False, msg + + return result, msg + + def trim_ill_formed_residual_codes(self): + nu_set = set() + for code in self.codeset: + if not self._check_residual_code_well_formed(code): + msg = "Residual code " + str(code) + " not in allowed residual list - removing" + self.logger.warning(msg) + self.codes = [topcode for topcode in self.codes if topcode != code] + else: + nu_set.add(code) + self.codeset = sorted(list(nu_set)) + + def _check_residual_code_well_formed(self, code): + max_code_len = 12 + max_sophont_len = 35 + if code not in TradeCodes.ext_codes and code not in TradeCodes.ex_codes: + if code in TradeCodes.weird_codes: + return True + + open_brackets = code.count('(') + close_brackets = code.count(')') + if open_brackets != close_brackets: + return False + open_braces = code.count('{') + close_braces = code.count('}') + if open_braces != close_braces: + return False + open_squares = code.count('[') + close_squares = code.count(']') + if open_squares != close_squares: + return False + + if code.startswith('['): + if len(code) > max_sophont_len: + return False + return True + elif len(code) > max_code_len: + return False + + if len(code) > max_code_len or (code.startswith('[') and len(code) > max_sophont_len): + return False + if code.startswith('Di(') or code.startswith('(') or code.endswith(')') or code.endswith(')?'): # minor race homeworld + if ')' not in code: + return False + return True + if code.startswith('[') and (code.endswith(']') or ']' == code[-2]): # major race homeworld + if ']' not in code: + return False + return True + if code not in TradeCodes.allowed_residual_codes: + return False + return True + + def _check_code_pairs_allowed(self): + msg = "" + + # Exclude weird codes, sophont codes and military rule straight-up + sortset = sorted([code for code in self.codeset if code not in TradeCodes.weird_codes and code[0] not in '([' and not code.startswith('Mr')]) + outside = set() + + for code, other in itertools.combinations(sortset, 2): + pair = (code, other) if code < other else (other, code) + if pair not in TradeCodes.ok_pairs: + outside.add(pair) + + if 0 < len(outside): + msg = "Code pair(s)" + for pair in outside: + pairline = ' ("' + pair[0] + '", "' + pair[1] + '"),' + msg += pairline + msg = msg.strip(',') + msg += " not in allowed list" + return False, msg + + return True, msg diff --git a/PyRoute/WikiReview.py b/PyRoute/WikiReview.py index 3f0d88c6e..8e5118615 100644 --- a/PyRoute/WikiReview.py +++ b/PyRoute/WikiReview.py @@ -37,7 +37,7 @@ def __init__(self, search, replace, text, replace_count): super(PageReviewSearch, self).__init__(search, replace, text, replace_count) def review(self, page, formats): - (s, r, t) = self.update_formats(formats) + (s, _, _) = self.update_formats(formats) logger.debug("searching for {} in {}".format(s, page.title)) if re.search(s, page.getWikiText()): logger.info("Article {} has match search {}".format(page.title, self.search)) @@ -51,7 +51,7 @@ def __init__(self, search, replace, text, replace_count): def review(self, page, formats): logger.debug('Searching for replacements in {}'.format(page.title)) - (s, r, t) = self.update_formats(formats) + (s, r, _) = self.update_formats(formats) page_text = re.sub(s, r, page.getWikiText(), count=self.replace_count) return page_text @@ -209,7 +209,7 @@ def get_linked_here(self, page): titles = [] for r in results: pages = r['query']['pages'] - key, values = pages.popitem() + _, values = pages.popitem() for value in values['linkshere']: # logger.debug(value) titles.append(value['title']) diff --git a/PyRoute/wikistats.py b/PyRoute/wikistats.py index b1596d870..7b7355549 100644 --- a/PyRoute/wikistats.py +++ b/PyRoute/wikistats.py @@ -67,8 +67,7 @@ def write_statistics(self): if self.json_data: self.write_json() - - def output_template (self, template, filename, parameters): + def output_template(self, template, filename, parameters): template = self.env.get_template(template) path = os.path.join(self.galaxy.output_path, filename) with open(path, 'w+', encoding='utf-8') as f: diff --git a/Tests/Hypothesis/testTradeCodes.py b/Tests/Hypothesis/testTradeCodes.py new file mode 100644 index 000000000..f75e7dae8 --- /dev/null +++ b/Tests/Hypothesis/testTradeCodes.py @@ -0,0 +1,63 @@ +import unittest +import unittest +from datetime import timedelta + +from hypothesis import given, assume, example, HealthCheck, settings +from hypothesis.strategies import text + +from PyRoute.TradeCodes import TradeCodes + + +class testTradeCodes(unittest.TestCase): + + """ + Given an otherwise-valid input string, it should parse cleanly to a well-formed TradeCodes object that then should + cleanly round-trip to/from string + """ + @given(text(min_size=15, alphabet='0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWYXZ -{}()[]?\'+*')) + @example('(000000000000000000000000000000000000)') + @example('000000000000000') + @example('Pi (Feime)? Re Sa ') + @example('Cp Cp ') + @example('000000000000000') + @example('00000000000000000000000000000000000000') + @example('(0000000000000000000000000000000000000') + @example('0000000000 0000') + @example('0000000000000000000000000000000000000)') + @example('000000000000000000000000000000000000()') + @example('[0000000000000000000000000000000000000') + @example('{0000000000000000000000000000000000000') + @example('000000000000000') + @example('000000000 A0000 ') + @example('000000000000000 ') + @example('Hi Cs [Vilani] ') + @example(' Fl Ph Varg6 O:2723 ') + @example('Ag Ni C:1627 ') + @example('00000000000000( ') + @example(' Ni Pa (Ashdak Meshukiiba) Sa ') + @example('000000000000 {} ') + @example('00000000000 { } ') + @example('000000000(0) 00 ') + @example(' (0)000000000 00 ') + @example('00000000000(0)0 ') + @example(' 000000000000( ) ') + @example(' Ga Lt (minor) ') + def test_parse_text_to_trade_code(self, s): + trade = TradeCodes(s) + + result, msg = trade.is_well_formed() + self.assertTrue(result, msg) + + trade_string = str(trade) + + nu_trade = TradeCodes(trade_string) + result, msg = nu_trade.is_well_formed() + self.assertTrue(result, msg) + + nu_trade_string = str(nu_trade) + msg = "Re-parsed TradeCodes string does not equal original parsed string" + self.assertEqual(trade_string, nu_trade_string, msg) + + +if __name__ == '__main__': + unittest.main() diff --git a/Tests/testDeltaReduce.py b/Tests/testDeltaReduce.py index 9e284c1e5..9162747f0 100644 --- a/Tests/testDeltaReduce.py +++ b/Tests/testDeltaReduce.py @@ -317,7 +317,7 @@ def test_star_having_no_sector_attribute(self): delta[sector.name] = sector reducer = DeltaReduce(delta, args) - reducer.is_initial_state_uninteresting() + reducer.is_initial_state_uninteresting(reraise=True) def test_population_balance_over_two_sectors(self): args = self._make_args_no_line() @@ -334,7 +334,7 @@ def test_population_balance_over_two_sectors(self): self.assertEqual(2, len(delta), "Should only be two sectors in dictionary") reducer = DeltaReduce(delta, args, args.interestingline, args.interestingtype) - reducer.is_initial_state_uninteresting() + reducer.is_initial_state_uninteresting(reraise=True) def test_pax_and_trade_balance_over_reft_sector(self): args = self._make_args_no_line() @@ -345,7 +345,7 @@ def test_pax_and_trade_balance_over_reft_sector(self): delta[sector.name] = sector reducer = DeltaReduce(delta, args, args.interestingline, args.interestingtype) - reducer.is_initial_state_uninteresting() + reducer.is_initial_state_uninteresting(reraise=True) def _make_args(self): args = argparse.ArgumentParser(description='PyRoute input minimiser.') diff --git a/Tests/testStar.py b/Tests/testStar.py index 5ccadf627..95e9474b6 100644 --- a/Tests/testStar.py +++ b/Tests/testStar.py @@ -13,6 +13,7 @@ from PyRoute.Position.Hex import Hex from PyRoute.Calculation.TradeCalculation import TradeCalculation from PyRoute.Galaxy import Allegiance +from PyRoute.StatCalculation import StatCalculation sys.path.append('../PyRoute') from PyRoute.Star import Star @@ -420,6 +421,26 @@ def test_parse_to_line(self): nu_line = star2.parse_to_line() self.assertEqual(mid_line, nu_line, "Regenerated star line does not match round-trip star line") + def testShortSophontCodeIntoStatsCalculation(self): + line = '2926 B8B2613-C He Fl Ni HakW Pz { 1 } (735+3) [458B] - M A 514 16 HvFd G4 V M1 V ' + sector = Sector('# Phlask', '# 3,-3') + + star = Star.parse_line_into_star(line, sector, 'fixed', 'fixed') + star.index = 0 + star.allegiance_base = star.alg_code + self.assertEqual(5, star.population, "Expected star population") + result, msg = star.tradeCode.is_well_formed() + self.assertTrue(result, msg) + galaxy = Galaxy(0) + stat_calc = StatCalculation(galaxy) + + stat_calc.add_pop_to_sophont(galaxy.stats, star) + stats = galaxy.stats + self.assertTrue('Hak' in stats.populations) + self.assertTrue('Huma' in stats.populations) + self.assertEqual(5, stats.populations['Hak'].population) + self.assertEqual(0, stats.populations['Huma'].population) + if __name__ == "__main__": # import sys;sys.argv = ['', 'Test.testName'] diff --git a/Tests/testTradeCode.py b/Tests/testTradeCode.py index 90fbbc780..45739ed3e 100644 --- a/Tests/testTradeCode.py +++ b/Tests/testTradeCode.py @@ -56,12 +56,12 @@ def testOwned(self): def testSophonts(self): code = TradeCodes(u"(Wiki)") - self.assertEqual([u'WikiW'], code.homeworld, code.homeworld) + self.assertEqual([u'Wiki'], code.homeworld, code.homeworld) self.assertEqual([u'WikiW'], code.sophonts, code.sophonts) def testSophontsPartial(self): code = TradeCodes(u"(Wiki)4") - self.assertEqual([u'Wiki4'], code.homeworld, code.homeworld) + self.assertEqual([u'Wiki'], code.homeworld, code.homeworld) self.assertEqual([u'Wiki4'], code.sophonts) def testWorldSophont(self): @@ -88,14 +88,14 @@ def testAllSophontCodesAreBelongToUs(self): def testWorldSophontsMultiple(self): code = TradeCodes("Ag Wiki4 Huma2") self.assertFalse(code.homeworld) - self.assertEqual(['Wiki4', 'Huma2'], code.sophonts) + self.assertEqual(['Huma2', 'Wiki4'], code.sophonts) self.assertEqual(['Ag'], code.codeset) def testSophontCombined(self): code = TradeCodes("Ri (Wiki) Huma4 Alph2 (Deneb)2") self.assertTrue(len(code.homeworld) > 0) - self.assertEqual(['Huma4', 'Alph2', 'WikiW', 'Dene2'], code.sophonts, msg=code.sophonts) - self.assertEqual(['WikiW', 'Dene2'], code.homeworld, msg=code.homeworld) + self.assertEqual(['Alph2', 'Dene2', 'Huma4', 'WikiW'], code.sophonts, msg=code.sophonts) + self.assertEqual(['Deneb', 'Wiki'], code.homeworld, msg=code.homeworld) self.assertEqual(['Ri'], code.codeset, code.codeset) def testCodeCheck(self): @@ -133,6 +133,207 @@ def testCodeCheckFails(self): log.output ) + def testSophontHomeworldWithSpaces(self): + cases = [ + ('Minor race', 'Ni Pa (Ashdak Meshukiiba) Sa ', '(Ashdak Meshukiiba) Ni Pa Sa'), + ('Major race', 'Ni Pa [Ashdak Meshukiiba] Sa ', 'Ni Pa Sa [Ashdak Meshukiiba]') + ] + + for msg, line, expected_line in cases: + with self.subTest(msg): + code = TradeCodes(line) + result, msg = code.is_well_formed() + self.assertTrue(result, msg) + self.assertEqual(1, len(code.sophont_list)) + self.assertEqual(1, len(code.homeworld_list)) + self.assertEqual(['AshdW'], code.sophonts, 'Unexpected sophont list') + self.assertEqual(['Ashdak Meshukiiba'], code.homeworld_list, 'Unexpected homeworld list') + self.assertEqual(expected_line, str(code)) + + def testSophontDiebackAlongsideActivePopulations(self): + line = 'An Asla1 Cs Hi MiyaX S\'mr0' + code = TradeCodes(line) + result, msg = code.is_well_formed() + self.assertTrue(result, msg) + + def testAvoidFakeoutHomeworldAtEnd(self): + line = '000000000(0) 00' + code = TradeCodes(line) + self.assertEqual(0, len(code.sophont_list), "Fake homeworld code should not result in sophont") + self.assertEqual(0, len(code.homeworld_list), "Fake homeworld code should not result in homeworld") + + def testAvoidFakeoutHomeworldAtStart(self): + line = '(0)000000000 00' + code = TradeCodes(line) + self.assertEqual(0, len(code.sophont_list), "Fake homeworld code should not result in sophont") + self.assertEqual(0, len(code.homeworld_list), "Fake homeworld code should not result in homeworld") + + def testAvoidFakeoutHomeworldInMiddle(self): + line = '00000000000(0)0' + code = TradeCodes(line) + self.assertEqual(0, len(code.sophont_list), "Fake homeworld code should not result in sophont") + self.assertEqual(0, len(code.homeworld_list), "Fake homeworld code should not result in homeworld") + + def testAvoidFakeoutHomeworldWithSpace(self): + line = '000000000000( )' + code = TradeCodes(line) + self.assertEqual(0, len(code.sophont_list), "Fake homeworld code should not result in sophont") + self.assertEqual(0, len(code.homeworld_list), "Fake homeworld code should not result in homeworld") + + def testVerifyActualHomeworld(self): + line = '(0000)W' + code = TradeCodes(line) + self.assertEqual(1, len(code.sophont_list), "Actual homeworld code should result in sophont") + self.assertEqual(1, len(code.homeworld_list), "Actual homeworld code should result in sophont") + + def testVerifyHomeworldDoesNotDuplicate(self): + line = '(Ashd)W Ni Pa Sa' + code = TradeCodes(line) + self.assertEqual(1, len(code.sophont_list), "Actual homeworld code should result in sophont") + self.assertEqual(1, len(code.homeworld_list), "Actual homeworld code should result in sophont") + + nuline = str(code) + self.assertEqual(line, nuline) + + def testVerifyHomeworldWithUnknownPopCountsAsZero(self): + line = 'Pi (Feime)? Re Sa' + code = TradeCodes(line) + self.assertEqual(1, len(code.sophont_list), "Actual homeworld code should result in sophont") + self.assertEqual(1, len(code.homeworld_list), "Actual homeworld code should result in homeworld") + self.assertEqual(['Feim0'], code.sophont_list) + self.assertEqual(['Feime'], code.homeworld_list) + + result, msg = code.is_well_formed() + self.assertTrue(result, msg) + expected_line = '(Feime)? Pi Re Sa' + self.assertEqual(expected_line, str(code), "Unexpected parsed trade code") + + def testVerifyHomeworldMinorRace(self): + line = 'Hi In (Anixii)W Da' + code = TradeCodes(line) + self.assertEqual(1, len(code.sophont_list), "Actual homeworld code should result in sophont") + self.assertEqual(1, len(code.homeworld_list), "Actual homeworld code should result in homeworld") + self.assertEqual(['AnixW'], code.sophont_list) + self.assertEqual(['Anixii'], code.homeworld_list) + + result, msg = code.is_well_formed() + self.assertTrue(result, msg) + expected_line = '(Anixii)W Da Hi In' + self.assertEqual(expected_line, str(code), "Unexpected string representation") + + def testVerifyHomeworldMajorRaceImplicitPop(self): + line = 'Hi In [Anixii] Da' + code = TradeCodes(line) + self.assertEqual(1, len(code.sophont_list), "Actual homeworld code should result in sophont") + self.assertEqual(1, len(code.homeworld_list), "Actual homeworld code should result in homeworld") + self.assertEqual(['AnixW'], code.sophont_list) + self.assertEqual(['Anixii'], code.homeworld_list) + + result, msg = code.is_well_formed() + self.assertTrue(result, msg) + expected_line = 'Da Hi In [Anixii]' + self.assertEqual(expected_line, str(code), "Unexpected string representation") + + def testVerifyHomeworldMajorRaceExplicitPop(self): + line = 'Hi In [Anixii]9 Da' + code = TradeCodes(line) + self.assertEqual(1, len(code.sophont_list), "Actual homeworld code should result in sophont") + self.assertEqual(1, len(code.homeworld_list), "Actual homeworld code should result in homeworld") + self.assertEqual(['Anix9'], code.sophont_list) + self.assertEqual(['Anixii'], code.homeworld_list) + + result, msg = code.is_well_formed() + self.assertTrue(result, msg) + expected_line = 'Da Hi In [Anixii]9' + self.assertEqual(expected_line, str(code), "Unexpected string representation") + + def testVerifyDeadworldDoesntSpawnHomeworld(self): + line = '(Miya)X An Asla1 Cs Hi S\'mr0' + code = TradeCodes(line) + self.assertEqual(3, len(code.sophont_list), "Actual homeworld code should result in sophont") + self.assertEqual(1, len(code.homeworld_list), "Actual homeworld code should result in homeworld") + + expected = '(Miya)X An Asla1 Cs Hi S\'mr0' + self.assertEqual(expected, str(code), "Unexpected trade code result") + + def testVerifyCompactChirperCodeProcessing(self): + poplevel = '0123456789W' + + for rawcode in poplevel: + with self.subTest("Population code " + rawcode): + line = 'C' + rawcode + code = TradeCodes(line) + expected = 'Chir' + rawcode + self.assertEqual(1, len(code.sophont_list), "Compacted Chirper code should result in sophont") + self.assertEqual(expected, str(code)) + + def testVerifyCompactDroyneCodeProcessing(self): + poplevel = '0123456789W' + + for rawcode in poplevel: + with self.subTest("Population code " + rawcode): + line = 'D' + rawcode + code = TradeCodes(line) + expected = 'Droy' + rawcode + self.assertEqual(1, len(code.sophont_list), "Compacted Droyne code should result in sophont") + self.assertEqual(expected, str(code)) + + def testSharedHomeworld(self): + cases = [ + ('Both minor, both populations explicit', 'Hi Pz (S\'mrii)7 (Kiakh\'iee)3 ', '(Kiakh\'iee)3 (S\'mrii)7 Hi Pz', ['Kiak3', 'SXmr7']), + ('Both major, both populations explicit', 'Hi Pz [S\'mrii]7 [Kiakh\'iee]3 ', 'Hi Pz [Kiakh\'iee]3 [S\'mrii]7', ['Kiak3', 'SXmr7']), + ('First major, both populations explicit', 'Hi Pz [S\'mrii]7 (Kiakh\'iee)3 ', '(Kiakh\'iee)3 Hi Pz [S\'mrii]7', ['Kiak3', 'SXmr7']), + ('Second major, both populations explicit', 'Hi Pz (S\'mrii)7 [Kiakh\'iee]3 ', '(S\'mrii)7 Hi Pz [Kiakh\'iee]3', ['Kiak3', 'SXmr7']), + ('Second major, first population zero, second population implicit', 'Hi Pz (S\'mrii)0 [Kiakh\'iee] ', '(S\'mrii)0 Hi Pz [Kiakh\'iee]', ['KiakW', 'SXmr0']), + ('First major, both populations zero', 'Hi Pz [S\'mrii]0 (Kiakh\'iee)0 ', '(Kiakh\'iee)0 Hi Pz [S\'mrii]0', ['Kiak0', 'SXmr0']), + ('Second major, first population explicit, second population zero', 'Hi Pz (S\'mrii)7 [Kiakh\'iee]0 ', '(S\'mrii)7 Hi Pz [Kiakh\'iee]0', ['Kiak0', 'SXmr7']), + ('Both minor, first population implicit, second population zero', 'Hi Pz (S\'mrii) (Kiakh\'iee)0 ', '(Kiakh\'iee)0 (S\'mrii) Hi Pz', ['Kiak0', 'SXmrW']), + ('Both major, first population implicit, second population zero', 'Hi Pz [S\'mrii] [Kiakh\'iee]0 ', 'Hi Pz [Kiakh\'iee]0 [S\'mrii]', ['Kiak0', 'SXmrW']), + ('First major, first population zero, second population implicit', 'Hi Pz [S\'mrii]0 (Kiakh\'iee) ', '(Kiakh\'iee) Hi Pz [S\'mrii]0', ['KiakW', 'SXmr0']), + ('First major, first population zero, second population explicit', 'Hi Pz [S\'mrii]0 (Kiakh\'iee)3 ', '(Kiakh\'iee)3 Hi Pz [S\'mrii]0', ['Kiak3', 'SXmr0']) + ] + + for label, line, expected_line, expected_sophont in cases: + with self.subTest(label): + code = TradeCodes(line) + result, msg = code.is_well_formed() + self.assertTrue(result, msg) + + self.assertEqual(2, len(code.sophont_list), "Shared homeworld should result in two sophont") + self.assertEqual(2, len(code.homeworld_list), "Shared homeworld should result in two homeworld entries") + + self.assertEqual(expected_sophont, code.sophonts, "Unexpected sophont list") + expected_homeworld = ['Kiakh\'iee', 'S\'mrii'] + self.assertEqual(expected_homeworld, code.homeworld, "Unexpected homeworld list") + + self.assertEqual(expected_line, str(code), "Unexpected string representation") + + nu_code = TradeCodes(str(code)) + result, msg = nu_code.is_well_formed() + self.assertTrue(result, msg) + + def testHandleOverlyLongSophontName(self): + cases = [ + ('Minor race', '(', ')'), + ('Major race', '(', ')') + ] + + for msg, left_bracket, right_bracket in cases: + with self.subTest(msg): + line = left_bracket + '000000000000000000000000000000000000' + right_bracket + code = TradeCodes(line) + sophont = line[1:36] # strip the brackets and trim what's left to 35 characters + + self.assertEqual(1, len(code.sophont_list), "Actual homeworld code should result in sophont") + self.assertEqual(1, len(code.homeworld_list), "Actual homeworld code should result in homeworld") + self.assertEqual(['0000W'], code.sophont_list) + self.assertEqual([sophont], code.homeworld_list) + + # verify shortened soph code turns up + expected_sophont = left_bracket + sophont + right_bracket + nu_line = str(code) + self.assertEqual(expected_sophont, nu_line) + if __name__ == "__main__": # import sys;sys.argv = ['', 'Test.testName'] diff --git a/requirements.txt b/requirements.txt index 17c4b9334..02a2a08c6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ pypdflite @ git+https://github.com/tjoneslo/pypdflite.git@python3 inflect Pillow >= 10.0 wikitools3==3.0.1 +hypothesis jinja2 jsonpickle numpy @@ -10,4 +11,4 @@ pytest pytest-console-scripts pytest-randomly pytest-subtests -ruff >= 0.1.0 +ruff >= 0.1.5