diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000000..17a60ad125f7 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,5 @@ +[report] +exclude_lines = + pragma: no cover + if TYPE_CHECKING: + if typing.TYPE_CHECKING: diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 000000000000..2743104f410e --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,31 @@ +'is: documentation': +- changed-files: + - all-globs-to-all-files: '{**/docs/**,**/README.md}' + +'affects: webhost': +- changed-files: + - all-globs-to-any-file: 'WebHost.py' + - all-globs-to-any-file: 'WebHostLib/**/*' + +'affects: core': +- changed-files: + - all-globs-to-any-file: + - '!*Client.py' + - '!README.md' + - '!LICENSE' + - '!*.yml' + - '!.gitignore' + - '!**/docs/**' + - '!typings/kivy/**' + - '!test/**' + - '!data/**' + - '!.run/**' + - '!.github/**' + - '!worlds_disabled/**' + - '!worlds/**' + - '!WebHost.py' + - '!WebHostLib/**' + - any-glob-to-any-file: # exceptions to the above rules of "stuff that isn't core" + - 'worlds/generic/**/*.py' + - 'worlds/*.py' + - 'CommonClient.py' diff --git a/.github/workflows/analyze-modified-files.yml b/.github/workflows/analyze-modified-files.yml index ba2660809aaa..d01365745c96 100644 --- a/.github/workflows/analyze-modified-files.yml +++ b/.github/workflows/analyze-modified-files.yml @@ -71,7 +71,7 @@ jobs: continue-on-error: true if: env.diff != '' && matrix.task == 'flake8' run: | - flake8 --count --max-complexity=10 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }} + flake8 --count --max-complexity=14 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }} - name: "mypy: Type check modified files" continue-on-error: true diff --git a/.github/workflows/label-pull-requests.yml b/.github/workflows/label-pull-requests.yml new file mode 100644 index 000000000000..e26f6f34a4d2 --- /dev/null +++ b/.github/workflows/label-pull-requests.yml @@ -0,0 +1,46 @@ +name: Label Pull Request +on: + pull_request_target: + types: ['opened', 'reopened', 'synchronize', 'ready_for_review', 'converted_to_draft', 'closed'] + branches: ['main'] +permissions: + contents: read + pull-requests: write + +jobs: + labeler: + name: 'Apply content-based labels' + if: github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'synchronize' + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v5 + with: + sync-labels: true + peer_review: + name: 'Apply peer review label' + needs: labeler + if: >- + (github.event.action == 'opened' || github.event.action == 'reopened' || + github.event.action == 'ready_for_review') && !github.event.pull_request.draft + runs-on: ubuntu-latest + steps: + - name: 'Add label' + run: "gh pr edit \"$PR_URL\" --add-label 'waiting-on: peer-review'" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + unblock_draft_prs: + name: 'Remove waiting-on labels' + needs: labeler + if: github.event.action == 'converted_to_draft' || github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - name: 'Remove labels' + run: |- + gh pr edit "$PR_URL" --remove-label 'waiting-on: peer-review' \ + --remove-label 'waiting-on: core-review' \ + --remove-label 'waiting-on: world-maintainer' \ + --remove-label 'waiting-on: author' + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/scan-build.yml b/.github/workflows/scan-build.yml new file mode 100644 index 000000000000..5234d862b4d3 --- /dev/null +++ b/.github/workflows/scan-build.yml @@ -0,0 +1,65 @@ +name: Native Code Static Analysis + +on: + push: + paths: + - '**.c' + - '**.cc' + - '**.cpp' + - '**.cxx' + - '**.h' + - '**.hh' + - '**.hpp' + - '**.pyx' + - 'setup.py' + - 'requirements.txt' + - '.github/workflows/scan-build.yml' + pull_request: + paths: + - '**.c' + - '**.cc' + - '**.cpp' + - '**.cxx' + - '**.h' + - '**.hh' + - '**.hpp' + - '**.pyx' + - 'setup.py' + - 'requirements.txt' + - '.github/workflows/scan-build.yml' + +jobs: + scan-build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install newer Clang + run: | + wget https://apt.llvm.org/llvm.sh + chmod +x ./llvm.sh + sudo ./llvm.sh 17 + - name: Install scan-build command + run: | + sudo apt install clang-tools-17 + - name: Get a recent python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install dependencies + run: | + python -m venv venv + source venv/bin/activate + python -m pip install --upgrade pip -r requirements.txt + - name: scan-build + run: | + source venv/bin/activate + scan-build-17 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y + - name: Store report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: scan-build-reports + path: scan-build-reports diff --git a/.run/Archipelago Unittests.run.xml b/.run/Archipelago Unittests.run.xml new file mode 100644 index 000000000000..24fea0f73fec --- /dev/null +++ b/.run/Archipelago Unittests.run.xml @@ -0,0 +1,18 @@ + + + + + \ No newline at end of file diff --git a/AM2RClient.py b/AM2RClient.py new file mode 100644 index 000000000000..0906405f36b8 --- /dev/null +++ b/AM2RClient.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +import ModuleUpdate +ModuleUpdate.update() + +from worlds.am2r.Client import launch +import Utils + +if __name__ == "__main__": + Utils.init_logging("AM2RClient", exception_logger="Client") + launch() diff --git a/AdventureClient.py b/AdventureClient.py index d2f4e734ac2c..06e4d60dad43 100644 --- a/AdventureClient.py +++ b/AdventureClient.py @@ -115,11 +115,12 @@ def on_package(self, cmd: str, args: dict): msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}" self._set_message(msg, SYSTEM_MESSAGE_ID) elif cmd == "Retrieved": - self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"] - if self.freeincarnates_used is None: - self.freeincarnates_used = 0 - self.freeincarnates_used += self.freeincarnate_pending - self.send_pending_freeincarnates() + if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]: + self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"] + if self.freeincarnates_used is None: + self.freeincarnates_used = 0 + self.freeincarnates_used += self.freeincarnate_pending + self.send_pending_freeincarnates() elif cmd == "SetReply": if args["key"] == f"adventure_{self.auth}_freeincarnates_used": self.freeincarnates_used = args["value"] diff --git a/BaseClasses.py b/BaseClasses.py index 7965eb8b0d0d..2be9a9820d07 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -18,11 +18,14 @@ import Options import Utils +if typing.TYPE_CHECKING: + from worlds import AutoWorld + class Group(TypedDict, total=False): name: str game: str - world: auto_world + world: "AutoWorld.World" players: Set[int] item_pool: Set[str] replacement_items: Dict[int, Optional[str]] @@ -55,7 +58,7 @@ class MultiWorld(): plando_texts: List[Dict[str, str]] plando_items: List[List[Dict[str, Any]]] plando_connections: List - worlds: Dict[int, auto_world] + worlds: Dict[int, "AutoWorld.World"] groups: Dict[int, Group] regions: RegionManager itempool: List[Item] @@ -107,10 +110,14 @@ def __iadd__(self, other: Iterable[Region]): return self def append(self, region: Region): + assert region.name not in self.region_cache[region.player], \ + f"{region.name} already exists in region cache." self.region_cache[region.player][region.name] = region def extend(self, regions: Iterable[Region]): for region in regions: + assert region.name not in self.region_cache[region.player], \ + f"{region.name} already exists in region cache." self.region_cache[region.player][region.name] = region def add_group(self, new_id: int): @@ -156,11 +163,11 @@ def __init__(self, players: int): self.fix_trock_doors = self.AttributeProxy( lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted') self.fix_skullwoods_exit = self.AttributeProxy( - lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple']) + lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeons_simple']) self.fix_palaceofdarkness_exit = self.AttributeProxy( - lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple']) + lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeons_simple']) self.fix_trock_exit = self.AttributeProxy( - lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple']) + lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeons_simple']) for player in range(1, players + 1): def set_player_attr(attr, val): @@ -219,6 +226,8 @@ def get_all_ids(self) -> Tuple[int, ...]: def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tuple[int, Group]: """Create a group with name and return the assigned player ID and group. If a group of this name already exists, the set of players is extended instead of creating a new one.""" + from worlds import AutoWorld + for group_id, group in self.groups.items(): if group["name"] == name: group["players"] |= players @@ -252,19 +261,28 @@ def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optio range(1, self.players + 1)} def set_options(self, args: Namespace) -> None: + # TODO - remove this section once all worlds use options dataclasses + from worlds import AutoWorld + + all_keys: Set[str] = {key for player in self.player_ids for key in + AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints} + for option_key in all_keys: + option = Utils.DeprecateDict(f"Getting options from multiworld is now deprecated. " + f"Please use `self.options.{option_key}` instead.") + option.update(getattr(args, option_key, {})) + setattr(self, option_key, option) + for player in self.player_ids: world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]] self.worlds[player] = world_type(self, player) self.worlds[player].random = self.per_slot_randoms[player] - for option_key in world_type.options_dataclass.type_hints: - option_values = getattr(args, option_key, {}) - setattr(self, option_key, option_values) - # TODO - remove this loop once all worlds use options dataclasses - options_dataclass: typing.Type[Options.PerGameCommonOptions] = self.worlds[player].options_dataclass + options_dataclass: typing.Type[Options.PerGameCommonOptions] = world_type.options_dataclass self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player] for option_key in options_dataclass.type_hints}) def set_item_links(self): + from worlds import AutoWorld + item_links = {} replacement_prio = [False, True, None] for player in self.player_ids: @@ -491,7 +509,7 @@ def has_beaten_game(self, state: CollectionState, player: Optional[int] = None) else: return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1))) - def can_beat_game(self, starting_state: Optional[CollectionState] = None): + def can_beat_game(self, starting_state: Optional[CollectionState] = None) -> bool: if starting_state: if self.has_beaten_game(starting_state): return True @@ -504,7 +522,7 @@ def can_beat_game(self, starting_state: Optional[CollectionState] = None): and location.item.advancement and location not in state.locations_checked} while prog_locations: - sphere = set() + sphere: Set[Location] = set() # build up spheres of collection radius. # Everything in each sphere is independent from each other in dependencies and only depends on lower spheres for location in prog_locations: @@ -524,12 +542,19 @@ def can_beat_game(self, starting_state: Optional[CollectionState] = None): return False - def get_spheres(self): + def get_spheres(self) -> Iterator[Set[Location]]: + """ + yields a set of locations for each logical sphere + + If there are unreachable locations, the last sphere of reachable + locations is followed by an empty set, and then a set of all of the + unreachable locations. + """ state = CollectionState(self) locations = set(self.get_filled_locations()) while locations: - sphere = set() + sphere: Set[Location] = set() for location in locations: if location.can_reach(state): @@ -560,9 +585,10 @@ def fulfills_accessibility(self, state: Optional[CollectionState] = None): def location_condition(location: Location): """Determine if this location has to be accessible, location is already filtered by location_relevant""" - if location.player in players["minimal"]: - return False - return True + if location.player in players["locations"] or (location.item and location.item.player not in + players["minimal"]): + return True + return False def location_relevant(location: Location): """Determine if this location is relevant to sweep.""" @@ -639,34 +665,34 @@ def __init__(self, parent: MultiWorld): def update_reachable_regions(self, player: int): self.stale[player] = False - rrp = self.reachable_regions[player] - bc = self.blocked_connections[player] + reachable_regions = self.reachable_regions[player] + blocked_connections = self.blocked_connections[player] queue = deque(self.blocked_connections[player]) - start = self.multiworld.get_region('Menu', player) + start = self.multiworld.get_region("Menu", player) # init on first call - this can't be done on construction since the regions don't exist yet - if start not in rrp: - rrp.add(start) - bc.update(start.exits) + if start not in reachable_regions: + reachable_regions.add(start) + blocked_connections.update(start.exits) queue.extend(start.exits) # run BFS on all connections, and keep track of those blocked by missing items while queue: connection = queue.popleft() new_region = connection.connected_region - if new_region in rrp: - bc.remove(connection) + if new_region in reachable_regions: + blocked_connections.remove(connection) elif connection.can_reach(self): assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region" - rrp.add(new_region) - bc.remove(connection) - bc.update(new_region.exits) + reachable_regions.add(new_region) + blocked_connections.remove(connection) + blocked_connections.update(new_region.exits) queue.extend(new_region.exits) self.path[new_region] = (new_region.name, self.path.get(connection, None)) # Retry connections if the new region can unblock them for new_entrance in self.multiworld.indirect_connections.get(new_region, set()): - if new_entrance in bc and new_entrance not in queue: + if new_entrance in blocked_connections and new_entrance not in queue: queue.append(new_entrance) def copy(self) -> CollectionState: @@ -691,14 +717,23 @@ def can_reach(self, assert isinstance(player, int), "can_reach: player is required if spot is str" # try to resolve a name if resolution_hint == 'Location': - spot = self.multiworld.get_location(spot, player) + return self.can_reach_location(spot, player) elif resolution_hint == 'Entrance': - spot = self.multiworld.get_entrance(spot, player) + return self.can_reach_entrance(spot, player) else: # default to Region - spot = self.multiworld.get_region(spot, player) + return self.can_reach_region(spot, player) return spot.can_reach(self) + def can_reach_location(self, spot: str, player: int) -> bool: + return self.multiworld.get_location(spot, player).can_reach(self) + + def can_reach_entrance(self, spot: str, player: int) -> bool: + return self.multiworld.get_entrance(spot, player).can_reach(self) + + def can_reach_region(self, spot: str, player: int) -> bool: + return self.multiworld.get_region(spot, player).can_reach(self) + def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None: if locations is None: locations = self.multiworld.get_filled_locations() @@ -811,8 +846,8 @@ def __repr__(self): return self.__str__() def __str__(self): - world = self.parent_region.multiworld if self.parent_region else None - return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})' + multiworld = self.parent_region.multiworld if self.parent_region else None + return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})' class Region: @@ -855,6 +890,8 @@ def __delitem__(self, index: int) -> None: del(self.region_manager.location_cache[location.player][location.name]) def insert(self, index: int, value: Location) -> None: + assert value.name not in self.region_manager.location_cache[value.player], \ + f"{value.name} already exists in the location cache." self._list.insert(index, value) self.region_manager.location_cache[value.player][value.name] = value @@ -865,6 +902,8 @@ def __delitem__(self, index: int) -> None: del(self.region_manager.entrance_cache[entrance.player][entrance.name]) def insert(self, index: int, value: Entrance) -> None: + assert value.name not in self.region_manager.entrance_cache[value.player], \ + f"{value.name} already exists in the entrance cache." self._list.insert(index, value) self.region_manager.entrance_cache[value.player][value.name] = value @@ -1028,8 +1067,8 @@ def __repr__(self): return self.__str__() def __str__(self): - world = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None - return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})' + multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None + return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})' def __hash__(self): return hash((self.name, self.player)) @@ -1044,9 +1083,6 @@ def native_item(self) -> bool: @property def hint_text(self) -> str: - hint_text = getattr(self, "_hint_text", None) - if hint_text: - return hint_text return "at " + self.name.replace("_", " ").replace("-", " ") @@ -1166,7 +1202,7 @@ def set_entrance(self, entrance: str, exit_: str, direction: str, player: int) - {"player": player, "entrance": entrance, "exit": exit_, "direction": direction} def create_playthrough(self, create_paths: bool = True) -> None: - """Destructive to the world while it is run, damage gets repaired afterwards.""" + """Destructive to the multiworld while it is run, damage gets repaired afterwards.""" from itertools import chain # get locations containing progress items multiworld = self.multiworld @@ -1253,12 +1289,12 @@ def create_playthrough(self, create_paths: bool = True) -> None: 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)) + + required_locations -= sphere if not sphere: raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}') @@ -1317,6 +1353,8 @@ def get_path(state: CollectionState, region: Region) -> List[Union[Tuple[str, st get_path(state, multiworld.get_region('Inverted Big Bomb Shop', player)) def to_file(self, filename: str) -> None: + from worlds import AutoWorld + def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None: res = getattr(self.multiworld.worlds[player].options, option_key) display_name = getattr(option_obj, "display_name", option_key) @@ -1440,8 +1478,3 @@ def get_seed(seed: Optional[int] = None) -> int: random.seed(None) return random.randint(0, pow(10, seeddigits) - 1) return seed - - -from worlds import AutoWorld - -auto_world = AutoWorld.World diff --git a/CommonClient.py b/CommonClient.py index c4d80f341611..c75ca3fd806e 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -460,7 +460,7 @@ async def prepare_data_package(self, relevant_games: typing.Set[str], else: self.update_game(cached_game) if needed_updates: - await self.send_msgs([{"cmd": "GetDataPackage", "games": list(needed_updates)}]) + await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates]) def update_game(self, game_package: dict): for item_name, item_id in game_package["item_name_to_id"].items(): @@ -477,6 +477,7 @@ def consume_network_data_package(self, data_package: dict): current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {}) current_cache.update(data_package["games"]) Utils.persistent_store("datapackage", "games", current_cache) + logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}") for game, game_data in data_package["games"].items(): Utils.store_data_package_for_checksum(game, game_data) @@ -727,7 +728,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict): await ctx.server_auth(args['password']) elif cmd == 'DataPackage': - logger.info("Got new ID/Name DataPackage") ctx.consume_network_data_package(args['data']) elif cmd == 'ConnectionRefused': @@ -941,4 +941,5 @@ async def main(args): if __name__ == '__main__': + logging.getLogger().setLevel(logging.INFO) # force log-level to work around log level resetting to WARNING run_as_textclient() diff --git a/Fill.py b/Fill.py index 342c155079dd..2d6257eae30a 100644 --- a/Fill.py +++ b/Fill.py @@ -27,12 +27,12 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] return new_state -def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location], +def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: typing.List[Location], item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False, swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None, allow_partial: bool = False, allow_excluded: bool = False, name: str = "Unknown") -> None: """ - :param world: Multiworld to be filled. + :param multiworld: Multiworld to be filled. :param base_state: State assumed before fill. :param locations: Locations to be filled with item_pool :param item_pool: Items to fill into the locations @@ -68,7 +68,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: maximum_exploration_state = sweep_from_pool( base_state, item_pool + unplaced_items) - has_beaten_game = world.has_beaten_game(maximum_exploration_state) + has_beaten_game = multiworld.has_beaten_game(maximum_exploration_state) while items_to_place: # if we have run out of locations to fill,break out of this loop @@ -80,8 +80,8 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: spot_to_fill: typing.Optional[Location] = None # if minimal accessibility, only check whether location is reachable if game not beatable - if world.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal: - perform_access_check = not world.has_beaten_game(maximum_exploration_state, + if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal: + perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state, item_to_place.player) \ if single_player_placement else not has_beaten_game else: @@ -122,11 +122,11 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: # Verify placing this item won't reduce available locations, which would be a useless swap. prev_state = swap_state.copy() prev_loc_count = len( - world.get_reachable_locations(prev_state)) + multiworld.get_reachable_locations(prev_state)) swap_state.collect(item_to_place, True) new_loc_count = len( - world.get_reachable_locations(swap_state)) + multiworld.get_reachable_locations(swap_state)) if new_loc_count >= prev_loc_count: # Add this item to the existing placement, and @@ -156,7 +156,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: else: unplaced_items.append(item_to_place) continue - world.push_item(spot_to_fill, item_to_place, False) + multiworld.push_item(spot_to_fill, item_to_place, False) spot_to_fill.locked = lock placements.append(spot_to_fill) spot_to_fill.event = item_to_place.advancement @@ -173,7 +173,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: # validate all placements and remove invalid ones state = sweep_from_pool(base_state, []) for placement in placements: - if world.accessibility[placement.item.player] != "minimal" and not placement.can_reach(state): + if multiworld.worlds[placement.item.player].options.accessibility != "minimal" and not placement.can_reach(state): placement.item.location = None unplaced_items.append(placement.item) placement.item = None @@ -188,7 +188,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: if excluded_locations: for location in excluded_locations: location.progress_type = location.progress_type.DEFAULT - fill_restrictive(world, base_state, excluded_locations, unplaced_items, single_player_placement, lock, + fill_restrictive(multiworld, base_state, excluded_locations, unplaced_items, single_player_placement, lock, swap, on_place, allow_partial, False) for location in excluded_locations: if not location.item: @@ -196,7 +196,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: if not allow_partial and len(unplaced_items) > 0 and len(locations) > 0: # There are leftover unplaceable items and locations that won't accept them - if world.can_beat_game(): + if multiworld.can_beat_game(): logging.warning( f'Not all items placed. Game beatable anyway. (Could not place {unplaced_items})') else: @@ -206,9 +206,10 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: item_pool.extend(unplaced_items) -def remaining_fill(world: MultiWorld, +def remaining_fill(multiworld: MultiWorld, locations: typing.List[Location], - itempool: typing.List[Item]) -> None: + itempool: typing.List[Item], + name: str = "Remaining") -> None: unplaced_items: typing.List[Item] = [] placements: typing.List[Location] = [] swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter() @@ -261,14 +262,14 @@ def remaining_fill(world: MultiWorld, unplaced_items.append(item_to_place) continue - world.push_item(spot_to_fill, item_to_place, False) + multiworld.push_item(spot_to_fill, item_to_place, False) placements.append(spot_to_fill) placed += 1 if not placed % 1000: - _log_fill_progress("Remaining", placed, total) + _log_fill_progress(name, placed, total) if total > 1000: - _log_fill_progress("Remaining", placed, total) + _log_fill_progress(name, placed, total) if unplaced_items and locations: # There are leftover unplaceable items and locations that won't accept them @@ -278,19 +279,19 @@ def remaining_fill(world: MultiWorld, itempool.extend(unplaced_items) -def fast_fill(world: MultiWorld, +def fast_fill(multiworld: MultiWorld, item_pool: typing.List[Item], fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]: placing = min(len(item_pool), len(fill_locations)) for item, location in zip(item_pool, fill_locations): - world.push_item(location, item, False) + multiworld.push_item(location, item, False) return item_pool[placing:], fill_locations[placing:] -def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]): +def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]): maximum_exploration_state = sweep_from_pool(state, pool) - minimal_players = {player for player in world.player_ids if world.worlds[player].options.accessibility == "minimal"} - unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and + minimal_players = {player for player in multiworld.player_ids if multiworld.worlds[player].options.accessibility == "minimal"} + unreachable_locations = [location for location in multiworld.get_locations() if location.player in minimal_players and not location.can_reach(maximum_exploration_state)] for location in unreachable_locations: if (location.item is not None and location.item.advancement and location.address is not None and not @@ -304,36 +305,36 @@ def accessibility_corrections(world: MultiWorld, state: CollectionState, locatio locations.append(location) if pool and locations: locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY) - fill_restrictive(world, state, locations, pool, name="Accessibility Corrections") + fill_restrictive(multiworld, state, locations, pool, name="Accessibility Corrections") -def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations): +def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState, locations): maximum_exploration_state = sweep_from_pool(state) unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)] if unreachable_locations: def forbid_important_item_rule(item: Item): - return not ((item.classification & 0b0011) and world.worlds[item.player].options.accessibility != 'minimal') + return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != 'minimal') for location in unreachable_locations: add_item_rule(location, forbid_important_item_rule) -def distribute_early_items(world: MultiWorld, +def distribute_early_items(multiworld: MultiWorld, fill_locations: typing.List[Location], itempool: typing.List[Item]) -> typing.Tuple[typing.List[Location], typing.List[Item]]: """ returns new fill_locations and itempool """ early_items_count: typing.Dict[typing.Tuple[str, int], typing.List[int]] = {} - for player in world.player_ids: - items = itertools.chain(world.early_items[player], world.local_early_items[player]) + for player in multiworld.player_ids: + items = itertools.chain(multiworld.early_items[player], multiworld.local_early_items[player]) for item in items: - early_items_count[item, player] = [world.early_items[player].get(item, 0), - world.local_early_items[player].get(item, 0)] + early_items_count[item, player] = [multiworld.early_items[player].get(item, 0), + multiworld.local_early_items[player].get(item, 0)] if early_items_count: early_locations: typing.List[Location] = [] early_priority_locations: typing.List[Location] = [] loc_indexes_to_remove: typing.Set[int] = set() - base_state = world.state.copy() - base_state.sweep_for_events(locations=(loc for loc in world.get_filled_locations() if loc.address is None)) + base_state = multiworld.state.copy() + base_state.sweep_for_events(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None)) for i, loc in enumerate(fill_locations): if loc.can_reach(base_state): if loc.progress_type == LocationProgressType.PRIORITY: @@ -345,8 +346,8 @@ def distribute_early_items(world: MultiWorld, early_prog_items: typing.List[Item] = [] early_rest_items: typing.List[Item] = [] - early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids} - early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids} + early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in multiworld.player_ids} + early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in multiworld.player_ids} item_indexes_to_remove: typing.Set[int] = set() for i, item in enumerate(itempool): if (item.name, item.player) in early_items_count: @@ -370,28 +371,28 @@ def distribute_early_items(world: MultiWorld, if len(early_items_count) == 0: break itempool = [item for i, item in enumerate(itempool) if i not in item_indexes_to_remove] - for player in world.player_ids: + for player in multiworld.player_ids: player_local = early_local_rest_items[player] - fill_restrictive(world, base_state, + fill_restrictive(multiworld, base_state, [loc for loc in early_locations if loc.player == player], player_local, lock=True, allow_partial=True, name=f"Local Early Items P{player}") if player_local: logging.warning(f"Could not fulfill rules of early items: {player_local}") early_rest_items.extend(early_local_rest_items[player]) early_locations = [loc for loc in early_locations if not loc.item] - fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True, allow_partial=True, + fill_restrictive(multiworld, base_state, early_locations, early_rest_items, lock=True, allow_partial=True, name="Early Items") early_locations += early_priority_locations - for player in world.player_ids: + for player in multiworld.player_ids: player_local = early_local_prog_items[player] - fill_restrictive(world, base_state, + fill_restrictive(multiworld, base_state, [loc for loc in early_locations if loc.player == player], player_local, lock=True, allow_partial=True, name=f"Local Early Progression P{player}") if player_local: logging.warning(f"Could not fulfill rules of early items: {player_local}") early_prog_items.extend(player_local) early_locations = [loc for loc in early_locations if not loc.item] - fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True, allow_partial=True, + fill_restrictive(multiworld, base_state, early_locations, early_prog_items, lock=True, allow_partial=True, name="Early Progression") unplaced_early_items = early_rest_items + early_prog_items if unplaced_early_items: @@ -400,18 +401,18 @@ def distribute_early_items(world: MultiWorld, itempool += unplaced_early_items fill_locations.extend(early_locations) - world.random.shuffle(fill_locations) + multiworld.random.shuffle(fill_locations) return fill_locations, itempool -def distribute_items_restrictive(world: MultiWorld) -> None: - fill_locations = sorted(world.get_unfilled_locations()) - world.random.shuffle(fill_locations) +def distribute_items_restrictive(multiworld: MultiWorld) -> None: + fill_locations = sorted(multiworld.get_unfilled_locations()) + multiworld.random.shuffle(fill_locations) # get items to distribute - itempool = sorted(world.itempool) - world.random.shuffle(itempool) + itempool = sorted(multiworld.itempool) + multiworld.random.shuffle(itempool) - fill_locations, itempool = distribute_early_items(world, fill_locations, itempool) + fill_locations, itempool = distribute_early_items(multiworld, fill_locations, itempool) progitempool: typing.List[Item] = [] usefulitempool: typing.List[Item] = [] @@ -425,7 +426,7 @@ def distribute_items_restrictive(world: MultiWorld) -> None: else: filleritempool.append(item) - call_all(world, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations) + call_all(multiworld, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations) locations: typing.Dict[LocationProgressType, typing.List[Location]] = { loc_type: [] for loc_type in LocationProgressType} @@ -446,34 +447,34 @@ def mark_for_locking(location: Location): if prioritylocations: # "priority fill" - fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking, + fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking, name="Priority") - accessibility_corrections(world, world.state, prioritylocations, progitempool) + accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool) defaultlocations = prioritylocations + defaultlocations if progitempool: # "advancement/progression fill" - fill_restrictive(world, world.state, defaultlocations, progitempool, name="Progression") + fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, name="Progression") if progitempool: raise FillError( f'Not enough locations for progress items. There are {len(progitempool)} more items than locations') - accessibility_corrections(world, world.state, defaultlocations) + accessibility_corrections(multiworld, multiworld.state, defaultlocations) for location in lock_later: if location.item: location.locked = True del mark_for_locking, lock_later - inaccessible_location_rules(world, world.state, defaultlocations) + inaccessible_location_rules(multiworld, multiworld.state, defaultlocations) - remaining_fill(world, excludedlocations, filleritempool) + remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded") if excludedlocations: raise FillError( f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items") restitempool = filleritempool + usefulitempool - remaining_fill(world, defaultlocations, restitempool) + remaining_fill(multiworld, defaultlocations, restitempool) unplaced = restitempool unfilled = defaultlocations @@ -481,40 +482,40 @@ def mark_for_locking(location: Location): if unplaced or unfilled: logging.warning( f'Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}') - items_counter = Counter(location.item.player for location in world.get_locations() if location.item) - locations_counter = Counter(location.player for location in world.get_locations()) + items_counter = Counter(location.item.player for location in multiworld.get_locations() if location.item) + locations_counter = Counter(location.player for location in multiworld.get_locations()) items_counter.update(item.player for item in unplaced) locations_counter.update(location.player for location in unfilled) print_data = {"items": items_counter, "locations": locations_counter} logging.info(f'Per-Player counts: {print_data})') -def flood_items(world: MultiWorld) -> None: +def flood_items(multiworld: MultiWorld) -> None: # get items to distribute - world.random.shuffle(world.itempool) - itempool = world.itempool + multiworld.random.shuffle(multiworld.itempool) + itempool = multiworld.itempool progress_done = False # sweep once to pick up preplaced items - world.state.sweep_for_events() + multiworld.state.sweep_for_events() - # fill world from top of itempool while we can + # fill multiworld from top of itempool while we can while not progress_done: - location_list = world.get_unfilled_locations() - world.random.shuffle(location_list) + location_list = multiworld.get_unfilled_locations() + multiworld.random.shuffle(location_list) spot_to_fill = None for location in location_list: - if location.can_fill(world.state, itempool[0]): + if location.can_fill(multiworld.state, itempool[0]): spot_to_fill = location break if spot_to_fill: item = itempool.pop(0) - world.push_item(spot_to_fill, item, True) + multiworld.push_item(spot_to_fill, item, True) continue # ran out of spots, check if we need to step in and correct things - if len(world.get_reachable_locations()) == len(world.get_locations()): + if len(multiworld.get_reachable_locations()) == len(multiworld.get_locations()): progress_done = True continue @@ -524,7 +525,7 @@ def flood_items(world: MultiWorld) -> None: for item in itempool: if item.advancement: candidate_item_to_place = item - if world.unlocks_new_location(item): + if multiworld.unlocks_new_location(item): item_to_place = item break @@ -537,20 +538,20 @@ def flood_items(world: MultiWorld) -> None: raise FillError('No more progress items left to place.') # find item to replace with progress item - location_list = world.get_reachable_locations() - world.random.shuffle(location_list) + location_list = multiworld.get_reachable_locations() + multiworld.random.shuffle(location_list) for location in location_list: if location.item is not None and not location.item.advancement: # safe to replace replace_item = location.item replace_item.location = None itempool.append(replace_item) - world.push_item(location, item_to_place, True) + multiworld.push_item(location, item_to_place, True) itempool.remove(item_to_place) break -def balance_multiworld_progression(world: MultiWorld) -> None: +def balance_multiworld_progression(multiworld: MultiWorld) -> None: # A system to reduce situations where players have no checks remaining, popularly known as "BK mode." # Overall progression balancing algorithm: # Gather up all locations in a sphere. @@ -558,28 +559,28 @@ def balance_multiworld_progression(world: MultiWorld) -> None: # If other players are below the threshold value, swap progression in this sphere into earlier spheres, # which gives more locations available by this sphere. balanceable_players: typing.Dict[int, float] = { - player: world.worlds[player].options.progression_balancing / 100 - for player in world.player_ids - if world.worlds[player].options.progression_balancing > 0 + player: multiworld.worlds[player].options.progression_balancing / 100 + for player in multiworld.player_ids + if multiworld.worlds[player].options.progression_balancing > 0 } if not balanceable_players: logging.info('Skipping multiworld progression balancing.') else: logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.') logging.debug(balanceable_players) - state: CollectionState = CollectionState(world) + state: CollectionState = CollectionState(multiworld) checked_locations: typing.Set[Location] = set() - unchecked_locations: typing.Set[Location] = set(world.get_locations()) + unchecked_locations: typing.Set[Location] = set(multiworld.get_locations()) total_locations_count: typing.Counter[int] = Counter( location.player - for location in world.get_locations() + for location in multiworld.get_locations() if not location.locked ) reachable_locations_count: typing.Dict[int, int] = { player: 0 - for player in world.player_ids - if total_locations_count[player] and len(world.get_filled_locations(player)) != 0 + for player in multiworld.player_ids + if total_locations_count[player] and len(multiworld.get_filled_locations(player)) != 0 } balanceable_players = { player: balanceable_players[player] @@ -658,7 +659,7 @@ def item_percentage(player: int, num: int) -> float: balancing_unchecked_locations.remove(location) if not location.locked: balancing_reachables[location.player] += 1 - if world.has_beaten_game(balancing_state) or all( + if multiworld.has_beaten_game(balancing_state) or all( item_percentage(player, reachables) >= threshold_percentages[player] for player, reachables in balancing_reachables.items() if player in threshold_percentages): @@ -675,7 +676,7 @@ def item_percentage(player: int, num: int) -> float: locations_to_test = unlocked_locations[player] items_to_test = list(candidate_items[player]) items_to_test.sort() - world.random.shuffle(items_to_test) + multiworld.random.shuffle(items_to_test) while items_to_test: testing = items_to_test.pop() reducing_state = state.copy() @@ -687,8 +688,8 @@ def item_percentage(player: int, num: int) -> float: reducing_state.sweep_for_events(locations=locations_to_test) - if world.has_beaten_game(balancing_state): - if not world.has_beaten_game(reducing_state): + if multiworld.has_beaten_game(balancing_state): + if not multiworld.has_beaten_game(reducing_state): items_to_replace.append(testing) else: reduced_sphere = get_sphere_locations(reducing_state, locations_to_test) @@ -696,33 +697,32 @@ def item_percentage(player: int, num: int) -> float: if p < threshold_percentages[player]: items_to_replace.append(testing) - replaced_items = False + old_moved_item_count = moved_item_count # sort then shuffle to maintain deterministic behaviour, # while allowing use of set for better algorithm growth behaviour elsewhere replacement_locations = sorted(l for l in checked_locations if not l.event and not l.locked) - world.random.shuffle(replacement_locations) + multiworld.random.shuffle(replacement_locations) items_to_replace.sort() - world.random.shuffle(items_to_replace) + multiworld.random.shuffle(items_to_replace) # Start swapping items. Since we swap into earlier spheres, no need for accessibility checks. while replacement_locations and items_to_replace: old_location = items_to_replace.pop() - for new_location in replacement_locations: + for i, new_location in enumerate(replacement_locations): if new_location.can_fill(state, old_location.item, False) and \ old_location.can_fill(state, new_location.item, False): - replacement_locations.remove(new_location) + replacement_locations.pop(i) swap_location_item(old_location, new_location) logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, " f"displacing {old_location.item} into {old_location}") moved_item_count += 1 state.collect(new_location.item, True, new_location) - replaced_items = True break else: logging.warning(f"Could not Progression Balance {old_location.item}") - if replaced_items: + if old_moved_item_count < moved_item_count: logging.debug(f"Moved {moved_item_count} items so far\n") unlocked = {fresh for player in balancing_players for fresh in unlocked_locations[player]} for location in get_sphere_locations(state, unlocked): @@ -736,7 +736,7 @@ def item_percentage(player: int, num: int) -> float: state.collect(location.item, True, location) checked_locations |= sphere_locations - if world.has_beaten_game(state): + if multiworld.has_beaten_game(state): break elif not sphere_locations: logging.warning("Progression Balancing ran out of paths.") @@ -756,7 +756,7 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked: location_1.event, location_2.event = location_2.event, location_1.event -def distribute_planned(world: MultiWorld) -> None: +def distribute_planned(multiworld: MultiWorld) -> None: def warn(warning: str, force: typing.Union[bool, str]) -> None: if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']: logging.warning(f'{warning}') @@ -769,24 +769,24 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: else: warn(warning, force) - swept_state = world.state.copy() + swept_state = multiworld.state.copy() swept_state.sweep_for_events() - reachable = frozenset(world.get_reachable_locations(swept_state)) + reachable = frozenset(multiworld.get_reachable_locations(swept_state)) early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list) non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list) - for loc in world.get_unfilled_locations(): + for loc in multiworld.get_unfilled_locations(): if loc in reachable: early_locations[loc.player].append(loc.name) else: # not reachable with swept state non_early_locations[loc.player].append(loc.name) - world_name_lookup = world.world_name_lookup + world_name_lookup = multiworld.world_name_lookup block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str] plando_blocks: typing.List[typing.Dict[str, typing.Any]] = [] - player_ids = set(world.player_ids) + player_ids = set(multiworld.player_ids) for player in player_ids: - for block in world.plando_items[player]: + for block in multiworld.plando_items[player]: block['player'] = player if 'force' not in block: block['force'] = 'silent' @@ -800,12 +800,12 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: else: target_world = block['world'] - if target_world is False or world.players == 1: # target own world + if target_world is False or multiworld.players == 1: # target own world worlds: typing.Set[int] = {player} elif target_world is True: # target any worlds besides own - worlds = set(world.player_ids) - {player} + worlds = set(multiworld.player_ids) - {player} elif target_world is None: # target all worlds - worlds = set(world.player_ids) + worlds = set(multiworld.player_ids) elif type(target_world) == list: # list of target worlds worlds = set() for listed_world in target_world: @@ -815,9 +815,9 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: continue worlds.add(world_name_lookup[listed_world]) elif type(target_world) == int: # target world by slot number - if target_world not in range(1, world.players + 1): + if target_world not in range(1, multiworld.players + 1): failed( - f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})", + f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})", block['force']) continue worlds = {target_world} @@ -845,7 +845,7 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: item_list: typing.List[str] = [] for key, value in items.items(): if value is True: - value = world.itempool.count(world.worlds[player].create_item(key)) + value = multiworld.itempool.count(multiworld.worlds[player].create_item(key)) item_list += [key] * value items = item_list if isinstance(items, str): @@ -895,17 +895,17 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: count = block['count'] failed(f"Plando count {count} greater than locations specified", block['force']) block['count'] = len(block['locations']) - block['count']['target'] = world.random.randint(block['count']['min'], block['count']['max']) + block['count']['target'] = multiworld.random.randint(block['count']['min'], block['count']['max']) if block['count']['target'] > 0: plando_blocks.append(block) # shuffle, but then sort blocks by number of locations minus number of items, # so less-flexible blocks get priority - world.random.shuffle(plando_blocks) + multiworld.random.shuffle(plando_blocks) plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target'] if len(block['locations']) > 0 - else len(world.get_unfilled_locations(player)) - block['count']['target'])) + else len(multiworld.get_unfilled_locations(player)) - block['count']['target'])) for placement in plando_blocks: player = placement['player'] @@ -916,19 +916,19 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: maxcount = placement['count']['target'] from_pool = placement['from_pool'] - candidates = list(world.get_unfilled_locations_for_players(locations, sorted(worlds))) - world.random.shuffle(candidates) - world.random.shuffle(items) + candidates = list(multiworld.get_unfilled_locations_for_players(locations, sorted(worlds))) + multiworld.random.shuffle(candidates) + multiworld.random.shuffle(items) count = 0 err: typing.List[str] = [] successful_pairs: typing.List[typing.Tuple[Item, Location]] = [] for item_name in items: - item = world.worlds[player].create_item(item_name) + item = multiworld.worlds[player].create_item(item_name) for location in reversed(candidates): if (location.address is None) == (item.code is None): # either both None or both not None if not location.item: if location.item_rule(item): - if location.can_fill(world.state, item, False): + if location.can_fill(multiworld.state, item, False): successful_pairs.append((item, location)) candidates.remove(location) count = count + 1 @@ -946,21 +946,21 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: if count < placement['count']['min']: m = placement['count']['min'] failed( - f"Plando block failed to place {m - count} of {m} item(s) for {world.player_name[player]}, error(s): {' '.join(err)}", + f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}", placement['force']) for (item, location) in successful_pairs: - world.push_item(location, item, collect=False) + multiworld.push_item(location, item, collect=False) location.event = True # flag location to be checked during fill location.locked = True logging.debug(f"Plando placed {item} at {location}") if from_pool: try: - world.itempool.remove(item) + multiworld.itempool.remove(item) except ValueError: warn( - f"Could not remove {item} from pool for {world.player_name[player]} as it's already missing from it.", + f"Could not remove {item} from pool for {multiworld.player_name[player]} as it's already missing from it.", placement['force']) except Exception as e: raise Exception( - f"Error running plando for player {player} ({world.player_name[player]})") from e + f"Error running plando for player {player} ({multiworld.player_name[player]})") from e diff --git a/Generate.py b/Generate.py index e19a7a973f23..ecdc81833a15 100644 --- a/Generate.py +++ b/Generate.py @@ -302,7 +302,9 @@ def handle_name(name: str, player: int, name_counter: Counter): NUMBER=(number if number > 1 else ''), player=player, PLAYER=(player if player > 1 else ''))) - new_name = new_name.strip()[:16] + # Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace. + # Could cause issues for some clients that cannot handle the additional whitespace. + new_name = new_name.strip()[:16].strip() if new_name == "Archipelago": raise Exception(f"You cannot name yourself \"{new_name}\"") return new_name @@ -315,20 +317,6 @@ def prefer_int(input_data: str) -> Union[str, int]: return input_data -goals = { - 'ganon': 'ganon', - 'crystals': 'crystals', - 'bosses': 'bosses', - 'pedestal': 'pedestal', - 'ganon_pedestal': 'ganonpedestal', - 'triforce_hunt': 'triforcehunt', - 'local_triforce_hunt': 'localtriforcehunt', - 'ganon_triforce_hunt': 'ganontriforcehunt', - 'local_ganon_triforce_hunt': 'localganontriforcehunt', - 'ice_rod_hunt': 'icerodhunt', -} - - def roll_percentage(percentage: Union[int, float]) -> bool: """Roll a percentage chance. percentage is expected to be in range [0, 100]""" @@ -357,15 +345,6 @@ def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any: if options[option_key].supports_weighting: return get_choice(option_key, category_dict) return category_dict[option_key] - if game == "A Link to the Past": # TODO wow i hate this - if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode", - "triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra", - "triforce_pieces_required", "shop_shuffle", "mode", "item_pool", "item_functionality", - "boss_shuffle", "enemy_damage", "enemy_health", "timer", "countdown_start_time", - "red_clock_time", "blue_clock_time", "green_clock_time", "dungeon_counters", "shuffle_prizes", - "misery_mire_medallion", "turtle_rock_medallion", "sprite_pool", "sprite", - "random_sprite_on_event"}: - return get_choice(option_key, category_dict) raise Exception(f"Error generating meta option {option_key} for {game}.") @@ -485,120 +464,23 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b handle_option(ret, game_weights, option_key, option, plando_options) if PlandoOptions.items in plando_options: ret.plando_items = game_weights.get("plando_items", []) - if ret.game == "Minecraft" or ret.game == "Ocarina of Time": - # bad hardcoded behavior to make this work for now - ret.plando_connections = [] - if PlandoOptions.connections in plando_options: - options = game_weights.get("plando_connections", []) - for placement in options: - if roll_percentage(get_choice("percentage", placement, 100)): - ret.plando_connections.append(PlandoConnection( - get_choice("entrance", placement), - get_choice("exit", placement), - get_choice("direction", placement) - )) - elif ret.game == "A Link to the Past": + if ret.game == "A Link to the Past": roll_alttp_settings(ret, game_weights, plando_options) + if PlandoOptions.connections in plando_options: + ret.plando_connections = [] + options = game_weights.get("plando_connections", []) + for placement in options: + if roll_percentage(get_choice("percentage", placement, 100)): + ret.plando_connections.append(PlandoConnection( + get_choice("entrance", placement), + get_choice("exit", placement), + get_choice("direction", placement, "both") + )) return ret def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options): - if "dungeon_items" in weights and get_choice_legacy('dungeon_items', weights, "none") != "none": - raise Exception(f"dungeon_items key in A Link to the Past was removed, but is present in these weights as {get_choice_legacy('dungeon_items', weights, False)}.") - glitches_required = get_choice_legacy('glitches_required', weights) - if glitches_required not in [None, 'none', 'no_logic', 'overworld_glitches', 'hybrid_major_glitches', 'minor_glitches']: - logging.warning("Only NMG, OWG, HMG and No Logic supported") - glitches_required = 'none' - ret.logic = {None: 'noglitches', 'none': 'noglitches', 'no_logic': 'nologic', 'overworld_glitches': 'owglitches', - 'minor_glitches': 'minorglitches', 'hybrid_major_glitches': 'hybridglitches'}[ - glitches_required] - - ret.dark_room_logic = get_choice_legacy("dark_room_logic", weights, "lamp") - if not ret.dark_room_logic: # None/False - ret.dark_room_logic = "none" - if ret.dark_room_logic == "sconces": - ret.dark_room_logic = "torches" - if ret.dark_room_logic not in {"lamp", "torches", "none"}: - raise ValueError(f"Unknown Dark Room Logic: \"{ret.dark_room_logic}\"") - - entrance_shuffle = get_choice_legacy('entrance_shuffle', weights, 'vanilla') - if entrance_shuffle.startswith('none-'): - ret.shuffle = 'vanilla' - else: - ret.shuffle = entrance_shuffle if entrance_shuffle != 'none' else 'vanilla' - - goal = get_choice_legacy('goals', weights, 'ganon') - - ret.goal = goals[goal] - - - extra_pieces = get_choice_legacy('triforce_pieces_mode', weights, 'available') - - ret.triforce_pieces_required = LttPOptions.TriforcePieces.from_any(get_choice_legacy('triforce_pieces_required', weights, 20)) - - # sum a percentage to required - if extra_pieces == 'percentage': - percentage = max(100, float(get_choice_legacy('triforce_pieces_percentage', weights, 150))) / 100 - ret.triforce_pieces_available = int(round(ret.triforce_pieces_required * percentage, 0)) - # vanilla mode (specify how many pieces are) - elif extra_pieces == 'available': - ret.triforce_pieces_available = LttPOptions.TriforcePieces.from_any( - get_choice_legacy('triforce_pieces_available', weights, 30)) - # required pieces + fixed extra - elif extra_pieces == 'extra': - extra_pieces = max(0, int(get_choice_legacy('triforce_pieces_extra', weights, 10))) - ret.triforce_pieces_available = ret.triforce_pieces_required + extra_pieces - - # change minimum to required pieces to avoid problems - ret.triforce_pieces_available = min(max(ret.triforce_pieces_required, int(ret.triforce_pieces_available)), 90) - - ret.shop_shuffle = get_choice_legacy('shop_shuffle', weights, '') - if not ret.shop_shuffle: - ret.shop_shuffle = '' - - ret.mode = get_choice_legacy("mode", weights) - - ret.difficulty = get_choice_legacy('item_pool', weights) - - ret.item_functionality = get_choice_legacy('item_functionality', weights) - - - ret.enemy_damage = {None: 'default', - 'default': 'default', - 'shuffled': 'shuffled', - 'random': 'chaos', # to be removed - 'chaos': 'chaos', - }[get_choice_legacy('enemy_damage', weights)] - - ret.enemy_health = get_choice_legacy('enemy_health', weights) - - ret.timer = {'none': False, - None: False, - False: False, - 'timed': 'timed', - 'timed_ohko': 'timed-ohko', - 'ohko': 'ohko', - 'timed_countdown': 'timed-countdown', - 'display': 'display'}[get_choice_legacy('timer', weights, False)] - - ret.countdown_start_time = int(get_choice_legacy('countdown_start_time', weights, 10)) - ret.red_clock_time = int(get_choice_legacy('red_clock_time', weights, -2)) - ret.blue_clock_time = int(get_choice_legacy('blue_clock_time', weights, 2)) - ret.green_clock_time = int(get_choice_legacy('green_clock_time', weights, 4)) - - ret.dungeon_counters = get_choice_legacy('dungeon_counters', weights, 'default') - - ret.shuffle_prizes = get_choice_legacy('shuffle_prizes', weights, "g") - - ret.required_medallions = [get_choice_legacy("misery_mire_medallion", weights, "random"), - get_choice_legacy("turtle_rock_medallion", weights, "random")] - - for index, medallion in enumerate(ret.required_medallions): - ret.required_medallions[index] = {"ether": "Ether", "quake": "Quake", "bombos": "Bombos", "random": "random"} \ - .get(medallion.lower(), None) - if not ret.required_medallions[index]: - raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}") ret.plando_texts = {} if PlandoOptions.texts in plando_options: @@ -612,17 +494,6 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options): raise Exception(f"No text target \"{at}\" found.") ret.plando_texts[at] = str(get_choice_legacy("text", placement)) - ret.plando_connections = [] - if PlandoOptions.connections in plando_options: - options = weights.get("plando_connections", []) - for placement in options: - if roll_percentage(get_choice_legacy("percentage", placement, 100)): - ret.plando_connections.append(PlandoConnection( - get_choice_legacy("entrance", placement), - get_choice_legacy("exit", placement), - get_choice_legacy("direction", placement, "both") - )) - ret.sprite_pool = weights.get('sprite_pool', []) ret.sprite = get_choice_legacy('sprite', weights, "Link") if 'random_sprite_on_event' in weights: diff --git a/Launcher.py b/Launcher.py index 9e184bf1088d..890957958391 100644 --- a/Launcher.py +++ b/Launcher.py @@ -161,7 +161,7 @@ def launch(exe, in_terminal=False): def run_gui(): - from kvui import App, ContainerLayout, GridLayout, Button, Label + from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget from kivy.uix.image import AsyncImage from kivy.uix.relativelayout import RelativeLayout @@ -185,11 +185,16 @@ def build(self): self.container = ContainerLayout() self.grid = GridLayout(cols=2) self.container.add_widget(self.grid) - self.grid.add_widget(Label(text="General")) - self.grid.add_widget(Label(text="Clients")) - button_layout = self.grid # make buttons fill the window - - def build_button(component: Component): + self.grid.add_widget(Label(text="General", size_hint_y=None, height=40)) + self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40)) + tool_layout = ScrollBox() + tool_layout.layout.orientation = "vertical" + self.grid.add_widget(tool_layout) + client_layout = ScrollBox() + client_layout.layout.orientation = "vertical" + self.grid.add_widget(client_layout) + + def build_button(component: Component) -> Widget: """ Builds a button widget for a given component. @@ -200,31 +205,26 @@ def build_button(component: Component): None. The button is added to the parent grid layout. """ - button = Button(text=component.display_name) + button = Button(text=component.display_name, size_hint_y=None, height=40) button.component = component button.bind(on_release=self.component_action) if component.icon != "icon": image = AsyncImage(source=icon_paths[component.icon], size=(38, 38), size_hint=(None, 1), pos=(5, 0)) - box_layout = RelativeLayout() + box_layout = RelativeLayout(size_hint_y=None, height=40) box_layout.add_widget(button) box_layout.add_widget(image) - button_layout.add_widget(box_layout) - else: - button_layout.add_widget(button) + return box_layout + return button for (tool, client) in itertools.zip_longest(itertools.chain( self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()): # column 1 if tool: - build_button(tool[1]) - else: - button_layout.add_widget(Label()) + tool_layout.layout.add_widget(build_button(tool[1])) # column 2 if client: - build_button(client[1]) - else: - button_layout.add_widget(Label()) + client_layout.layout.add_widget(build_button(client[1])) return self.container diff --git a/LinksAwakeningClient.py b/LinksAwakeningClient.py index f3fc9d2cdb72..a51645feac92 100644 --- a/LinksAwakeningClient.py +++ b/LinksAwakeningClient.py @@ -348,7 +348,8 @@ async def wait_for_retroarch_connection(self): await asyncio.sleep(1.0) continue self.stop_bizhawk_spam = False - logger.info(f"Connected to Retroarch {version.decode('ascii')} running {rom_name.decode('ascii')}") + logger.info(f"Connected to Retroarch {version.decode('ascii', errors='replace')} " + f"running {rom_name.decode('ascii', errors='replace')}") return except (BlockingIOError, TimeoutError, ConnectionResetError): await asyncio.sleep(1.0) diff --git a/Main.py b/Main.py index b64650478bfe..f1d2f63692d6 100644 --- a/Main.py +++ b/Main.py @@ -30,49 +30,49 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No output_path.cached_path = args.outputpath start = time.perf_counter() - # initialize the world - world = MultiWorld(args.multi) + # initialize the multiworld + multiworld = MultiWorld(args.multi) logger = logging.getLogger() - world.set_seed(seed, args.race, str(args.outputname) if args.outputname else None) - world.plando_options = args.plando_options - - world.shuffle = args.shuffle.copy() - world.logic = args.logic.copy() - world.mode = args.mode.copy() - world.difficulty = args.difficulty.copy() - world.item_functionality = args.item_functionality.copy() - world.timer = args.timer.copy() - world.goal = args.goal.copy() - world.boss_shuffle = args.shufflebosses.copy() - world.enemy_health = args.enemy_health.copy() - world.enemy_damage = args.enemy_damage.copy() - world.beemizer_total_chance = args.beemizer_total_chance.copy() - world.beemizer_trap_chance = args.beemizer_trap_chance.copy() - world.countdown_start_time = args.countdown_start_time.copy() - world.red_clock_time = args.red_clock_time.copy() - world.blue_clock_time = args.blue_clock_time.copy() - world.green_clock_time = args.green_clock_time.copy() - world.dungeon_counters = args.dungeon_counters.copy() - world.triforce_pieces_available = args.triforce_pieces_available.copy() - world.triforce_pieces_required = args.triforce_pieces_required.copy() - world.shop_shuffle = args.shop_shuffle.copy() - world.shuffle_prizes = args.shuffle_prizes.copy() - world.sprite_pool = args.sprite_pool.copy() - world.dark_room_logic = args.dark_room_logic.copy() - world.plando_items = args.plando_items.copy() - world.plando_texts = args.plando_texts.copy() - world.plando_connections = args.plando_connections.copy() - world.required_medallions = args.required_medallions.copy() - world.game = args.game.copy() - world.player_name = args.name.copy() - world.sprite = args.sprite.copy() - world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option. - - world.set_options(args) - world.set_item_links() - world.state = CollectionState(world) - logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed) + multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None) + multiworld.plando_options = args.plando_options + + multiworld.shuffle = args.shuffle.copy() + multiworld.logic = args.logic.copy() + multiworld.mode = args.mode.copy() + multiworld.difficulty = args.difficulty.copy() + multiworld.item_functionality = args.item_functionality.copy() + multiworld.timer = args.timer.copy() + multiworld.goal = args.goal.copy() + multiworld.boss_shuffle = args.shufflebosses.copy() + multiworld.enemy_health = args.enemy_health.copy() + multiworld.enemy_damage = args.enemy_damage.copy() + multiworld.beemizer_total_chance = args.beemizer_total_chance.copy() + multiworld.beemizer_trap_chance = args.beemizer_trap_chance.copy() + multiworld.countdown_start_time = args.countdown_start_time.copy() + multiworld.red_clock_time = args.red_clock_time.copy() + multiworld.blue_clock_time = args.blue_clock_time.copy() + multiworld.green_clock_time = args.green_clock_time.copy() + multiworld.dungeon_counters = args.dungeon_counters.copy() + multiworld.triforce_pieces_available = args.triforce_pieces_available.copy() + multiworld.triforce_pieces_required = args.triforce_pieces_required.copy() + multiworld.shop_shuffle = args.shop_shuffle.copy() + multiworld.shuffle_prizes = args.shuffle_prizes.copy() + multiworld.sprite_pool = args.sprite_pool.copy() + multiworld.dark_room_logic = args.dark_room_logic.copy() + multiworld.plando_items = args.plando_items.copy() + multiworld.plando_texts = args.plando_texts.copy() + multiworld.plando_connections = args.plando_connections.copy() + multiworld.required_medallions = args.required_medallions.copy() + multiworld.game = args.game.copy() + multiworld.player_name = args.name.copy() + multiworld.sprite = args.sprite.copy() + multiworld.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option. + + multiworld.set_options(args) + multiworld.set_item_links() + multiworld.state = CollectionState(multiworld) + logger.info('Archipelago Version %s - Seed: %s\n', __version__, multiworld.seed) logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:") longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types) @@ -103,76 +103,93 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No # This assertion method should not be necessary to run if we are not outputting any multidata. if not args.skip_output: - AutoWorld.call_stage(world, "assert_generate") + AutoWorld.call_stage(multiworld, "assert_generate") - AutoWorld.call_all(world, "generate_early") + AutoWorld.call_all(multiworld, "generate_early") logger.info('') - for player in world.player_ids: - for item_name, count in world.worlds[player].options.start_inventory.value.items(): + for player in multiworld.player_ids: + for item_name, count in multiworld.worlds[player].options.start_inventory.value.items(): for _ in range(count): - world.push_precollected(world.create_item(item_name, player)) + multiworld.push_precollected(multiworld.create_item(item_name, player)) - for item_name, count in world.start_inventory_from_pool.setdefault(player, StartInventoryPool({})).value.items(): + for item_name, count in getattr(multiworld.worlds[player].options, + "start_inventory_from_pool", + StartInventoryPool({})).value.items(): for _ in range(count): - world.push_precollected(world.create_item(item_name, player)) - - logger.info('Creating World.') - AutoWorld.call_all(world, "create_regions") + multiworld.push_precollected(multiworld.create_item(item_name, player)) + # remove from_pool items also from early items handling, as starting is plenty early. + early = multiworld.early_items[player].get(item_name, 0) + if early: + multiworld.early_items[player][item_name] = max(0, early-count) + remaining_count = count-early + if remaining_count > 0: + local_early = multiworld.early_local_items[player].get(item_name, 0) + if local_early: + multiworld.early_items[player][item_name] = max(0, local_early - remaining_count) + del local_early + del early + + logger.info('Creating MultiWorld.') + AutoWorld.call_all(multiworld, "create_regions") logger.info('Creating Items.') - AutoWorld.call_all(world, "create_items") + AutoWorld.call_all(multiworld, "create_items") logger.info('Calculating Access Rules.') - for player in world.player_ids: + for player in multiworld.player_ids: # items can't be both local and non-local, prefer local - world.worlds[player].options.non_local_items.value -= world.worlds[player].options.local_items.value - world.worlds[player].options.non_local_items.value -= set(world.local_early_items[player]) + multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value + multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player]) - AutoWorld.call_all(world, "set_rules") + AutoWorld.call_all(multiworld, "set_rules") - for player in world.player_ids: - exclusion_rules(world, player, world.worlds[player].options.exclude_locations.value) - world.worlds[player].options.priority_locations.value -= world.worlds[player].options.exclude_locations.value - for location_name in world.worlds[player].options.priority_locations.value: + for player in multiworld.player_ids: + exclusion_rules(multiworld, player, multiworld.worlds[player].options.exclude_locations.value) + multiworld.worlds[player].options.priority_locations.value -= multiworld.worlds[player].options.exclude_locations.value + for location_name in multiworld.worlds[player].options.priority_locations.value: try: - location = world.get_location(location_name, player) + location = multiworld.get_location(location_name, player) except KeyError as e: # failed to find the given location. Check if it's a legitimate location - if location_name not in world.worlds[player].location_name_to_id: + if location_name not in multiworld.worlds[player].location_name_to_id: raise Exception(f"Unable to prioritize location {location_name} in player {player}'s world.") from e else: location.progress_type = LocationProgressType.PRIORITY # Set local and non-local item rules. - if world.players > 1: - locality_rules(world) + if multiworld.players > 1: + locality_rules(multiworld) else: - world.worlds[1].options.non_local_items.value = set() - world.worlds[1].options.local_items.value = set() + multiworld.worlds[1].options.non_local_items.value = set() + multiworld.worlds[1].options.local_items.value = set() - AutoWorld.call_all(world, "generate_basic") + AutoWorld.call_all(multiworld, "generate_basic") # remove starting inventory from pool items. # Because some worlds don't actually create items during create_items this has to be as late as possible. - if any(world.start_inventory_from_pool[player].value for player in world.player_ids): + if any(getattr(multiworld.worlds[player].options, "start_inventory_from_pool", None) for player in multiworld.player_ids): new_items: List[Item] = [] depletion_pool: Dict[int, Dict[str, int]] = { - player: world.start_inventory_from_pool[player].value.copy() for player in world.player_ids} + player: getattr(multiworld.worlds[player].options, + "start_inventory_from_pool", + StartInventoryPool({})).value.copy() + for player in multiworld.player_ids + } for player, items in depletion_pool.items(): - player_world: AutoWorld.World = world.worlds[player] + player_world: AutoWorld.World = multiworld.worlds[player] for count in items.values(): for _ in range(count): new_items.append(player_world.create_filler()) target: int = sum(sum(items.values()) for items in depletion_pool.values()) - for i, item in enumerate(world.itempool): + for i, item in enumerate(multiworld.itempool): if depletion_pool[item.player].get(item.name, 0): target -= 1 depletion_pool[item.player][item.name] -= 1 # quick abort if we have found all items if not target: - new_items.extend(world.itempool[i+1:]) + new_items.extend(multiworld.itempool[i+1:]) break else: new_items.append(item) @@ -182,19 +199,19 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No for player, remaining_items in depletion_pool.items(): remaining_items = {name: count for name, count in remaining_items.items() if count} if remaining_items: - raise Exception(f"{world.get_player_name(player)}" + raise Exception(f"{multiworld.get_player_name(player)}" f" is trying to remove items from their pool that don't exist: {remaining_items}") - assert len(world.itempool) == len(new_items), "Item Pool amounts should not change." - world.itempool[:] = new_items + assert len(multiworld.itempool) == len(new_items), "Item Pool amounts should not change." + multiworld.itempool[:] = new_items # temporary home for item links, should be moved out of Main - for group_id, group in world.groups.items(): + for group_id, group in multiworld.groups.items(): def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[ Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]] ]: classifications: Dict[str, int] = collections.defaultdict(int) counters = {player: {name: 0 for name in shared_pool} for player in players} - for item in world.itempool: + for item in multiworld.itempool: if item.player in counters and item.name in shared_pool: counters[item.player][item.name] += 1 classifications[item.name] |= item.classification @@ -229,13 +246,13 @@ def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[ new_item.classification |= classifications[item_name] new_itempool.append(new_item) - region = Region("Menu", group_id, world, "ItemLink") - world.regions.append(region) + region = Region("Menu", group_id, multiworld, "ItemLink") + multiworld.regions.append(region) locations = region.locations - for item in world.itempool: + for item in multiworld.itempool: count = common_item_count.get(item.player, {}).get(item.name, 0) if count: - loc = Location(group_id, f"Item Link: {item.name} -> {world.player_name[item.player]} {count}", + loc = Location(group_id, f"Item Link: {item.name} -> {multiworld.player_name[item.player]} {count}", None, region) loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \ state.has(item_name, group_id_, count_) @@ -246,10 +263,10 @@ def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[ else: new_itempool.append(item) - itemcount = len(world.itempool) - world.itempool = new_itempool + itemcount = len(multiworld.itempool) + multiworld.itempool = new_itempool - while itemcount > len(world.itempool): + while itemcount > len(multiworld.itempool): items_to_add = [] for player in group["players"]: if group["link_replacement"]: @@ -257,64 +274,64 @@ def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[ else: item_player = player if group["replacement_items"][player]: - items_to_add.append(AutoWorld.call_single(world, "create_item", item_player, + items_to_add.append(AutoWorld.call_single(multiworld, "create_item", item_player, group["replacement_items"][player])) else: - items_to_add.append(AutoWorld.call_single(world, "create_filler", item_player)) - world.random.shuffle(items_to_add) - world.itempool.extend(items_to_add[:itemcount - len(world.itempool)]) + items_to_add.append(AutoWorld.call_single(multiworld, "create_filler", item_player)) + multiworld.random.shuffle(items_to_add) + multiworld.itempool.extend(items_to_add[:itemcount - len(multiworld.itempool)]) - if any(world.item_links.values()): - world._all_state = None + if any(multiworld.item_links.values()): + multiworld._all_state = None logger.info("Running Item Plando.") - distribute_planned(world) + distribute_planned(multiworld) logger.info('Running Pre Main Fill.') - AutoWorld.call_all(world, "pre_fill") + AutoWorld.call_all(multiworld, "pre_fill") - logger.info(f'Filling the world with {len(world.itempool)} items.') + logger.info(f'Filling the multiworld with {len(multiworld.itempool)} items.') - if world.algorithm == 'flood': - flood_items(world) # different algo, biased towards early game progress items - elif world.algorithm == 'balanced': - distribute_items_restrictive(world) + if multiworld.algorithm == 'flood': + flood_items(multiworld) # different algo, biased towards early game progress items + elif multiworld.algorithm == 'balanced': + distribute_items_restrictive(multiworld) - AutoWorld.call_all(world, 'post_fill') + AutoWorld.call_all(multiworld, 'post_fill') - if world.players > 1 and not args.skip_prog_balancing: - balance_multiworld_progression(world) + if multiworld.players > 1 and not args.skip_prog_balancing: + balance_multiworld_progression(multiworld) else: logger.info("Progression balancing skipped.") # we're about to output using multithreading, so we're removing the global random state to prevent accidental use - world.random.passthrough = False + multiworld.random.passthrough = False if args.skip_output: logger.info('Done. Skipped output/spoiler generation. Total Time: %s', time.perf_counter() - start) - return world + return multiworld logger.info(f'Beginning output...') - outfilebase = 'AP_' + world.seed_name + outfilebase = 'AP_' + multiworld.seed_name output = tempfile.TemporaryDirectory() with output as temp_dir: - output_players = [player for player in world.player_ids if AutoWorld.World.generate_output.__code__ - is not world.worlds[player].generate_output.__code__] + output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__ + is not multiworld.worlds[player].generate_output.__code__] with concurrent.futures.ThreadPoolExecutor(len(output_players) + 2) as pool: - check_accessibility_task = pool.submit(world.fulfills_accessibility) + check_accessibility_task = pool.submit(multiworld.fulfills_accessibility) - output_file_futures = [pool.submit(AutoWorld.call_stage, world, "generate_output", temp_dir)] + output_file_futures = [pool.submit(AutoWorld.call_stage, multiworld, "generate_output", temp_dir)] for player in output_players: # skip starting a thread for methods that say "pass". output_file_futures.append( - pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir)) + pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir)) # collect ER hint info er_hint_data: Dict[int, Dict[int, str]] = {} - AutoWorld.call_all(world, 'extend_hint_information', er_hint_data) + AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data) def write_multidata(): import NetUtils @@ -323,38 +340,38 @@ def write_multidata(): games = {} minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions} slot_info = {} - names = [[name for player, name in sorted(world.player_name.items())]] - for slot in world.player_ids: - player_world: AutoWorld.World = world.worlds[slot] + names = [[name for player, name in sorted(multiworld.player_name.items())]] + for slot in multiworld.player_ids: + player_world: AutoWorld.World = multiworld.worlds[slot] minimum_versions["server"] = max(minimum_versions["server"], player_world.required_server_version) client_versions[slot] = player_world.required_client_version - games[slot] = world.game[slot] - slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], world.game[slot], - world.player_types[slot]) - for slot, group in world.groups.items(): - games[slot] = world.game[slot] - slot_info[slot] = NetUtils.NetworkSlot(group["name"], world.game[slot], world.player_types[slot], + games[slot] = multiworld.game[slot] + slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], multiworld.game[slot], + multiworld.player_types[slot]) + for slot, group in multiworld.groups.items(): + games[slot] = multiworld.game[slot] + slot_info[slot] = NetUtils.NetworkSlot(group["name"], multiworld.game[slot], multiworld.player_types[slot], group_members=sorted(group["players"])) precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int] - for player, world_precollected in world.precollected_items.items()} - precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))} + for player, world_precollected in multiworld.precollected_items.items()} + precollected_hints = {player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))} - for slot in world.player_ids: - slot_data[slot] = world.worlds[slot].fill_slot_data() + for slot in multiworld.player_ids: + slot_data[slot] = multiworld.worlds[slot].fill_slot_data() def precollect_hint(location): entrance = er_hint_data.get(location.player, {}).get(location.address, "") hint = NetUtils.Hint(location.item.player, location.player, location.address, location.item.code, False, entrance, location.item.flags) precollected_hints[location.player].add(hint) - if location.item.player not in world.groups: + if location.item.player not in multiworld.groups: precollected_hints[location.item.player].add(hint) else: - for player in world.groups[location.item.player]["players"]: + for player in multiworld.groups[location.item.player]["players"]: precollected_hints[player].add(hint) - locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in world.player_ids} - for location in world.get_filled_locations(): + locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids} + for location in multiworld.get_filled_locations(): if type(location.address) == int: assert location.item.code is not None, "item code None should be event, " \ "location.address should then also be None. Location: " \ @@ -364,18 +381,18 @@ def precollect_hint(location): f"{locations_data[location.player][location.address]}") locations_data[location.player][location.address] = \ location.item.code, location.item.player, location.item.flags - if location.name in world.worlds[location.player].options.start_location_hints: + if location.name in multiworld.worlds[location.player].options.start_location_hints: precollect_hint(location) - elif location.item.name in world.worlds[location.item.player].options.start_hints: + elif location.item.name in multiworld.worlds[location.item.player].options.start_hints: precollect_hint(location) - elif any([location.item.name in world.worlds[player].options.start_hints - for player in world.groups.get(location.item.player, {}).get("players", [])]): + elif any([location.item.name in multiworld.worlds[player].options.start_hints + for player in multiworld.groups.get(location.item.player, {}).get("players", [])]): precollect_hint(location) # embedded data package data_package = { game_world.game: worlds.network_data_package["games"][game_world.game] - for game_world in world.worlds.values() + for game_world in multiworld.worlds.values() } checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {} @@ -383,7 +400,7 @@ def precollect_hint(location): multidata = { "slot_data": slot_data, "slot_info": slot_info, - "connect_names": {name: (0, player) for player, name in world.player_name.items()}, + "connect_names": {name: (0, player) for player, name in multiworld.player_name.items()}, "locations": locations_data, "checks_in_area": checks_in_area, "server_options": baked_server_options, @@ -393,10 +410,10 @@ def precollect_hint(location): "version": tuple(version_tuple), "tags": ["AP"], "minimum_versions": minimum_versions, - "seed_name": world.seed_name, + "seed_name": multiworld.seed_name, "datapackage": data_package, } - AutoWorld.call_all(world, "modify_multidata", multidata) + AutoWorld.call_all(multiworld, "modify_multidata", multidata) multidata = zlib.compress(pickle.dumps(multidata), 9) @@ -406,7 +423,7 @@ def precollect_hint(location): output_file_futures.append(pool.submit(write_multidata)) if not check_accessibility_task.result(): - if not world.can_beat_game(): + if not multiworld.can_beat_game(): raise Exception("Game appears as unbeatable. Aborting.") else: logger.warning("Location Accessibility requirements not fulfilled.") @@ -419,12 +436,12 @@ def precollect_hint(location): if args.spoiler > 1: logger.info('Calculating playthrough.') - world.spoiler.create_playthrough(create_paths=args.spoiler > 2) + multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2) if args.spoiler: - world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase)) + multiworld.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase)) - zipfilename = output_path(f"AP_{world.seed_name}.zip") + zipfilename = output_path(f"AP_{multiworld.seed_name}.zip") logger.info(f"Creating final archive at {zipfilename}") with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as zf: @@ -432,4 +449,4 @@ def precollect_hint(location): zf.write(file.path, arcname=file.name) logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start) - return world + return multiworld diff --git a/ModuleUpdate.py b/ModuleUpdate.py index c33e894e8b5f..c3dc8c8a87b2 100644 --- a/ModuleUpdate.py +++ b/ModuleUpdate.py @@ -4,14 +4,29 @@ import multiprocessing import warnings -local_dir = os.path.dirname(__file__) -requirements_files = {os.path.join(local_dir, 'requirements.txt')} if sys.version_info < (3, 8, 6): raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.") # don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess) -update_ran = getattr(sys, "frozen", False) or multiprocessing.parent_process() +_skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process()) +update_ran = _skip_update + + +class RequirementsSet(set): + def add(self, e): + global update_ran + update_ran &= _skip_update + super().add(e) + + def update(self, *s): + global update_ran + update_ran &= _skip_update + super().update(*s) + + +local_dir = os.path.dirname(__file__) +requirements_files = RequirementsSet((os.path.join(local_dir, 'requirements.txt'),)) if not update_ran: for entry in os.scandir(os.path.join(local_dir, "worlds")): diff --git a/MultiServer.py b/MultiServer.py index 9d2e9b564e75..62dab3298e6b 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -656,7 +656,8 @@ def get_aliased_name(self, team: int, slot: int): else: return self.player_names[team, slot] - def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False): + def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False, + recipients: typing.Sequence[int] = None): """Send and remember hints.""" if only_new: hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]] @@ -685,12 +686,13 @@ def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: b for slot in new_hint_events: self.on_new_hint(team, slot) for slot, hint_data in concerns.items(): - clients = self.clients[team].get(slot) - if not clients: - continue - client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)] - for client in clients: - async_start(self.send_msgs(client, client_hints)) + if recipients is None or slot in recipients: + clients = self.clients[team].get(slot) + if not clients: + continue + client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)] + for client in clients: + async_start(self.send_msgs(client, client_hints)) # "events" @@ -1429,9 +1431,13 @@ def get_hints(self, input_text: str, for_location: bool = False) -> bool: hints = {hint.re_check(self.ctx, self.client.team) for hint in self.ctx.hints[self.client.team, self.client.slot]} self.ctx.hints[self.client.team, self.client.slot] = hints - self.ctx.notify_hints(self.client.team, list(hints)) + self.ctx.notify_hints(self.client.team, list(hints), recipients=(self.client.slot,)) self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. " f"You have {points_available} points.") + if hints and Utils.version_tuple < (0, 5, 0): + self.output("It was recently changed, so that the above hints are only shown to you. " + "If you meant to alert another player of an above hint, " + "please let them know of the content or to run !hint themselves.") return True elif input_text.isnumeric(): @@ -2210,25 +2216,24 @@ def parse_args() -> argparse.Namespace: async def auto_shutdown(ctx, to_cancel=None): await asyncio.sleep(ctx.auto_shutdown) + + def inactivity_shutdown(): + ctx.server.ws_server.close() + ctx.exit_event.set() + if to_cancel: + for task in to_cancel: + task.cancel() + logging.info("Shutting down due to inactivity.") + while not ctx.exit_event.is_set(): if not ctx.client_activity_timers.values(): - ctx.server.ws_server.close() - ctx.exit_event.set() - if to_cancel: - for task in to_cancel: - task.cancel() - logging.info("Shutting down due to inactivity.") + inactivity_shutdown() else: newest_activity = max(ctx.client_activity_timers.values()) delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity seconds = ctx.auto_shutdown - delta.total_seconds() if seconds < 0: - ctx.server.ws_server.close() - ctx.exit_event.set() - if to_cancel: - for task in to_cancel: - task.cancel() - logging.info("Shutting down due to inactivity.") + inactivity_shutdown() else: await asyncio.sleep(seconds) diff --git a/OoTAdjuster.py b/OoTAdjuster.py index 38ebe62e2ae1..9519b191e704 100644 --- a/OoTAdjuster.py +++ b/OoTAdjuster.py @@ -195,10 +195,10 @@ def set_icon(window): window.tk.call('wm', 'iconphoto', window._w, logo) def adjust(args): - # Create a fake world and OOTWorld to use as a base - world = MultiWorld(1) - world.per_slot_randoms = {1: random} - ootworld = OOTWorld(world, 1) + # Create a fake multiworld and OOTWorld to use as a base + multiworld = MultiWorld(1) + multiworld.per_slot_randoms = {1: random} + ootworld = OOTWorld(multiworld, 1) # Set options in the fake OOTWorld for name, option in chain(cosmetic_options.items(), sfx_options.items()): result = getattr(args, name, None) diff --git a/Options.py b/Options.py index 2e3927aae3f3..ff8ad11c5a5a 100644 --- a/Options.py +++ b/Options.py @@ -1,19 +1,18 @@ from __future__ import annotations import abc -import logging -from copy import deepcopy -from dataclasses import dataclass import functools +import logging import math import numbers import random import typing from copy import deepcopy +from dataclasses import dataclass from schema import And, Optional, Or, Schema -from Utils import get_fuzzy_results +from Utils import get_fuzzy_results, is_iterable_of_str if typing.TYPE_CHECKING: from BaseClasses import PlandoOptions @@ -59,6 +58,7 @@ def __new__(mcs, name, bases, attrs): def verify(self, *args, **kwargs) -> None: for f in verifiers: f(self, *args, **kwargs) + attrs["verify"] = verify else: assert verifiers, "class Option is supposed to implement def verify" @@ -183,6 +183,7 @@ def get_option_name(cls, value: str) -> str: class NumericOption(Option[int], numbers.Integral, abc.ABC): default = 0 + # note: some of the `typing.Any`` here is a result of unresolved issue in python standards # `int` is not a `numbers.Integral` according to the official typestubs # (even though isinstance(5, numbers.Integral) == True) @@ -598,7 +599,7 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P if isinstance(self.value, int): return from BaseClasses import PlandoOptions - if not(PlandoOptions.bosses & plando_options): + if not (PlandoOptions.bosses & plando_options): # plando is disabled but plando options were given so pull the option and change it to an int option = self.value.split(";")[-1] self.value = self.options[option] @@ -727,7 +728,7 @@ def __new__(cls, value: int) -> SpecialRange: "Consider switching to NamedRange, which supports all use-cases of SpecialRange, and more. In " "NamedRange, range_start specifies the lower end of the regular range, while special values can be " "placed anywhere (below, inside, or above the regular range).") - return super().__new__(cls, value) + return super().__new__(cls) @classmethod def weighted_range(cls, text) -> Range: @@ -765,7 +766,7 @@ class VerifyKeys(metaclass=FreezeValidKeys): value: typing.Any @classmethod - def verify_keys(cls, data: typing.List[str]): + def verify_keys(cls, data: typing.Iterable[str]) -> None: if cls.valid_keys: data = set(data) dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data) @@ -843,11 +844,11 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys): # If only unique entries are needed and input order of elements does not matter, OptionSet should be used instead. # Not a docstring so it doesn't get grabbed by the options system. - default: typing.List[typing.Any] = [] + default: typing.Union[typing.List[typing.Any], typing.Tuple[typing.Any, ...]] = () supports_weighting = False - def __init__(self, value: typing.List[typing.Any]): - self.value = deepcopy(value) + def __init__(self, value: typing.Iterable[str]): + self.value = list(deepcopy(value)) super(OptionList, self).__init__() @classmethod @@ -856,7 +857,7 @@ def from_text(cls, text: str): @classmethod def from_any(cls, data: typing.Any): - if type(data) == list: + if is_iterable_of_str(data): cls.verify_keys(data) return cls(data) return cls.from_text(str(data)) @@ -882,7 +883,7 @@ def from_text(cls, text: str): @classmethod def from_any(cls, data: typing.Any): - if isinstance(data, (list, set, frozenset)): + if is_iterable_of_str(data): cls.verify_keys(data) return cls(data) return cls.from_text(str(data)) @@ -932,7 +933,7 @@ def __new__(mcs, bases: typing.Tuple[type, ...], attrs: typing.Dict[str, typing.Any]) -> "OptionsMetaProperty": for attr_type in attrs.values(): - assert not isinstance(attr_type, AssembleOptions),\ + assert not isinstance(attr_type, AssembleOptions), \ f"Options for {name} should be type hinted on the class, not assigned" return super().__new__(mcs, name, bases, attrs) @@ -1110,6 +1111,11 @@ class PerGameCommonOptions(CommonOptions): item_links: ItemLinks +@dataclass +class DeathLinkMixin: + death_link: DeathLink + + def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True): import os diff --git a/Patch.py b/Patch.py index 113d0658c6b7..091545700059 100644 --- a/Patch.py +++ b/Patch.py @@ -8,7 +8,7 @@ import ModuleUpdate ModuleUpdate.update() -from worlds.Files import AutoPatchRegister, APDeltaPatch +from worlds.Files import AutoPatchRegister, APPatch class RomMeta(TypedDict): @@ -20,7 +20,7 @@ class RomMeta(TypedDict): def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]: auto_handler = AutoPatchRegister.get_handler(patch_file) if auto_handler: - handler: APDeltaPatch = auto_handler(patch_file) + handler: APPatch = auto_handler(patch_file) target = os.path.splitext(patch_file)[0]+handler.result_file_ending handler.patch(target) return {"server": handler.server, diff --git a/README.md b/README.md index a1e03293d587..3c3c41475bab 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,9 @@ Currently, the following games are supported: * Heretic * Landstalker: The Treasures of King Nole * Final Fantasy Mystic Quest +* TUNIC +* Kirby's Dream Land 3 +* Celeste 64 For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled diff --git a/Utils.py b/Utils.py index 5955e924322f..cea6405a38b4 100644 --- a/Utils.py +++ b/Utils.py @@ -19,14 +19,13 @@ from argparse import Namespace from settings import Settings, get_settings from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union -from yaml import load, load_all, dump, SafeLoader +from typing_extensions import TypeGuard +from yaml import load, load_all, dump try: - from yaml import CLoader as UnsafeLoader - from yaml import CDumper as Dumper + from yaml import CLoader as UnsafeLoader, CSafeLoader as SafeLoader, CDumper as Dumper except ImportError: - from yaml import Loader as UnsafeLoader - from yaml import Dumper + from yaml import Loader as UnsafeLoader, SafeLoader, Dumper if typing.TYPE_CHECKING: import tkinter @@ -779,6 +778,25 @@ def deprecate(message: str): import warnings warnings.warn(message) + +class DeprecateDict(dict): + log_message: str + should_error: bool + + def __init__(self, message, error: bool = False) -> None: + self.log_message = message + self.should_error = error + super().__init__() + + def __getitem__(self, item: Any) -> Any: + if self.should_error: + deprecate(self.log_message) + elif __debug__: + import warnings + warnings.warn(self.log_message) + return super().__getitem__(item) + + def _extend_freeze_support() -> None: """Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn.""" # upstream issue: https://github.com/python/cpython/issues/76327 @@ -852,8 +870,8 @@ def visualize_regions(root_region: Region, file_name: str, *, Example usage in Main code: from Utils import visualize_regions - for player in world.player_ids: - visualize_regions(world.get_region("Menu", player), f"{world.get_out_file_name_base(player)}.puml") + for player in multiworld.player_ids: + visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml") """ assert root_region.multiworld, "The multiworld attribute of root_region has to be filled" from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region @@ -949,3 +967,13 @@ def __bool__(self): def __len__(self): return sum(len(iterable) for iterable in self.iterable) + + +def is_iterable_of_str(obj: object) -> TypeGuard[typing.Iterable[str]]: + """ but not a `str` (because technically, `str` is `Iterable[str]`) """ + if isinstance(obj, str): + return False + if not isinstance(obj, typing.Iterable): + return False + obj_it: typing.Iterable[object] = obj + return all(isinstance(v, str) for v in obj_it) diff --git a/WebHostLib/api/generate.py b/WebHostLib/api/generate.py index 61e9164e2652..5a66d1e69331 100644 --- a/WebHostLib/api/generate.py +++ b/WebHostLib/api/generate.py @@ -20,8 +20,8 @@ def generate_api(): race = False meta_options_source = {} if 'file' in request.files: - file = request.files['file'] - options = get_yaml_data(file) + files = request.files.getlist('file') + options = get_yaml_data(files) if isinstance(options, Markup): return {"text": options.striptags()}, 400 if isinstance(options, str): diff --git a/WebHostLib/check.py b/WebHostLib/check.py index 4db2ec2ce35e..e739dda02d79 100644 --- a/WebHostLib/check.py +++ b/WebHostLib/check.py @@ -1,3 +1,4 @@ +import os import zipfile import base64 from typing import Union, Dict, Set, Tuple @@ -6,13 +7,7 @@ from markupsafe import Markup from WebHostLib import app - -banned_zip_contents = (".sfc",) - - -def allowed_file(filename): - return filename.endswith(('.txt', ".yaml", ".zip")) - +from WebHostLib.upload import allowed_options, allowed_options_extensions, banned_file from Generate import roll_settings, PlandoOptions from Utils import parse_yamls @@ -51,33 +46,41 @@ def mysterycheck(): def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]: options = {} for uploaded_file in files: - # if user does not select file, browser also - # submit an empty part without filename - if uploaded_file.filename == '': - return 'No selected file' + if banned_file(uploaded_file.filename): + return ("Uploaded data contained a rom file, which is likely to contain copyrighted material. " + "Your file was deleted.") + # If the user does not select file, the browser will still submit an empty string without a file name. + elif uploaded_file.filename == "": + return "No selected file." elif uploaded_file.filename in options: - return f'Conflicting files named {uploaded_file.filename} submitted' - elif uploaded_file and allowed_file(uploaded_file.filename): + return f"Conflicting files named {uploaded_file.filename} submitted." + elif uploaded_file and allowed_options(uploaded_file.filename): if uploaded_file.filename.endswith(".zip"): - - with zipfile.ZipFile(uploaded_file, 'r') as zfile: - infolist = zfile.infolist() - - if any(file.filename.endswith(".archipelago") for file in infolist): - return Markup("Error: Your .zip file contains an .archipelago file. " - 'Did you mean to host a game?') - - for file in infolist: - if file.filename.endswith(banned_zip_contents): - return ("Uploaded data contained a rom file, " - "which is likely to contain copyrighted material. " - "Your file was deleted.") - elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")): + if not zipfile.is_zipfile(uploaded_file): + return f"Uploaded file {uploaded_file.filename} is not a valid .zip file and cannot be opened." + + uploaded_file.seek(0) # offset from is_zipfile check + with zipfile.ZipFile(uploaded_file, "r") as zfile: + for file in zfile.infolist(): + # Remove folder pathing from str (e.g. "__MACOSX/" folder paths from archives created by macOS). + base_filename = os.path.basename(file.filename) + + if base_filename.endswith(".archipelago"): + return Markup("Error: Your .zip file contains an .archipelago file. " + 'Did you mean to host a game?') + elif base_filename.endswith(".zip"): + return "Nested .zip files inside a .zip are not supported." + elif banned_file(base_filename): + return ("Uploaded data contained a rom file, which is likely to contain copyrighted " + "material. Your file was deleted.") + # Ignore dot-files. + elif not base_filename.startswith(".") and allowed_options(base_filename): options[file.filename] = zfile.open(file, "r").read() else: options[uploaded_file.filename] = uploaded_file.read() + if not options: - return "Did not find a .yaml file to process." + return f"Did not find any valid files to process. Accepted formats: {allowed_options_extensions}" return options diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt index 654104252cec..62707d78cf1f 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -5,5 +5,5 @@ Flask-Caching>=2.1.0 Flask-Compress>=1.14 Flask-Limiter>=3.5.0 bokeh>=3.1.1; python_version <= '3.8' -bokeh>=3.2.2; python_version >= '3.9' +bokeh>=3.3.2; python_version >= '3.9' markupsafe>=2.1.3 diff --git a/WebHostLib/templates/generate.html b/WebHostLib/templates/generate.html index 33f8dbc09e6c..53d98dfae6ba 100644 --- a/WebHostLib/templates/generate.html +++ b/WebHostLib/templates/generate.html @@ -69,8 +69,8 @@

Generate Game{% if race %} (Race Mode){% endif %}

@@ -185,12 +185,12 @@

Generate Game{% if race %} (Race Mode){% endif %}

+ +
+
- -
-
diff --git a/WebHostLib/templates/hostRoom.html b/WebHostLib/templates/hostRoom.html index ba15d64acac1..2981c41452f0 100644 --- a/WebHostLib/templates/hostRoom.html +++ b/WebHostLib/templates/hostRoom.html @@ -3,6 +3,16 @@ {% block head %} Multiworld {{ room.id|suuid }} {% if should_refresh %}{% endif %} + + + + {% if room.seed.slots|length < 2 %} + + {% else %} + + {% endif %} {% endblock %} diff --git a/WebHostLib/templates/islandFooter.html b/WebHostLib/templates/islandFooter.html index 7b89c4a9e079..08cf227990b8 100644 --- a/WebHostLib/templates/islandFooter.html +++ b/WebHostLib/templates/islandFooter.html @@ -1,6 +1,6 @@ {% block footer %}