diff --git a/BaseClasses.py b/BaseClasses.py index 8327dbeb..0989999d 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -1369,6 +1369,157 @@ class Spoiler(): self.bosses[str(player)]["Ganons Tower"] = "Agahnim 2" self.bosses[str(player)]["Ganon"] = "Ganon" + def create_playthrough(self, create_paths: bool = True): + """Destructive to the world while it is run, damage gets repaired afterwards.""" + from itertools import chain + # get locations containing progress items + multiworld = self.multiworld + prog_locations = {location for location in multiworld.get_filled_locations() if location.item.advancement} + state_cache = [None] + collection_spheres: List[Set[Location]] = [] + state = CollectionState(multiworld) + sphere_candidates = set(prog_locations) + logging.debug('Building up collection spheres.') + while sphere_candidates: + + # build up spheres of collection radius. + # Everything in each sphere is independent from each other in dependencies and only depends on lower spheres + + sphere = {location for location in sphere_candidates if state.can_reach(location)} + + for location in sphere: + state.collect(location.item, True, location) + + sphere_candidates -= sphere + collection_spheres.append(sphere) + state_cache.append(state.copy()) + + logging.debug('Calculated sphere %i, containing %i of %i progress items.', len(collection_spheres), + len(sphere), + len(prog_locations)) + if not sphere: + logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % ( + location.item.name, location.item.player, location.name, location.player) for location in + sphere_candidates]) + if any([multiworld.accessibility[location.item.player] != 'minimal' for location in sphere_candidates]): + raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). ' + f'Something went terribly wrong here.') + else: + self.unreachables = sphere_candidates + break + + # in the second phase, we cull each sphere such that the game is still beatable, + # reducing each range of influence to the bare minimum required inside it + restore_later = {} + for num, sphere in reversed(tuple(enumerate(collection_spheres))): + to_delete = set() + for location in sphere: + # we remove the item at location and check if game is still beatable + logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name, + location.item.player) + old_item = location.item + location.item = None + if multiworld.can_beat_game(state_cache[num]): + to_delete.add(location) + restore_later[location] = old_item + else: + # still required, got to keep it around + location.item = old_item + + # cull entries in spheres for spoiler walkthrough at end + sphere -= to_delete + + # second phase, sphere 0 + removed_precollected = [] + for item in (i for i in chain.from_iterable(multiworld.precollected_items.values()) if i.advancement): + logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player) + multiworld.precollected_items[item.player].remove(item) + multiworld.state.remove(item) + if not multiworld.can_beat_game(): + multiworld.push_precollected(item) + else: + removed_precollected.append(item) + + # we are now down to just the required progress items in collection_spheres. Unfortunately + # the previous pruning stage could potentially have made certain items dependant on others + # in the same or later sphere (because the location had 2 ways to access but the item originally + # used to access it was deemed not required.) So we need to do one final sphere collection pass + # to build up the correct spheres + + required_locations = {item for sphere in collection_spheres for item in sphere} + state = CollectionState(multiworld) + collection_spheres = [] + while required_locations: + state.sweep_for_events(key_only=True) + + sphere = set(filter(state.can_reach, required_locations)) + + for location in sphere: + state.collect(location.item, True, location) + + required_locations -= sphere + + collection_spheres.append(sphere) + + logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres), + len(sphere), len(required_locations)) + if not sphere: + raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}') + + # we can finally output our playthrough + self.playthrough = {"0": sorted([str(item) for item in + chain.from_iterable(multiworld.precollected_items.values()) + if item.advancement])} + + for i, sphere in enumerate(collection_spheres): + self.playthrough[str(i + 1)] = { + str(location): str(location.item) for location in sorted(sphere)} + if create_paths: + self.create_paths(state, collection_spheres) + + # repair the multiworld again + for location, item in restore_later.items(): + location.item = item + + for item in removed_precollected: + multiworld.push_precollected(item) + + def create_paths(self, state: CollectionState, collection_spheres: List[Set[Location]]): + from itertools import zip_longest + multiworld = self.multiworld + + def flist_to_iter(node): + while node: + value, node = node + yield value + + def get_path(state, region): + reversed_path_as_flist = state.path.get(region, (region, None)) + string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist)))) + # Now we combine the flat string list into (region, exit) pairs + pathsiter = iter(string_path_flat) + pathpairs = zip_longest(pathsiter, pathsiter) + return list(pathpairs) + + self.paths = {} + topology_worlds = (player for player in multiworld.player_ids if multiworld.worlds[player].topology_present) + for player in topology_worlds: + self.paths.update( + {str(location): get_path(state, location.parent_region) + for sphere in collection_spheres for location in sphere + if location.player == player}) + if player in multiworld.get_game_players("A Link to the Past"): + # If Pyramid Fairy Entrance needs to be reached, also path to Big Bomb Shop + # Maybe move the big bomb over to the Event system instead? + if any(exit_path == 'Pyramid Fairy' for path in self.paths.values() + for (_, exit_path) in path): + if multiworld.mode[player] != 'inverted': + self.paths[str(multiworld.get_region('Big Bomb Shop', player))] = \ + get_path(state, multiworld.get_region('Big Bomb Shop', player)) + else: + self.paths[str(multiworld.get_region('Inverted Big Bomb Shop', player))] = \ + get_path(state, multiworld.get_region('Inverted Big Bomb Shop', player)) + def to_json(self): self.parse_data() out = OrderedDict() diff --git a/Main.py b/Main.py index f059f335..78da5285 100644 --- a/Main.py +++ b/Main.py @@ -1,5 +1,4 @@ import collections -from itertools import zip_longest, chain import logging import os import time @@ -417,7 +416,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No if args.spoiler > 1: logger.info('Calculating playthrough.') - create_playthrough(world) + world.spoiler.create_playthrough(create_paths=args.spoiler > 2) if args.spoiler: world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase)) @@ -431,143 +430,3 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start) return world - - -def create_playthrough(world): - """Destructive to the world while it is run, damage gets repaired afterwards.""" - # get locations containing progress items - prog_locations = {location for location in world.get_filled_locations() if location.item.advancement} - state_cache = [None] - collection_spheres = [] - state = CollectionState(world) - sphere_candidates = set(prog_locations) - logging.debug('Building up collection spheres.') - while sphere_candidates: - - # build up spheres of collection radius. - # Everything in each sphere is independent from each other in dependencies and only depends on lower spheres - - sphere = {location for location in sphere_candidates if state.can_reach(location)} - - for location in sphere: - state.collect(location.item, True, location) - - sphere_candidates -= sphere - collection_spheres.append(sphere) - state_cache.append(state.copy()) - - logging.debug('Calculated sphere %i, containing %i of %i progress items.', len(collection_spheres), len(sphere), - len(prog_locations)) - if not sphere: - logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % ( - location.item.name, location.item.player, location.name, location.player) for location in - sphere_candidates]) - if any([world.accessibility[location.item.player] != 'minimal' for location in sphere_candidates]): - raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). ' - f'Something went terribly wrong here.') - else: - world.spoiler.unreachables = sphere_candidates - break - - # in the second phase, we cull each sphere such that the game is still beatable, - # reducing each range of influence to the bare minimum required inside it - restore_later = {} - for num, sphere in reversed(tuple(enumerate(collection_spheres))): - to_delete = set() - for location in sphere: - # we remove the item at location and check if game is still beatable - logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name, - location.item.player) - old_item = location.item - location.item = None - if world.can_beat_game(state_cache[num]): - to_delete.add(location) - restore_later[location] = old_item - else: - # still required, got to keep it around - location.item = old_item - - # cull entries in spheres for spoiler walkthrough at end - sphere -= to_delete - - # second phase, sphere 0 - removed_precollected = [] - for item in (i for i in chain.from_iterable(world.precollected_items.values()) if i.advancement): - logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player) - world.precollected_items[item.player].remove(item) - world.state.remove(item) - if not world.can_beat_game(): - world.push_precollected(item) - else: - removed_precollected.append(item) - - # we are now down to just the required progress items in collection_spheres. Unfortunately - # the previous pruning stage could potentially have made certain items dependant on others - # in the same or later sphere (because the location had 2 ways to access but the item originally - # used to access it was deemed not required.) So we need to do one final sphere collection pass - # to build up the correct spheres - - required_locations = {item for sphere in collection_spheres for item in sphere} - state = CollectionState(world) - collection_spheres = [] - while required_locations: - state.sweep_for_events(key_only=True) - - sphere = set(filter(state.can_reach, required_locations)) - - for location in sphere: - state.collect(location.item, True, location) - - required_locations -= sphere - - collection_spheres.append(sphere) - - logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres), - len(sphere), len(required_locations)) - if not sphere: - raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}') - - def flist_to_iter(node): - while node: - value, node = node - yield value - - def get_path(state, region): - reversed_path_as_flist = state.path.get(region, (region, None)) - string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist)))) - # Now we combine the flat string list into (region, exit) pairs - pathsiter = iter(string_path_flat) - pathpairs = zip_longest(pathsiter, pathsiter) - return list(pathpairs) - - world.spoiler.paths = {} - topology_worlds = (player for player in world.player_ids if world.worlds[player].topology_present) - for player in topology_worlds: - world.spoiler.paths.update( - {str(location): get_path(state, location.parent_region) for sphere in collection_spheres for location in - sphere if location.player == player}) - if player in world.get_game_players("A Link to the Past"): - # If Pyramid Fairy Entrance needs to be reached, also path to Big Bomb Shop - # Maybe move the big bomb over to the Event system instead? - if any(exit_path == 'Pyramid Fairy' for path in world.spoiler.paths.values() for (_, exit_path) in path): - if world.mode[player] != 'inverted': - world.spoiler.paths[str(world.get_region('Big Bomb Shop', player))] = \ - get_path(state, world.get_region('Big Bomb Shop', player)) - else: - world.spoiler.paths[str(world.get_region('Inverted Big Bomb Shop', player))] = \ - get_path(state, world.get_region('Inverted Big Bomb Shop', player)) - - # we can finally output our playthrough - world.spoiler.playthrough = {"0": sorted([str(item) for item in - chain.from_iterable(world.precollected_items.values()) - if item.advancement])} - - for i, sphere in enumerate(collection_spheres): - world.spoiler.playthrough[str(i + 1)] = {str(location): str(location.item) for location in sorted(sphere)} - - # repair the world again - for location, item in restore_later.items(): - location.item = item - - for item in removed_precollected: - world.push_precollected(item) diff --git a/Utils.py b/Utils.py index 074316ee..fd6435d6 100644 --- a/Utils.py +++ b/Utils.py @@ -273,7 +273,7 @@ def get_default_options() -> OptionsType: "players": 0, "weights_file_path": "weights.yaml", "meta_file_path": "meta.yaml", - "spoiler": 2, + "spoiler": 3, "glitch_triforce_room": 1, "race": 0, "plando_options": "bosses", diff --git a/WebHostLib/generate.py b/WebHostLib/generate.py index c229698c..6a2c720a 100644 --- a/WebHostLib/generate.py +++ b/WebHostLib/generate.py @@ -114,7 +114,7 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non erargs = parse_arguments(['--multi', str(playercount)]) erargs.seed = seed erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery - erargs.spoiler = 0 if race else 2 + erargs.spoiler = 0 if race else 3 erargs.race = race erargs.outputname = seedname erargs.outputpath = target.name diff --git a/host.yaml b/host.yaml index 4e94a9a3..f2d90c87 100644 --- a/host.yaml +++ b/host.yaml @@ -68,9 +68,10 @@ generator: meta_file_path: "meta.yaml" # Create a spoiler file # 0 -> None - # 1 -> Spoiler without playthrough - # 2 -> Full spoiler - spoiler: 2 + # 1 -> Spoiler without playthrough or paths to playthrough required items + # 2 -> Spoiler with playthrough (viable solution to goals) + # 3 -> Spoiler with playthrough and traversal paths towards items + spoiler: 3 # Glitch to Triforce room from Ganon # When disabled, you have to have a weapon that can hurt ganon (master sword or swordless/easy item functionality + hammer) # and have completed the goal required for killing ganon to be able to access the triforce room.