diff --git a/BaseClasses.py b/BaseClasses.py index 02d050c66761..535338b4ec75 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -202,14 +202,7 @@ def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tu self.player_types[new_id] = NetUtils.SlotType.group self._region_cache[new_id] = {} world_type = AutoWorld.AutoWorldRegister.world_types[game] - for option_key, option in world_type.option_definitions.items(): - getattr(self, option_key)[new_id] = option(option.default) - for option_key, option in Options.common_options.items(): - getattr(self, option_key)[new_id] = option(option.default) - for option_key, option in Options.per_game_common_options.items(): - getattr(self, option_key)[new_id] = option(option.default) - - self.worlds[new_id] = world_type(self, new_id) + self.worlds[new_id] = world_type.create_group(self, new_id, players) self.worlds[new_id].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id]) self.player_name[new_id] = name @@ -853,14 +846,6 @@ def can_reach(self, state: CollectionState) -> bool: state.update_reachable_regions(self.player) return self in state.reachable_regions[self.player] - def can_reach_private(self, state: CollectionState) -> bool: - for entrance in self.entrances: - if entrance.can_reach(state): - if not self in state.path: - state.path[self] = (self.name, state.path.get(entrance, None)) - return True - return False - @property def hint_text(self) -> str: return self._hint_text if self._hint_text else self.name diff --git a/Fill.py b/Fill.py index 3e0342f42cd3..7c81aed7ba93 100644 --- a/Fill.py +++ b/Fill.py @@ -753,8 +753,6 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: else: # not reachable with swept state non_early_locations[loc.player].append(loc.name) - # TODO: remove. Preferably by implementing key drop - from worlds.alttp.Regions import key_drop_data world_name_lookup = world.world_name_lookup block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str] @@ -840,12 +838,12 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: if "early_locations" in locations: locations.remove("early_locations") - for player in worlds: - locations += early_locations[player] + for target_player in worlds: + locations += early_locations[target_player] if "non_early_locations" in locations: locations.remove("non_early_locations") - for player in worlds: - locations += non_early_locations[player] + for target_player in worlds: + locations += non_early_locations[target_player] block['locations'] = locations @@ -897,10 +895,6 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: for item_name in items: item = world.worlds[player].create_item(item_name) for location in reversed(candidates): - if location in key_drop_data: - warn( - f"Can't place '{item_name}' at '{placement.location}', as key drop shuffle locations are not supported yet.") - continue if not location.item: if location.item_rule(item): if location.can_fill(world.state, item, False): diff --git a/Main.py b/Main.py index fe56dc7d9e09..48b37764a995 100644 --- a/Main.py +++ b/Main.py @@ -139,7 +139,13 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No exclusion_rules(world, player, world.exclude_locations[player].value) world.priority_locations[player].value -= world.exclude_locations[player].value for location_name in world.priority_locations[player].value: - world.get_location(location_name, player).progress_type = LocationProgressType.PRIORITY + try: + location = world.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: + 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: @@ -159,7 +165,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No for player, items in depletion_pool.items(): player_world: AutoWorld.World = world.worlds[player] for count in items.values(): - new_items.append(player_world.create_filler()) + 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): if depletion_pool[item.player].get(item.name, 0): @@ -179,6 +186,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No if remaining_items: raise Exception(f"{world.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 # temporary home for item links, should be moved out of Main @@ -392,7 +400,7 @@ def precollect_hint(location): f.write(bytes([3])) # version of format f.write(multidata) - multidata_task = pool.submit(write_multidata) + output_file_futures.append(pool.submit(write_multidata)) if not check_accessibility_task.result(): if not world.can_beat_game(): raise Exception("Game appears as unbeatable. Aborting.") @@ -400,7 +408,6 @@ def precollect_hint(location): logger.warning("Location Accessibility requirements not fulfilled.") # retrieve exceptions via .result() if they occurred. - multidata_task.result() for i, future in enumerate(concurrent.futures.as_completed(output_file_futures), start=1): if i % 10 == 0 or i == len(output_file_futures): logger.info(f'Generating output files ({i}/{len(output_file_futures)}).') diff --git a/NetUtils.py b/NetUtils.py index c31aa695104c..a2db6a2ac5c4 100644 --- a/NetUtils.py +++ b/NetUtils.py @@ -407,14 +407,22 @@ def get_remaining(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[in if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub LocationStore = _LocationStore else: - try: - import pyximport - pyximport.install() - except ImportError: - pyximport = None try: from _speedups import LocationStore + import _speedups + import os.path + if os.path.isfile("_speedups.pyx") and os.path.getctime(_speedups.__file__) < os.path.getctime("_speedups.pyx"): + warnings.warn(f"{_speedups.__file__} outdated! " + f"Please rebuild with `cythonize -b -i _speedups.pyx` or delete it!") except ImportError: - warnings.warn("_speedups not available. Falling back to pure python LocationStore. " - "Install a matching C++ compiler for your platform to compile _speedups.") - LocationStore = _LocationStore + try: + import pyximport + pyximport.install() + except ImportError: + pyximport = None + try: + from _speedups import LocationStore + except ImportError: + warnings.warn("_speedups not available. Falling back to pure python LocationStore. " + "Install a matching C++ compiler for your platform to compile _speedups.") + LocationStore = _LocationStore diff --git a/SNIClient.py b/SNIClient.py index c2fafd477314..062d7a7cbea1 100644 --- a/SNIClient.py +++ b/SNIClient.py @@ -68,12 +68,11 @@ def connect_to_snes(self, snes_options: str = "") -> bool: options = snes_options.split() num_options = len(options) - if num_options > 0: - snes_device_number = int(options[0]) - if num_options > 1: snes_address = options[0] snes_device_number = int(options[1]) + elif num_options > 0: + snes_device_number = int(options[0]) self.ctx.snes_reconnect_address = None if self.ctx.snes_connect_task: diff --git a/Starcraft2Client.py b/Starcraft2Client.py index cdcdb39a0b44..87b50d35063e 100644 --- a/Starcraft2Client.py +++ b/Starcraft2Client.py @@ -1,1049 +1,11 @@ from __future__ import annotations -import asyncio -import copy -import ctypes -import logging -import multiprocessing -import os.path -import re -import sys -import typing -import queue -import zipfile -import io -from pathlib import Path +import ModuleUpdate +ModuleUpdate.update() -# CommonClient import first to trigger ModuleUpdater -from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser -from Utils import init_logging, is_windows +from worlds.sc2wol.Client import launch +import Utils if __name__ == "__main__": - init_logging("SC2Client", exception_logger="Client") - -logger = logging.getLogger("Client") -sc2_logger = logging.getLogger("Starcraft2") - -import nest_asyncio -from worlds._sc2common import bot -from worlds._sc2common.bot.data import Race -from worlds._sc2common.bot.main import run_game -from worlds._sc2common.bot.player import Bot -from worlds.sc2wol import SC2WoLWorld -from worlds.sc2wol.Items import lookup_id_to_name, item_table, ItemData, type_flaggroups -from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET -from worlds.sc2wol.MissionTables import lookup_id_to_mission -from worlds.sc2wol.Regions import MissionInfo - -import colorama -from NetUtils import ClientStatus, NetworkItem, RawJSONtoTextParser -from MultiServer import mark_raw - -nest_asyncio.apply() -max_bonus: int = 8 -victory_modulo: int = 100 - - -class StarcraftClientProcessor(ClientCommandProcessor): - ctx: SC2Context - - def _cmd_difficulty(self, difficulty: str = "") -> bool: - """Overrides the current difficulty set for the seed. Takes the argument casual, normal, hard, or brutal""" - options = difficulty.split() - num_options = len(options) - - if num_options > 0: - difficulty_choice = options[0].lower() - if difficulty_choice == "casual": - self.ctx.difficulty_override = 0 - elif difficulty_choice == "normal": - self.ctx.difficulty_override = 1 - elif difficulty_choice == "hard": - self.ctx.difficulty_override = 2 - elif difficulty_choice == "brutal": - self.ctx.difficulty_override = 3 - else: - self.output("Unable to parse difficulty '" + options[0] + "'") - return False - - self.output("Difficulty set to " + options[0]) - return True - - else: - if self.ctx.difficulty == -1: - self.output("Please connect to a seed before checking difficulty.") - else: - self.output("Current difficulty: " + ["Casual", "Normal", "Hard", "Brutal"][self.ctx.difficulty]) - self.output("To change the difficulty, add the name of the difficulty after the command.") - return False - - def _cmd_disable_mission_check(self) -> bool: - """Disables the check to see if a mission is available to play. Meant for co-op runs where one player can play - the next mission in a chain the other player is doing.""" - self.ctx.missions_unlocked = True - sc2_logger.info("Mission check has been disabled") - return True - - def _cmd_play(self, mission_id: str = "") -> bool: - """Start a Starcraft 2 mission""" - - options = mission_id.split() - num_options = len(options) - - if num_options > 0: - mission_number = int(options[0]) - - self.ctx.play_mission(mission_number) - - else: - sc2_logger.info( - "Mission ID needs to be specified. Use /unfinished or /available to view ids for available missions.") - return False - - return True - - def _cmd_available(self) -> bool: - """Get what missions are currently available to play""" - - request_available_missions(self.ctx) - return True - - def _cmd_unfinished(self) -> bool: - """Get what missions are currently available to play and have not had all locations checked""" - - request_unfinished_missions(self.ctx) - return True - - @mark_raw - def _cmd_set_path(self, path: str = '') -> bool: - """Manually set the SC2 install directory (if the automatic detection fails).""" - if path: - os.environ["SC2PATH"] = path - is_mod_installed_correctly() - return True - else: - sc2_logger.warning("When using set_path, you must type the path to your SC2 install directory.") - return False - - def _cmd_download_data(self) -> bool: - """Download the most recent release of the necessary files for playing SC2 with - Archipelago. Will overwrite existing files.""" - if "SC2PATH" not in os.environ: - check_game_install_path() - - if os.path.exists(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt"): - with open(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt", "r") as f: - current_ver = f.read() - else: - current_ver = None - - tempzip, version = download_latest_release_zip('TheCondor07', 'Starcraft2ArchipelagoData', - current_version=current_ver, force_download=True) - - if tempzip != '': - try: - zipfile.ZipFile(tempzip).extractall(path=os.environ["SC2PATH"]) - sc2_logger.info(f"Download complete. Version {version} installed.") - with open(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt", "w") as f: - f.write(version) - finally: - os.remove(tempzip) - else: - sc2_logger.warning("Download aborted/failed. Read the log for more information.") - return False - return True - - -class SC2Context(CommonContext): - command_processor = StarcraftClientProcessor - game = "Starcraft 2 Wings of Liberty" - items_handling = 0b111 - difficulty = -1 - all_in_choice = 0 - mission_order = 0 - mission_req_table: typing.Dict[str, MissionInfo] = {} - final_mission: int = 29 - announcements = queue.Queue() - sc2_run_task: typing.Optional[asyncio.Task] = None - missions_unlocked: bool = False # allow launching missions ignoring requirements - current_tooltip = None - last_loc_list = None - difficulty_override = -1 - mission_id_to_location_ids: typing.Dict[int, typing.List[int]] = {} - last_bot: typing.Optional[ArchipelagoBot] = None - - def __init__(self, *args, **kwargs): - super(SC2Context, self).__init__(*args, **kwargs) - self.raw_text_parser = RawJSONtoTextParser(self) - - async def server_auth(self, password_requested: bool = False): - if password_requested and not self.password: - await super(SC2Context, self).server_auth(password_requested) - await self.get_username() - await self.send_connect() - - def on_package(self, cmd: str, args: dict): - if cmd in {"Connected"}: - self.difficulty = args["slot_data"]["game_difficulty"] - self.all_in_choice = args["slot_data"]["all_in_map"] - slot_req_table = args["slot_data"]["mission_req"] - # Maintaining backwards compatibility with older slot data - self.mission_req_table = { - mission: MissionInfo( - **{field: value for field, value in mission_info.items() if field in MissionInfo._fields} - ) - for mission, mission_info in slot_req_table.items() - } - self.mission_order = args["slot_data"].get("mission_order", 0) - self.final_mission = args["slot_data"].get("final_mission", 29) - - self.build_location_to_mission_mapping() - - # Looks for the required maps and mods for SC2. Runs check_game_install_path. - maps_present = is_mod_installed_correctly() - if os.path.exists(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt"): - with open(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt", "r") as f: - current_ver = f.read() - if is_mod_update_available("TheCondor07", "Starcraft2ArchipelagoData", current_ver): - sc2_logger.info("NOTICE: Update for required files found. Run /download_data to install.") - elif maps_present: - sc2_logger.warning("NOTICE: Your map files may be outdated (version number not found). " - "Run /download_data to update them.") - - - def on_print_json(self, args: dict): - # goes to this world - if "receiving" in args and self.slot_concerns_self(args["receiving"]): - relevant = True - # found in this world - elif "item" in args and self.slot_concerns_self(args["item"].player): - relevant = True - # not related - else: - relevant = False - - if relevant: - self.announcements.put(self.raw_text_parser(copy.deepcopy(args["data"]))) - - super(SC2Context, self).on_print_json(args) - - def run_gui(self): - from kvui import GameManager, HoverBehavior, ServerToolTip - from kivy.app import App - from kivy.clock import Clock - from kivy.uix.tabbedpanel import TabbedPanelItem - from kivy.uix.gridlayout import GridLayout - from kivy.lang import Builder - from kivy.uix.label import Label - from kivy.uix.button import Button - from kivy.uix.floatlayout import FloatLayout - from kivy.properties import StringProperty - - class HoverableButton(HoverBehavior, Button): - pass - - class MissionButton(HoverableButton): - tooltip_text = StringProperty("Test") - ctx: SC2Context - - def __init__(self, *args, **kwargs): - super(HoverableButton, self).__init__(*args, **kwargs) - self.layout = FloatLayout() - self.popuplabel = ServerToolTip(text=self.text) - self.layout.add_widget(self.popuplabel) - - def on_enter(self): - self.popuplabel.text = self.tooltip_text - - if self.ctx.current_tooltip: - App.get_running_app().root.remove_widget(self.ctx.current_tooltip) - - if self.tooltip_text == "": - self.ctx.current_tooltip = None - else: - App.get_running_app().root.add_widget(self.layout) - self.ctx.current_tooltip = self.layout - - def on_leave(self): - self.ctx.ui.clear_tooltip() - - @property - def ctx(self) -> CommonContext: - return App.get_running_app().ctx - - class MissionLayout(GridLayout): - pass - - class MissionCategory(GridLayout): - pass - - class SC2Manager(GameManager): - logging_pairs = [ - ("Client", "Archipelago"), - ("Starcraft2", "Starcraft2"), - ] - base_title = "Archipelago Starcraft 2 Client" - - mission_panel = None - last_checked_locations = {} - mission_id_to_button = {} - launching: typing.Union[bool, int] = False # if int -> mission ID - refresh_from_launching = True - first_check = True - ctx: SC2Context - - def __init__(self, ctx): - super().__init__(ctx) - - def clear_tooltip(self): - if self.ctx.current_tooltip: - App.get_running_app().root.remove_widget(self.ctx.current_tooltip) - - self.ctx.current_tooltip = None - - def build(self): - container = super().build() - - panel = TabbedPanelItem(text="Starcraft 2 Launcher") - self.mission_panel = panel.content = MissionLayout() - - self.tabs.add_widget(panel) - - Clock.schedule_interval(self.build_mission_table, 0.5) - - return container - - def build_mission_table(self, dt): - if (not self.launching and (not self.last_checked_locations == self.ctx.checked_locations or - not self.refresh_from_launching)) or self.first_check: - self.refresh_from_launching = True - - self.mission_panel.clear_widgets() - if self.ctx.mission_req_table: - self.last_checked_locations = self.ctx.checked_locations.copy() - self.first_check = False - - self.mission_id_to_button = {} - categories = {} - available_missions, unfinished_missions = calc_unfinished_missions(self.ctx) - - # separate missions into categories - for mission in self.ctx.mission_req_table: - if not self.ctx.mission_req_table[mission].category in categories: - categories[self.ctx.mission_req_table[mission].category] = [] - - categories[self.ctx.mission_req_table[mission].category].append(mission) - - for category in categories: - category_panel = MissionCategory() - if category.startswith('_'): - category_display_name = '' - else: - category_display_name = category - category_panel.add_widget( - Label(text=category_display_name, size_hint_y=None, height=50, outline_width=1)) - - for mission in categories[category]: - text: str = mission - tooltip: str = "" - mission_id: int = self.ctx.mission_req_table[mission].id - # Map has uncollected locations - if mission in unfinished_missions: - text = f"[color=6495ED]{text}[/color]" - elif mission in available_missions: - text = f"[color=FFFFFF]{text}[/color]" - # Map requirements not met - else: - text = f"[color=a9a9a9]{text}[/color]" - tooltip = f"Requires: " - if self.ctx.mission_req_table[mission].required_world: - tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission - 1] for - req_mission in - self.ctx.mission_req_table[mission].required_world) - - if self.ctx.mission_req_table[mission].number: - tooltip += " and " - if self.ctx.mission_req_table[mission].number: - tooltip += f"{self.ctx.mission_req_table[mission].number} missions completed" - remaining_location_names: typing.List[str] = [ - self.ctx.location_names[loc] for loc in self.ctx.locations_for_mission(mission) - if loc in self.ctx.missing_locations] - - if mission_id == self.ctx.final_mission: - if mission in available_missions: - text = f"[color=FFBC95]{mission}[/color]" - else: - text = f"[color=D0C0BE]{mission}[/color]" - if tooltip: - tooltip += "\n" - tooltip += "Final Mission" - - if remaining_location_names: - if tooltip: - tooltip += "\n" - tooltip += f"Uncollected locations:\n" - tooltip += "\n".join(remaining_location_names) - - mission_button = MissionButton(text=text, size_hint_y=None, height=50) - mission_button.tooltip_text = tooltip - mission_button.bind(on_press=self.mission_callback) - self.mission_id_to_button[mission_id] = mission_button - category_panel.add_widget(mission_button) - - category_panel.add_widget(Label(text="")) - self.mission_panel.add_widget(category_panel) - - elif self.launching: - self.refresh_from_launching = False - - self.mission_panel.clear_widgets() - self.mission_panel.add_widget(Label(text="Launching Mission: " + - lookup_id_to_mission[self.launching])) - if self.ctx.ui: - self.ctx.ui.clear_tooltip() - - def mission_callback(self, button): - if not self.launching: - mission_id: int = next(k for k, v in self.mission_id_to_button.items() if v == button) - self.ctx.play_mission(mission_id) - self.launching = mission_id - Clock.schedule_once(self.finish_launching, 10) - - def finish_launching(self, dt): - self.launching = False - - self.ui = SC2Manager(self) - self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") - import pkgutil - data = pkgutil.get_data(SC2WoLWorld.__module__, "Starcraft2.kv").decode() - Builder.load_string(data) - - async def shutdown(self): - await super(SC2Context, self).shutdown() - if self.last_bot: - self.last_bot.want_close = True - if self.sc2_run_task: - self.sc2_run_task.cancel() - - def play_mission(self, mission_id: int): - if self.missions_unlocked or \ - is_mission_available(self, mission_id): - if self.sc2_run_task: - if not self.sc2_run_task.done(): - sc2_logger.warning("Starcraft 2 Client is still running!") - self.sc2_run_task.cancel() # doesn't actually close the game, just stops the python task - if self.slot is None: - sc2_logger.warning("Launching Mission without Archipelago authentication, " - "checks will not be registered to server.") - self.sc2_run_task = asyncio.create_task(starcraft_launch(self, mission_id), - name="Starcraft 2 Launch") - else: - sc2_logger.info( - f"{lookup_id_to_mission[mission_id]} is not currently unlocked. " - f"Use /unfinished or /available to see what is available.") - - def build_location_to_mission_mapping(self): - mission_id_to_location_ids: typing.Dict[int, typing.Set[int]] = { - mission_info.id: set() for mission_info in self.mission_req_table.values() - } - - for loc in self.server_locations: - mission_id, objective = divmod(loc - SC2WOL_LOC_ID_OFFSET, victory_modulo) - mission_id_to_location_ids[mission_id].add(objective) - self.mission_id_to_location_ids = {mission_id: sorted(objectives) for mission_id, objectives in - mission_id_to_location_ids.items()} - - def locations_for_mission(self, mission: str): - mission_id: int = self.mission_req_table[mission].id - objectives = self.mission_id_to_location_ids[self.mission_req_table[mission].id] - for objective in objectives: - yield SC2WOL_LOC_ID_OFFSET + mission_id * 100 + objective - - -async def main(): - multiprocessing.freeze_support() - parser = get_base_parser() - parser.add_argument('--name', default=None, help="Slot Name to connect as.") - args = parser.parse_args() - - ctx = SC2Context(args.connect, args.password) - ctx.auth = args.name - if ctx.server_task is None: - ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") - - if gui_enabled: - ctx.run_gui() - ctx.run_cli() - - await ctx.exit_event.wait() - - await ctx.shutdown() - - -maps_table = [ - "ap_traynor01", "ap_traynor02", "ap_traynor03", - "ap_thanson01", "ap_thanson02", "ap_thanson03a", "ap_thanson03b", - "ap_ttychus01", "ap_ttychus02", "ap_ttychus03", "ap_ttychus04", "ap_ttychus05", - "ap_ttosh01", "ap_ttosh02", "ap_ttosh03a", "ap_ttosh03b", - "ap_thorner01", "ap_thorner02", "ap_thorner03", "ap_thorner04", "ap_thorner05s", - "ap_tzeratul01", "ap_tzeratul02", "ap_tzeratul03", "ap_tzeratul04", - "ap_tvalerian01", "ap_tvalerian02a", "ap_tvalerian02b", "ap_tvalerian03" -] - -wol_default_categories = [ - "Mar Sara", "Mar Sara", "Mar Sara", "Colonist", "Colonist", "Colonist", "Colonist", - "Artifact", "Artifact", "Artifact", "Artifact", "Artifact", "Covert", "Covert", "Covert", "Covert", - "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Prophecy", "Prophecy", "Prophecy", "Prophecy", - "Char", "Char", "Char", "Char" -] -wol_default_category_names = [ - "Mar Sara", "Colonist", "Artifact", "Covert", "Rebellion", "Prophecy", "Char" -] - - -def calculate_items(items: typing.List[NetworkItem]) -> typing.List[int]: - network_item: NetworkItem - accumulators: typing.List[int] = [0 for _ in type_flaggroups] - - for network_item in items: - name: str = lookup_id_to_name[network_item.item] - item_data: ItemData = item_table[name] - - # exists exactly once - if item_data.quantity == 1: - accumulators[type_flaggroups[item_data.type]] |= 1 << item_data.number - - # exists multiple times - elif item_data.type == "Upgrade": - accumulators[type_flaggroups[item_data.type]] += 1 << item_data.number - - # sum - else: - accumulators[type_flaggroups[item_data.type]] += item_data.number - - return accumulators - - -def calc_difficulty(difficulty): - if difficulty == 0: - return 'C' - elif difficulty == 1: - return 'N' - elif difficulty == 2: - return 'H' - elif difficulty == 3: - return 'B' - - return 'X' - - -async def starcraft_launch(ctx: SC2Context, mission_id: int): - sc2_logger.info(f"Launching {lookup_id_to_mission[mission_id]}. If game does not launch check log file for errors.") - - with DllDirectory(None): - run_game(bot.maps.get(maps_table[mission_id - 1]), [Bot(Race.Terran, ArchipelagoBot(ctx, mission_id), - name="Archipelago", fullscreen=True)], realtime=True) - - -class ArchipelagoBot(bot.bot_ai.BotAI): - game_running: bool = False - mission_completed: bool = False - boni: typing.List[bool] - setup_done: bool - ctx: SC2Context - mission_id: int - want_close: bool = False - can_read_game = False - - last_received_update: int = 0 - - def __init__(self, ctx: SC2Context, mission_id): - self.setup_done = False - self.ctx = ctx - self.ctx.last_bot = self - self.mission_id = mission_id - self.boni = [False for _ in range(max_bonus)] - - super(ArchipelagoBot, self).__init__() - - async def on_step(self, iteration: int): - if self.want_close: - self.want_close = False - await self._client.leave() - return - game_state = 0 - if not self.setup_done: - self.setup_done = True - start_items = calculate_items(self.ctx.items_received) - if self.ctx.difficulty_override >= 0: - difficulty = calc_difficulty(self.ctx.difficulty_override) - else: - difficulty = calc_difficulty(self.ctx.difficulty) - await self.chat_send("ArchipelagoLoad {} {} {} {} {} {} {} {} {} {} {} {} {}".format( - difficulty, - start_items[0], start_items[1], start_items[2], start_items[3], start_items[4], - start_items[5], start_items[6], start_items[7], start_items[8], start_items[9], - self.ctx.all_in_choice, start_items[10])) - self.last_received_update = len(self.ctx.items_received) - - else: - if not self.ctx.announcements.empty(): - message = self.ctx.announcements.get(timeout=1) - await self.chat_send("SendMessage " + message) - self.ctx.announcements.task_done() - - # Archipelago reads the health - for unit in self.all_own_units(): - if unit.health_max == 38281: - game_state = int(38281 - unit.health) - self.can_read_game = True - - if iteration == 160 and not game_state & 1: - await self.chat_send("SendMessage Warning: Archipelago unable to connect or has lost connection to " + - "Starcraft 2 (This is likely a map issue)") - - if self.last_received_update < len(self.ctx.items_received): - current_items = calculate_items(self.ctx.items_received) - await self.chat_send("UpdateTech {} {} {} {} {} {} {} {}".format( - current_items[0], current_items[1], current_items[2], current_items[3], current_items[4], - current_items[5], current_items[6], current_items[7])) - self.last_received_update = len(self.ctx.items_received) - - if game_state & 1: - if not self.game_running: - print("Archipelago Connected") - self.game_running = True - - if self.can_read_game: - if game_state & (1 << 1) and not self.mission_completed: - if self.mission_id != self.ctx.final_mission: - print("Mission Completed") - await self.ctx.send_msgs( - [{"cmd": 'LocationChecks', - "locations": [SC2WOL_LOC_ID_OFFSET + victory_modulo * self.mission_id]}]) - self.mission_completed = True - else: - print("Game Complete") - await self.ctx.send_msgs([{"cmd": 'StatusUpdate', "status": ClientStatus.CLIENT_GOAL}]) - self.mission_completed = True - - for x, completed in enumerate(self.boni): - if not completed and game_state & (1 << (x + 2)): - await self.ctx.send_msgs( - [{"cmd": 'LocationChecks', - "locations": [SC2WOL_LOC_ID_OFFSET + victory_modulo * self.mission_id + x + 1]}]) - self.boni[x] = True - - else: - await self.chat_send("LostConnection - Lost connection to game.") - - -def request_unfinished_missions(ctx: SC2Context): - if ctx.mission_req_table: - message = "Unfinished Missions: " - unlocks = initialize_blank_mission_dict(ctx.mission_req_table) - unfinished_locations = initialize_blank_mission_dict(ctx.mission_req_table) - - _, unfinished_missions = calc_unfinished_missions(ctx, unlocks=unlocks) - - # Removing All-In from location pool - final_mission = lookup_id_to_mission[ctx.final_mission] - if final_mission in unfinished_missions.keys(): - message = f"Final Mission Available: {final_mission}[{ctx.final_mission}]\n" + message - if unfinished_missions[final_mission] == -1: - unfinished_missions.pop(final_mission) - - message += ", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}[{ctx.mission_req_table[mission].id}] " + - mark_up_objectives( - f"[{len(unfinished_missions[mission])}/" - f"{sum(1 for _ in ctx.locations_for_mission(mission))}]", - ctx, unfinished_locations, mission) - for mission in unfinished_missions) - - if ctx.ui: - ctx.ui.log_panels['All'].on_message_markup(message) - ctx.ui.log_panels['Starcraft2'].on_message_markup(message) - else: - sc2_logger.info(message) - else: - sc2_logger.warning("No mission table found, you are likely not connected to a server.") - - -def calc_unfinished_missions(ctx: SC2Context, unlocks=None): - unfinished_missions = [] - locations_completed = [] - - if not unlocks: - unlocks = initialize_blank_mission_dict(ctx.mission_req_table) - - available_missions = calc_available_missions(ctx, unlocks) - - for name in available_missions: - objectives = set(ctx.locations_for_mission(name)) - if objectives: - objectives_completed = ctx.checked_locations & objectives - if len(objectives_completed) < len(objectives): - unfinished_missions.append(name) - locations_completed.append(objectives_completed) - - else: # infer that this is the final mission as it has no objectives - unfinished_missions.append(name) - locations_completed.append(-1) - - return available_missions, dict(zip(unfinished_missions, locations_completed)) - - -def is_mission_available(ctx: SC2Context, mission_id_to_check): - unfinished_missions = calc_available_missions(ctx) - - return any(mission_id_to_check == ctx.mission_req_table[mission].id for mission in unfinished_missions) - - -def mark_up_mission_name(ctx: SC2Context, mission, unlock_table): - """Checks if the mission is required for game completion and adds '*' to the name to mark that.""" - - if ctx.mission_req_table[mission].completion_critical: - if ctx.ui: - message = "[color=AF99EF]" + mission + "[/color]" - else: - message = "*" + mission + "*" - else: - message = mission - - if ctx.ui: - unlocks = unlock_table[mission] - - if len(unlocks) > 0: - pre_message = f"[ref={list(ctx.mission_req_table).index(mission)}|Unlocks: " - pre_message += ", ".join(f"{unlock}({ctx.mission_req_table[unlock].id})" for unlock in unlocks) - pre_message += f"]" - message = pre_message + message + "[/ref]" - - return message - - -def mark_up_objectives(message, ctx, unfinished_locations, mission): - formatted_message = message - - if ctx.ui: - locations = unfinished_locations[mission] - - pre_message = f"[ref={list(ctx.mission_req_table).index(mission) + 30}|" - pre_message += "
".join(location for location in locations) - pre_message += f"]" - formatted_message = pre_message + message + "[/ref]" - - return formatted_message - - -def request_available_missions(ctx: SC2Context): - if ctx.mission_req_table: - message = "Available Missions: " - - # Initialize mission unlock table - unlocks = initialize_blank_mission_dict(ctx.mission_req_table) - - missions = calc_available_missions(ctx, unlocks) - message += \ - ", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}" - f"[{ctx.mission_req_table[mission].id}]" - for mission in missions) - - if ctx.ui: - ctx.ui.log_panels['All'].on_message_markup(message) - ctx.ui.log_panels['Starcraft2'].on_message_markup(message) - else: - sc2_logger.info(message) - else: - sc2_logger.warning("No mission table found, you are likely not connected to a server.") - - -def calc_available_missions(ctx: SC2Context, unlocks=None): - available_missions = [] - missions_complete = 0 - - # Get number of missions completed - for loc in ctx.checked_locations: - if loc % victory_modulo == 0: - missions_complete += 1 - - for name in ctx.mission_req_table: - # Go through the required missions for each mission and fill up unlock table used later for hover-over tooltips - if unlocks: - for unlock in ctx.mission_req_table[name].required_world: - unlocks[list(ctx.mission_req_table)[unlock - 1]].append(name) - - if mission_reqs_completed(ctx, name, missions_complete): - available_missions.append(name) - - return available_missions - - -def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete: int): - """Returns a bool signifying if the mission has all requirements complete and can be done - - Arguments: - ctx -- instance of SC2Context - locations_to_check -- the mission string name to check - missions_complete -- an int of how many missions have been completed - mission_path -- a list of missions that have already been checked -""" - if len(ctx.mission_req_table[mission_name].required_world) >= 1: - # A check for when the requirements are being or'd - or_success = False - - # Loop through required missions - for req_mission in ctx.mission_req_table[mission_name].required_world: - req_success = True - - # Check if required mission has been completed - if not (ctx.mission_req_table[list(ctx.mission_req_table)[req_mission - 1]].id * - victory_modulo + SC2WOL_LOC_ID_OFFSET) in ctx.checked_locations: - if not ctx.mission_req_table[mission_name].or_requirements: - return False - else: - req_success = False - - # Grid-specific logic (to avoid long path checks and infinite recursion) - if ctx.mission_order in (3, 4): - if req_success: - return True - else: - if req_mission is ctx.mission_req_table[mission_name].required_world[-1]: - return False - else: - continue - - # Recursively check required mission to see if it's requirements are met, in case !collect has been done - # Skipping recursive check on Grid settings to speed up checks and avoid infinite recursion - if not mission_reqs_completed(ctx, list(ctx.mission_req_table)[req_mission - 1], missions_complete): - if not ctx.mission_req_table[mission_name].or_requirements: - return False - else: - req_success = False - - # If requirement check succeeded mark or as satisfied - if ctx.mission_req_table[mission_name].or_requirements and req_success: - or_success = True - - if ctx.mission_req_table[mission_name].or_requirements: - # Return false if or requirements not met - if not or_success: - return False - - # Check number of missions - if missions_complete >= ctx.mission_req_table[mission_name].number: - return True - else: - return False - else: - return True - - -def initialize_blank_mission_dict(location_table): - unlocks = {} - - for mission in list(location_table): - unlocks[mission] = [] - - return unlocks - - -def check_game_install_path() -> bool: - # First thing: go to the default location for ExecuteInfo. - # An exception for Windows is included because it's very difficult to find ~\Documents if the user moved it. - if is_windows: - # The next five lines of utterly inscrutable code are brought to you by copy-paste from Stack Overflow. - # https://stackoverflow.com/questions/6227590/finding-the-users-my-documents-path/30924555# - import ctypes.wintypes - CSIDL_PERSONAL = 5 # My Documents - SHGFP_TYPE_CURRENT = 0 # Get current, not default value - - buf = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH) - ctypes.windll.shell32.SHGetFolderPathW(None, CSIDL_PERSONAL, None, SHGFP_TYPE_CURRENT, buf) - documentspath = buf.value - einfo = str(documentspath / Path("StarCraft II\\ExecuteInfo.txt")) - else: - einfo = str(bot.paths.get_home() / Path(bot.paths.USERPATH[bot.paths.PF])) - - # Check if the file exists. - if os.path.isfile(einfo): - - # Open the file and read it, picking out the latest executable's path. - with open(einfo) as f: - content = f.read() - if content: - try: - base = re.search(r" = (.*)Versions", content).group(1) - except AttributeError: - sc2_logger.warning(f"Found {einfo}, but it was empty. Run SC2 through the Blizzard launcher, then " - f"try again.") - return False - if os.path.exists(base): - executable = bot.paths.latest_executeble(Path(base).expanduser() / "Versions") - - # Finally, check the path for an actual executable. - # If we find one, great. Set up the SC2PATH. - if os.path.isfile(executable): - sc2_logger.info(f"Found an SC2 install at {base}!") - sc2_logger.debug(f"Latest executable at {executable}.") - os.environ["SC2PATH"] = base - sc2_logger.debug(f"SC2PATH set to {base}.") - return True - else: - sc2_logger.warning(f"We may have found an SC2 install at {base}, but couldn't find {executable}.") - else: - sc2_logger.warning(f"{einfo} pointed to {base}, but we could not find an SC2 install there.") - else: - sc2_logger.warning(f"Couldn't find {einfo}. Run SC2 through the Blizzard launcher, then try again. " - f"If that fails, please run /set_path with your SC2 install directory.") - return False - - -def is_mod_installed_correctly() -> bool: - """Searches for all required files.""" - if "SC2PATH" not in os.environ: - check_game_install_path() - - mapdir = os.environ['SC2PATH'] / Path('Maps/ArchipelagoCampaign') - modfile = os.environ["SC2PATH"] / Path("Mods/Archipelago.SC2Mod") - wol_required_maps = [ - "ap_thanson01.SC2Map", "ap_thanson02.SC2Map", "ap_thanson03a.SC2Map", "ap_thanson03b.SC2Map", - "ap_thorner01.SC2Map", "ap_thorner02.SC2Map", "ap_thorner03.SC2Map", "ap_thorner04.SC2Map", "ap_thorner05s.SC2Map", - "ap_traynor01.SC2Map", "ap_traynor02.SC2Map", "ap_traynor03.SC2Map", - "ap_ttosh01.SC2Map", "ap_ttosh02.SC2Map", "ap_ttosh03a.SC2Map", "ap_ttosh03b.SC2Map", - "ap_ttychus01.SC2Map", "ap_ttychus02.SC2Map", "ap_ttychus03.SC2Map", "ap_ttychus04.SC2Map", "ap_ttychus05.SC2Map", - "ap_tvalerian01.SC2Map", "ap_tvalerian02a.SC2Map", "ap_tvalerian02b.SC2Map", "ap_tvalerian03.SC2Map", - "ap_tzeratul01.SC2Map", "ap_tzeratul02.SC2Map", "ap_tzeratul03.SC2Map", "ap_tzeratul04.SC2Map" - ] - needs_files = False - - # Check for maps. - missing_maps = [] - for mapfile in wol_required_maps: - if not os.path.isfile(mapdir / mapfile): - missing_maps.append(mapfile) - if len(missing_maps) >= 19: - sc2_logger.warning(f"All map files missing from {mapdir}.") - needs_files = True - elif len(missing_maps) > 0: - for map in missing_maps: - sc2_logger.debug(f"Missing {map} from {mapdir}.") - sc2_logger.warning(f"Missing {len(missing_maps)} map files.") - needs_files = True - else: # Must be no maps missing - sc2_logger.info(f"All maps found in {mapdir}.") - - # Check for mods. - if os.path.isfile(modfile): - sc2_logger.info(f"Archipelago mod found at {modfile}.") - else: - sc2_logger.warning(f"Archipelago mod could not be found at {modfile}.") - needs_files = True - - # Final verdict. - if needs_files: - sc2_logger.warning(f"Required files are missing. Run /download_data to acquire them.") - return False - else: - return True - - -class DllDirectory: - # Credit to Black Sliver for this code. - # More info: https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-setdlldirectoryw - _old: typing.Optional[str] = None - _new: typing.Optional[str] = None - - def __init__(self, new: typing.Optional[str]): - self._new = new - - def __enter__(self): - old = self.get() - if self.set(self._new): - self._old = old - - def __exit__(self, *args): - if self._old is not None: - self.set(self._old) - - @staticmethod - def get() -> typing.Optional[str]: - if sys.platform == "win32": - n = ctypes.windll.kernel32.GetDllDirectoryW(0, None) - buf = ctypes.create_unicode_buffer(n) - ctypes.windll.kernel32.GetDllDirectoryW(n, buf) - return buf.value - # NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific - return None - - @staticmethod - def set(s: typing.Optional[str]) -> bool: - if sys.platform == "win32": - return ctypes.windll.kernel32.SetDllDirectoryW(s) != 0 - # NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific - return False - - -def download_latest_release_zip(owner: str, repo: str, current_version: str = None, force_download=False) -> (str, str): - """Downloads the latest release of a GitHub repo to the current directory as a .zip file.""" - import requests - - headers = {"Accept": 'application/vnd.github.v3+json'} - url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest" - - r1 = requests.get(url, headers=headers) - if r1.status_code == 200: - latest_version = r1.json()["tag_name"] - sc2_logger.info(f"Latest version: {latest_version}.") - else: - sc2_logger.warning(f"Status code: {r1.status_code}") - sc2_logger.warning(f"Failed to reach GitHub. Could not find download link.") - sc2_logger.warning(f"text: {r1.text}") - return "", current_version - - if (force_download is False) and (current_version == latest_version): - sc2_logger.info("Latest version already installed.") - return "", current_version - - sc2_logger.info(f"Attempting to download version {latest_version} of {repo}.") - download_url = r1.json()["assets"][0]["browser_download_url"] - - r2 = requests.get(download_url, headers=headers) - if r2.status_code == 200 and zipfile.is_zipfile(io.BytesIO(r2.content)): - with open(f"{repo}.zip", "wb") as fh: - fh.write(r2.content) - sc2_logger.info(f"Successfully downloaded {repo}.zip.") - return f"{repo}.zip", latest_version - else: - sc2_logger.warning(f"Status code: {r2.status_code}") - sc2_logger.warning("Download failed.") - sc2_logger.warning(f"text: {r2.text}") - return "", current_version - - -def is_mod_update_available(owner: str, repo: str, current_version: str) -> bool: - import requests - - headers = {"Accept": 'application/vnd.github.v3+json'} - url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest" - - r1 = requests.get(url, headers=headers) - if r1.status_code == 200: - latest_version = r1.json()["tag_name"] - if current_version != latest_version: - return True - else: - return False - - else: - sc2_logger.warning(f"Failed to reach GitHub while checking for updates.") - sc2_logger.warning(f"Status code: {r1.status_code}") - sc2_logger.warning(f"text: {r1.text}") - return False - - -if __name__ == '__main__': - colorama.init() - asyncio.run(main()) - colorama.deinit() + Utils.init_logging("Starcraft2Client", exception_logger="Client") + launch() diff --git a/UndertaleClient.py b/UndertaleClient.py index 6419707211a6..62fbe128bdb9 100644 --- a/UndertaleClient.py +++ b/UndertaleClient.py @@ -29,31 +29,31 @@ def _cmd_resync(self): def _cmd_patch(self): """Patch the game.""" if isinstance(self.ctx, UndertaleContext): - os.makedirs(name=os.getcwd() + "\\Undertale", exist_ok=True) + os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True) self.ctx.patch_game() self.output("Patched.") def _cmd_savepath(self, directory: str): """Redirect to proper save data folder. (Use before connecting!)""" if isinstance(self.ctx, UndertaleContext): - UndertaleContext.save_game_folder = directory - self.output("Changed to the following directory: " + directory) + self.ctx.save_game_folder = directory + self.output("Changed to the following directory: " + self.ctx.save_game_folder) @mark_raw def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None): """Patch the game automatically.""" if isinstance(self.ctx, UndertaleContext): - os.makedirs(name=os.getcwd() + "\\Undertale", exist_ok=True) + os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True) tempInstall = steaminstall if not os.path.isfile(os.path.join(tempInstall, "data.win")): tempInstall = None if tempInstall is None: tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale" - if not os.path.exists("C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"): + if not os.path.exists(tempInstall): tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale" elif not os.path.exists(tempInstall): tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale" - if not os.path.exists("C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"): + if not os.path.exists(tempInstall): tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale" if not os.path.exists(tempInstall) or not os.path.exists(tempInstall) or not os.path.isfile(os.path.join(tempInstall, "data.win")): self.output("ERROR: Cannot find Undertale. Please rerun the command with the correct folder." @@ -61,8 +61,8 @@ def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None): else: for file_name in os.listdir(tempInstall): if file_name != "steam_api.dll": - shutil.copy(tempInstall+"\\"+file_name, - os.getcwd() + "\\Undertale\\" + file_name) + shutil.copy(os.path.join(tempInstall, file_name), + os.path.join(os.getcwd(), "Undertale", file_name)) self.ctx.patch_game() self.output("Patching successful!") @@ -111,13 +111,13 @@ def __init__(self, server_address, password): self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE") def patch_game(self): - with open(os.getcwd() + "/Undertale/data.win", "rb") as f: + with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "rb") as f: patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff")) - with open(os.getcwd() + "/Undertale/data.win", "wb") as f: + with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "wb") as f: f.write(patchedFile) - os.makedirs(name=os.getcwd() + "\\Undertale\\" + "Custom Sprites", exist_ok=True) - with open(os.path.expandvars(os.getcwd() + "\\Undertale\\" + "Custom Sprites\\" + - "Which Character.txt"), "w") as f: + os.makedirs(name=os.path.join(os.getcwd(), "Undertale", "Custom Sprites"), exist_ok=True) + with open(os.path.expandvars(os.path.join(os.getcwd(), "Undertale", "Custom Sprites", + "Which Character.txt")), "w") as f: f.writelines(["// Put the folder name of the sprites you want to play as, make sure it is the only " "line other than this one.\n", "frisk"]) f.close() @@ -385,7 +385,7 @@ async def multi_watcher(ctx: UndertaleContext): for root, dirs, files in os.walk(path): for file in files: if "spots.mine" in file and "Online" in ctx.tags: - with open(root + "/" + file, "r") as mine: + with open(os.path.join(root, file), "r") as mine: this_x = mine.readline() this_y = mine.readline() this_room = mine.readline() @@ -408,7 +408,7 @@ async def game_watcher(ctx: UndertaleContext): for root, dirs, files in os.walk(path): for file in files: if ".item" in file: - os.remove(root+"/"+file) + os.remove(os.path.join(root, file)) sync_msg = [{"cmd": "Sync"}] if ctx.locations_checked: sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)}) @@ -424,13 +424,13 @@ async def game_watcher(ctx: UndertaleContext): for root, dirs, files in os.walk(path): for file in files: if "DontBeMad.mad" in file: - os.remove(root+"/"+file) + os.remove(os.path.join(root, file)) if "DeathLink" in ctx.tags: await ctx.send_death() if "scout" == file: sending = [] try: - with open(root+"/"+file, "r") as f: + with open(os.path.join(root, file), "r") as f: lines = f.readlines() for l in lines: if ctx.server_locations.__contains__(int(l)+12000): @@ -438,11 +438,11 @@ async def game_watcher(ctx: UndertaleContext): finally: await ctx.send_msgs([{"cmd": "LocationScouts", "locations": sending, "create_as_hint": int(2)}]) - os.remove(root+"/"+file) + os.remove(os.path.join(root, file)) if "check.spot" in file: sending = [] try: - with open(root+"/"+file, "r") as f: + with open(os.path.join(root, file), "r") as f: lines = f.readlines() for l in lines: sending = sending+[(int(l.rstrip('\n')))+12000] @@ -451,7 +451,7 @@ async def game_watcher(ctx: UndertaleContext): if "victory" in file and str(ctx.route) in file: victory = True if ".playerspot" in file and "Online" not in ctx.tags: - os.remove(root+"/"+file) + os.remove(os.path.join(root, file)) if "victory" in file: if str(ctx.route) == "all_routes": if "neutral" in file and ctx.completed_routes["neutral"] != 1: diff --git a/Utils.py b/Utils.py index 159c6cdcb161..9ceba48299ca 100644 --- a/Utils.py +++ b/Utils.py @@ -44,7 +44,7 @@ def as_simple_string(self) -> str: return ".".join(str(item) for item in self) -__version__ = "0.4.2" +__version__ = "0.4.3" version_tuple = tuplize_version(__version__) is_linux = sys.platform.startswith("linux") @@ -359,11 +359,13 @@ def get_unique_identifier(): class RestrictedUnpickler(pickle.Unpickler): + generic_properties_module: Optional[object] + def __init__(self, *args, **kwargs): super(RestrictedUnpickler, self).__init__(*args, **kwargs) self.options_module = importlib.import_module("Options") self.net_utils_module = importlib.import_module("NetUtils") - self.generic_properties_module = importlib.import_module("worlds.generic") + self.generic_properties_module = None def find_class(self, module, name): if module == "builtins" and name in safe_builtins: @@ -373,6 +375,8 @@ def find_class(self, module, name): return getattr(self.net_utils_module, name) # Options and Plando are unpickled by WebHost -> Generate if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}: + if not self.generic_properties_module: + self.generic_properties_module = importlib.import_module("worlds.generic") return getattr(self.generic_properties_module, name) # pep 8 specifies that modules should have "all-lowercase names" (options, not Options) if module.lower().endswith("options"): @@ -572,7 +576,7 @@ def run(*args: str): zenity = which("zenity") if zenity: z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes) - selection = (f'--filename="{suggest}',) if suggest else () + selection = (f"--filename={suggest}",) if suggest else () return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) # fall back to tk @@ -584,7 +588,10 @@ def run(*args: str): f'This attempt was made because open_filename was used for "{title}".') raise e else: - root = tkinter.Tk() + try: + root = tkinter.Tk() + except tkinter.TclError: + return None # GUI not available. None is the same as a user clicking "cancel" root.withdraw() return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes), initialfile=suggest or None) @@ -597,13 +604,14 @@ def run(*args: str): if is_linux: # prefer native dialog from shutil import which - kdialog = None#which("kdialog") + kdialog = which("kdialog") if kdialog: - return run(kdialog, f"--title={title}", "--getexistingdirectory", suggest or ".") - zenity = None#which("zenity") + return run(kdialog, f"--title={title}", "--getexistingdirectory", + os.path.abspath(suggest) if suggest else ".") + zenity = which("zenity") if zenity: z_filters = ("--directory",) - selection = (f'--filename="{suggest}',) if suggest else () + selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else () return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) # fall back to tk @@ -615,7 +623,10 @@ def run(*args: str): f'This attempt was made because open_filename was used for "{title}".') raise e else: - root = tkinter.Tk() + try: + root = tkinter.Tk() + except tkinter.TclError: + return None # GUI not available. None is the same as a user clicking "cancel" root.withdraw() return tkinter.filedialog.askdirectory(title=title, mustexist=True, initialdir=suggest or None) diff --git a/WebHost.py b/WebHost.py index 45d017cf1f67..8595fa7a27a4 100644 --- a/WebHost.py +++ b/WebHost.py @@ -13,15 +13,6 @@ import settings Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8 - -from WebHostLib import register, app as raw_app -from waitress import serve - -from WebHostLib.models import db -from WebHostLib.autolauncher import autohost, autogen -from WebHostLib.lttpsprites import update_sprites_lttp -from WebHostLib.options import create as create_options_files - settings.no_gui = True configpath = os.path.abspath("config.yaml") if not os.path.exists(configpath): # fall back to config.yaml in home @@ -29,6 +20,9 @@ def get_app(): + from WebHostLib import register, cache, app as raw_app + from WebHostLib.models import db + register() app = raw_app if os.path.exists(configpath) and not app.config["TESTING"]: @@ -40,6 +34,7 @@ def get_app(): app.config["HOST_ADDRESS"] = Utils.get_public_ipv4() logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}") + cache.init_app(app) db.bind(**app.config["PONY"]) db.generate_mapping(create_tables=True) return app @@ -120,6 +115,11 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]] multiprocessing.freeze_support() multiprocessing.set_start_method('spawn') logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO) + + from WebHostLib.lttpsprites import update_sprites_lttp + from WebHostLib.autolauncher import autohost, autogen + from WebHostLib.options import create as create_options_files + try: update_sprites_lttp() except Exception as e: @@ -136,4 +136,5 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]] if app.config["DEBUG"]: app.run(debug=True, port=app.config["PORT"]) else: + from waitress import serve serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"]) diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py index a59e3aa553f3..441f3272fd3c 100644 --- a/WebHostLib/__init__.py +++ b/WebHostLib/__init__.py @@ -49,11 +49,11 @@ 'create_db': True } app.config["MAX_ROLL"] = 20 -app.config["CACHE_TYPE"] = "flask_caching.backends.SimpleCache" +app.config["CACHE_TYPE"] = "SimpleCache" app.config["JSON_AS_ASCII"] = False app.config["HOST_ADDRESS"] = "" -cache = Cache(app) +cache = Cache() Compress(app) diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py index 0475a6329727..90838671200c 100644 --- a/WebHostLib/autolauncher.py +++ b/WebHostLib/autolauncher.py @@ -3,8 +3,6 @@ import json import logging import multiprocessing -import os -import sys import threading import time import typing @@ -13,55 +11,7 @@ from pony.orm import db_session, select, commit from Utils import restricted_loads - - -class CommonLocker(): - """Uses a file lock to signal that something is already running""" - lock_folder = "file_locks" - - def __init__(self, lockname: str, folder=None): - if folder: - self.lock_folder = folder - os.makedirs(self.lock_folder, exist_ok=True) - self.lockname = lockname - self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck") - - -class AlreadyRunningException(Exception): - pass - - -if sys.platform == 'win32': - class Locker(CommonLocker): - def __enter__(self): - try: - if os.path.exists(self.lockfile): - os.unlink(self.lockfile) - self.fp = os.open( - self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR) - except OSError as e: - raise AlreadyRunningException() from e - - def __exit__(self, _type, value, tb): - fp = getattr(self, "fp", None) - if fp: - os.close(self.fp) - os.unlink(self.lockfile) -else: # unix - import fcntl - - - class Locker(CommonLocker): - def __enter__(self): - try: - self.fp = open(self.lockfile, "wb") - fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) - except OSError as e: - raise AlreadyRunningException() from e - - def __exit__(self, _type, value, tb): - fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN) - self.fp.close() +from .locker import Locker, AlreadyRunningException def launch_room(room: Room, config: dict): diff --git a/WebHostLib/check.py b/WebHostLib/check.py index 0c1e090dbe47..c5dfd9f55693 100644 --- a/WebHostLib/check.py +++ b/WebHostLib/check.py @@ -24,8 +24,8 @@ def check(): if 'file' not in request.files: flash('No file part') else: - file = request.files['file'] - options = get_yaml_data(file) + files = request.files.getlist('file') + options = get_yaml_data(files) if isinstance(options, str): flash(options) else: @@ -39,30 +39,33 @@ def mysterycheck(): return redirect(url_for("check"), 301) -def get_yaml_data(file) -> Union[Dict[str, str], str, Markup]: +def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]: options = {} - # if user does not select file, browser also - # submit an empty part without filename - if file.filename == '': - return 'No selected file' - elif file and allowed_file(file.filename): - if file.filename.endswith(".zip"): - - with zipfile.ZipFile(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")): - options[file.filename] = zfile.open(file, "r").read() - else: - options = {file.filename: file.read()} + for file in files: + # if user does not select file, browser also + # submit an empty part without filename + if file.filename == '': + return 'No selected file' + elif file.filename in options: + return f'Conflicting files named {file.filename} submitted' + elif file and allowed_file(file.filename): + if file.filename.endswith(".zip"): + + with zipfile.ZipFile(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")): + options[file.filename] = zfile.open(file, "r").read() + else: + options[file.filename] = file.read() if not options: return "Did not find a .yaml file to process." return options diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py index 8fbf692dec20..6d633314b2be 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -11,6 +11,7 @@ import threading import time import typing +import sys import websockets from pony.orm import commit, db_session, select @@ -19,6 +20,7 @@ from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert from Utils import restricted_loads, cache_argsless +from .locker import Locker from .models import Command, GameDataPackage, Room, db @@ -163,16 +165,21 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict, db.generate_mapping(check_tables=False) async def main(): + if "worlds" in sys.modules: + raise Exception("Worlds system should not be loaded in the custom server.") + + import gc Utils.init_logging(str(room_id), write_mode="a") ctx = WebHostContext(static_server_data) ctx.load(room_id) ctx.init_save() ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None + gc.collect() # free intermediate objects used during setup try: ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context) await ctx.server - except Exception: # likely port in use - in windows this is OSError, but I didn't check the others + except OSError: # likely port in use ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context) await ctx.server @@ -198,16 +205,15 @@ async def main(): await ctx.shutdown_task logging.info("Shutting down") - from .autolauncher import Locker with Locker(room_id): try: asyncio.run(main()) - except KeyboardInterrupt: + except (KeyboardInterrupt, SystemExit): with db_session: room = Room.get(id=room_id) # ensure the Room does not spin up again on its own, minute of safety buffer room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout) - except: + except Exception: with db_session: room = Room.get(id=room_id) room.last_port = -1 diff --git a/WebHostLib/generate.py b/WebHostLib/generate.py index 91d7594a1f23..ddcc5ffb6c7b 100644 --- a/WebHostLib/generate.py +++ b/WebHostLib/generate.py @@ -64,8 +64,8 @@ def generate(race=False): if 'file' not in request.files: flash('No file part') else: - file = request.files['file'] - options = get_yaml_data(file) + files = request.files.getlist('file') + options = get_yaml_data(files) if isinstance(options, str): flash(options) else: diff --git a/WebHostLib/locker.py b/WebHostLib/locker.py new file mode 100644 index 000000000000..5293352887d3 --- /dev/null +++ b/WebHostLib/locker.py @@ -0,0 +1,51 @@ +import os +import sys + + +class CommonLocker: + """Uses a file lock to signal that something is already running""" + lock_folder = "file_locks" + + def __init__(self, lockname: str, folder=None): + if folder: + self.lock_folder = folder + os.makedirs(self.lock_folder, exist_ok=True) + self.lockname = lockname + self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck") + + +class AlreadyRunningException(Exception): + pass + + +if sys.platform == 'win32': + class Locker(CommonLocker): + def __enter__(self): + try: + if os.path.exists(self.lockfile): + os.unlink(self.lockfile) + self.fp = os.open( + self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR) + except OSError as e: + raise AlreadyRunningException() from e + + def __exit__(self, _type, value, tb): + fp = getattr(self, "fp", None) + if fp: + os.close(self.fp) + os.unlink(self.lockfile) +else: # unix + import fcntl + + + class Locker(CommonLocker): + def __enter__(self): + try: + self.fp = open(self.lockfile, "wb") + fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as e: + raise AlreadyRunningException() from e + + def __exit__(self, _type, value, tb): + fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN) + self.fp.close() diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt index a8b2865aae34..a3695e338301 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -3,7 +3,8 @@ pony>=0.7.16; python_version <= '3.10' pony @ https://github.com/Berserker66/pony/releases/download/v0.7.16/pony-0.7.16-py3-none-any.whl#0.7.16 ; python_version >= '3.11' waitress>=2.1.2 Flask-Caching>=2.0.2 -Flask-Compress>=1.13 -Flask-Limiter>=3.3.0 -bokeh>=3.1.1 +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' markupsafe>=2.1.3 diff --git a/WebHostLib/static/assets/supportedGames.js b/WebHostLib/static/assets/supportedGames.js new file mode 100644 index 000000000000..1acf0e0cc5ac --- /dev/null +++ b/WebHostLib/static/assets/supportedGames.js @@ -0,0 +1,83 @@ +window.addEventListener('load', () => { + const gameHeaders = document.getElementsByClassName('collapse-toggle'); + Array.from(gameHeaders).forEach((header) => { + const gameName = header.getAttribute('data-game'); + header.addEventListener('click', () => { + const gameArrow = document.getElementById(`${gameName}-arrow`); + const gameInfo = document.getElementById(gameName); + if (gameInfo.classList.contains('collapsed')) { + gameArrow.innerText = '▼'; + gameInfo.classList.remove('collapsed'); + } else { + gameArrow.innerText = '▶'; + gameInfo.classList.add('collapsed'); + } + }); + }); + + // Handle game filter input + const gameSearch = document.getElementById('game-search'); + gameSearch.value = ''; + + gameSearch.addEventListener('input', (evt) => { + if (!evt.target.value.trim()) { + // If input is empty, display all collapsed games + return Array.from(gameHeaders).forEach((header) => { + header.style.display = null; + const gameName = header.getAttribute('data-game'); + document.getElementById(`${gameName}-arrow`).innerText = '▶'; + document.getElementById(gameName).classList.add('collapsed'); + }); + } + + // Loop over all the games + Array.from(gameHeaders).forEach((header) => { + const gameName = header.getAttribute('data-game'); + const gameArrow = document.getElementById(`${gameName}-arrow`); + const gameInfo = document.getElementById(gameName); + + // If the game name includes the search string, display the game. If not, hide it + if (gameName.toLowerCase().includes(evt.target.value.toLowerCase())) { + header.style.display = null; + gameArrow.innerText = '▼'; + gameInfo.classList.remove('collapsed'); + } else { + console.log(header); + header.style.display = 'none'; + gameArrow.innerText = '▶'; + gameInfo.classList.add('collapsed'); + } + }); + }); + + document.getElementById('expand-all').addEventListener('click', expandAll); + document.getElementById('collapse-all').addEventListener('click', collapseAll); +}); + +const expandAll = () => { + const gameHeaders = document.getElementsByClassName('collapse-toggle'); + // Loop over all the games + Array.from(gameHeaders).forEach((header) => { + const gameName = header.getAttribute('data-game'); + const gameArrow = document.getElementById(`${gameName}-arrow`); + const gameInfo = document.getElementById(gameName); + + if (header.style.display === 'none') { return; } + gameArrow.innerText = '▼'; + gameInfo.classList.remove('collapsed'); + }); +}; + +const collapseAll = () => { + const gameHeaders = document.getElementsByClassName('collapse-toggle'); + // Loop over all the games + Array.from(gameHeaders).forEach((header) => { + const gameName = header.getAttribute('data-game'); + const gameArrow = document.getElementById(`${gameName}-arrow`); + const gameInfo = document.getElementById(gameName); + + if (header.style.display === 'none') { return; } + gameArrow.innerText = '▶'; + gameInfo.classList.add('collapsed'); + }); +}; diff --git a/WebHostLib/static/assets/trackerCommon.js b/WebHostLib/static/assets/trackerCommon.js index c08590cbf7db..41c4020dace8 100644 --- a/WebHostLib/static/assets/trackerCommon.js +++ b/WebHostLib/static/assets/trackerCommon.js @@ -14,6 +14,17 @@ const adjustTableHeight = () => { } }; +/** + * Convert an integer number of seconds into a human readable HH:MM format + * @param {Number} seconds + * @returns {string} + */ +const secondsToHours = (seconds) => { + let hours = Math.floor(seconds / 3600); + let minutes = Math.floor((seconds - (hours * 3600)) / 60).toString().padStart(2, '0'); + return `${hours}:${minutes}`; +}; + window.addEventListener('load', () => { const tables = $(".table").DataTable({ paging: false, @@ -27,7 +38,18 @@ window.addEventListener('load', () => { stateLoadCallback: function(settings) { return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`)); }, + footerCallback: function(tfoot, data, start, end, display) { + if (tfoot) { + const activityData = this.api().column('lastActivity:name').data().toArray().filter(x => !isNaN(x)); + Array.from(tfoot?.children).find(td => td.classList.contains('last-activity')).innerText = + (activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None'; + } + }, columnDefs: [ + { + targets: 'last-activity', + name: 'lastActivity' + }, { targets: 'hours', render: function (data, type, row) { @@ -40,11 +62,7 @@ window.addEventListener('load', () => { if (data === "None") return data; - let hours = Math.floor(data / 3600); - let minutes = Math.floor((data - (hours * 3600)) / 60); - - if (minutes < 10) {minutes = "0"+minutes;} - return hours+':'+minutes; + return secondsToHours(data); } }, { @@ -114,11 +132,16 @@ window.addEventListener('load', () => { if (status === "success") { target.find(".table").each(function (i, new_table) { const new_trs = $(new_table).find("tbody>tr"); + const footer_tr = $(new_table).find("tfoot>tr"); const old_table = tables.eq(i); const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop(); const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft(); old_table.clear(); - old_table.rows.add(new_trs).draw(); + if (footer_tr.length) { + $(old_table.table).find("tfoot").html(footer_tr); + } + old_table.rows.add(new_trs); + old_table.draw(); $(old_table.settings()[0].nScrollBody).scrollTop(topscroll); $(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll); }); diff --git a/WebHostLib/static/assets/weighted-settings.js b/WebHostLib/static/assets/weighted-settings.js index 6e86d470f05c..fb7d3a349b2d 100644 --- a/WebHostLib/static/assets/weighted-settings.js +++ b/WebHostLib/static/assets/weighted-settings.js @@ -160,6 +160,7 @@ const buildUI = (settingData) => { weightedSettingsDiv.classList.add('invisible'); itemPoolDiv.classList.add('invisible'); hintsDiv.classList.add('invisible'); + locationsDiv.classList.add('invisible'); expandButton.classList.remove('invisible'); }); @@ -168,6 +169,7 @@ const buildUI = (settingData) => { weightedSettingsDiv.classList.remove('invisible'); itemPoolDiv.classList.remove('invisible'); hintsDiv.classList.remove('invisible'); + locationsDiv.classList.remove('invisible'); expandButton.classList.add('invisible'); }); }); @@ -1134,8 +1136,8 @@ const validateSettings = () => { return; } - // Remove any disabled options Object.keys(settings[game]).forEach((setting) => { + // Remove any disabled options Object.keys(settings[game][setting]).forEach((option) => { if (settings[game][setting][option] === 0) { delete settings[game][setting][option]; @@ -1149,6 +1151,32 @@ const validateSettings = () => { ) { errorMessage = `${game} // ${setting} has no values above zero!`; } + + // Remove weights from options with only one possibility + if ( + Object.keys(settings[game][setting]).length === 1 && + !Array.isArray(settings[game][setting]) && + setting !== 'start_inventory' + ) { + settings[game][setting] = Object.keys(settings[game][setting])[0]; + } + + // Remove empty arrays + else if ( + ['exclude_locations', 'priority_locations', 'local_items', + 'non_local_items', 'start_hints', 'start_location_hints'].includes(setting) && + settings[game][setting].length === 0 + ) { + delete settings[game][setting]; + } + + // Remove empty start inventory + else if ( + setting === 'start_inventory' && + Object.keys(settings[game]['start_inventory']).length === 0 + ) { + delete settings[game]['start_inventory']; + } }); }); @@ -1156,6 +1184,11 @@ const validateSettings = () => { errorMessage = 'You have not chosen a game to play!'; } + // Remove weights if there is only one game + else if (Object.keys(settings.game).length === 1) { + settings.game = Object.keys(settings.game)[0]; + } + // If an error occurred, alert the user and do not export the file if (errorMessage) { userMessage.innerText = errorMessage; diff --git a/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L1.png b/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L1.png new file mode 100644 index 000000000000..8fb366b93ff0 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L1.png differ diff --git a/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L2.png b/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L2.png new file mode 100644 index 000000000000..336dc5f77af2 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L2.png differ diff --git a/WebHostLib/static/static/icons/sc2/advanceballistics.png b/WebHostLib/static/static/icons/sc2/advanceballistics.png new file mode 100644 index 000000000000..1bf7df9fb74c Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/advanceballistics.png differ diff --git a/WebHostLib/static/static/icons/sc2/autoturretblackops.png b/WebHostLib/static/static/icons/sc2/autoturretblackops.png new file mode 100644 index 000000000000..552707831a00 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/autoturretblackops.png differ diff --git a/WebHostLib/static/static/icons/sc2/biomechanicaldrone.png b/WebHostLib/static/static/icons/sc2/biomechanicaldrone.png new file mode 100644 index 000000000000..e7ebf4031619 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/biomechanicaldrone.png differ diff --git a/WebHostLib/static/static/icons/sc2/burstcapacitors.png b/WebHostLib/static/static/icons/sc2/burstcapacitors.png new file mode 100644 index 000000000000..3af9b20a1698 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/burstcapacitors.png differ diff --git a/WebHostLib/static/static/icons/sc2/crossspectrumdampeners.png b/WebHostLib/static/static/icons/sc2/crossspectrumdampeners.png new file mode 100644 index 000000000000..d1c0c6c9a010 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/crossspectrumdampeners.png differ diff --git a/WebHostLib/static/static/icons/sc2/cyclone.png b/WebHostLib/static/static/icons/sc2/cyclone.png new file mode 100644 index 000000000000..d2016116ea3b Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/cyclone.png differ diff --git a/WebHostLib/static/static/icons/sc2/cyclonerangeupgrade.png b/WebHostLib/static/static/icons/sc2/cyclonerangeupgrade.png new file mode 100644 index 000000000000..351be570d11b Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/cyclonerangeupgrade.png differ diff --git a/WebHostLib/static/static/icons/sc2/drillingclaws.png b/WebHostLib/static/static/icons/sc2/drillingclaws.png new file mode 100644 index 000000000000..2b067a6e44d4 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/drillingclaws.png differ diff --git a/WebHostLib/static/static/icons/sc2/emergencythrusters.png b/WebHostLib/static/static/icons/sc2/emergencythrusters.png new file mode 100644 index 000000000000..159fba37c903 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/emergencythrusters.png differ diff --git a/WebHostLib/static/static/icons/sc2/hellionbattlemode.png b/WebHostLib/static/static/icons/sc2/hellionbattlemode.png new file mode 100644 index 000000000000..56bfd98c924c Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/hellionbattlemode.png differ diff --git a/WebHostLib/static/static/icons/sc2/high-explosive-spidermine.png b/WebHostLib/static/static/icons/sc2/high-explosive-spidermine.png new file mode 100644 index 000000000000..40a5991ebb80 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/high-explosive-spidermine.png differ diff --git a/WebHostLib/static/static/icons/sc2/hyperflightrotors.png b/WebHostLib/static/static/icons/sc2/hyperflightrotors.png new file mode 100644 index 000000000000..375325845876 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/hyperflightrotors.png differ diff --git a/WebHostLib/static/static/icons/sc2/hyperfluxor.png b/WebHostLib/static/static/icons/sc2/hyperfluxor.png new file mode 100644 index 000000000000..cdd95bb515be Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/hyperfluxor.png differ diff --git a/WebHostLib/static/static/icons/sc2/impalerrounds.png b/WebHostLib/static/static/icons/sc2/impalerrounds.png new file mode 100644 index 000000000000..b00e0c475827 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/impalerrounds.png differ diff --git a/WebHostLib/static/static/icons/sc2/improvedburstlaser.png b/WebHostLib/static/static/icons/sc2/improvedburstlaser.png new file mode 100644 index 000000000000..8a48e38e874d Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/improvedburstlaser.png differ diff --git a/WebHostLib/static/static/icons/sc2/improvedsiegemode.png b/WebHostLib/static/static/icons/sc2/improvedsiegemode.png new file mode 100644 index 000000000000..f19dad952bb5 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/improvedsiegemode.png differ diff --git a/WebHostLib/static/static/icons/sc2/interferencematrix.png b/WebHostLib/static/static/icons/sc2/interferencematrix.png new file mode 100644 index 000000000000..ced928aa57a9 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/interferencematrix.png differ diff --git a/WebHostLib/static/static/icons/sc2/internalizedtechmodule.png b/WebHostLib/static/static/icons/sc2/internalizedtechmodule.png new file mode 100644 index 000000000000..e97f3db0d29a Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/internalizedtechmodule.png differ diff --git a/WebHostLib/static/static/icons/sc2/jotunboosters.png b/WebHostLib/static/static/icons/sc2/jotunboosters.png new file mode 100644 index 000000000000..25720306e5c2 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/jotunboosters.png differ diff --git a/WebHostLib/static/static/icons/sc2/jumpjets.png b/WebHostLib/static/static/icons/sc2/jumpjets.png new file mode 100644 index 000000000000..dfdfef4052ca Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/jumpjets.png differ diff --git a/WebHostLib/static/static/icons/sc2/lasertargetingsystem.png b/WebHostLib/static/static/icons/sc2/lasertargetingsystem.png new file mode 100644 index 000000000000..c57899b270ff Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/lasertargetingsystem.png differ diff --git a/WebHostLib/static/static/icons/sc2/liberator.png b/WebHostLib/static/static/icons/sc2/liberator.png new file mode 100644 index 000000000000..31507be5fe68 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/liberator.png differ diff --git a/WebHostLib/static/static/icons/sc2/lockdown.png b/WebHostLib/static/static/icons/sc2/lockdown.png new file mode 100644 index 000000000000..a2e7f5dc3e3f Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/lockdown.png differ diff --git a/WebHostLib/static/static/icons/sc2/magfieldaccelerator.png b/WebHostLib/static/static/icons/sc2/magfieldaccelerator.png new file mode 100644 index 000000000000..0272b4b73892 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/magfieldaccelerator.png differ diff --git a/WebHostLib/static/static/icons/sc2/magrailmunitions.png b/WebHostLib/static/static/icons/sc2/magrailmunitions.png new file mode 100644 index 000000000000..ec303498ccdb Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/magrailmunitions.png differ diff --git a/WebHostLib/static/static/icons/sc2/medivacemergencythrusters.png b/WebHostLib/static/static/icons/sc2/medivacemergencythrusters.png new file mode 100644 index 000000000000..1c7ce9d6ab1a Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/medivacemergencythrusters.png differ diff --git a/WebHostLib/static/static/icons/sc2/neosteelfortifiedarmor.png b/WebHostLib/static/static/icons/sc2/neosteelfortifiedarmor.png new file mode 100644 index 000000000000..04d68d35dc46 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/neosteelfortifiedarmor.png differ diff --git a/WebHostLib/static/static/icons/sc2/opticalflare.png b/WebHostLib/static/static/icons/sc2/opticalflare.png new file mode 100644 index 000000000000..f888fd518b99 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/opticalflare.png differ diff --git a/WebHostLib/static/static/icons/sc2/optimizedlogistics.png b/WebHostLib/static/static/icons/sc2/optimizedlogistics.png new file mode 100644 index 000000000000..dcf5fd72da86 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/optimizedlogistics.png differ diff --git a/WebHostLib/static/static/icons/sc2/reapercombatdrugs.png b/WebHostLib/static/static/icons/sc2/reapercombatdrugs.png new file mode 100644 index 000000000000..b9f2f055c265 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/reapercombatdrugs.png differ diff --git a/WebHostLib/static/static/icons/sc2/restoration.png b/WebHostLib/static/static/icons/sc2/restoration.png new file mode 100644 index 000000000000..f5c94e1aeefd Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/restoration.png differ diff --git a/WebHostLib/static/static/icons/sc2/ripwavemissiles.png b/WebHostLib/static/static/icons/sc2/ripwavemissiles.png new file mode 100644 index 000000000000..f68e82039765 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/ripwavemissiles.png differ diff --git a/WebHostLib/static/static/icons/sc2/shreddermissile.png b/WebHostLib/static/static/icons/sc2/shreddermissile.png new file mode 100644 index 000000000000..40899095fe3a Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/shreddermissile.png differ diff --git a/WebHostLib/static/static/icons/sc2/siegetank-spidermines.png b/WebHostLib/static/static/icons/sc2/siegetank-spidermines.png new file mode 100644 index 000000000000..1b9f8cf06097 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/siegetank-spidermines.png differ diff --git a/WebHostLib/static/static/icons/sc2/siegetankrange.png b/WebHostLib/static/static/icons/sc2/siegetankrange.png new file mode 100644 index 000000000000..5aef00a656c9 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/siegetankrange.png differ diff --git a/WebHostLib/static/static/icons/sc2/specialordance.png b/WebHostLib/static/static/icons/sc2/specialordance.png new file mode 100644 index 000000000000..4f7410d7ca9e Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/specialordance.png differ diff --git a/WebHostLib/static/static/icons/sc2/spidermine.png b/WebHostLib/static/static/icons/sc2/spidermine.png new file mode 100644 index 000000000000..bb39cf0bf8ce Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/spidermine.png differ diff --git a/WebHostLib/static/static/icons/sc2/staticempblast.png b/WebHostLib/static/static/icons/sc2/staticempblast.png new file mode 100644 index 000000000000..38f361510775 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/staticempblast.png differ diff --git a/WebHostLib/static/static/icons/sc2/superstimpack.png b/WebHostLib/static/static/icons/sc2/superstimpack.png new file mode 100644 index 000000000000..0fba8ce5749a Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/superstimpack.png differ diff --git a/WebHostLib/static/static/icons/sc2/targetingoptics.png b/WebHostLib/static/static/icons/sc2/targetingoptics.png new file mode 100644 index 000000000000..057a40f08e30 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/targetingoptics.png differ diff --git a/WebHostLib/static/static/icons/sc2/terran-cloak-color.png b/WebHostLib/static/static/icons/sc2/terran-cloak-color.png new file mode 100644 index 000000000000..44d1bb9541fb Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/terran-cloak-color.png differ diff --git a/WebHostLib/static/static/icons/sc2/terran-emp-color.png b/WebHostLib/static/static/icons/sc2/terran-emp-color.png new file mode 100644 index 000000000000..972b828c75e2 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/terran-emp-color.png differ diff --git a/WebHostLib/static/static/icons/sc2/terrandefendermodestructureattack.png b/WebHostLib/static/static/icons/sc2/terrandefendermodestructureattack.png new file mode 100644 index 000000000000..9d5982655183 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/terrandefendermodestructureattack.png differ diff --git a/WebHostLib/static/static/icons/sc2/thorsiegemode.png b/WebHostLib/static/static/icons/sc2/thorsiegemode.png new file mode 100644 index 000000000000..a298fb57de5a Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/thorsiegemode.png differ diff --git a/WebHostLib/static/static/icons/sc2/transformationservos.png b/WebHostLib/static/static/icons/sc2/transformationservos.png new file mode 100644 index 000000000000..f7f0524ac15c Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/transformationservos.png differ diff --git a/WebHostLib/static/static/icons/sc2/valkyrie.png b/WebHostLib/static/static/icons/sc2/valkyrie.png new file mode 100644 index 000000000000..9cbf339b10db Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/valkyrie.png differ diff --git a/WebHostLib/static/static/icons/sc2/warpjump.png b/WebHostLib/static/static/icons/sc2/warpjump.png new file mode 100644 index 000000000000..ff0a7b1af4aa Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/warpjump.png differ diff --git a/WebHostLib/static/static/icons/sc2/widowmine-attackrange.png b/WebHostLib/static/static/icons/sc2/widowmine-attackrange.png new file mode 100644 index 000000000000..8f5e09c6a593 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/widowmine-attackrange.png differ diff --git a/WebHostLib/static/static/icons/sc2/widowmine-deathblossom.png b/WebHostLib/static/static/icons/sc2/widowmine-deathblossom.png new file mode 100644 index 000000000000..7097db05e6c0 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/widowmine-deathblossom.png differ diff --git a/WebHostLib/static/static/icons/sc2/widowmine.png b/WebHostLib/static/static/icons/sc2/widowmine.png new file mode 100644 index 000000000000..802c49a83d88 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/widowmine.png differ diff --git a/WebHostLib/static/static/icons/sc2/widowminehidden.png b/WebHostLib/static/static/icons/sc2/widowminehidden.png new file mode 100644 index 000000000000..e568742e8a50 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/widowminehidden.png differ diff --git a/WebHostLib/static/styles/sc2wolTracker.css b/WebHostLib/static/styles/sc2wolTracker.css index b68668ecf60e..a7d8bd28c4f8 100644 --- a/WebHostLib/static/styles/sc2wolTracker.css +++ b/WebHostLib/static/styles/sc2wolTracker.css @@ -9,7 +9,7 @@ border-top-left-radius: 4px; border-top-right-radius: 4px; padding: 3px 3px 10px; - width: 500px; + width: 710px; background-color: #525494; } @@ -34,10 +34,12 @@ max-height: 40px; border: 1px solid #000000; filter: grayscale(100%) contrast(75%) brightness(20%); + background-color: black; } #inventory-table img.acquired{ filter: none; + background-color: black; } #inventory-table div.counted-item { @@ -52,7 +54,7 @@ } #location-table{ - width: 500px; + width: 710px; border-left: 2px solid #000000; border-right: 2px solid #000000; border-bottom: 2px solid #000000; diff --git a/WebHostLib/static/styles/supportedGames.css b/WebHostLib/static/styles/supportedGames.css index f86ab581ca47..1e9a98c17a0e 100644 --- a/WebHostLib/static/styles/supportedGames.css +++ b/WebHostLib/static/styles/supportedGames.css @@ -18,6 +18,16 @@ margin-bottom: 2px; } +#games h2 .collapse-arrow{ + font-size: 20px; + vertical-align: middle; + cursor: pointer; +} + +#games p.collapsed{ + display: none; +} + #games a{ font-size: 16px; } @@ -31,3 +41,13 @@ line-height: 25px; margin-bottom: 7px; } + +#games #page-controls{ + display: flex; + flex-direction: row; + margin-top: 0.25rem; +} + +#games #page-controls button{ + margin-left: 0.5rem; +} diff --git a/WebHostLib/static/styles/tracker.css b/WebHostLib/static/styles/tracker.css index 0e00553c72c8..0cc2ede59fe3 100644 --- a/WebHostLib/static/styles/tracker.css +++ b/WebHostLib/static/styles/tracker.css @@ -55,16 +55,16 @@ table.dataTable thead{ font-family: LexendDeca-Regular, sans-serif; } -table.dataTable tbody{ +table.dataTable tbody, table.dataTable tfoot{ background-color: #dce2bd; font-family: LexendDeca-Light, sans-serif; } -table.dataTable tbody tr:hover{ +table.dataTable tbody tr:hover, table.dataTable tfoot tr:hover{ background-color: #e2eabb; } -table.dataTable tbody td{ +table.dataTable tbody td, table.dataTable tfoot td{ padding: 4px 6px; } @@ -97,10 +97,14 @@ table.dataTable thead th.lower-row{ top: 46px; } -table.dataTable tbody td{ +table.dataTable tbody td, table.dataTable tfoot td{ border: 1px solid #bba967; } +table.dataTable tfoot td{ + font-weight: bold; +} + div.dataTables_scrollBody{ background-color: inherit !important; } diff --git a/WebHostLib/templates/check.html b/WebHostLib/templates/check.html index 04b51340b513..8a3da7db472a 100644 --- a/WebHostLib/templates/check.html +++ b/WebHostLib/templates/check.html @@ -17,9 +17,9 @@

Upload Yaml

- +
- +
diff --git a/WebHostLib/templates/generate.html b/WebHostLib/templates/generate.html index dd25a908049d..33f8dbc09e6c 100644 --- a/WebHostLib/templates/generate.html +++ b/WebHostLib/templates/generate.html @@ -203,10 +203,10 @@

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

- +
- + diff --git a/WebHostLib/templates/multiFactorioTracker.html b/WebHostLib/templates/multiFactorioTracker.html index e8fa7b152cf2..faca756ee975 100644 --- a/WebHostLib/templates/multiFactorioTracker.html +++ b/WebHostLib/templates/multiFactorioTracker.html @@ -27,14 +27,14 @@ {% endblock %} {% block custom_table_row scoped %} {% if games[player] == "Factorio" %} -{% set player_inventory = inventory[team][player] %} -{% set prog_science = player_inventory[custom_items["progressive-science-pack"]] %} -{% if player_inventory[custom_items["logistic-science-pack"]] or prog_science %}✔{% endif %} -{% if player_inventory[custom_items["military-science-pack"]] or prog_science > 1%}✔{% endif %} -{% if player_inventory[custom_items["chemical-science-pack"]] or prog_science > 2%}✔{% endif %} -{% if player_inventory[custom_items["production-science-pack"]] or prog_science > 3%}✔{% endif %} -{% if player_inventory[custom_items["utility-science-pack"]] or prog_science > 4%}✔{% endif %} -{% if player_inventory[custom_items["space-science-pack"]] or prog_science > 5%}✔{% endif %} +{% set player_inventory = named_inventory[team][player] %} +{% set prog_science = player_inventory["progressive-science-pack"] %} +{% if player_inventory["logistic-science-pack"] or prog_science %}✔{% endif %} +{% if player_inventory["military-science-pack"] or prog_science > 1%}✔{% endif %} +{% if player_inventory["chemical-science-pack"] or prog_science > 2%}✔{% endif %} +{% if player_inventory["production-science-pack"] or prog_science > 3%}✔{% endif %} +{% if player_inventory["utility-science-pack"] or prog_science > 4%}✔{% endif %} +{% if player_inventory["space-science-pack"] or prog_science > 5%}✔{% endif %} {% else %} ❌ ❌ diff --git a/WebHostLib/templates/multiTracker.html b/WebHostLib/templates/multiTracker.html index 2232cd0fd1c1..40d89eb4c6cc 100644 --- a/WebHostLib/templates/multiTracker.html +++ b/WebHostLib/templates/multiTracker.html @@ -37,7 +37,7 @@ {% endblock %} Checks % - Last
Activity + Last
Activity @@ -64,6 +64,19 @@ {%- endfor -%} + {% if not self.custom_table_headers() | trim %} + + + + Total + All Games + {{ completed_worlds }}/{{ players|length }} Complete + {{ players.values()|sum(attribute='Total') }}/{{ total_locations[team] }} + {{ (players.values()|sum(attribute='Total') / total_locations[team] * 100) | int }} + + + + {% endif %} {% endfor %} diff --git a/WebHostLib/templates/sc2wolTracker.html b/WebHostLib/templates/sc2wolTracker.html index af27e30b27f2..49c31a579544 100644 --- a/WebHostLib/templates/sc2wolTracker.html +++ b/WebHostLib/templates/sc2wolTracker.html @@ -11,7 +11,7 @@
- @@ -26,7 +26,7 @@ --> - @@ -37,120 +37,266 @@ + + + - - - - - - + + + + + - + + + + + + - - + + + + + + + + + + + + + + + + - + + - - - - - + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + - - - - - + + + + + + + + + + - - - - - - - - - - + + + + + + + + + - + + + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + - + + + + + + + + - - - + + + + + + + + + + + + + + - - - - - - + + + + + + + + - @@ -165,36 +311,18 @@ - - - - - - - - - - + - - - - - - - - - - - + - diff --git a/WebHostLib/templates/supportedGames.html b/WebHostLib/templates/supportedGames.html index 82f6348db2e9..63b70216d705 100644 --- a/WebHostLib/templates/supportedGames.html +++ b/WebHostLib/templates/supportedGames.html @@ -4,16 +4,27 @@ Supported Games + {% endblock %} {% block body %} {% include 'header/oceanHeader.html' %}

Currently Supported Games

+
+
+
+ + + +
+
{% for game_name in worlds | title_sorted %} {% set world = worlds[game_name] %} -

{{ game_name }}

-

+

+  {{ game_name }} +

+

Changes from the vanilla game

-This randomizer takes Kingdom Hearts 2 and randomizes the locations of the items for a more dynamic play experience. The items that randomize currently are all items within Chests, Popups, Get Bonuses, Form Levels, and Sora's Levels. This allows abilities that Sora would normally have to also be placed on Keyblades with random stats. With several options on ways to finish the game. +This randomizer creates a more dynamic play experience by randomizing the locations of most items in Kingdom Hearts 2. Currently all items within Chests, Popups, Get Bonuses, Form Levels, and Sora's Levels are randomized. This allows abilities that Sora would normally have to be placed on Keyblades with random stats. Additionally, there are several options for ways to finish the game, allowing for different goals beyond beating the final boss.

Where is the settings page

@@ -12,12 +12,18 @@ The [player settings page for this game](../player-settings) contains all the op

What is randomized in this game?

-The Chests, Popups, Get Bonuses, Form Levels, and Sora's Levels. +- Chests +- Popups +- Get Bonuses +- Form Levels +- Sora's Levels +- Keyblade Stats +- Keyblade Abilities

What Kingdom Hearts 2 items can appear in other players' worlds?

-Every item in the game with the exception being party members' abilities. +Every item in the game except for party members' abilities.

What is The Garden of Assemblage "GoA"?

@@ -37,10 +43,10 @@ It is added to your inventory. If you obtain magic, you will need to pause your

What Happens if I die before Room Saving?

-When you die in Kingdom Hearts 2, you are reverted to the last non-boss room you entered and your status is reverted to what it was at that time. However, in archipelago, any item that you have sent/received will not be taken away from the player, any chest you have opened will remain open, and you will keep your level but lose the expereince. Unlike vanilla Kingdom Hearts 2. +When you die in vanilla Kingdom Hearts 2, you are reverted to the last non-boss room you entered and your status is reverted to what it was at that time. However, in archipelago, any item that you have sent/received will not be taken away from the player, any chest you have opened will remain open, and you will keep your level, but lose the experience. -For example, if you are fighting Roxas and you receive Reflect Element and you die fighting Roxas, you will keep that reflect. You will still need to pause your game to have it show up in your inventory, then enter a new room for it to become properly usable. +For example, if you are fighting Roxas, receive Reflect Element, then die mid-fight, you will keep that Reflect Element. You will still need to pause your game to have it show up in your inventory, then enter a new room for it to become properly usable.

Customization options:

@@ -49,13 +55,12 @@ For example, if you are fighting Roxas and you receive Reflect Element and you d 1. Obtain Three Proofs. 2. Obtain a desired amount of Lucky Emblems. 3. Obtain a desired amount of Bounties that are on late locations. -- Customize how many World Locking Items You Need to Progress in that World. -- Customize the Amount of World Locking Items You Start With. -- Customize how many locations you want on Sora's Levels. -- Customize the EXP Multiplier of everything that affects Sora. -- Customize the Available Abilities on Keyblades. -- Customize the level of Progressive Movement (Growth Abilities) you start with. -- Customize the amount of Progressive Movement (Growth Abilities) you start with. +- Customize how many World-Locking Items you need to progress in that world. +- Customize the amount of World-Locking Items you start with. +- Customize how many of Sora's Levels are locations. +- Customize the EXP multiplier for Sora, his Drive Forms, and his Summons. +- Customize the available abilities on keyblades. +- Customize the amount and level of progressive movement (Growth Abilities) you start with. - Customize start inventory, i.e., begin every run with certain items or spells of your choice.

Quality of life:

diff --git a/worlds/lufia2ac/docs/setup_en.md b/worlds/lufia2ac/docs/setup_en.md index f9e0d3725cfb..4236c26e8a70 100644 --- a/worlds/lufia2ac/docs/setup_en.md +++ b/worlds/lufia2ac/docs/setup_en.md @@ -82,8 +82,7 @@ first time launching, you may be prompted to allow it to communicate through the 3. Click on **New Lua Script Window...** 4. In the new window, click **Browse...** 5. Select the connector lua file included with your client - - Look in the Archipelago folder for `/SNI/lua/x64` or `/SNI/lua/x86` depending on if the - emulator is 64-bit or 32-bit. + - Look in the Archipelago folder for `/SNI/lua/Connector.lua`. 6. If you see an error while loading the script that states `socket.dll missing` or similar, navigate to the folder of the lua you are using in your file explorer and copy the `socket.dll` to the base folder of your snes9x install. @@ -94,9 +93,8 @@ the lua you are using in your file explorer and copy the `socket.dll` to the bas - (≥ 2.9) `Config` 〉 `Preferred Cores` 〉 `SNES` 〉 `BSNESv115+` 2. Load your ROM file if it hasn't already been loaded. If you changed your core preference after loading the ROM, don't forget to reload it (default hotkey: Ctrl+R). -3. Drag+drop the `Connector.lua` file that you downloaded above onto the main EmuHawk window. - - Look in the Archipelago folder for `/SNI/lua/x64` or `/SNI/lua/x86` depending on if the - emulator is 64-bit or 32-bit. Please note the most recent versions of BizHawk are 64-bit only. +3. Drag+drop the `Connector.lua` file included with your client onto the main EmuHawk window. + - Look in the Archipelago folder for `/SNI/lua/Connector.lua`. - You could instead open the Lua Console manually, click `Script` 〉 `Open Script`, and navigate to `Connector.lua` with the file picker. diff --git a/worlds/messenger/Rules.py b/worlds/messenger/Rules.py index b72d454a7e0f..c24f60fbaadb 100644 --- a/worlds/messenger/Rules.py +++ b/worlds/messenger/Rules.py @@ -182,8 +182,10 @@ def __init__(self, world: MessengerWorld) -> None: "Searing Crags Seal - Raining Rocks": lambda state: self.has_vertical(state) or self.can_destroy_projectiles(state), "Searing Crags Seal - Rhythm Rocks": lambda state: self.has_vertical(state) or self.can_destroy_projectiles(state), "Searing Crags - Power Thistle": lambda state: self.has_vertical(state) or self.can_destroy_projectiles(state), - "Glacial Peak Seal - Ice Climbers": self.has_vertical, + "Glacial Peak Seal - Ice Climbers": lambda state: self.has_vertical(state) or self.can_dboost(state), "Glacial Peak Seal - Projectile Spike Pit": self.true, + "Glacial Peak Seal - Glacial Air Swag": lambda state: self.has_windmill(state) or self.has_vertical(state), + "Glacial Peak Mega Shard": lambda state: self.has_windmill(state) or self.has_vertical(state), "Cloud Ruins Seal - Ghost Pit": self.true, "Bamboo Creek - Claustro": self.has_wingsuit, "Tower of Time Seal - Lantern Climb": self.has_wingsuit, @@ -201,10 +203,7 @@ def __init__(self, world: MessengerWorld) -> None: "Elemental Skylands - Key of Symbiosis": lambda state: self.has_windmill(state) or self.can_dboost(state), "Autumn Hills Seal - Spike Ball Darts": lambda state: (self.has_dart(state) and self.has_windmill(state)) or self.has_wingsuit(state), - "Glacial Peak Seal - Glacial Air Swag": self.has_windmill, - "Glacial Peak Seal - Ice Climbers": lambda state: self.has_wingsuit(state) or self.can_dboost(state), - "Underworld Seal - Fireball Wave": lambda state: state.has_all({"Lightfoot Tabi", "Windmill Shuriken"}, - self.player), + "Underworld Seal - Fireball Wave": self.has_windmill, } def has_windmill(self, state: CollectionState) -> bool: diff --git a/worlds/messenger/test/TestLogic.py b/worlds/messenger/test/TestLogic.py index 45b0d0dab629..932bc1386701 100644 --- a/worlds/messenger/test/TestLogic.py +++ b/worlds/messenger/test/TestLogic.py @@ -1,3 +1,5 @@ +from typing import Iterable, List + from BaseClasses import ItemClassification from . import MessengerTestBase @@ -5,6 +7,7 @@ class HardLogicTest(MessengerTestBase): options = { "logic_level": "hard", + "shuffle_shards": "true", } def testVertical(self) -> None: @@ -19,16 +22,20 @@ def testVertical(self) -> None: "Autumn Hills - Climbing Claws", "Autumn Hills - Key of Hope", "Autumn Hills - Leaf Golem", "Autumn Hills Seal - Trip Saws", "Autumn Hills Seal - Double Swing Saws", "Autumn Hills Seal - Spike Ball Swing", "Autumn Hills Seal - Spike Ball Darts", + "Autumn Hills Mega Shard", "Hidden Entrance Mega Shard", # forlorn temple "Forlorn Temple - Demon King", "Forlorn Temple Seal - Rocket Maze", "Forlorn Temple Seal - Rocket Sunset", + "Sunny Day Mega Shard", "Down Under Mega Shard", # catacombs "Catacombs - Necro", "Catacombs - Ruxxtin's Amulet", "Catacombs - Ruxxtin", "Catacombs Seal - Triple Spike Crushers", "Catacombs Seal - Crusher Gauntlet", "Catacombs Seal - Dirty Pond", + "Catacombs Mega Shard", # bamboo creek "Bamboo Creek - Claustro", "Bamboo Creek Seal - Spike Crushers and Doors", "Bamboo Creek Seal - Spike Ball Pits", "Bamboo Creek Seal - Spike Crushers and Doors v2", + "Above Entrance Mega Shard", "Abandoned Mega Shard", "Time Loop Mega Shard", # howling grotto "Howling Grotto - Emerald Golem", "Howling Grotto Seal - Crushing Pits", "Howling Grotto Seal - Crushing Pits", # searing crags @@ -36,6 +43,7 @@ def testVertical(self) -> None: # cloud ruins "Cloud Ruins - Acro", "Cloud Ruins Seal - Ghost Pit", "Cloud Ruins Seal - Toothbrush Alley", "Cloud Ruins Seal - Saw Pit", "Cloud Ruins Seal - Money Farm Room", + "Cloud Entrance Mega Shard", "Time Warp Mega Shard", "Money Farm Room Mega Shard 1", "Money Farm Room Mega Shard 2", # underworld "Underworld Seal - Rising Fanta", "Underworld Seal - Sharp and Windy Climb", # elemental skylands @@ -73,6 +81,18 @@ def testWindmill(self) -> None: item = self.get_item_by_name("Rope Dart") self.collect(item) self.assertTrue(self.can_reach_location(special_loc)) + + def testGlacial(self) -> None: + """Test Glacial Peak locations.""" + self.assertAccessDependency(["Glacial Peak Seal - Ice Climbers"], + [["Second Wind", "Meditation"], ["Rope Dart"], ["Wingsuit"]], + True) + self.assertAccessDependency(["Glacial Peak Seal - Projectile Spike Pit"], + [["Strike of the Ninja"], ["Windmill Shuriken"], ["Rope Dart"], ["Wingsuit"]], + True) + self.assertAccessDependency(["Glacial Peak Seal - Glacial Air Swag", "Glacial Peak Mega Shard"], + [["Windmill Shuriken"], ["Wingsuit"], ["Rope Dart"]], + True) class NoLogicTest(MessengerTestBase): diff --git a/worlds/minecraft/docs/en_Minecraft.md b/worlds/minecraft/docs/en_Minecraft.md index 2d4f063b79a9..1ef347983bc4 100644 --- a/worlds/minecraft/docs/en_Minecraft.md +++ b/worlds/minecraft/docs/en_Minecraft.md @@ -29,82 +29,82 @@ sequence either by skipping it or watching hit play out. ## Which recipes are locked? * Archery - * Bow - * Arrow - * Crossbow + * Bow + * Arrow + * Crossbow * Brewing - * Blaze Powder - * Brewing Stand + * Blaze Powder + * Brewing Stand * Enchanting - * Enchanting Table - * Bookshelf + * Enchanting Table + * Bookshelf * Bucket * Flint & Steel * All Beds * Bottles * Shield * Fishing Rod - * Fishing Rod - * Carrot on a Stick - * Warped Fungus on a Stick + * Fishing Rod + * Carrot on a Stick + * Warped Fungus on a Stick * Campfire - * Campfire - * Soul Campfire + * Campfire + * Soul Campfire * Spyglass * Lead * Progressive Weapons - * Tier I - * Stone Sword - * Stone Axe - * Tier II - * Iron Sword - * Iron Axe - * Tier III - * Diamond Sword - * Diamond Axe + * Tier I + * Stone Sword + * Stone Axe + * Tier II + * Iron Sword + * Iron Axe + * Tier III + * Diamond Sword + * Diamond Axe * Progessive Tools - * Tier I - * Stone Shovel - * Stone Hoe - * Tier II - * Iron Shovel - * Iron Hoe - * Tier III - * Diamond Shovel - * Diamond Hoe - * Netherite Ingot + * Tier I + * Stone Shovel + * Stone Hoe + * Tier II + * Iron Shovel + * Iron Hoe + * Tier III + * Diamond Shovel + * Diamond Hoe + * Netherite Ingot * Progressive Armor - * Tier I - * Iron Helmet - * Iron Chestplate - * Iron Leggings - * Iron Boots - * Tier II - * Diamond Helmet - * Diamond Chestplate - * Diamond Leggings - * Diamond Boots + * Tier I + * Iron Helmet + * Iron Chestplate + * Iron Leggings + * Iron Boots + * Tier II + * Diamond Helmet + * Diamond Chestplate + * Diamond Leggings + * Diamond Boots * Progressive Resource Crafting - * Tier I - * Iron Ingot from Nuggets - * Iron Nugget - * Gold Ingot from Nuggets - * Gold Nugget - * Furnace - * Blast Furnace - * Tier II - * Redstone - * Redstone Block - * Glowstone - * Iron Ingot from Iron Block - * Iron Block - * Gold Ingot from Gold Block - * Gold Block - * Diamond - * Diamond Block - * Netherite Block - * Netherite Ingot from Netherite Block - * Anvil - * Emerald - * Emerald Block - * Copper Block + * Tier I + * Iron Ingot from Nuggets + * Iron Nugget + * Gold Ingot from Nuggets + * Gold Nugget + * Furnace + * Blast Furnace + * Tier II + * Redstone + * Redstone Block + * Glowstone + * Iron Ingot from Iron Block + * Iron Block + * Gold Ingot from Gold Block + * Gold Block + * Diamond + * Diamond Block + * Netherite Block + * Netherite Ingot from Netherite Block + * Anvil + * Emerald + * Emerald Block + * Copper Block diff --git a/worlds/musedash/Options.py b/worlds/musedash/Options.py index cc9f5d705684..b2f15ecc8e6c 100644 --- a/worlds/musedash/Options.py +++ b/worlds/musedash/Options.py @@ -3,8 +3,8 @@ class AllowJustAsPlannedDLCSongs(Toggle): - """Whether 'Just as Planned DLC' songs, and all the DLCs along with it, will be included in the randomizer.""" - display_name = "Allow Just As Planned DLC Songs" + """Whether [Just as Planned]/[Muse Plus] DLC Songs, and all the DLCs along with it, will be included in the randomizer.""" + display_name = "Allow [Just as Planned]/[Muse Plus] DLC Songs" class StreamerModeEnabled(Toggle): @@ -125,7 +125,7 @@ class TrapTypes(Choice): - VFX Traps consist of visual effects that play over the song. (i.e. Grayscale.) - SFX Traps consist of changing your sfx setting to one possibly more annoying sfx. Traps last the length of a song, or until you die. - Note: SFX traps are only available with Just As Planned dlc songs. + Note: SFX traps are only available if [Just as Planned] DLC songs are enabled. """ display_name = "Available Trap Types" option_None = 0 diff --git a/worlds/musedash/docs/en_Muse Dash.md b/worlds/musedash/docs/en_Muse Dash.md index 5f4673d256a8..008fd4d2df0c 100644 --- a/worlds/musedash/docs/en_Muse Dash.md +++ b/worlds/musedash/docs/en_Muse Dash.md @@ -17,7 +17,7 @@ The goal of Muse Dash is to collect a number of **Music Sheets**. Once you've co Only the base Muse Dash game is required in order to play this game. -However, the **Just as Planned DLC** is recommended as the number of possible songs increases from 60+ to 400+ songs, which adds to the variety and increases replayability. +However, the **[Just as Planned]**/**[Muse Plus]** DLC is recommended, as it increases the number of possible songs from ~60 to 400+ songs, which adds to the variety and increases replayability. ## What Other Adjustments have been made to the Base Game? - Several song select filters have been added to make finding songs to play easy. diff --git a/worlds/musedash/docs/setup_en.md b/worlds/musedash/docs/setup_en.md index 7ad701829735..1ab61ff22ac6 100644 --- a/worlds/musedash/docs/setup_en.md +++ b/worlds/musedash/docs/setup_en.md @@ -8,7 +8,7 @@ - Windows 8 or Newer. - Muse Dash: [Available on Steam](https://store.steampowered.com/app/774171/Muse_Dash/) - - \[Optional\] Just As Planned DLC: [Also Available on Steam](https://store.steampowered.com/app/1055810/Muse_Dash__Just_as_planned/) + - \[Optional\] [Just as Planned] DLC: [Also Available on Steam](https://store.steampowered.com/app/1055810/Muse_Dash__Just_as_planned/) - Melon Loader: [GitHub](https://github.com/LavaGang/MelonLoader/releases/latest) - .Net Framework 4.8 may be needed for the installer: [Download](https://dotnet.microsoft.com/en-us/download/dotnet-framework/net48) - .Net 6.0 (If not already installed): [Download](https://dotnet.microsoft.com/en-us/download/dotnet/6.0#runtime-6.0.15) diff --git a/worlds/pokemon_rb/__init__.py b/worlds/pokemon_rb/__init__.py index 64f62adddb63..11aa737e0f26 100644 --- a/worlds/pokemon_rb/__init__.py +++ b/worlds/pokemon_rb/__init__.py @@ -49,14 +49,25 @@ class RomStart(str): class PokemonWebWorld(WebWorld): - tutorials = [Tutorial( + setup_en = Tutorial( "Multiworld Setup Guide", "A guide to playing Pokemon Red and Blue with Archipelago.", "English", "setup_en.md", "setup/en", ["Alchav"] - )] + ) + + setup_es = Tutorial( + setup_en.tutorial_name, + setup_en.description, + "Español", + "setup_es.md", + "setup/es", + ["Shiny"] + ) + + tutorials = [setup_en, setup_es] class PokemonRedBlueWorld(World): @@ -138,7 +149,7 @@ def encode_name(name, t): if self.multiworld.key_items_only[self.player]: self.multiworld.trainersanity[self.player] = self.multiworld.trainersanity[self.player].from_text("off") - self.multiworld.dexsanity[self.player] = self.multiworld.dexsanity[self.player].from_text("false") + self.multiworld.dexsanity[self.player].value = 0 self.multiworld.randomize_hidden_items[self.player] = \ self.multiworld.randomize_hidden_items[self.player].from_text("off") @@ -717,6 +728,15 @@ def fill_slot_data(self) -> dict: "death_link": self.multiworld.death_link[self.player].value, "prizesanity": self.multiworld.prizesanity[self.player].value, "key_items_only": self.multiworld.key_items_only[self.player].value, + "poke_doll_skip": self.multiworld.poke_doll_skip[self.player].value, + "bicycle_gate_skips": self.multiworld.bicycle_gate_skips[self.player].value, + "stonesanity": self.multiworld.stonesanity[self.player].value, + "door_shuffle": self.multiworld.door_shuffle[self.player].value, + "warp_tile_shuffle": self.multiworld.warp_tile_shuffle[self.player].value, + "dark_rock_tunnel_logic": self.multiworld.dark_rock_tunnel_logic[self.player].value, + "split_card_key": self.multiworld.split_card_key[self.player].value, + "all_elevators_locked": self.multiworld.all_elevators_locked[self.player].value, + } @@ -730,4 +750,4 @@ def __init__(self, name, player: int = None): name, item_data.classification, item_data.id, player - ) \ No newline at end of file + ) diff --git a/worlds/pokemon_rb/docs/en_Pokemon Red and Blue.md b/worlds/pokemon_rb/docs/en_Pokemon Red and Blue.md index 535054182707..daefd6b2f7eb 100644 --- a/worlds/pokemon_rb/docs/en_Pokemon Red and Blue.md +++ b/worlds/pokemon_rb/docs/en_Pokemon Red and Blue.md @@ -20,7 +20,7 @@ Many baseline changes are made to the game, including: * PC item storage increased to 64 slots (up from 50). * You can hold B to run (or bike extra fast!). * You can hold select while talking to a trainer to re-battle them. -* You can select "Pallet Warp" below the "Continue" option to warp to Pallet Towna s you load your save. +* You can select "Pallet Warp" below the "Continue" option to warp to Pallet Town as you load your save. * Mew can be encountered at the S.S. Anne dock truck. This can be randomized depending on your settings. * The S.S. Anne will never depart. * Seafoam Islands entrances are swapped. This means you need Strength to travel through from Cinnabar Island to Fuchsia diff --git a/worlds/pokemon_rb/docs/setup_es.md b/worlds/pokemon_rb/docs/setup_es.md new file mode 100644 index 000000000000..40731037757a --- /dev/null +++ b/worlds/pokemon_rb/docs/setup_es.md @@ -0,0 +1,110 @@ +# Guía de instalación para Pokémon Red and Blue: Archipelago + +## Importante + +Al usar BizHawk, esta guía solo es aplicable en los sistemas de Windows y Linux. + +## Software Requerido + +- BizHawk: [BizHawk Releases en TASVideos](https://tasvideos.org/BizHawk/ReleaseHistory) + - La versión 2.3.1 y posteriores son soportadas. Se recomienda la versión 2.7 para estabilidad. + - Instrucciones de instalación detalladas para BizHawk se pueden encontrar en el enlace de arriba. + - Los usuarios de Windows deben ejecutar el instalador de prerrequisitos (prereq installer) primero, que también se + encuentra en el enlace de arriba. +- El cliente incorporado de Archipelago, que se puede encontrar [aquí](https://github.com/ArchipelagoMW/Archipelago/releases) + (selecciona `Pokemon Client` durante la instalación). +- Los ROMs originales de Pokémon Red y/o Blue. La comunidad de Archipelago no puede proveerlos. + +## Software Opcional + +- [Tracker de mapa para Pokémon Red and Blue Archipelago](https://github.com/j-imbo/pkmnrb_jim/releases/latest), para usar con [PopTracker](https://github.com/black-sliver/PopTracker/releases) + + +## Configurando BizHawk + +Una vez que Bizhawk se haya instalado, abre Emuhawk y cambia las siguientes configuraciones: + +- (≤ 2.8) Abrir EmuHawk e ir a Config > Customize. Abrir la pestaña Advanced, y en la opción de Lua Core cambiar desde + "NLua+KopiLua" a "Lua+LuaInterface". Luego reinicia EmuHawk. Esto es fundamental para que el script de Lua funcione + correctamente. + **NOTA: Incluso si "Lua+LuaInterface" ya estaba seleccionado, cambia entre las opciones y vuelvelo a seleccionar. + **Algunas instalaciones de versiones nuevas de EmuHawk tienen una tendencia a mostrar "Lua+LuaInterface" por defecto + **pero siguen cargando "NLua+KopiLua" hasta completar este paso.** +- Aun en la pestaña Advanced, asegurate que la casilla de AutoSaveRAM este marcada, y selecciona también la casilla 5s. + Esto reduce la posibilidad de que se pierdan datos guardados en el caso de que el emulador deje de funcionar (crash). +- En Config > Customize, pestaña General, marcar la casilla "Run in background". Esto evitará que te desconectes del + cliente mientras EmuHawk se esta ejecutando en segundo plano. + +Es muy recomendado asociar los archivos GB (\*.gb) al emulador EmuHawk que se acaba de instalar. +Para hacerlo, simplemente busca uno de los ROMs de gameboy, presiona con el click derecho sobre el y selecciona +"Abrir con...", despliega la lista que aparece y selecciona la opción al final de la lista "Buscar otra aplicación en" +"el equipo", luego navega a la carpeta de Bizhawk y selecciona EmuHawk.exe. + +## Configura tu archivo YAML + +### Que es un archivo YAML y por qué necesito uno? + +Tu archivo YAML contiene un número de opciones que proveen al generador con información sobre como debe generar tu +juego. Cada jugador de un multiworld entregara su propio archivo YAML. Esto permite que cada jugador disfrute de una +experiencia personalizada a su manera, y que diferentes jugadores dentro del mismo multiworld pueden tener diferentes +opciones. + +### Donde puedo obtener un archivo YAML? + +Puedes generar un archivo YAML or descargar su plantilla en la [pagina de configuración de jugador de Pokemon Red and Blue](/games/Pokemon%20Red%20and%20Blue/player-settings) + +Es importante tener en cuenta que la opción `game_version` determina el ROM que será parcheado. +Tanto el jugador como la persona que genera (si está generando localmente) necesitarán el archivo del ROM +correspondiente. + +Para las opciones `trainer_name` y `rival_name`, los siguientes caracteres normales son permitidos: + +* `‘’“”·… ABCDEFGHIJKLMNOPQRSTUVWXYZ():;[]abcdefghijklmnopqrstuvwxyzé'-?!.♂$×/,♀0123456789` + +Y los siguientes caracteres especiales (cada uno ocupa un caracter): +* `<'d>` +* `<'l>` +* `<'t>` +* `<'v>` +* `<'r>` +* `<'m>` +* `` +* `` +* `` alias para `♂` +* `` alias para `♀` + +## Unirse a un juego MultiWorld + +### Obtener tu parche de Pokémon + +Cuando te unes a un juego multiworld, se te pedirá que entregues tu archivo YAML a quien lo este organizando. +Una vez que la generación acabe, el anfitrión te dará un enlace a tu archivo, o un .zip con los archivos de +todos. Tu archivo tiene una extensión `.apred` o `.apblue`. + +Haz doble click en tu archivo `.apred` o `.apblue` para que se ejecute el cliente y realize el parcheado de la ROM. +Una vez acabe ese proceso (esto puede tardar un poco), el cliente y el emulador se abrirán automaticamente (si es que se +ha asociado la extensión al emulador tal como fue recomendado) + +### Conectarse al multiserver + +Una vez ejecutado tanto el cliente como el emulador, hay que conectarlos. Abre la carpeta de instalación de Archipelago, +luego abre `data/lua`, y simplemente arrastra el archivo `connector_pkmn_rb.lua` a la ventana principal de Emuhawk. +(Alternativamente, puedes abrir la consola de Lua manualmente. En Emuhawk ir a Tools > Lua Console, luego ir al menú +`Script` 〉 `Open Script`, navegar a la ubicación de `connector_pkmn_rb.lua` y seleccionarlo.) + +Para conectar el cliente con el servidor, simplemente pon `:` en la caja de texto superior y presiona +enter (si el servidor tiene contraseña, en la caja de texto inferior escribir `/connect : [contraseña]`) + +Ahora ya estás listo para tu aventura en Kanto. + +## Auto-Tracking + +Pokémon Red and Blue tiene un mapa completamente funcional que soporta seguimiento automático. + +1. Descarga el [Tracker de mapa para Pokémon Red and Blue Archipelago](https://github.com/j-imbo/pkmnrb_jim/releases/latest) y [PopTracker](https://github.com/black-sliver/PopTracker/releases). +2. Abre PopTracker, y carga el pack de Pokémon Red and Blue. +3. Haz click en el símbolo "AP" en la parte superior. +4. Ingresa la dirección de AP, nombre del slot y contraseña (si es que hay). + +Y ya, el resto debería hacerse solo! Los items y checks seran marcados automaticamente, e incluso reconocerá tus +configuraciones - Ocultará checks y ajustará la logica segun corresponda. diff --git a/worlds/pokemon_rb/logic.py b/worlds/pokemon_rb/logic.py index 87398c7267fe..cbe28e0ddb47 100644 --- a/worlds/pokemon_rb/logic.py +++ b/worlds/pokemon_rb/logic.py @@ -53,7 +53,7 @@ def has_key_items(state, count, player): "Hideout Key", "Card Key 2F", "Card Key 3F", "Card Key 4F", "Card Key 5F", "Card Key 6F", "Card Key 7F", "Card Key 8F", "Card Key 9F", "Card Key 10F", "Card Key 11F", "Exp. All", "Fire Stone", "Thunder Stone", "Water Stone", - "Leaf Stone"] if state.has(item, player)]) + "Leaf Stone", "Moon Stone"] if state.has(item, player)]) + min(state.count("Progressive Card Key", player), 10)) return key_items >= count diff --git a/worlds/pokemon_rb/rom.py b/worlds/pokemon_rb/rom.py index 0757d3343510..4b191d91765b 100644 --- a/worlds/pokemon_rb/rom.py +++ b/worlds/pokemon_rb/rom.py @@ -238,18 +238,19 @@ def generate_output(self, output_directory: str): data[address] = 0 if "Elevator" in connected_map_name else warp_to_ids[i] data[address + 1] = map_ids[connected_map_name] - for i, gym_leader in enumerate(("Pewter Gym - Brock TM", "Cerulean Gym - Misty TM", - "Vermilion Gym - Lt. Surge TM", "Celadon Gym - Erika TM", - "Fuchsia Gym - Koga TM", "Saffron Gym - Sabrina TM", - "Cinnabar Gym - Blaine TM", "Viridian Gym - Giovanni TM")): - item_name = self.multiworld.get_location(gym_leader, self.player).item.name - if item_name.startswith("TM"): - try: - tm = int(item_name[2:4]) - move = poke_data.moves[self.local_tms[tm - 1]]["id"] - data[rom_addresses["Gym_Leader_Moves"] + (2 * i)] = move - except KeyError: - pass + if not self.multiworld.key_items_only[self.player]: + for i, gym_leader in enumerate(("Pewter Gym - Brock TM", "Cerulean Gym - Misty TM", + "Vermilion Gym - Lt. Surge TM", "Celadon Gym - Erika TM", + "Fuchsia Gym - Koga TM", "Saffron Gym - Sabrina TM", + "Cinnabar Gym - Blaine TM", "Viridian Gym - Giovanni TM")): + item_name = self.multiworld.get_location(gym_leader, self.player).item.name + if item_name.startswith("TM"): + try: + tm = int(item_name[2:4]) + move = poke_data.moves[self.local_tms[tm - 1]]["id"] + data[rom_addresses["Gym_Leader_Moves"] + (2 * i)] = move + except KeyError: + pass def set_trade_mon(address, loc): mon = self.multiworld.get_location(loc, self.player).item.name diff --git a/worlds/raft/__init__.py b/worlds/raft/__init__.py index d00b5faa9e90..fec60c3bd51b 100644 --- a/worlds/raft/__init__.py +++ b/worlds/raft/__init__.py @@ -138,6 +138,8 @@ def create_resourcePack(self, rpName: str) -> Item: return RaftItem(rpName, ItemClassification.filler, self.item_name_to_id[rpName], player=self.player) def collect_item(self, state, item, remove=False): + if item.advancement is False: + return None if item.name in progressive_item_list: prog_table = progressive_item_list[item.name] if remove: diff --git a/worlds/ror2/docs/en_Risk of Rain 2.md b/worlds/ror2/docs/en_Risk of Rain 2.md index ca22d1a44d70..d30edf888944 100644 --- a/worlds/ror2/docs/en_Risk of Rain 2.md +++ b/worlds/ror2/docs/en_Risk of Rain 2.md @@ -8,7 +8,7 @@ config file. ## What does randomization do to this game? Risk of Rain is already a random game, by virtue of being a roguelite. The Archipelago mod implements pure multiworld -functionality in which certain chests (made clear via a location check progress bar) will send an item out to the +functionality in which certain chests will send an item out to the multiworld. The items that _would have been_ in those chests will be returned to the Risk of Rain player via grants by other players in other worlds. @@ -16,28 +16,30 @@ There are two modes in risk of rain. Classic Mode and Explore Mode Classic Mode: - - Classic mode implements pure multiworld -functionality in which certain chests (made clear via a location check progress bar) will send an item out to the -multiworld. The items that _would have been_ in those chests will be returned to the Risk of Rain player via grants by -other players in other worlds. + - Certain chests (made clear via a location check progress bar) will send an item out to the + multiworld. The location of these chests do not matter, since all environments share a unified location pool. Explore Mode: - - Just like in Classic mode chests will send out an item to the multiworld. The difference is that each environment - will have a set amount that can be sent out and shrines along with other things that will need to be checked. - Also, each environment is an item and, you'll need it to be able to access it. + - Chests will continue to work as they did in Classic Mode, the difference being that each environment + will have a set amount of items that can be sent out. In addition, shrines, radio scanners, newt altars, + and scavenger bags will need to be checked, depending on your settings. + This mode also makes each environment an item. In order to access a particular stage, you'll need it to be + sent in the multiworld. ## What is the goal of Risk of Rain 2 in Archipelago? -Just like in the original game, any way to "beat the game" counts as a win. Alternatively, if you are new to the game and +Just like in the original game, any way to "beat the game" counts as a win. This means beating one of the bosses +on Commencement, The Planetarium, or A Moment, Whole. Alternatively, if you are new to the game and aren't very confident in being able to "beat the game", you can set **Final Stage Death is Win** to true -(You can turn this on in your player settings.) This will make it so if you die on either Commencement or The Planetarium, -it will count as your goal, and **Obliterating yourself** will count as well. +(You can turn this on in your player settings.) This will make it so dying on either Commencement or The Planetarium, +or **obliterating yourself in A Moment, Fractured** will count as your goal. **You do not need to complete all the location checks** to win; any item you don't collect may be released if the server options allow. If you die before you accomplish your goal, you can start a new run. You will start the run with any items that you -received from other players. Any items that you picked up the "normal" way will be lost. +received from other players. However, these items will be randomized within their rarity at the start of each run. +Any items that you picked up the "normal" way will be lost. Note, you can play Simulacrum mode as part of an Archipelago, but you can't achieve any of the victory conditions in Simulacrum. So you could, for example, collect most of your items through a Simulacrum run(only works in classic mode), @@ -72,10 +74,10 @@ The Risk of Rain items are: Each item grants you a random in-game item from the category it belongs to. -When an item is granted by another world to the Risk of Rain player (one of the items listed above) then a random +When an item is granted by another world to the Risk of Rain player then a random in-game item of that tier will appear in the Risk of Rain player's inventory. If the item grant is an `Equipment` and -the player already has an equipment item equipped then the _item that was equipped_ will be dropped on the ground and _ -the new equipment_ will take it's place. (If you want the old one back, pick it up.) +the player already has an equipment item equipped then the _item that was equipped_ will be dropped on the ground and +_the new equipment_ will take it's place. Explore Mode items are: @@ -93,10 +95,10 @@ Dlc_Sotv items * `Sulfur Pools` * `Void Locus` -When a explore item is granted it will unlock that environment and will now be accessible to progress to victory! The -game will still pick randomly which environment is next but it will first check to see if they are available. If you have -them unlocked it will weight the game to have a ***higher chance*** to go to one you have checks versus one you have -already completed. You will still not be able to goto a stage 3 environment from a stage 1 environment. +When an explore item is granted, it will unlock that environment and will now be accessible! The +game will still pick randomly which environment is next, but it will first check to see if they are available. If you have +multiple of the next environments unlocked, it will weight the game to have a ***higher chance*** to go to one you +have checks in versus one you have already completed. You will still be unable to go to a stage 3 environment from a stage 1 environment. @@ -108,9 +110,9 @@ to 250** items. The number of items will be randomized between all players, so y item pickup step based on how many items the other players in the multiworld have. (Around 100 seems to be a good ballpark if you want to have a similar number of items to most other games.) -In explore mode the amount of checks base on how many **chests, shrines, scavengers, radio scanners and, newt altars** -are in the pool. With just the base game the numbers are **52 to 516** and with the dlc its **60 to 660** with -everything on default being **216** +In explore mode, the amount of checks are based on how many **chests, shrines, scavengers, radio scanners, and newt altars** +are in the pool. With just the base game, checks can range from **52 to 516**, with the DLC expanding it to **60 to 660**. +Leaving everything on default, the total number of checks comes out to **216** locations. After you have completed the specified number of checks, you won't send anything else to the multiworld. You can receive up to the specified number of randomized items from the multiworld as the players find them. In either case, @@ -120,12 +122,15 @@ you can continue to collect items as normal in Risk of Rain 2 if you've already When the Risk of Rain player fills up their location check bar then the next spawned item will become an item grant for another player's world (or possibly get sent back to yourself). The item in Risk of Rain will disappear in a poof of -smoke and the grant will automatically go out to the multiworld. +smoke and the grant will automatically go out to the multiworld. Additionally, you will see a message in the chat saying +what item you sent out. If the message does not appear, this likely means that another game has collected their items from you. ## What is the item pickup step? -The item pickup step is a YAML setting which allows you to set how many items you need to spawn before the _next_ item -that is spawned disappears (in a poof of smoke) and goes out to the multiworld. +The item pickup step is a setting in the YAML which allows you to set how many items you need to spawn before the _next_ item +that is spawned disappears (in a poof of smoke) and goes out to the multiworld. For instance, an item step of **1** means that +every other chest will send an item to the multiworld. An item step of **2** means that every third chest sends out an item +just as an item step of **0** would send an item on **each chest.** ## Is Archipelago compatible with other Risk of Rain 2 mods? diff --git a/worlds/sc2wol/Client.py b/worlds/sc2wol/Client.py new file mode 100644 index 000000000000..a9bb826b7447 --- /dev/null +++ b/worlds/sc2wol/Client.py @@ -0,0 +1,1211 @@ +from __future__ import annotations + +import asyncio +import copy +import ctypes +import json +import logging +import multiprocessing +import os.path +import re +import sys +import typing +import queue +import zipfile +import io +import random +from pathlib import Path + +# CommonClient import first to trigger ModuleUpdater +from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser +from Utils import init_logging, is_windows + +if __name__ == "__main__": + init_logging("SC2Client", exception_logger="Client") + +logger = logging.getLogger("Client") +sc2_logger = logging.getLogger("Starcraft2") + +import nest_asyncio +from worlds._sc2common import bot +from worlds._sc2common.bot.data import Race +from worlds._sc2common.bot.main import run_game +from worlds._sc2common.bot.player import Bot +from worlds.sc2wol import SC2WoLWorld +from worlds.sc2wol.Items import lookup_id_to_name, get_full_item_list, ItemData, type_flaggroups, upgrade_numbers +from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET +from worlds.sc2wol.MissionTables import lookup_id_to_mission +from worlds.sc2wol.Regions import MissionInfo + +import colorama +from NetUtils import ClientStatus, NetworkItem, RawJSONtoTextParser, JSONtoTextParser, JSONMessagePart +from MultiServer import mark_raw + +loop = asyncio.get_event_loop_policy().new_event_loop() +nest_asyncio.apply(loop) +max_bonus: int = 13 +victory_modulo: int = 100 + +# GitHub repo where the Map/mod data is hosted for /download_data command +DATA_REPO_OWNER = "Ziktofel" +DATA_REPO_NAME = "Archipelago-SC2-data" +DATA_API_VERSION = "API2" + + +# Data version file path. +# This file is used to tell if the downloaded data are outdated +# Associated with /download_data command +def get_metadata_file(): + return os.environ["SC2PATH"] + os.sep + "ArchipelagoSC2Metadata.txt" + + +class StarcraftClientProcessor(ClientCommandProcessor): + ctx: SC2Context + + def _cmd_difficulty(self, difficulty: str = "") -> bool: + """Overrides the current difficulty set for the world. Takes the argument casual, normal, hard, or brutal""" + options = difficulty.split() + num_options = len(options) + + if num_options > 0: + difficulty_choice = options[0].lower() + if difficulty_choice == "casual": + self.ctx.difficulty_override = 0 + elif difficulty_choice == "normal": + self.ctx.difficulty_override = 1 + elif difficulty_choice == "hard": + self.ctx.difficulty_override = 2 + elif difficulty_choice == "brutal": + self.ctx.difficulty_override = 3 + else: + self.output("Unable to parse difficulty '" + options[0] + "'") + return False + + self.output("Difficulty set to " + options[0]) + return True + + else: + if self.ctx.difficulty == -1: + self.output("Please connect to a seed before checking difficulty.") + else: + current_difficulty = self.ctx.difficulty + if self.ctx.difficulty_override >= 0: + current_difficulty = self.ctx.difficulty_override + self.output("Current difficulty: " + ["Casual", "Normal", "Hard", "Brutal"][current_difficulty]) + self.output("To change the difficulty, add the name of the difficulty after the command.") + return False + + + def _cmd_game_speed(self, game_speed: str = "") -> bool: + """Overrides the current game speed for the world. + Takes the arguments default, slower, slow, normal, fast, faster""" + options = game_speed.split() + num_options = len(options) + + if num_options > 0: + speed_choice = options[0].lower() + if speed_choice == "default": + self.ctx.game_speed_override = 0 + elif speed_choice == "slower": + self.ctx.game_speed_override = 1 + elif speed_choice == "slow": + self.ctx.game_speed_override = 2 + elif speed_choice == "normal": + self.ctx.game_speed_override = 3 + elif speed_choice == "fast": + self.ctx.game_speed_override = 4 + elif speed_choice == "faster": + self.ctx.game_speed_override = 5 + else: + self.output("Unable to parse game speed '" + options[0] + "'") + return False + + self.output("Game speed set to " + options[0]) + return True + + else: + if self.ctx.game_speed == -1: + self.output("Please connect to a seed before checking game speed.") + else: + current_speed = self.ctx.game_speed + if self.ctx.game_speed_override >= 0: + current_speed = self.ctx.game_speed_override + self.output("Current game speed: " + + ["Default", "Slower", "Slow", "Normal", "Fast", "Faster"][current_speed]) + self.output("To change the game speed, add the name of the speed after the command," + " or Default to select based on difficulty.") + return False + + def _cmd_color(self, color: str = "") -> bool: + player_colors = [ + "White", "Red", "Blue", "Teal", + "Purple", "Yellow", "Orange", "Green", + "LightPink", "Violet", "LightGrey", "DarkGreen", + "Brown", "LightGreen", "DarkGrey", "Pink", + "Rainbow", "Random", "Default" + ] + match_colors = [player_color.lower() for player_color in player_colors] + if color: + if color.lower() not in match_colors: + self.output(color + " is not a valid color. Available colors: " + ', '.join(player_colors)) + return False + if color.lower() == "random": + color = random.choice(player_colors[:16]) + self.ctx.player_color = match_colors.index(color.lower()) + self.output("Color set to " + player_colors[self.ctx.player_color]) + else: + self.output("Current player color: " + player_colors[self.ctx.player_color]) + self.output("To change your colors, add the name of the color after the command.") + self.output("Available colors: " + ', '.join(player_colors)) + + def _cmd_disable_mission_check(self) -> bool: + """Disables the check to see if a mission is available to play. Meant for co-op runs where one player can play + the next mission in a chain the other player is doing.""" + self.ctx.missions_unlocked = True + sc2_logger.info("Mission check has been disabled") + return True + + def _cmd_play(self, mission_id: str = "") -> bool: + """Start a Starcraft 2 mission""" + + options = mission_id.split() + num_options = len(options) + + if num_options > 0: + mission_number = int(options[0]) + + self.ctx.play_mission(mission_number) + + else: + sc2_logger.info( + "Mission ID needs to be specified. Use /unfinished or /available to view ids for available missions.") + return False + + return True + + def _cmd_available(self) -> bool: + """Get what missions are currently available to play""" + + request_available_missions(self.ctx) + return True + + def _cmd_unfinished(self) -> bool: + """Get what missions are currently available to play and have not had all locations checked""" + + request_unfinished_missions(self.ctx) + return True + + @mark_raw + def _cmd_set_path(self, path: str = '') -> bool: + """Manually set the SC2 install directory (if the automatic detection fails).""" + if path: + os.environ["SC2PATH"] = path + is_mod_installed_correctly() + return True + else: + sc2_logger.warning("When using set_path, you must type the path to your SC2 install directory.") + return False + + def _cmd_download_data(self) -> bool: + """Download the most recent release of the necessary files for playing SC2 with + Archipelago. Will overwrite existing files.""" + if "SC2PATH" not in os.environ: + check_game_install_path() + + if os.path.exists(get_metadata_file()): + with open(get_metadata_file(), "r") as f: + metadata = f.read() + else: + metadata = None + + tempzip, metadata = download_latest_release_zip(DATA_REPO_OWNER, DATA_REPO_NAME, DATA_API_VERSION, + metadata=metadata, force_download=True) + + if tempzip != '': + try: + zipfile.ZipFile(tempzip).extractall(path=os.environ["SC2PATH"]) + sc2_logger.info(f"Download complete. Package installed.") + with open(get_metadata_file(), "w") as f: + f.write(metadata) + finally: + os.remove(tempzip) + else: + sc2_logger.warning("Download aborted/failed. Read the log for more information.") + return False + return True + + +class SC2JSONtoTextParser(JSONtoTextParser): + def __init__(self, ctx): + self.handlers = { + "ItemSend": self._handle_color, + "ItemCheat": self._handle_color, + "Hint": self._handle_color, + } + super().__init__(ctx) + + def _handle_color(self, node: JSONMessagePart): + codes = node["color"].split(";") + buffer = "".join(self.color_code(code) for code in codes if code in self.color_codes) + return buffer + self._handle_text(node) + '' + + def color_code(self, code: str): + return '' + + +class SC2Context(CommonContext): + command_processor = StarcraftClientProcessor + game = "Starcraft 2 Wings of Liberty" + items_handling = 0b111 + difficulty = -1 + game_speed = -1 + all_in_choice = 0 + mission_order = 0 + player_color = 2 + mission_req_table: typing.Dict[str, MissionInfo] = {} + final_mission: int = 29 + announcements = queue.Queue() + sc2_run_task: typing.Optional[asyncio.Task] = None + missions_unlocked: bool = False # allow launching missions ignoring requirements + generic_upgrade_missions = 0 + generic_upgrade_research = 0 + generic_upgrade_items = 0 + current_tooltip = None + last_loc_list = None + difficulty_override = -1 + game_speed_override = -1 + mission_id_to_location_ids: typing.Dict[int, typing.List[int]] = {} + last_bot: typing.Optional[ArchipelagoBot] = None + + def __init__(self, *args, **kwargs): + super(SC2Context, self).__init__(*args, **kwargs) + self.raw_text_parser = SC2JSONtoTextParser(self) + + async def server_auth(self, password_requested: bool = False): + if password_requested and not self.password: + await super(SC2Context, self).server_auth(password_requested) + await self.get_username() + await self.send_connect() + + def on_package(self, cmd: str, args: dict): + if cmd in {"Connected"}: + self.difficulty = args["slot_data"]["game_difficulty"] + if "game_speed" in args["slot_data"]: + self.game_speed = args["slot_data"]["game_speed"] + else: + self.game_speed = 0 + self.all_in_choice = args["slot_data"]["all_in_map"] + slot_req_table = args["slot_data"]["mission_req"] + # Maintaining backwards compatibility with older slot data + self.mission_req_table = { + mission: MissionInfo( + **{field: value for field, value in mission_info.items() if field in MissionInfo._fields} + ) + for mission, mission_info in slot_req_table.items() + } + self.mission_order = args["slot_data"].get("mission_order", 0) + self.final_mission = args["slot_data"].get("final_mission", 29) + self.player_color = args["slot_data"].get("player_color", 2) + self.generic_upgrade_missions = args["slot_data"].get("generic_upgrade_missions", 0) + self.generic_upgrade_items = args["slot_data"].get("generic_upgrade_items", 0) + self.generic_upgrade_research = args["slot_data"].get("generic_upgrade_research", 0) + + self.build_location_to_mission_mapping() + + # Looks for the required maps and mods for SC2. Runs check_game_install_path. + maps_present = is_mod_installed_correctly() + if os.path.exists(get_metadata_file()): + with open(get_metadata_file(), "r") as f: + current_ver = f.read() + sc2_logger.debug(f"Current version: {current_ver}") + if is_mod_update_available(DATA_REPO_OWNER, DATA_REPO_NAME, DATA_API_VERSION, current_ver): + sc2_logger.info("NOTICE: Update for required files found. Run /download_data to install.") + elif maps_present: + sc2_logger.warning("NOTICE: Your map files may be outdated (version number not found). " + "Run /download_data to update them.") + + + def on_print_json(self, args: dict): + # goes to this world + if "receiving" in args and self.slot_concerns_self(args["receiving"]): + relevant = True + # found in this world + elif "item" in args and self.slot_concerns_self(args["item"].player): + relevant = True + # not related + else: + relevant = False + + if relevant: + self.announcements.put(self.raw_text_parser(copy.deepcopy(args["data"]))) + + super(SC2Context, self).on_print_json(args) + + def run_gui(self): + from kvui import GameManager, HoverBehavior, ServerToolTip + from kivy.app import App + from kivy.clock import Clock + from kivy.uix.tabbedpanel import TabbedPanelItem + from kivy.uix.gridlayout import GridLayout + from kivy.lang import Builder + from kivy.uix.label import Label + from kivy.uix.button import Button + from kivy.uix.floatlayout import FloatLayout + from kivy.properties import StringProperty + + class HoverableButton(HoverBehavior, Button): + pass + + class MissionButton(HoverableButton): + tooltip_text = StringProperty("Test") + ctx: SC2Context + + def __init__(self, *args, **kwargs): + super(HoverableButton, self).__init__(*args, **kwargs) + self.layout = FloatLayout() + self.popuplabel = ServerToolTip(text=self.text) + self.layout.add_widget(self.popuplabel) + + def on_enter(self): + self.popuplabel.text = self.tooltip_text + + if self.ctx.current_tooltip: + App.get_running_app().root.remove_widget(self.ctx.current_tooltip) + + if self.tooltip_text == "": + self.ctx.current_tooltip = None + else: + App.get_running_app().root.add_widget(self.layout) + self.ctx.current_tooltip = self.layout + + def on_leave(self): + self.ctx.ui.clear_tooltip() + + @property + def ctx(self) -> CommonContext: + return App.get_running_app().ctx + + class MissionLayout(GridLayout): + pass + + class MissionCategory(GridLayout): + pass + + class SC2Manager(GameManager): + logging_pairs = [ + ("Client", "Archipelago"), + ("Starcraft2", "Starcraft2"), + ] + base_title = "Archipelago Starcraft 2 Client" + + mission_panel = None + last_checked_locations = {} + mission_id_to_button = {} + launching: typing.Union[bool, int] = False # if int -> mission ID + refresh_from_launching = True + first_check = True + ctx: SC2Context + + def __init__(self, ctx): + super().__init__(ctx) + + def clear_tooltip(self): + if self.ctx.current_tooltip: + App.get_running_app().root.remove_widget(self.ctx.current_tooltip) + + self.ctx.current_tooltip = None + + def build(self): + container = super().build() + + panel = TabbedPanelItem(text="Starcraft 2 Launcher") + self.mission_panel = panel.content = MissionLayout() + + self.tabs.add_widget(panel) + + Clock.schedule_interval(self.build_mission_table, 0.5) + + return container + + def build_mission_table(self, dt): + if (not self.launching and (not self.last_checked_locations == self.ctx.checked_locations or + not self.refresh_from_launching)) or self.first_check: + self.refresh_from_launching = True + + self.mission_panel.clear_widgets() + if self.ctx.mission_req_table: + self.last_checked_locations = self.ctx.checked_locations.copy() + self.first_check = False + + self.mission_id_to_button = {} + categories = {} + available_missions, unfinished_missions = calc_unfinished_missions(self.ctx) + + # separate missions into categories + for mission in self.ctx.mission_req_table: + if not self.ctx.mission_req_table[mission].category in categories: + categories[self.ctx.mission_req_table[mission].category] = [] + + categories[self.ctx.mission_req_table[mission].category].append(mission) + + for category in categories: + category_panel = MissionCategory() + if category.startswith('_'): + category_display_name = '' + else: + category_display_name = category + category_panel.add_widget( + Label(text=category_display_name, size_hint_y=None, height=50, outline_width=1)) + + for mission in categories[category]: + text: str = mission + tooltip: str = "" + mission_id: int = self.ctx.mission_req_table[mission].id + # Map has uncollected locations + if mission in unfinished_missions: + text = f"[color=6495ED]{text}[/color]" + elif mission in available_missions: + text = f"[color=FFFFFF]{text}[/color]" + # Map requirements not met + else: + text = f"[color=a9a9a9]{text}[/color]" + tooltip = f"Requires: " + if self.ctx.mission_req_table[mission].required_world: + tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission - 1] for + req_mission in + self.ctx.mission_req_table[mission].required_world) + + if self.ctx.mission_req_table[mission].number: + tooltip += " and " + if self.ctx.mission_req_table[mission].number: + tooltip += f"{self.ctx.mission_req_table[mission].number} missions completed" + remaining_location_names: typing.List[str] = [ + self.ctx.location_names[loc] for loc in self.ctx.locations_for_mission(mission) + if loc in self.ctx.missing_locations] + + if mission_id == self.ctx.final_mission: + if mission in available_missions: + text = f"[color=FFBC95]{mission}[/color]" + else: + text = f"[color=D0C0BE]{mission}[/color]" + if tooltip: + tooltip += "\n" + tooltip += "Final Mission" + + if remaining_location_names: + if tooltip: + tooltip += "\n" + tooltip += f"Uncollected locations:\n" + tooltip += "\n".join(remaining_location_names) + + mission_button = MissionButton(text=text, size_hint_y=None, height=50) + mission_button.tooltip_text = tooltip + mission_button.bind(on_press=self.mission_callback) + self.mission_id_to_button[mission_id] = mission_button + category_panel.add_widget(mission_button) + + category_panel.add_widget(Label(text="")) + self.mission_panel.add_widget(category_panel) + + elif self.launching: + self.refresh_from_launching = False + + self.mission_panel.clear_widgets() + self.mission_panel.add_widget(Label(text="Launching Mission: " + + lookup_id_to_mission[self.launching])) + if self.ctx.ui: + self.ctx.ui.clear_tooltip() + + def mission_callback(self, button): + if not self.launching: + mission_id: int = next(k for k, v in self.mission_id_to_button.items() if v == button) + if self.ctx.play_mission(mission_id): + self.launching = mission_id + Clock.schedule_once(self.finish_launching, 10) + + def finish_launching(self, dt): + self.launching = False + + self.ui = SC2Manager(self) + self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") + import pkgutil + data = pkgutil.get_data(SC2WoLWorld.__module__, "Starcraft2.kv").decode() + Builder.load_string(data) + + async def shutdown(self): + await super(SC2Context, self).shutdown() + if self.last_bot: + self.last_bot.want_close = True + if self.sc2_run_task: + self.sc2_run_task.cancel() + + def play_mission(self, mission_id: int) -> bool: + if self.missions_unlocked or \ + is_mission_available(self, mission_id): + if self.sc2_run_task: + if not self.sc2_run_task.done(): + sc2_logger.warning("Starcraft 2 Client is still running!") + self.sc2_run_task.cancel() # doesn't actually close the game, just stops the python task + if self.slot is None: + sc2_logger.warning("Launching Mission without Archipelago authentication, " + "checks will not be registered to server.") + self.sc2_run_task = asyncio.create_task(starcraft_launch(self, mission_id), + name="Starcraft 2 Launch") + return True + else: + sc2_logger.info( + f"{lookup_id_to_mission[mission_id]} is not currently unlocked. " + f"Use /unfinished or /available to see what is available.") + return False + + def build_location_to_mission_mapping(self): + mission_id_to_location_ids: typing.Dict[int, typing.Set[int]] = { + mission_info.id: set() for mission_info in self.mission_req_table.values() + } + + for loc in self.server_locations: + mission_id, objective = divmod(loc - SC2WOL_LOC_ID_OFFSET, victory_modulo) + mission_id_to_location_ids[mission_id].add(objective) + self.mission_id_to_location_ids = {mission_id: sorted(objectives) for mission_id, objectives in + mission_id_to_location_ids.items()} + + def locations_for_mission(self, mission: str): + mission_id: int = self.mission_req_table[mission].id + objectives = self.mission_id_to_location_ids[self.mission_req_table[mission].id] + for objective in objectives: + yield SC2WOL_LOC_ID_OFFSET + mission_id * 100 + objective + + +async def main(): + multiprocessing.freeze_support() + parser = get_base_parser() + parser.add_argument('--name', default=None, help="Slot Name to connect as.") + args = parser.parse_args() + + ctx = SC2Context(args.connect, args.password) + ctx.auth = args.name + if ctx.server_task is None: + ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") + + if gui_enabled: + ctx.run_gui() + ctx.run_cli() + + await ctx.exit_event.wait() + + await ctx.shutdown() + + +maps_table = [ + "ap_liberation_day", "ap_the_outlaws", "ap_zero_hour", + "ap_evacuation", "ap_outbreak", "ap_safe_haven", "ap_havens_fall", + "ap_smash_and_grab", "ap_the_dig", "ap_the_moebius_factor", "ap_supernova", "ap_maw_of_the_void", + "ap_devils_playground", "ap_welcome_to_the_jungle", "ap_breakout", "ap_ghost_of_a_chance", + "ap_the_great_train_robbery", "ap_cutthroat", "ap_engine_of_destruction", "ap_media_blitz", "ap_piercing_the_shroud", + "ap_whispers_of_doom", "ap_a_sinister_turn", "ap_echoes_of_the_future", "ap_in_utter_darkness", + "ap_gates_of_hell", "ap_belly_of_the_beast", "ap_shatter_the_sky", "ap_all_in" +] + +wol_default_categories = [ + "Mar Sara", "Mar Sara", "Mar Sara", "Colonist", "Colonist", "Colonist", "Colonist", + "Artifact", "Artifact", "Artifact", "Artifact", "Artifact", "Covert", "Covert", "Covert", "Covert", + "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Prophecy", "Prophecy", "Prophecy", "Prophecy", + "Char", "Char", "Char", "Char" +] +wol_default_category_names = [ + "Mar Sara", "Colonist", "Artifact", "Covert", "Rebellion", "Prophecy", "Char" +] + + +def calculate_items(ctx: SC2Context) -> typing.List[int]: + items = ctx.items_received + network_item: NetworkItem + accumulators: typing.List[int] = [0 for _ in type_flaggroups] + + for network_item in items: + name: str = lookup_id_to_name[network_item.item] + item_data: ItemData = get_full_item_list()[name] + + # exists exactly once + if item_data.quantity == 1: + accumulators[type_flaggroups[item_data.type]] |= 1 << item_data.number + + # exists multiple times + elif item_data.type == "Upgrade" or item_data.type == "Progressive Upgrade": + flaggroup = type_flaggroups[item_data.type] + + # Generic upgrades apply only to Weapon / Armor upgrades + if item_data.type != "Upgrade" or ctx.generic_upgrade_items == 0: + accumulators[flaggroup] += 1 << item_data.number + else: + for bundled_number in upgrade_numbers[item_data.number]: + accumulators[flaggroup] += 1 << bundled_number + + # sum + else: + accumulators[type_flaggroups[item_data.type]] += item_data.number + + # Upgrades from completed missions + if ctx.generic_upgrade_missions > 0: + upgrade_flaggroup = type_flaggroups["Upgrade"] + num_missions = ctx.generic_upgrade_missions * len(ctx.mission_req_table) + amounts = [ + num_missions // 100, + 2 * num_missions // 100, + 3 * num_missions // 100 + ] + upgrade_count = 0 + completed = len([id for id in ctx.mission_id_to_location_ids if SC2WOL_LOC_ID_OFFSET + victory_modulo * id in ctx.checked_locations]) + for amount in amounts: + if completed >= amount: + upgrade_count += 1 + # Equivalent to "Progressive Weapon/Armor Upgrade" item + for bundled_number in upgrade_numbers[5]: + accumulators[upgrade_flaggroup] += upgrade_count << bundled_number + + return accumulators + + +def calc_difficulty(difficulty): + if difficulty == 0: + return 'C' + elif difficulty == 1: + return 'N' + elif difficulty == 2: + return 'H' + elif difficulty == 3: + return 'B' + + return 'X' + + +async def starcraft_launch(ctx: SC2Context, mission_id: int): + sc2_logger.info(f"Launching {lookup_id_to_mission[mission_id]}. If game does not launch check log file for errors.") + + with DllDirectory(None): + run_game(bot.maps.get(maps_table[mission_id - 1]), [Bot(Race.Terran, ArchipelagoBot(ctx, mission_id), + name="Archipelago", fullscreen=True)], realtime=True) + + +class ArchipelagoBot(bot.bot_ai.BotAI): + game_running: bool = False + mission_completed: bool = False + boni: typing.List[bool] + setup_done: bool + ctx: SC2Context + mission_id: int + want_close: bool = False + can_read_game = False + last_received_update: int = 0 + + def __init__(self, ctx: SC2Context, mission_id): + self.setup_done = False + self.ctx = ctx + self.ctx.last_bot = self + self.mission_id = mission_id + self.boni = [False for _ in range(max_bonus)] + + super(ArchipelagoBot, self).__init__() + + async def on_step(self, iteration: int): + if self.want_close: + self.want_close = False + await self._client.leave() + return + game_state = 0 + if not self.setup_done: + self.setup_done = True + start_items = calculate_items(self.ctx) + if self.ctx.difficulty_override >= 0: + difficulty = calc_difficulty(self.ctx.difficulty_override) + else: + difficulty = calc_difficulty(self.ctx.difficulty) + if self.ctx.game_speed_override >= 0: + game_speed = self.ctx.game_speed_override + else: + game_speed = self.ctx.game_speed + await self.chat_send("?SetOptions {} {} {} {}".format( + difficulty, + self.ctx.generic_upgrade_research, + self.ctx.all_in_choice, + game_speed + )) + await self.chat_send("?GiveResources {} {} {}".format( + start_items[8], + start_items[9], + start_items[10] + )) + await self.chat_send("?GiveTerranTech {} {} {} {} {} {} {} {} {} {}".format( + start_items[0], start_items[1], start_items[2], start_items[3], start_items[4], + start_items[5], start_items[6], start_items[12], start_items[13], start_items[14])) + await self.chat_send("?GiveProtossTech {}".format(start_items[7])) + await self.chat_send("?SetColor rr " + str(self.ctx.player_color)) # TODO: Add faction color options + await self.chat_send("?LoadFinished") + self.last_received_update = len(self.ctx.items_received) + + else: + if not self.ctx.announcements.empty(): + message = self.ctx.announcements.get(timeout=1) + await self.chat_send("?SendMessage " + message) + self.ctx.announcements.task_done() + + # Archipelago reads the health + for unit in self.all_own_units(): + if unit.health_max == 38281: + game_state = int(38281 - unit.health) + self.can_read_game = True + + if iteration == 160 and not game_state & 1: + await self.chat_send("?SendMessage Warning: Archipelago unable to connect or has lost connection to " + + "Starcraft 2 (This is likely a map issue)") + + if self.last_received_update < len(self.ctx.items_received): + current_items = calculate_items(self.ctx) + await self.chat_send("?GiveTerranTech {} {} {} {} {} {} {} {} {} {}".format( + current_items[0], current_items[1], current_items[2], current_items[3], current_items[4], + current_items[5], current_items[6], current_items[12], current_items[13], current_items[14])) + await self.chat_send("?GiveProtossTech {}".format(current_items[7])) + self.last_received_update = len(self.ctx.items_received) + + if game_state & 1: + if not self.game_running: + print("Archipelago Connected") + self.game_running = True + + if self.can_read_game: + if game_state & (1 << 1) and not self.mission_completed: + if self.mission_id != self.ctx.final_mission: + print("Mission Completed") + await self.ctx.send_msgs( + [{"cmd": 'LocationChecks', + "locations": [SC2WOL_LOC_ID_OFFSET + victory_modulo * self.mission_id]}]) + self.mission_completed = True + else: + print("Game Complete") + await self.ctx.send_msgs([{"cmd": 'StatusUpdate', "status": ClientStatus.CLIENT_GOAL}]) + self.mission_completed = True + + for x, completed in enumerate(self.boni): + if not completed and game_state & (1 << (x + 2)): + await self.ctx.send_msgs( + [{"cmd": 'LocationChecks', + "locations": [SC2WOL_LOC_ID_OFFSET + victory_modulo * self.mission_id + x + 1]}]) + self.boni[x] = True + + else: + await self.chat_send("?SendMessage LostConnection - Lost connection to game.") + + +def request_unfinished_missions(ctx: SC2Context): + if ctx.mission_req_table: + message = "Unfinished Missions: " + unlocks = initialize_blank_mission_dict(ctx.mission_req_table) + unfinished_locations = initialize_blank_mission_dict(ctx.mission_req_table) + + _, unfinished_missions = calc_unfinished_missions(ctx, unlocks=unlocks) + + # Removing All-In from location pool + final_mission = lookup_id_to_mission[ctx.final_mission] + if final_mission in unfinished_missions.keys(): + message = f"Final Mission Available: {final_mission}[{ctx.final_mission}]\n" + message + if unfinished_missions[final_mission] == -1: + unfinished_missions.pop(final_mission) + + message += ", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}[{ctx.mission_req_table[mission].id}] " + + mark_up_objectives( + f"[{len(unfinished_missions[mission])}/" + f"{sum(1 for _ in ctx.locations_for_mission(mission))}]", + ctx, unfinished_locations, mission) + for mission in unfinished_missions) + + if ctx.ui: + ctx.ui.log_panels['All'].on_message_markup(message) + ctx.ui.log_panels['Starcraft2'].on_message_markup(message) + else: + sc2_logger.info(message) + else: + sc2_logger.warning("No mission table found, you are likely not connected to a server.") + + +def calc_unfinished_missions(ctx: SC2Context, unlocks=None): + unfinished_missions = [] + locations_completed = [] + + if not unlocks: + unlocks = initialize_blank_mission_dict(ctx.mission_req_table) + + available_missions = calc_available_missions(ctx, unlocks) + + for name in available_missions: + objectives = set(ctx.locations_for_mission(name)) + if objectives: + objectives_completed = ctx.checked_locations & objectives + if len(objectives_completed) < len(objectives): + unfinished_missions.append(name) + locations_completed.append(objectives_completed) + + else: # infer that this is the final mission as it has no objectives + unfinished_missions.append(name) + locations_completed.append(-1) + + return available_missions, dict(zip(unfinished_missions, locations_completed)) + + +def is_mission_available(ctx: SC2Context, mission_id_to_check): + unfinished_missions = calc_available_missions(ctx) + + return any(mission_id_to_check == ctx.mission_req_table[mission].id for mission in unfinished_missions) + + +def mark_up_mission_name(ctx: SC2Context, mission, unlock_table): + """Checks if the mission is required for game completion and adds '*' to the name to mark that.""" + + if ctx.mission_req_table[mission].completion_critical: + if ctx.ui: + message = "[color=AF99EF]" + mission + "[/color]" + else: + message = "*" + mission + "*" + else: + message = mission + + if ctx.ui: + unlocks = unlock_table[mission] + + if len(unlocks) > 0: + pre_message = f"[ref={list(ctx.mission_req_table).index(mission)}|Unlocks: " + pre_message += ", ".join(f"{unlock}({ctx.mission_req_table[unlock].id})" for unlock in unlocks) + pre_message += f"]" + message = pre_message + message + "[/ref]" + + return message + + +def mark_up_objectives(message, ctx, unfinished_locations, mission): + formatted_message = message + + if ctx.ui: + locations = unfinished_locations[mission] + + pre_message = f"[ref={list(ctx.mission_req_table).index(mission) + 30}|" + pre_message += "
".join(location for location in locations) + pre_message += f"]" + formatted_message = pre_message + message + "[/ref]" + + return formatted_message + + +def request_available_missions(ctx: SC2Context): + if ctx.mission_req_table: + message = "Available Missions: " + + # Initialize mission unlock table + unlocks = initialize_blank_mission_dict(ctx.mission_req_table) + + missions = calc_available_missions(ctx, unlocks) + message += \ + ", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}" + f"[{ctx.mission_req_table[mission].id}]" + for mission in missions) + + if ctx.ui: + ctx.ui.log_panels['All'].on_message_markup(message) + ctx.ui.log_panels['Starcraft2'].on_message_markup(message) + else: + sc2_logger.info(message) + else: + sc2_logger.warning("No mission table found, you are likely not connected to a server.") + + +def calc_available_missions(ctx: SC2Context, unlocks=None): + available_missions = [] + missions_complete = 0 + + # Get number of missions completed + for loc in ctx.checked_locations: + if loc % victory_modulo == 0: + missions_complete += 1 + + for name in ctx.mission_req_table: + # Go through the required missions for each mission and fill up unlock table used later for hover-over tooltips + if unlocks: + for unlock in ctx.mission_req_table[name].required_world: + unlocks[list(ctx.mission_req_table)[unlock - 1]].append(name) + + if mission_reqs_completed(ctx, name, missions_complete): + available_missions.append(name) + + return available_missions + + +def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete: int): + """Returns a bool signifying if the mission has all requirements complete and can be done + + Arguments: + ctx -- instance of SC2Context + locations_to_check -- the mission string name to check + missions_complete -- an int of how many missions have been completed + mission_path -- a list of missions that have already been checked +""" + if len(ctx.mission_req_table[mission_name].required_world) >= 1: + # A check for when the requirements are being or'd + or_success = False + + # Loop through required missions + for req_mission in ctx.mission_req_table[mission_name].required_world: + req_success = True + + # Check if required mission has been completed + if not (ctx.mission_req_table[list(ctx.mission_req_table)[req_mission - 1]].id * + victory_modulo + SC2WOL_LOC_ID_OFFSET) in ctx.checked_locations: + if not ctx.mission_req_table[mission_name].or_requirements: + return False + else: + req_success = False + + # Grid-specific logic (to avoid long path checks and infinite recursion) + if ctx.mission_order in (3, 4): + if req_success: + return True + else: + if req_mission is ctx.mission_req_table[mission_name].required_world[-1]: + return False + else: + continue + + # Recursively check required mission to see if it's requirements are met, in case !collect has been done + # Skipping recursive check on Grid settings to speed up checks and avoid infinite recursion + if not mission_reqs_completed(ctx, list(ctx.mission_req_table)[req_mission - 1], missions_complete): + if not ctx.mission_req_table[mission_name].or_requirements: + return False + else: + req_success = False + + # If requirement check succeeded mark or as satisfied + if ctx.mission_req_table[mission_name].or_requirements and req_success: + or_success = True + + if ctx.mission_req_table[mission_name].or_requirements: + # Return false if or requirements not met + if not or_success: + return False + + # Check number of missions + if missions_complete >= ctx.mission_req_table[mission_name].number: + return True + else: + return False + else: + return True + + +def initialize_blank_mission_dict(location_table): + unlocks = {} + + for mission in list(location_table): + unlocks[mission] = [] + + return unlocks + + +def check_game_install_path() -> bool: + # First thing: go to the default location for ExecuteInfo. + # An exception for Windows is included because it's very difficult to find ~\Documents if the user moved it. + if is_windows: + # The next five lines of utterly inscrutable code are brought to you by copy-paste from Stack Overflow. + # https://stackoverflow.com/questions/6227590/finding-the-users-my-documents-path/30924555# + import ctypes.wintypes + CSIDL_PERSONAL = 5 # My Documents + SHGFP_TYPE_CURRENT = 0 # Get current, not default value + + buf = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH) + ctypes.windll.shell32.SHGetFolderPathW(None, CSIDL_PERSONAL, None, SHGFP_TYPE_CURRENT, buf) + documentspath = buf.value + einfo = str(documentspath / Path("StarCraft II\\ExecuteInfo.txt")) + else: + einfo = str(bot.paths.get_home() / Path(bot.paths.USERPATH[bot.paths.PF])) + + # Check if the file exists. + if os.path.isfile(einfo): + + # Open the file and read it, picking out the latest executable's path. + with open(einfo) as f: + content = f.read() + if content: + try: + base = re.search(r" = (.*)Versions", content).group(1) + except AttributeError: + sc2_logger.warning(f"Found {einfo}, but it was empty. Run SC2 through the Blizzard launcher, then " + f"try again.") + return False + if os.path.exists(base): + executable = bot.paths.latest_executeble(Path(base).expanduser() / "Versions") + + # Finally, check the path for an actual executable. + # If we find one, great. Set up the SC2PATH. + if os.path.isfile(executable): + sc2_logger.info(f"Found an SC2 install at {base}!") + sc2_logger.debug(f"Latest executable at {executable}.") + os.environ["SC2PATH"] = base + sc2_logger.debug(f"SC2PATH set to {base}.") + return True + else: + sc2_logger.warning(f"We may have found an SC2 install at {base}, but couldn't find {executable}.") + else: + sc2_logger.warning(f"{einfo} pointed to {base}, but we could not find an SC2 install there.") + else: + sc2_logger.warning(f"Couldn't find {einfo}. Run SC2 through the Blizzard launcher, then try again. " + f"If that fails, please run /set_path with your SC2 install directory.") + return False + + +def is_mod_installed_correctly() -> bool: + """Searches for all required files.""" + if "SC2PATH" not in os.environ: + check_game_install_path() + + mapdir = os.environ['SC2PATH'] / Path('Maps/ArchipelagoCampaign') + mods = ["ArchipelagoCore", "ArchipelagoPlayer", "ArchipelagoPlayerWoL", "ArchipelagoTriggers"] + modfiles = [os.environ["SC2PATH"] / Path("Mods/" + mod + ".SC2Mod") for mod in mods] + wol_required_maps = ["WoL" + os.sep + map_name + ".SC2Map" for map_name in maps_table] + needs_files = False + + # Check for maps. + missing_maps = [] + for mapfile in wol_required_maps: + if not os.path.isfile(mapdir / mapfile): + missing_maps.append(mapfile) + if len(missing_maps) >= 19: + sc2_logger.warning(f"All map files missing from {mapdir}.") + needs_files = True + elif len(missing_maps) > 0: + for map in missing_maps: + sc2_logger.debug(f"Missing {map} from {mapdir}.") + sc2_logger.warning(f"Missing {len(missing_maps)} map files.") + needs_files = True + else: # Must be no maps missing + sc2_logger.info(f"All maps found in {mapdir}.") + + # Check for mods. + for modfile in modfiles: + if os.path.isfile(modfile) or os.path.isdir(modfile): + sc2_logger.info(f"Archipelago mod found at {modfile}.") + else: + sc2_logger.warning(f"Archipelago mod could not be found at {modfile}.") + needs_files = True + + # Final verdict. + if needs_files: + sc2_logger.warning(f"Required files are missing. Run /download_data to acquire them.") + return False + else: + sc2_logger.debug(f"All map/mod files are properly installed.") + return True + + +class DllDirectory: + # Credit to Black Sliver for this code. + # More info: https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-setdlldirectoryw + _old: typing.Optional[str] = None + _new: typing.Optional[str] = None + + def __init__(self, new: typing.Optional[str]): + self._new = new + + def __enter__(self): + old = self.get() + if self.set(self._new): + self._old = old + + def __exit__(self, *args): + if self._old is not None: + self.set(self._old) + + @staticmethod + def get() -> typing.Optional[str]: + if sys.platform == "win32": + n = ctypes.windll.kernel32.GetDllDirectoryW(0, None) + buf = ctypes.create_unicode_buffer(n) + ctypes.windll.kernel32.GetDllDirectoryW(n, buf) + return buf.value + # NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific + return None + + @staticmethod + def set(s: typing.Optional[str]) -> bool: + if sys.platform == "win32": + return ctypes.windll.kernel32.SetDllDirectoryW(s) != 0 + # NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific + return False + + +def download_latest_release_zip(owner: str, repo: str, api_version: str, metadata: str = None, force_download=False) -> (str, str): + """Downloads the latest release of a GitHub repo to the current directory as a .zip file.""" + import requests + + headers = {"Accept": 'application/vnd.github.v3+json'} + url = f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{api_version}" + + r1 = requests.get(url, headers=headers) + if r1.status_code == 200: + latest_metadata = r1.json() + cleanup_downloaded_metadata(latest_metadata) + latest_metadata = str(latest_metadata) + # sc2_logger.info(f"Latest version: {latest_metadata}.") + else: + sc2_logger.warning(f"Status code: {r1.status_code}") + sc2_logger.warning(f"Failed to reach GitHub. Could not find download link.") + sc2_logger.warning(f"text: {r1.text}") + return "", metadata + + if (force_download is False) and (metadata == latest_metadata): + sc2_logger.info("Latest version already installed.") + return "", metadata + + sc2_logger.info(f"Attempting to download latest version of API version {api_version} of {repo}.") + download_url = r1.json()["assets"][0]["browser_download_url"] + + r2 = requests.get(download_url, headers=headers) + if r2.status_code == 200 and zipfile.is_zipfile(io.BytesIO(r2.content)): + with open(f"{repo}.zip", "wb") as fh: + fh.write(r2.content) + sc2_logger.info(f"Successfully downloaded {repo}.zip.") + return f"{repo}.zip", latest_metadata + else: + sc2_logger.warning(f"Status code: {r2.status_code}") + sc2_logger.warning("Download failed.") + sc2_logger.warning(f"text: {r2.text}") + return "", metadata + + +def cleanup_downloaded_metadata(medatada_json): + for asset in medatada_json['assets']: + del asset['download_count'] + + +def is_mod_update_available(owner: str, repo: str, api_version: str, metadata: str) -> bool: + import requests + + headers = {"Accept": 'application/vnd.github.v3+json'} + url = f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{api_version}" + + r1 = requests.get(url, headers=headers) + if r1.status_code == 200: + latest_metadata = r1.json() + cleanup_downloaded_metadata(latest_metadata) + latest_metadata = str(latest_metadata) + if metadata != latest_metadata: + return True + else: + return False + + else: + sc2_logger.warning(f"Failed to reach GitHub while checking for updates.") + sc2_logger.warning(f"Status code: {r1.status_code}") + sc2_logger.warning(f"text: {r1.text}") + return False + + +def launch(): + colorama.init() + asyncio.run(main()) + colorama.deinit() diff --git a/worlds/sc2wol/Items.py b/worlds/sc2wol/Items.py index ea495adf79bc..971a75375fe4 100644 --- a/worlds/sc2wol/Items.py +++ b/worlds/sc2wol/Items.py @@ -12,6 +12,7 @@ class ItemData(typing.NamedTuple): classification: ItemClassification = ItemClassification.useful quantity: int = 1 parent_item: str = None + origin: typing.Set[str] = {"wol"} class StarcraftWoLItem(Item): @@ -43,23 +44,36 @@ def get_full_item_list(): "Ghost": ItemData(15 + SC2WOL_ITEM_ID_OFFSET, "Unit", 15, classification=ItemClassification.progression), "Spectre": ItemData(16 + SC2WOL_ITEM_ID_OFFSET, "Unit", 16, classification=ItemClassification.progression), "Thor": ItemData(17 + SC2WOL_ITEM_ID_OFFSET, "Unit", 17, classification=ItemClassification.progression), + # EE units + "Liberator": ItemData(18 + SC2WOL_ITEM_ID_OFFSET, "Unit", 18, classification=ItemClassification.progression, origin={"nco", "ext"}), + "Valkyrie": ItemData(19 + SC2WOL_ITEM_ID_OFFSET, "Unit", 19, classification=ItemClassification.progression, origin={"bw"}), + "Widow Mine": ItemData(20 + SC2WOL_ITEM_ID_OFFSET, "Unit", 20, classification=ItemClassification.progression, origin={"ext"}), + "Cyclone": ItemData(21 + SC2WOL_ITEM_ID_OFFSET, "Unit", 21, classification=ItemClassification.progression, origin={"ext"}), + # Some other items are moved to Upgrade group because of the way how the bot message is parsed "Progressive Infantry Weapon": ItemData(100 + SC2WOL_ITEM_ID_OFFSET, "Upgrade", 0, quantity=3), "Progressive Infantry Armor": ItemData(102 + SC2WOL_ITEM_ID_OFFSET, "Upgrade", 2, quantity=3), "Progressive Vehicle Weapon": ItemData(103 + SC2WOL_ITEM_ID_OFFSET, "Upgrade", 4, quantity=3), "Progressive Vehicle Armor": ItemData(104 + SC2WOL_ITEM_ID_OFFSET, "Upgrade", 6, quantity=3), "Progressive Ship Weapon": ItemData(105 + SC2WOL_ITEM_ID_OFFSET, "Upgrade", 8, quantity=3), "Progressive Ship Armor": ItemData(106 + SC2WOL_ITEM_ID_OFFSET, "Upgrade", 10, quantity=3), + # Upgrade bundle 'number' values are used as indices to get affected 'number's + "Progressive Weapon Upgrade": ItemData(107 + SC2WOL_ITEM_ID_OFFSET, "Upgrade", 0, quantity=3), + "Progressive Armor Upgrade": ItemData(108 + SC2WOL_ITEM_ID_OFFSET, "Upgrade", 1, quantity=3), + "Progressive Infantry Upgrade": ItemData(109 + SC2WOL_ITEM_ID_OFFSET, "Upgrade", 2, quantity=3), + "Progressive Vehicle Upgrade": ItemData(110 + SC2WOL_ITEM_ID_OFFSET, "Upgrade", 3, quantity=3), + "Progressive Ship Upgrade": ItemData(111 + SC2WOL_ITEM_ID_OFFSET, "Upgrade", 4, quantity=3), + "Progressive Weapon/Armor Upgrade": ItemData(112 + SC2WOL_ITEM_ID_OFFSET, "Upgrade", 5, quantity=3), "Projectile Accelerator (Bunker)": ItemData(200 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 0, parent_item="Bunker"), "Neosteel Bunker (Bunker)": ItemData(201 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 1, parent_item="Bunker"), "Titanium Housing (Missile Turret)": ItemData(202 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 2, classification=ItemClassification.filler, parent_item="Missile Turret"), "Hellstorm Batteries (Missile Turret)": ItemData(203 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 3, parent_item="Missile Turret"), - "Advanced Construction (SCV)": ItemData(204 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 4, parent_item="SCV"), - "Dual-Fusion Welders (SCV)": ItemData(205 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 5, parent_item="SCV"), + "Advanced Construction (SCV)": ItemData(204 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 4), + "Dual-Fusion Welders (SCV)": ItemData(205 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 5), "Fire-Suppression System (Building)": ItemData(206 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 6), "Orbital Command (Building)": ItemData(207 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 7), - "Stimpack (Marine)": ItemData(208 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 8, parent_item="Marine"), + "Progressive Stimpack (Marine)": ItemData(208 + SC2WOL_ITEM_ID_OFFSET, "Progressive Upgrade", 0, parent_item="Marine", quantity=2), "Combat Shield (Marine)": ItemData(209 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 9, classification=ItemClassification.progression, parent_item="Marine"), "Advanced Medic Facilities (Medic)": ItemData(210 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 10, classification=ItemClassification.filler, parent_item="Medic"), "Stabilizer Medpacks (Medic)": ItemData(211 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 11, classification=ItemClassification.progression, parent_item="Medic"), @@ -69,10 +83,59 @@ def get_full_item_list(): "Kinetic Foam (Marauder)": ItemData(215 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 15, parent_item="Marauder"), "U-238 Rounds (Reaper)": ItemData(216 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 16, parent_item="Reaper"), "G-4 Clusterbomb (Reaper)": ItemData(217 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 17, classification=ItemClassification.progression, parent_item="Reaper"), + # Items from EE + "Mag-Field Accelerators (Cyclone)": ItemData(218 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 18, parent_item="Cyclone", origin={"ext"}), + "Mag-Field Launchers (Cyclone)": ItemData(219 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 19, parent_item="Cyclone", origin={"ext"}), + # Items from new mod + "Laser Targeting System (Marine)": ItemData(220 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 8, classification=ItemClassification.filler, parent_item="Marine", origin={"nco"}), # Freed slot from Stimpack + "Magrail Munitions (Marine)": ItemData(221 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 20, parent_item="Marine", origin={"nco"}), + "Optimized Logistics (Marine)": ItemData(222 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 21, classification=ItemClassification.filler, parent_item="Marine", origin={"nco"}), + "Restoration (Medic)": ItemData(223 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 22, classification=ItemClassification.filler, parent_item="Medic", origin={"bw"}), + "Optical Flare (Medic)": ItemData(224 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 23, classification=ItemClassification.filler, parent_item="Medic", origin={"bw"}), + "Optimized Logistics (Medic)": ItemData(225 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 24, classification=ItemClassification.filler, parent_item="Medic", origin={"bw"}), + "Progressive Stimpack (Firebat)": ItemData(226 + SC2WOL_ITEM_ID_OFFSET, "Progressive Upgrade", 6, parent_item="Firebat", quantity=2, origin={"bw"}), + "Optimized Logistics (Firebat)": ItemData(227 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 25, parent_item="Firebat", origin={"bw"}), + "Progressive Stimpack (Marauder)": ItemData(228 + SC2WOL_ITEM_ID_OFFSET, "Progressive Upgrade", 8, parent_item="Marauder", quantity=2, origin={"nco"}), + "Laser Targeting System (Marauder)": ItemData(229 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 26, classification=ItemClassification.filler, parent_item="Marauder", origin={"nco"}), + "Magrail Munitions (Marauder)": ItemData(230 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 27, classification=ItemClassification.filler, parent_item="Marauder", origin={"nco"}), + "Internal Tech Module (Marauder)": ItemData(231 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 28, classification=ItemClassification.filler, parent_item="Marauder", origin={"nco"}), + + # Items from new mod + "Progressive Stimpack (Reaper)": ItemData(250 + SC2WOL_ITEM_ID_OFFSET, "Progressive Upgrade", 10, parent_item="Reaper", quantity=2, origin={"nco"}), + "Laser Targeting System (Reaper)": ItemData(251 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 0, classification=ItemClassification.filler, parent_item="Reaper", origin={"nco"}), + "Advanced Cloaking Field (Reaper)": ItemData(252 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 1, parent_item="Reaper", origin={"nco"}), + "Spider Mines (Reaper)": ItemData(253 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 2, classification=ItemClassification.filler, parent_item="Reaper", origin={"nco"}), + "Combat Drugs (Reaper)": ItemData(254 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 3, classification=ItemClassification.filler, parent_item="Reaper", origin={"ext"}), + "Hellbat Aspect (Hellion)": ItemData(255 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 4, parent_item="Hellion", origin={"nco"}), + "Smart Servos (Hellion)": ItemData(256 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 5, parent_item="Hellion", origin={"nco"}), + "Optimized Logistics (Hellion)": ItemData(257 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 6, classification=ItemClassification.filler, parent_item="Hellion", origin={"nco"}), + "Jump Jets (Hellion)": ItemData(258 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 7, classification=ItemClassification.filler, parent_item="Hellion", origin={"nco"}), + "Progressive Stimpack (Hellion)": ItemData(259 + SC2WOL_ITEM_ID_OFFSET, "Progressive Upgrade", 12, parent_item="Hellion", quantity=2, origin={"nco"}), + "Ion Thrusters (Vulture)": ItemData(260 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 8, classification=ItemClassification.filler, parent_item="Vulture", origin={"bw"}), + "Auto Launchers (Vulture)": ItemData(261 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 9, parent_item="Vulture", origin={"bw"}), + "High Explosive Munition (Spider Mine)": ItemData(262 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 10, origin={"bw"}), + "Jump Jets (Goliath)": ItemData(263 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 11, classification=ItemClassification.filler, parent_item="Goliath", origin={"nco"}), + "Optimized Logistics (Goliath)": ItemData(264 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 12, classification=ItemClassification.filler, parent_item="Goliath", origin={"nco"}), + "Hyperfluxor (Diamondback)": ItemData(265 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 13, parent_item="Diamondback", origin={"ext"}), + "Burst Capacitors (Diamondback)": ItemData(266 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 14, classification=ItemClassification.filler, parent_item="Diamondback", origin={"ext"}), + "Optimized Logistics (Diamondback)": ItemData(267 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 15, parent_item="Diamondback", origin={"ext"}), + "Jump Jets (Siege Tank)": ItemData(268 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 16, parent_item="Siege Tank", origin={"nco"}), + "Spider Mines (Siege Tank)": ItemData(269 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 17, classification=ItemClassification.filler, parent_item="Siege Tank", origin={"nco"}), + "Smart Servos (Siege Tank)": ItemData(270 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 18, classification=ItemClassification.filler, parent_item="Siege Tank", origin={"nco"}), + "Graduating Range (Siege Tank)": ItemData(271 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 19, classification=ItemClassification.progression, parent_item="Siege Tank", origin={"ext"}), + "Laser Targeting System (Siege Tank)": ItemData(272 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 20, parent_item="Siege Tank", origin={"nco"}), + "Advanced Siege Tech (Siege Tank)": ItemData(273 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 21, parent_item="Siege Tank", origin={"ext"}), + "Internal Tech Module (Siege Tank)": ItemData(274 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 22, classification=ItemClassification.filler, parent_item="Siege Tank", origin={"nco"}), + "Optimized Logistics (Predator)": ItemData(275 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 23, classification=ItemClassification.filler, parent_item="Predator", origin={"ext"}), + "Expanded Hull (Medivac)": ItemData(276 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 24, classification=ItemClassification.filler, parent_item="Medivac", origin={"ext"}), + "Afterburners (Medivac)": ItemData(277 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 25, classification=ItemClassification.filler, parent_item="Medivac", origin={"ext"}), + "Advanced Laser Technology (Wraith)": ItemData(278 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 26, classification=ItemClassification.progression, parent_item="Wraith", origin={"ext"}), + "Smart Servos (Viking)": ItemData(279 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 27, parent_item="Viking", origin={"ext"}), + "Magrail Munitions (Viking)": ItemData(280 + SC2WOL_ITEM_ID_OFFSET, "Armory 3", 28, parent_item="Viking", origin={"ext"}), "Twin-Linked Flamethrower (Hellion)": ItemData(300 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 0, classification=ItemClassification.filler, parent_item="Hellion"), "Thermite Filaments (Hellion)": ItemData(301 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 1, parent_item="Hellion"), - "Cerberus Mine (Vulture)": ItemData(302 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 2, classification=ItemClassification.filler, parent_item="Vulture"), + "Cerberus Mine (Spider Mine)": ItemData(302 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 2, classification=ItemClassification.filler), "Replenishable Magazine (Vulture)": ItemData(303 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 3, classification=ItemClassification.filler, parent_item="Vulture"), "Multi-Lock Weapons System (Goliath)": ItemData(304 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 4, parent_item="Goliath"), "Ares-Class Targeting System (Goliath)": ItemData(305 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 5, parent_item="Goliath"), @@ -86,7 +149,7 @@ def get_full_item_list(): "Displacement Field (Wraith)": ItemData(313 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 13, classification=ItemClassification.filler, parent_item="Wraith"), "Ripwave Missiles (Viking)": ItemData(314 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 14, parent_item="Viking"), "Phobos-Class Weapons System (Viking)": ItemData(315 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 15, parent_item="Viking"), - "Cross-Spectrum Dampeners (Banshee)": ItemData(316 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 16, classification=ItemClassification.filler, parent_item="Banshee"), + "Progressive Cross-Spectrum Dampeners (Banshee)": ItemData(316 + SC2WOL_ITEM_ID_OFFSET, "Progressive Upgrade", 2, classification=ItemClassification.filler, parent_item="Banshee", quantity=2), "Shockwave Missile Battery (Banshee)": ItemData(317 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 17, parent_item="Banshee"), "Missile Pods (Battlecruiser)": ItemData(318 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 18, classification=ItemClassification.filler, parent_item="Battlecruiser"), "Defensive Matrix (Battlecruiser)": ItemData(319 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 19, classification=ItemClassification.filler, parent_item="Battlecruiser"), @@ -96,6 +159,47 @@ def get_full_item_list(): "Nyx-Class Cloaking Module (Spectre)": ItemData(323 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 23, parent_item="Spectre"), "330mm Barrage Cannon (Thor)": ItemData(324 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 24, classification=ItemClassification.filler, parent_item="Thor"), "Immortality Protocol (Thor)": ItemData(325 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 25, classification=ItemClassification.filler, parent_item="Thor"), + # Items from EE + "Advanced Ballistics (Liberator)": ItemData(326 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 26, parent_item="Liberator", origin={"ext"}), + "Raid Artillery (Liberator)": ItemData(327 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 27, classification=ItemClassification.progression, parent_item="Liberator", origin={"nco"}), + "Drilling Claws (Widow Mine)": ItemData(328 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 28, classification=ItemClassification.filler, parent_item="Widow Mine", origin={"ext"}), + "Concealment (Widow Mine)": ItemData(329 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 29, classification=ItemClassification.progression, parent_item="Widow Mine", origin={"ext"}), + + #Items from new mod + "Hyperflight Rotors (Banshee)": ItemData(350 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 0, classification=ItemClassification.filler, parent_item="Banshee", origin={"ext"}), + "Laser Targeting System (Banshee)": ItemData(351 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 1, classification=ItemClassification.filler, parent_item="Banshee", origin={"nco"}), + "Internal Tech Module (Banshee)": ItemData(352 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 2, classification=ItemClassification.filler, parent_item="Banshee", origin={"nco"}), + "Tactical Jump (Battlecruiser)": ItemData(353 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 3, parent_item="Battlecruiser", origin={"nco", "ext"}), + "Cloak (Battlecruiser)": ItemData(354 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 4, parent_item="Battlecruiser", origin={"nco"}), + "ATX Laser Battery (Battlecruiser)": ItemData(355 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 5, classification=ItemClassification.progression, parent_item="Battlecruiser", origin={"nco"}), + "Optimized Logistics (Battlecruiser)": ItemData(356 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 6, classification=ItemClassification.filler, parent_item="Battlecruiser", origin={"ext"}), + "Internal Tech Module (Battlecruiser)": ItemData(357 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 7, classification=ItemClassification.filler, parent_item="Battlecruiser", origin={"nco"}), + "EMP Rounds (Ghost)": ItemData(358 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 8, parent_item="Ghost", origin={"ext"}), + "Lockdown (Ghost)": ItemData(359 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 9, parent_item="Ghost", origin={"bw"}), + "Impaler Rounds (Spectre)": ItemData(360 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 10, parent_item="Spectre", origin={"ext"}), + "Progressive High Impact Payload (Thor)": ItemData(361 + SC2WOL_ITEM_ID_OFFSET, "Progressive Upgrade", 14, parent_item="Thor", quantity=2, origin={"ext"}), # L2 is Smart Servos + "Bio Mechanical Repair Drone (Raven)": ItemData(363 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 13, parent_item="Raven", origin={"nco"}), + "Spider Mines (Raven)": ItemData(364 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 14, parent_item="Raven", origin={"nco"}), + "Railgun Turret (Raven)": ItemData(365 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 15, parent_item="Raven", origin={"nco"}), + "Hunter-Seeker Weapon (Raven)": ItemData(366 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 16, parent_item="Raven", origin={"nco"}), + "Interference Matrix (Raven)": ItemData(367 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 17, parent_item="Raven", origin={"ext"}), + "Anti-Armor Missile (Raven)": ItemData(368 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 18, classification=ItemClassification.filler, parent_item="Raven", origin={"ext"}), + "Internal Tech Module (Raven)": ItemData(369 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 19, classification=ItemClassification.filler, parent_item="Raven", origin={"nco"}), + "EMP Shockwave (Science Vessel)": ItemData(370 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 20, parent_item="Science Vessel", origin={"bw"}), + "Defensive Matrix (Science Vessel)": ItemData(371 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 21, parent_item="Science Vessel", origin={"bw"}), + "Targeting Optics (Cyclone)": ItemData(372 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 22, parent_item="Cyclone", origin={"ext"}), + "Rapid Fire Launchers (Cyclone)": ItemData(373 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 23, parent_item="Cyclone", origin={"ext"}), + "Cloak (Liberator)": ItemData(374 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 24, classification=ItemClassification.filler, parent_item="Liberator", origin={"nco"}), + "Laser Targeting System (Liberator)": ItemData(375 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 25, classification=ItemClassification.filler, parent_item="Liberator", origin={"ext"}), + "Optimized Logistics (Liberator)": ItemData(376 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 26, classification=ItemClassification.filler, parent_item="Liberator", origin={"nco"}), + "Black Market Launchers (Widow Mine)": ItemData(377 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 27, classification=ItemClassification.filler, parent_item="Widow Mine", origin={"ext"}), + "Executioner Missiles (Widow Mine)": ItemData(378 + SC2WOL_ITEM_ID_OFFSET, "Armory 4", 28, parent_item="Widow Mine", origin={"ext"}), + + # Just lazy to create a new group for one unit + "Enhanced Cluster Launchers (Valkyrie)": ItemData(379 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 17, parent_item="Valkyrie", origin={"ext"}), + "Shaped Hull (Valkyrie)": ItemData(380 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 20, classification=ItemClassification.filler, parent_item="Valkyrie", origin={"ext"}), + "Burst Lasers (Valkyrie)": ItemData(381 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 21, parent_item="Valkyrie", origin={"ext"}), + "Afterburners (Valkyrie)": ItemData(382 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 22, classification=ItemClassification.filler, parent_item="Valkyrie", origin={"ext"}), "Bunker": ItemData(400 + SC2WOL_ITEM_ID_OFFSET, "Building", 0, classification=ItemClassification.progression), "Missile Turret": ItemData(401 + SC2WOL_ITEM_ID_OFFSET, "Building", 1, classification=ItemClassification.progression), @@ -120,14 +224,14 @@ def get_full_item_list(): "Science Vessel": ItemData(607 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 7, classification=ItemClassification.progression), "Tech Reactor": ItemData(608 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 8), "Orbital Strike": ItemData(609 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 9), - "Shrike Turret": ItemData(610 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 10, parent_item="Bunker"), - "Fortified Bunker": ItemData(611 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 11, parent_item="Bunker"), + "Shrike Turret (Bunker)": ItemData(610 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 10, parent_item="Bunker"), + "Fortified Bunker (Bunker)": ItemData(611 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 11, parent_item="Bunker"), "Planetary Fortress": ItemData(612 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 12, classification=ItemClassification.progression), "Perdition Turret": ItemData(613 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 13, classification=ItemClassification.progression), "Predator": ItemData(614 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 14, classification=ItemClassification.filler), "Hercules": ItemData(615 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 15, classification=ItemClassification.progression), "Cellular Reactor": ItemData(616 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 16), - "Regenerative Bio-Steel": ItemData(617 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 17), + "Progressive Regenerative Bio-Steel": ItemData(617 + SC2WOL_ITEM_ID_OFFSET, "Progressive Upgrade", 4, quantity=2), "Hive Mind Emulator": ItemData(618 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 18, ItemClassification.progression), "Psi Disrupter": ItemData(619 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 19, classification=ItemClassification.progression), @@ -136,18 +240,24 @@ def get_full_item_list(): "High Templar": ItemData(702 + SC2WOL_ITEM_ID_OFFSET, "Protoss", 2, classification=ItemClassification.progression), "Dark Templar": ItemData(703 + SC2WOL_ITEM_ID_OFFSET, "Protoss", 3, classification=ItemClassification.progression), "Immortal": ItemData(704 + SC2WOL_ITEM_ID_OFFSET, "Protoss", 4, classification=ItemClassification.progression), - "Colossus": ItemData(705 + SC2WOL_ITEM_ID_OFFSET, "Protoss", 5, classification=ItemClassification.progression), - "Phoenix": ItemData(706 + SC2WOL_ITEM_ID_OFFSET, "Protoss", 6, classification=ItemClassification.progression), + "Colossus": ItemData(705 + SC2WOL_ITEM_ID_OFFSET, "Protoss", 5), + "Phoenix": ItemData(706 + SC2WOL_ITEM_ID_OFFSET, "Protoss", 6, classification=ItemClassification.filler), "Void Ray": ItemData(707 + SC2WOL_ITEM_ID_OFFSET, "Protoss", 7, classification=ItemClassification.progression), "Carrier": ItemData(708 + SC2WOL_ITEM_ID_OFFSET, "Protoss", 8, classification=ItemClassification.progression), + # Filler items to fill remaining spots "+15 Starting Minerals": ItemData(800 + SC2WOL_ITEM_ID_OFFSET, "Minerals", 15, quantity=0, classification=ItemClassification.filler), "+15 Starting Vespene": ItemData(801 + SC2WOL_ITEM_ID_OFFSET, "Vespene", 15, quantity=0, classification=ItemClassification.filler), + # This Filler item isn't placed by the generator yet unless plando'd "+2 Starting Supply": ItemData(802 + SC2WOL_ITEM_ID_OFFSET, "Supply", 2, quantity=0, classification=ItemClassification.filler), + # This item is used to "remove" location from the game. Never placed unless plando'd + "Nothing": ItemData(803 + SC2WOL_ITEM_ID_OFFSET, "Nothing Group", 2, quantity=0, classification=ItemClassification.trap), # "Keystone Piece": ItemData(850 + SC2WOL_ITEM_ID_OFFSET, "Goal", 0, quantity=0, classification=ItemClassification.progression_skip_balancing) } +def get_item_table(multiworld: MultiWorld, player: int): + return item_table basic_units = { 'Marine', @@ -172,10 +282,49 @@ def get_basic_units(multiworld: MultiWorld, player: int) -> typing.Set[str]: item_name_groups = {} -for item, data in item_table.items(): +for item, data in get_full_item_list().items(): item_name_groups.setdefault(data.type, []).append(item) + if data.type in ("Armory 1", "Armory 2") and '(' in item: + short_name = item[:item.find(' (')] + item_name_groups[short_name] = [item] item_name_groups["Missions"] = ["Beat " + mission_name for mission_name in vanilla_mission_req_table] + +# Items that can be placed before resources if not already in +# General upgrades and Mercs +second_pass_placeable_items: typing.Tuple[str, ...] = ( + # Buildings without upgrades + "Sensor Tower", + "Hive Mind Emulator", + "Psi Disrupter", + "Perdition Turret", + # General upgrades without any dependencies + "Advanced Construction (SCV)", + "Dual-Fusion Welders (SCV)", + "Fire-Suppression System (Building)", + "Orbital Command (Building)", + "Ultra-Capacitors", + "Vanadium Plating", + "Orbital Depots", + "Micro-Filtering", + "Automated Refinery", + "Command Center Reactor", + "Tech Reactor", + "Planetary Fortress", + "Cellular Reactor", + "Progressive Regenerative Bio-Steel", # Place only L1 + # Mercenaries + "War Pigs", + "Devil Dogs", + "Hammer Securities", + "Spartan Company", + "Siege Breakers", + "Hel's Angel", + "Dusk Wings", + "Jackson's Revenge" +) + + filler_items: typing.Tuple[str, ...] = ( '+15 Starting Minerals', '+15 Starting Vespene' @@ -190,7 +339,10 @@ def get_basic_units(multiworld: MultiWorld, player: int) -> typing.Set[str]: # Bunker w/ Marine/Marauder: 3, "Perdition Turret": 2, "Missile Turret": 2, - "Vulture": 2 + "Vulture": 2, + "Liberator": 2, + "Widow Mine": 2 + # "Concealment (Widow Mine)": 1 } zerg_defense_ratings = { "Perdition Turret": 2, @@ -199,14 +351,61 @@ def get_basic_units(multiworld: MultiWorld, player: int) -> typing.Set[str]: "Psi Disruptor": 3 } +spider_mine_sources = { + "Vulture", + "Spider Mines (Reaper)", + "Spider Mines (Siege Tank)", + "Spider Mines (Raven)" +} + +progressive_if_nco = { + "Progressive Stimpack (Marine)", + "Progressive Stimpack (Firebat)", + "Progressive Cross-Spectrum Dampeners (Banshee)", + "Progressive Regenerative Bio-Steel" +} + +# 'number' values of upgrades for upgrade bundle items +upgrade_numbers = [ + {0, 4, 8}, # Weapon + {2, 6, 10}, # Armor + {0, 2}, # Infantry + {4, 6}, # Vehicle + {8, 10}, # Starship + {0, 2, 4, 6, 8, 10} # All +] +# Names of upgrades to be included for different options +upgrade_included_names = [ + { # Individual Items + "Progressive Infantry Weapon", + "Progressive Infantry Armor", + "Progressive Vehicle Weapon", + "Progressive Vehicle Armor", + "Progressive Ship Weapon", + "Progressive Ship Armor" + }, + { # Bundle Weapon And Armor + "Progressive Weapon Upgrade", + "Progressive Armor Upgrade" + }, + { # Bundle Unit Class + "Progressive Infantry Upgrade", + "Progressive Vehicle Upgrade", + "Progressive Starship Upgrade" + }, + { # Bundle All + "Progressive Weapon/Armor Upgrade" + } +] + lookup_id_to_name: typing.Dict[int, str] = {data.code: item_name for item_name, data in get_full_item_list().items() if data.code} # Map type to expected int type_flaggroups: typing.Dict[str, int] = { "Unit": 0, - "Upgrade": 1, - "Armory 1": 2, - "Armory 2": 3, + "Upgrade": 1, # Weapon / Armor upgrades + "Armory 1": 2, # Unit upgrades + "Armory 2": 3, # Unit upgrades "Building": 4, "Mercenary": 5, "Laboratory": 6, @@ -214,5 +413,9 @@ def get_basic_units(multiworld: MultiWorld, player: int) -> typing.Set[str]: "Minerals": 8, "Vespene": 9, "Supply": 10, - "Goal": 11 + "Goal": 11, + "Armory 3": 12, # Unit upgrades + "Armory 4": 13, # Unit upgrades + "Progressive Upgrade": 14, # Unit upgrades that exist multiple times (Stimpack / Super Stimpack) + "Nothing Group": 15 } diff --git a/worlds/sc2wol/Locations.py b/worlds/sc2wol/Locations.py index e91068c4f220..ae31fa8eaadd 100644 --- a/worlds/sc2wol/Locations.py +++ b/worlds/sc2wol/Locations.py @@ -1,3 +1,4 @@ +from enum import IntEnum from typing import List, Tuple, Optional, Callable, NamedTuple from BaseClasses import MultiWorld from .Options import get_option_value @@ -11,10 +12,18 @@ class SC2WoLLocation(Location): game: str = "Starcraft2WoL" +class LocationType(IntEnum): + VICTORY = 0 # Winning a mission + MISSION_PROGRESS = 1 # All tasks done for progressing the mission normally towards victory. All cleaning of expansion bases falls here + BONUS = 2 # Bonus objective, getting a campaign or mission bonus in vanilla (credits, research, bonus units or resources) + CHALLENGE = 3 # Challenging objectives, often harder than just completing a mission + OPTIONAL_BOSS = 4 # Any boss that's not required to win the mission. All Brutalisks, Loki, etc. + class LocationData(NamedTuple): region: str name: str code: Optional[int] + type: LocationType rule: Callable = lambda state: True @@ -22,256 +31,473 @@ def get_locations(multiworld: Optional[MultiWorld], player: Optional[int]) -> Tu # Note: rules which are ended with or True are rules identified as needed later when restricted units is an option logic_level = get_option_value(multiworld, player, 'required_tactics') location_table: List[LocationData] = [ - LocationData("Liberation Day", "Liberation Day: Victory", SC2WOL_LOC_ID_OFFSET + 100), - LocationData("Liberation Day", "Liberation Day: First Statue", SC2WOL_LOC_ID_OFFSET + 101), - LocationData("Liberation Day", "Liberation Day: Second Statue", SC2WOL_LOC_ID_OFFSET + 102), - LocationData("Liberation Day", "Liberation Day: Third Statue", SC2WOL_LOC_ID_OFFSET + 103), - LocationData("Liberation Day", "Liberation Day: Fourth Statue", SC2WOL_LOC_ID_OFFSET + 104), - LocationData("Liberation Day", "Liberation Day: Fifth Statue", SC2WOL_LOC_ID_OFFSET + 105), - LocationData("Liberation Day", "Liberation Day: Sixth Statue", SC2WOL_LOC_ID_OFFSET + 106), - LocationData("The Outlaws", "The Outlaws: Victory", SC2WOL_LOC_ID_OFFSET + 200, + LocationData("Liberation Day", "Liberation Day: Victory", SC2WOL_LOC_ID_OFFSET + 100, LocationType.VICTORY), + LocationData("Liberation Day", "Liberation Day: First Statue", SC2WOL_LOC_ID_OFFSET + 101, LocationType.BONUS), + LocationData("Liberation Day", "Liberation Day: Second Statue", SC2WOL_LOC_ID_OFFSET + 102, LocationType.BONUS), + LocationData("Liberation Day", "Liberation Day: Third Statue", SC2WOL_LOC_ID_OFFSET + 103, LocationType.BONUS), + LocationData("Liberation Day", "Liberation Day: Fourth Statue", SC2WOL_LOC_ID_OFFSET + 104, LocationType.BONUS), + LocationData("Liberation Day", "Liberation Day: Fifth Statue", SC2WOL_LOC_ID_OFFSET + 105, LocationType.BONUS), + LocationData("Liberation Day", "Liberation Day: Sixth Statue", SC2WOL_LOC_ID_OFFSET + 106, LocationType.BONUS), + LocationData("Liberation Day", "Liberation Day: Special Delivery", SC2WOL_LOC_ID_OFFSET + 107, LocationType.MISSION_PROGRESS), + LocationData("The Outlaws", "The Outlaws: Victory", SC2WOL_LOC_ID_OFFSET + 200, LocationType.VICTORY, + lambda state: state._sc2wol_has_common_unit(multiworld, player)), + LocationData("The Outlaws", "The Outlaws: Rebel Base", SC2WOL_LOC_ID_OFFSET + 201, LocationType.BONUS, lambda state: state._sc2wol_has_common_unit(multiworld, player)), - LocationData("The Outlaws", "The Outlaws: Rebel Base", SC2WOL_LOC_ID_OFFSET + 201, + LocationData("The Outlaws", "The Outlaws: North Resource Pickups", SC2WOL_LOC_ID_OFFSET + 202, LocationType.BONUS), + LocationData("The Outlaws", "The Outlaws: Bunker", SC2WOL_LOC_ID_OFFSET + 203, LocationType.MISSION_PROGRESS, lambda state: state._sc2wol_has_common_unit(multiworld, player)), - LocationData("Zero Hour", "Zero Hour: Victory", SC2WOL_LOC_ID_OFFSET + 300, + LocationData("Zero Hour", "Zero Hour: Victory", SC2WOL_LOC_ID_OFFSET + 300, LocationType.VICTORY, lambda state: state._sc2wol_has_common_unit(multiworld, player) and state._sc2wol_defense_rating(multiworld, player, True) >= 2 and (logic_level > 0 or state._sc2wol_has_anti_air(multiworld, player))), - LocationData("Zero Hour", "Zero Hour: First Group Rescued", SC2WOL_LOC_ID_OFFSET + 301), - LocationData("Zero Hour", "Zero Hour: Second Group Rescued", SC2WOL_LOC_ID_OFFSET + 302, + LocationData("Zero Hour", "Zero Hour: First Group Rescued", SC2WOL_LOC_ID_OFFSET + 301, LocationType.BONUS), + LocationData("Zero Hour", "Zero Hour: Second Group Rescued", SC2WOL_LOC_ID_OFFSET + 302, LocationType.BONUS, lambda state: state._sc2wol_has_common_unit(multiworld, player)), - LocationData("Zero Hour", "Zero Hour: Third Group Rescued", SC2WOL_LOC_ID_OFFSET + 303, + LocationData("Zero Hour", "Zero Hour: Third Group Rescued", SC2WOL_LOC_ID_OFFSET + 303, LocationType.BONUS, lambda state: state._sc2wol_has_common_unit(multiworld, player) and state._sc2wol_defense_rating(multiworld, player, True) >= 2), - LocationData("Evacuation", "Evacuation: Victory", SC2WOL_LOC_ID_OFFSET + 400, + LocationData("Zero Hour", "Zero Hour: First Hatchery", SC2WOL_LOC_ID_OFFSET + 304, LocationType.CHALLENGE, + lambda state: state._sc2wol_has_competent_comp(multiworld, player)), + LocationData("Zero Hour", "Zero Hour: Second Hatchery", SC2WOL_LOC_ID_OFFSET + 305, LocationType.CHALLENGE, + lambda state: state._sc2wol_has_competent_comp(multiworld, player)), + LocationData("Zero Hour", "Zero Hour: Third Hatchery", SC2WOL_LOC_ID_OFFSET + 306, LocationType.CHALLENGE, + lambda state: state._sc2wol_has_competent_comp(multiworld, player)), + LocationData("Zero Hour", "Zero Hour: Fourth Hatchery", SC2WOL_LOC_ID_OFFSET + 307, LocationType.CHALLENGE, + lambda state: state._sc2wol_has_competent_comp(multiworld, player)), + LocationData("Evacuation", "Evacuation: Victory", SC2WOL_LOC_ID_OFFSET + 400, LocationType.VICTORY, lambda state: state._sc2wol_has_common_unit(multiworld, player) and (logic_level > 0 and state._sc2wol_has_anti_air(multiworld, player) or state._sc2wol_has_competent_anti_air(multiworld, player))), - LocationData("Evacuation", "Evacuation: First Chysalis", SC2WOL_LOC_ID_OFFSET + 401), - LocationData("Evacuation", "Evacuation: Second Chysalis", SC2WOL_LOC_ID_OFFSET + 402, + LocationData("Evacuation", "Evacuation: First Chrysalis", SC2WOL_LOC_ID_OFFSET + 401, LocationType.BONUS), + LocationData("Evacuation", "Evacuation: Second Chrysalis", SC2WOL_LOC_ID_OFFSET + 402, LocationType.BONUS, lambda state: state._sc2wol_has_common_unit(multiworld, player)), - LocationData("Evacuation", "Evacuation: Third Chysalis", SC2WOL_LOC_ID_OFFSET + 403, + LocationData("Evacuation", "Evacuation: Third Chrysalis", SC2WOL_LOC_ID_OFFSET + 403, LocationType.BONUS, lambda state: state._sc2wol_has_common_unit(multiworld, player)), - LocationData("Outbreak", "Outbreak: Victory", SC2WOL_LOC_ID_OFFSET + 500, + LocationData("Evacuation", "Evacuation: Reach Hanson", SC2WOL_LOC_ID_OFFSET + 404, LocationType.MISSION_PROGRESS), + LocationData("Evacuation", "Evacuation: Secret Resource Stash", SC2WOL_LOC_ID_OFFSET + 405, LocationType.BONUS), + LocationData("Evacuation", "Evacuation: Flawless", SC2WOL_LOC_ID_OFFSET + 406, LocationType.CHALLENGE, + lambda state: state._sc2wol_has_common_unit(multiworld, player) and + state._sc2wol_defense_rating(multiworld, player, True, False) >= 2 and + (logic_level > 0 and state._sc2wol_has_anti_air(multiworld, player) + or state._sc2wol_has_competent_anti_air(multiworld, player))), + LocationData("Outbreak", "Outbreak: Victory", SC2WOL_LOC_ID_OFFSET + 500, LocationType.VICTORY, lambda state: state._sc2wol_defense_rating(multiworld, player, True, False) >= 4 and (state._sc2wol_has_common_unit(multiworld, player) or state.has("Reaper", player))), - LocationData("Outbreak", "Outbreak: Left Infestor", SC2WOL_LOC_ID_OFFSET + 501, + LocationData("Outbreak", "Outbreak: Left Infestor", SC2WOL_LOC_ID_OFFSET + 501, LocationType.BONUS, + lambda state: state._sc2wol_defense_rating(multiworld, player, True, False) >= 2 and + (state._sc2wol_has_common_unit(multiworld, player) or state.has("Reaper", player))), + LocationData("Outbreak", "Outbreak: Right Infestor", SC2WOL_LOC_ID_OFFSET + 502, LocationType.BONUS, + lambda state: state._sc2wol_defense_rating(multiworld, player, True, False) >= 2 and + (state._sc2wol_has_common_unit(multiworld, player) or state.has("Reaper", player))), + LocationData("Outbreak", "Outbreak: North Infested Command Center", SC2WOL_LOC_ID_OFFSET + 503, LocationType.MISSION_PROGRESS, lambda state: state._sc2wol_defense_rating(multiworld, player, True, False) >= 2 and (state._sc2wol_has_common_unit(multiworld, player) or state.has("Reaper", player))), - LocationData("Outbreak", "Outbreak: Right Infestor", SC2WOL_LOC_ID_OFFSET + 502, + LocationData("Outbreak", "Outbreak: South Infested Command Center", SC2WOL_LOC_ID_OFFSET + 504, LocationType.MISSION_PROGRESS, lambda state: state._sc2wol_defense_rating(multiworld, player, True, False) >= 2 and (state._sc2wol_has_common_unit(multiworld, player) or state.has("Reaper", player))), - LocationData("Safe Haven", "Safe Haven: Victory", SC2WOL_LOC_ID_OFFSET + 600, + LocationData("Outbreak", "Outbreak: Northwest Bar", SC2WOL_LOC_ID_OFFSET + 505, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_defense_rating(multiworld, player, True, False) >= 2 and + (state._sc2wol_has_common_unit(multiworld, player) or state.has("Reaper", player))), + LocationData("Outbreak", "Outbreak: North Bar", SC2WOL_LOC_ID_OFFSET + 506, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_defense_rating(multiworld, player, True, False) >= 2 and + (state._sc2wol_has_common_unit(multiworld, player) or state.has("Reaper", player))), + LocationData("Outbreak", "Outbreak: South Bar", SC2WOL_LOC_ID_OFFSET + 507, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_defense_rating(multiworld, player, True, False) >= 2 and + (state._sc2wol_has_common_unit(multiworld, player) or state.has("Reaper", player))), + LocationData("Safe Haven", "Safe Haven: Victory", SC2WOL_LOC_ID_OFFSET + 600, LocationType.VICTORY, lambda state: state._sc2wol_has_common_unit(multiworld, player) and state._sc2wol_has_competent_anti_air(multiworld, player)), - LocationData("Safe Haven", "Safe Haven: North Nexus", SC2WOL_LOC_ID_OFFSET + 601, + LocationData("Safe Haven", "Safe Haven: North Nexus", SC2WOL_LOC_ID_OFFSET + 601, LocationType.MISSION_PROGRESS, lambda state: state._sc2wol_has_common_unit(multiworld, player) and state._sc2wol_has_competent_anti_air(multiworld, player)), - LocationData("Safe Haven", "Safe Haven: East Nexus", SC2WOL_LOC_ID_OFFSET + 602, + LocationData("Safe Haven", "Safe Haven: East Nexus", SC2WOL_LOC_ID_OFFSET + 602, LocationType.MISSION_PROGRESS, lambda state: state._sc2wol_has_common_unit(multiworld, player) and state._sc2wol_has_competent_anti_air(multiworld, player)), - LocationData("Safe Haven", "Safe Haven: South Nexus", SC2WOL_LOC_ID_OFFSET + 603, + LocationData("Safe Haven", "Safe Haven: South Nexus", SC2WOL_LOC_ID_OFFSET + 603, LocationType.MISSION_PROGRESS, lambda state: state._sc2wol_has_common_unit(multiworld, player) and state._sc2wol_has_competent_anti_air(multiworld, player)), - LocationData("Haven's Fall", "Haven's Fall: Victory", SC2WOL_LOC_ID_OFFSET + 700, + LocationData("Safe Haven", "Safe Haven: First Terror Fleet", SC2WOL_LOC_ID_OFFSET + 604, LocationType.BONUS, + lambda state: state._sc2wol_has_common_unit(multiworld, player) and + state._sc2wol_has_competent_anti_air(multiworld, player)), + LocationData("Safe Haven", "Safe Haven: Second Terror Fleet", SC2WOL_LOC_ID_OFFSET + 605, LocationType.BONUS, + lambda state: state._sc2wol_has_common_unit(multiworld, player) and + state._sc2wol_has_competent_anti_air(multiworld, player)), + LocationData("Safe Haven", "Safe Haven: Third Terror Fleet", SC2WOL_LOC_ID_OFFSET + 606, LocationType.BONUS, + lambda state: state._sc2wol_has_common_unit(multiworld, player) and + state._sc2wol_has_competent_anti_air(multiworld, player)), + LocationData("Haven's Fall", "Haven's Fall: Victory", SC2WOL_LOC_ID_OFFSET + 700, LocationType.VICTORY, lambda state: state._sc2wol_has_common_unit(multiworld, player) and state._sc2wol_has_competent_anti_air(multiworld, player) and state._sc2wol_defense_rating(multiworld, player, True) >= 3), - LocationData("Haven's Fall", "Haven's Fall: North Hive", SC2WOL_LOC_ID_OFFSET + 701, + LocationData("Haven's Fall", "Haven's Fall: North Hive", SC2WOL_LOC_ID_OFFSET + 701, LocationType.MISSION_PROGRESS, lambda state: state._sc2wol_has_common_unit(multiworld, player) and state._sc2wol_has_competent_anti_air(multiworld, player) and state._sc2wol_defense_rating(multiworld, player, True) >= 3), - LocationData("Haven's Fall", "Haven's Fall: East Hive", SC2WOL_LOC_ID_OFFSET + 702, + LocationData("Haven's Fall", "Haven's Fall: East Hive", SC2WOL_LOC_ID_OFFSET + 702, LocationType.MISSION_PROGRESS, lambda state: state._sc2wol_has_common_unit(multiworld, player) and state._sc2wol_has_competent_anti_air(multiworld, player) and state._sc2wol_defense_rating(multiworld, player, True) >= 3), - LocationData("Haven's Fall", "Haven's Fall: South Hive", SC2WOL_LOC_ID_OFFSET + 703, + LocationData("Haven's Fall", "Haven's Fall: South Hive", SC2WOL_LOC_ID_OFFSET + 703, LocationType.MISSION_PROGRESS, lambda state: state._sc2wol_has_common_unit(multiworld, player) and state._sc2wol_has_competent_anti_air(multiworld, player) and state._sc2wol_defense_rating(multiworld, player, True) >= 3), - LocationData("Smash and Grab", "Smash and Grab: Victory", SC2WOL_LOC_ID_OFFSET + 800, + LocationData("Haven's Fall", "Haven's Fall: Northeast Colony Base", SC2WOL_LOC_ID_OFFSET + 704, LocationType.CHALLENGE, + lambda state: state._sc2wol_can_respond_to_colony_infestations), + LocationData("Haven's Fall", "Haven's Fall: East Colony Base", SC2WOL_LOC_ID_OFFSET + 705, LocationType.CHALLENGE, + lambda state: state._sc2wol_can_respond_to_colony_infestations), + LocationData("Haven's Fall", "Haven's Fall: Middle Colony Base", SC2WOL_LOC_ID_OFFSET + 706, LocationType.CHALLENGE, + lambda state: state._sc2wol_can_respond_to_colony_infestations), + LocationData("Haven's Fall", "Haven's Fall: Southeast Colony Base", SC2WOL_LOC_ID_OFFSET + 707, LocationType.CHALLENGE, + lambda state: state._sc2wol_can_respond_to_colony_infestations), + LocationData("Haven's Fall", "Haven's Fall: Southwest Colony Base", SC2WOL_LOC_ID_OFFSET + 708, LocationType.CHALLENGE, + lambda state: state._sc2wol_can_respond_to_colony_infestations), + LocationData("Smash and Grab", "Smash and Grab: Victory", SC2WOL_LOC_ID_OFFSET + 800, LocationType.VICTORY, lambda state: state._sc2wol_has_common_unit(multiworld, player) and (logic_level > 0 and state._sc2wol_has_anti_air(multiworld, player) or state._sc2wol_has_competent_anti_air(multiworld, player))), - LocationData("Smash and Grab", "Smash and Grab: First Relic", SC2WOL_LOC_ID_OFFSET + 801), - LocationData("Smash and Grab", "Smash and Grab: Second Relic", SC2WOL_LOC_ID_OFFSET + 802), - LocationData("Smash and Grab", "Smash and Grab: Third Relic", SC2WOL_LOC_ID_OFFSET + 803, + LocationData("Smash and Grab", "Smash and Grab: First Relic", SC2WOL_LOC_ID_OFFSET + 801, LocationType.BONUS), + LocationData("Smash and Grab", "Smash and Grab: Second Relic", SC2WOL_LOC_ID_OFFSET + 802, LocationType.BONUS), + LocationData("Smash and Grab", "Smash and Grab: Third Relic", SC2WOL_LOC_ID_OFFSET + 803, LocationType.BONUS, lambda state: state._sc2wol_has_common_unit(multiworld, player) and (logic_level > 0 and state._sc2wol_has_anti_air(multiworld, player) or state._sc2wol_has_competent_anti_air(multiworld, player))), - LocationData("Smash and Grab", "Smash and Grab: Fourth Relic", SC2WOL_LOC_ID_OFFSET + 804, + LocationData("Smash and Grab", "Smash and Grab: Fourth Relic", SC2WOL_LOC_ID_OFFSET + 804, LocationType.BONUS, lambda state: state._sc2wol_has_common_unit(multiworld, player) and (logic_level > 0 and state._sc2wol_has_anti_air(multiworld, player) or state._sc2wol_has_competent_anti_air(multiworld, player))), - LocationData("The Dig", "The Dig: Victory", SC2WOL_LOC_ID_OFFSET + 900, + LocationData("Smash and Grab", "Smash and Grab: First Forcefield Area Busted", SC2WOL_LOC_ID_OFFSET + 805, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_has_common_unit(multiworld, player) and + (logic_level > 0 and state._sc2wol_has_anti_air(multiworld, player) + or state._sc2wol_has_competent_anti_air(multiworld, player))), + LocationData("Smash and Grab", "Smash and Grab: Second Forcefield Area Busted", SC2WOL_LOC_ID_OFFSET + 806, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_has_common_unit(multiworld, player) and + (logic_level > 0 and state._sc2wol_has_anti_air(multiworld, player) + or state._sc2wol_has_competent_anti_air(multiworld, player))), + LocationData("The Dig", "The Dig: Victory", SC2WOL_LOC_ID_OFFSET + 900, LocationType.VICTORY, lambda state: state._sc2wol_has_anti_air(multiworld, player) and state._sc2wol_defense_rating(multiworld, player, False) >= 7), - LocationData("The Dig", "The Dig: Left Relic", SC2WOL_LOC_ID_OFFSET + 901, + LocationData("The Dig", "The Dig: Left Relic", SC2WOL_LOC_ID_OFFSET + 901, LocationType.BONUS, lambda state: state._sc2wol_defense_rating(multiworld, player, False) >= 5), - LocationData("The Dig", "The Dig: Right Ground Relic", SC2WOL_LOC_ID_OFFSET + 902, + LocationData("The Dig", "The Dig: Right Ground Relic", SC2WOL_LOC_ID_OFFSET + 902, LocationType.BONUS, lambda state: state._sc2wol_defense_rating(multiworld, player, False) >= 5), - LocationData("The Dig", "The Dig: Right Cliff Relic", SC2WOL_LOC_ID_OFFSET + 903, + LocationData("The Dig", "The Dig: Right Cliff Relic", SC2WOL_LOC_ID_OFFSET + 903, LocationType.BONUS, lambda state: state._sc2wol_defense_rating(multiworld, player, False) >= 5), - LocationData("The Moebius Factor", "The Moebius Factor: Victory", SC2WOL_LOC_ID_OFFSET + 1000, + LocationData("The Dig", "The Dig: Moebius Base", SC2WOL_LOC_ID_OFFSET + 904, LocationType.MISSION_PROGRESS), + LocationData("The Moebius Factor", "The Moebius Factor: Victory", SC2WOL_LOC_ID_OFFSET + 1000, LocationType.VICTORY, lambda state: state._sc2wol_has_anti_air(multiworld, player) and (state._sc2wol_has_air(multiworld, player) or state.has_any({'Medivac', 'Hercules'}, player) and state._sc2wol_has_common_unit(multiworld, player))), - LocationData("The Moebius Factor", "The Moebius Factor: South Rescue", SC2WOL_LOC_ID_OFFSET + 1003, + LocationData("The Moebius Factor", "The Moebius Factor: 1st Data Core", SC2WOL_LOC_ID_OFFSET + 1001, LocationType.MISSION_PROGRESS), + LocationData("The Moebius Factor", "The Moebius Factor: 2nd Data Core", SC2WOL_LOC_ID_OFFSET + 1002, LocationType.MISSION_PROGRESS, + lambda state: (state._sc2wol_has_air(multiworld, player) + or state.has_any({'Medivac', 'Hercules'}, player) + and state._sc2wol_has_common_unit(multiworld, player))), + LocationData("The Moebius Factor", "The Moebius Factor: South Rescue", SC2WOL_LOC_ID_OFFSET + 1003, LocationType.BONUS, lambda state: state._sc2wol_able_to_rescue(multiworld, player)), - LocationData("The Moebius Factor", "The Moebius Factor: Wall Rescue", SC2WOL_LOC_ID_OFFSET + 1004, + LocationData("The Moebius Factor", "The Moebius Factor: Wall Rescue", SC2WOL_LOC_ID_OFFSET + 1004, LocationType.BONUS, lambda state: state._sc2wol_able_to_rescue(multiworld, player)), - LocationData("The Moebius Factor", "The Moebius Factor: Mid Rescue", SC2WOL_LOC_ID_OFFSET + 1005, + LocationData("The Moebius Factor", "The Moebius Factor: Mid Rescue", SC2WOL_LOC_ID_OFFSET + 1005, LocationType.BONUS, lambda state: state._sc2wol_able_to_rescue(multiworld, player)), - LocationData("The Moebius Factor", "The Moebius Factor: Nydus Roof Rescue", SC2WOL_LOC_ID_OFFSET + 1006, + LocationData("The Moebius Factor", "The Moebius Factor: Nydus Roof Rescue", SC2WOL_LOC_ID_OFFSET + 1006, LocationType.BONUS, lambda state: state._sc2wol_able_to_rescue(multiworld, player)), - LocationData("The Moebius Factor", "The Moebius Factor: Alive Inside Rescue", SC2WOL_LOC_ID_OFFSET + 1007, + LocationData("The Moebius Factor", "The Moebius Factor: Alive Inside Rescue", SC2WOL_LOC_ID_OFFSET + 1007, LocationType.BONUS, lambda state: state._sc2wol_able_to_rescue(multiworld, player)), - LocationData("The Moebius Factor", "The Moebius Factor: Brutalisk", SC2WOL_LOC_ID_OFFSET + 1008, + LocationData("The Moebius Factor", "The Moebius Factor: Brutalisk", SC2WOL_LOC_ID_OFFSET + 1008, LocationType.OPTIONAL_BOSS, lambda state: state._sc2wol_has_anti_air(multiworld, player) and (state._sc2wol_has_air(multiworld, player) or state.has_any({'Medivac', 'Hercules'}, player) and state._sc2wol_has_common_unit(multiworld, player))), - LocationData("Supernova", "Supernova: Victory", SC2WOL_LOC_ID_OFFSET + 1100, + LocationData("The Moebius Factor", "The Moebius Factor: 3rd Data Core", SC2WOL_LOC_ID_OFFSET + 1009, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_has_anti_air(multiworld, player) and + (state._sc2wol_has_air(multiworld, player) + or state.has_any({'Medivac', 'Hercules'}, player) + and state._sc2wol_has_common_unit(multiworld, player))), + LocationData("Supernova", "Supernova: Victory", SC2WOL_LOC_ID_OFFSET + 1100, LocationType.VICTORY, + lambda state: state._sc2wol_beats_protoss_deathball(multiworld, player)), + LocationData("Supernova", "Supernova: West Relic", SC2WOL_LOC_ID_OFFSET + 1101, LocationType.BONUS), + LocationData("Supernova", "Supernova: North Relic", SC2WOL_LOC_ID_OFFSET + 1102, LocationType.BONUS), + LocationData("Supernova", "Supernova: South Relic", SC2WOL_LOC_ID_OFFSET + 1103, LocationType.BONUS, + lambda state: state._sc2wol_beats_protoss_deathball(multiworld, player)), + LocationData("Supernova", "Supernova: East Relic", SC2WOL_LOC_ID_OFFSET + 1104, LocationType.BONUS, lambda state: state._sc2wol_beats_protoss_deathball(multiworld, player)), - LocationData("Supernova", "Supernova: West Relic", SC2WOL_LOC_ID_OFFSET + 1101), - LocationData("Supernova", "Supernova: North Relic", SC2WOL_LOC_ID_OFFSET + 1102), - LocationData("Supernova", "Supernova: South Relic", SC2WOL_LOC_ID_OFFSET + 1103, + LocationData("Supernova", "Supernova: Landing Zone Cleared", SC2WOL_LOC_ID_OFFSET + 1105, LocationType.MISSION_PROGRESS), + LocationData("Supernova", "Supernova: Middle Base", SC2WOL_LOC_ID_OFFSET + 1106, LocationType.MISSION_PROGRESS, lambda state: state._sc2wol_beats_protoss_deathball(multiworld, player)), - LocationData("Supernova", "Supernova: East Relic", SC2WOL_LOC_ID_OFFSET + 1104, + LocationData("Supernova", "Supernova: Southeast Base", SC2WOL_LOC_ID_OFFSET + 1107, LocationType.MISSION_PROGRESS, lambda state: state._sc2wol_beats_protoss_deathball(multiworld, player)), - LocationData("Maw of the Void", "Maw of the Void: Victory", SC2WOL_LOC_ID_OFFSET + 1200, + LocationData("Maw of the Void", "Maw of the Void: Victory", SC2WOL_LOC_ID_OFFSET + 1200, LocationType.VICTORY, lambda state: state._sc2wol_survives_rip_field(multiworld, player)), - LocationData("Maw of the Void", "Maw of the Void: Landing Zone Cleared", SC2WOL_LOC_ID_OFFSET + 1201), - LocationData("Maw of the Void", "Maw of the Void: Expansion Prisoners", SC2WOL_LOC_ID_OFFSET + 1202, + LocationData("Maw of the Void", "Maw of the Void: Landing Zone Cleared", SC2WOL_LOC_ID_OFFSET + 1201, LocationType.MISSION_PROGRESS), + LocationData("Maw of the Void", "Maw of the Void: Expansion Prisoners", SC2WOL_LOC_ID_OFFSET + 1202, LocationType.BONUS, lambda state: logic_level > 0 or state._sc2wol_survives_rip_field(multiworld, player)), - LocationData("Maw of the Void", "Maw of the Void: South Close Prisoners", SC2WOL_LOC_ID_OFFSET + 1203, + LocationData("Maw of the Void", "Maw of the Void: South Close Prisoners", SC2WOL_LOC_ID_OFFSET + 1203, LocationType.BONUS, lambda state: logic_level > 0 or state._sc2wol_survives_rip_field(multiworld, player)), - LocationData("Maw of the Void", "Maw of the Void: South Far Prisoners", SC2WOL_LOC_ID_OFFSET + 1204, + LocationData("Maw of the Void", "Maw of the Void: South Far Prisoners", SC2WOL_LOC_ID_OFFSET + 1204, LocationType.BONUS, lambda state: state._sc2wol_survives_rip_field(multiworld, player)), - LocationData("Maw of the Void", "Maw of the Void: North Prisoners", SC2WOL_LOC_ID_OFFSET + 1205, + LocationData("Maw of the Void", "Maw of the Void: North Prisoners", SC2WOL_LOC_ID_OFFSET + 1205, LocationType.BONUS, lambda state: state._sc2wol_survives_rip_field(multiworld, player)), - LocationData("Devil's Playground", "Devil's Playground: Victory", SC2WOL_LOC_ID_OFFSET + 1300, + LocationData("Maw of the Void", "Maw of the Void: Mothership", SC2WOL_LOC_ID_OFFSET + 1206, LocationType.OPTIONAL_BOSS, + lambda state: state._sc2wol_survives_rip_field(multiworld, player)), + LocationData("Maw of the Void", "Maw of the Void: Expansion Rip Field Generator", SC2WOL_LOC_ID_OFFSET + 1207, LocationType.MISSION_PROGRESS, + lambda state: logic_level > 0 or state._sc2wol_survives_rip_field(multiworld, player)), + LocationData("Maw of the Void", "Maw of the Void: Middle Rip Field Generator", SC2WOL_LOC_ID_OFFSET + 1208, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_survives_rip_field(multiworld, player)), + LocationData("Maw of the Void", "Maw of the Void: Southeast Rip Field Generator", SC2WOL_LOC_ID_OFFSET + 1209, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_survives_rip_field(multiworld, player)), + LocationData("Maw of the Void", "Maw of the Void: Stargate Rip Field Generator", SC2WOL_LOC_ID_OFFSET + 1210, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_survives_rip_field(multiworld, player)), + LocationData("Maw of the Void", "Maw of the Void: Northwest Rip Field Generator", SC2WOL_LOC_ID_OFFSET + 1211, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_survives_rip_field(multiworld, player)), + LocationData("Maw of the Void", "Maw of the Void: West Rip Field Generator", SC2WOL_LOC_ID_OFFSET + 1212, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_survives_rip_field(multiworld, player)), + LocationData("Maw of the Void", "Maw of the Void: Southwest Rip Field Generator", SC2WOL_LOC_ID_OFFSET + 1213, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_survives_rip_field(multiworld, player)), + LocationData("Devil's Playground", "Devil's Playground: Victory", SC2WOL_LOC_ID_OFFSET + 1300, LocationType.VICTORY, lambda state: logic_level > 0 or state._sc2wol_has_anti_air(multiworld, player) and ( state._sc2wol_has_common_unit(multiworld, player) or state.has("Reaper", player))), - LocationData("Devil's Playground", "Devil's Playground: Tosh's Miners", SC2WOL_LOC_ID_OFFSET + 1301), - LocationData("Devil's Playground", "Devil's Playground: Brutalisk", SC2WOL_LOC_ID_OFFSET + 1302, + LocationData("Devil's Playground", "Devil's Playground: Tosh's Miners", SC2WOL_LOC_ID_OFFSET + 1301, LocationType.BONUS), + LocationData("Devil's Playground", "Devil's Playground: Brutalisk", SC2WOL_LOC_ID_OFFSET + 1302, LocationType.OPTIONAL_BOSS, lambda state: logic_level > 0 or state._sc2wol_has_common_unit(multiworld, player) or state.has("Reaper", player)), - LocationData("Welcome to the Jungle", "Welcome to the Jungle: Victory", SC2WOL_LOC_ID_OFFSET + 1400, - lambda state: state._sc2wol_has_common_unit(multiworld, player) and - state._sc2wol_has_competent_anti_air(multiworld, player)), - LocationData("Welcome to the Jungle", "Welcome to the Jungle: Close Relic", SC2WOL_LOC_ID_OFFSET + 1401), - LocationData("Welcome to the Jungle", "Welcome to the Jungle: West Relic", SC2WOL_LOC_ID_OFFSET + 1402, - lambda state: state._sc2wol_has_common_unit(multiworld, player) and - state._sc2wol_has_competent_anti_air(multiworld, player)), - LocationData("Welcome to the Jungle", "Welcome to the Jungle: North-East Relic", SC2WOL_LOC_ID_OFFSET + 1403, - lambda state: state._sc2wol_has_common_unit(multiworld, player) and - state._sc2wol_has_competent_anti_air(multiworld, player)), - LocationData("Breakout", "Breakout: Victory", SC2WOL_LOC_ID_OFFSET + 1500), - LocationData("Breakout", "Breakout: Diamondback Prison", SC2WOL_LOC_ID_OFFSET + 1501), - LocationData("Breakout", "Breakout: Siegetank Prison", SC2WOL_LOC_ID_OFFSET + 1502), - LocationData("Ghost of a Chance", "Ghost of a Chance: Victory", SC2WOL_LOC_ID_OFFSET + 1600), - LocationData("Ghost of a Chance", "Ghost of a Chance: Terrazine Tank", SC2WOL_LOC_ID_OFFSET + 1601), - LocationData("Ghost of a Chance", "Ghost of a Chance: Jorium Stockpile", SC2WOL_LOC_ID_OFFSET + 1602), - LocationData("Ghost of a Chance", "Ghost of a Chance: First Island Spectres", SC2WOL_LOC_ID_OFFSET + 1603), - LocationData("Ghost of a Chance", "Ghost of a Chance: Second Island Spectres", SC2WOL_LOC_ID_OFFSET + 1604), - LocationData("Ghost of a Chance", "Ghost of a Chance: Third Island Spectres", SC2WOL_LOC_ID_OFFSET + 1605), - LocationData("The Great Train Robbery", "The Great Train Robbery: Victory", SC2WOL_LOC_ID_OFFSET + 1700, + LocationData("Devil's Playground", "Devil's Playground: North Reapers", SC2WOL_LOC_ID_OFFSET + 1303, LocationType.BONUS), + LocationData("Devil's Playground", "Devil's Playground: Middle Reapers", SC2WOL_LOC_ID_OFFSET + 1304, LocationType.BONUS, + lambda state: logic_level > 0 or state._sc2wol_has_common_unit(multiworld, player) or state.has("Reaper", player)), + LocationData("Devil's Playground", "Devil's Playground: Southwest Reapers", SC2WOL_LOC_ID_OFFSET + 1305, LocationType.BONUS, + lambda state: logic_level > 0 or state._sc2wol_has_common_unit(multiworld, player) or state.has("Reaper", player)), + LocationData("Devil's Playground", "Devil's Playground: Southeast Reapers", SC2WOL_LOC_ID_OFFSET + 1306, LocationType.BONUS, + lambda state: logic_level > 0 or + state._sc2wol_has_anti_air(multiworld, player) and ( + state._sc2wol_has_common_unit(multiworld, player) or state.has("Reaper", player))), + LocationData("Devil's Playground", "Devil's Playground: East Reapers", SC2WOL_LOC_ID_OFFSET + 1307, LocationType.BONUS, + lambda state: state._sc2wol_has_anti_air(multiworld, player) and + (logic_level > 0 or + state._sc2wol_has_common_unit(multiworld, player) or state.has("Reaper", player))), + LocationData("Welcome to the Jungle", "Welcome to the Jungle: Victory", SC2WOL_LOC_ID_OFFSET + 1400, LocationType.VICTORY, + lambda state: state._sc2wol_welcome_to_the_jungle_requirement(multiworld, player)), + LocationData("Welcome to the Jungle", "Welcome to the Jungle: Close Relic", SC2WOL_LOC_ID_OFFSET + 1401, LocationType.BONUS), + LocationData("Welcome to the Jungle", "Welcome to the Jungle: West Relic", SC2WOL_LOC_ID_OFFSET + 1402, LocationType.BONUS, + lambda state: state._sc2wol_welcome_to_the_jungle_requirement(multiworld, player)), + LocationData("Welcome to the Jungle", "Welcome to the Jungle: North-East Relic", SC2WOL_LOC_ID_OFFSET + 1403, LocationType.BONUS, + lambda state: state._sc2wol_welcome_to_the_jungle_requirement(multiworld, player)), + LocationData("Welcome to the Jungle", "Welcome to the Jungle: Middle Base", SC2WOL_LOC_ID_OFFSET + 1404, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_welcome_to_the_jungle_requirement(multiworld, player)), + LocationData("Welcome to the Jungle", "Welcome to the Jungle: Main Base", SC2WOL_LOC_ID_OFFSET + 1405, LocationType.CHALLENGE, + lambda state: state._sc2wol_welcome_to_the_jungle_requirement(multiworld, player) + and state._sc2wol_beats_protoss_deathball(multiworld, player)), + LocationData("Welcome to the Jungle", "Welcome to the Jungle: No Terrazine Nodes Sealed", SC2WOL_LOC_ID_OFFSET + 1406, LocationType.CHALLENGE, + lambda state: state._sc2wol_welcome_to_the_jungle_requirement(multiworld, player) + and state._sc2wol_has_competent_ground_to_air(multiworld, player) + and state._sc2wol_beats_protoss_deathball(multiworld, player)), + LocationData("Welcome to the Jungle", "Welcome to the Jungle: Up to 1 Terrazine Node Sealed", SC2WOL_LOC_ID_OFFSET + 1407, LocationType.CHALLENGE, + lambda state: state._sc2wol_welcome_to_the_jungle_requirement(multiworld, player) + and state._sc2wol_has_competent_ground_to_air(multiworld, player) + and state._sc2wol_beats_protoss_deathball(multiworld, player)), + LocationData("Welcome to the Jungle", "Welcome to the Jungle: Up to 2 Terrazine Nodes Sealed", SC2WOL_LOC_ID_OFFSET + 1408, LocationType.CHALLENGE, + lambda state: state._sc2wol_welcome_to_the_jungle_requirement(multiworld, player) + and state._sc2wol_beats_protoss_deathball(multiworld, player)), + LocationData("Welcome to the Jungle", "Welcome to the Jungle: Up to 3 Terrazine Nodes Sealed", SC2WOL_LOC_ID_OFFSET + 1409, LocationType.CHALLENGE, + lambda state: state._sc2wol_welcome_to_the_jungle_requirement(multiworld, player) + and state._sc2wol_has_competent_comp(multiworld, player)), + LocationData("Welcome to the Jungle", "Welcome to the Jungle: Up to 4 Terrazine Nodes Sealed", SC2WOL_LOC_ID_OFFSET + 1410, LocationType.CHALLENGE, + lambda state: state._sc2wol_welcome_to_the_jungle_requirement(multiworld, player)), + LocationData("Welcome to the Jungle", "Welcome to the Jungle: Up to 5 Terrazine Nodes Sealed", SC2WOL_LOC_ID_OFFSET + 1411, LocationType.CHALLENGE, + lambda state: state._sc2wol_welcome_to_the_jungle_requirement(multiworld, player)), + LocationData("Breakout", "Breakout: Victory", SC2WOL_LOC_ID_OFFSET + 1500, LocationType.VICTORY), + LocationData("Breakout", "Breakout: Diamondback Prison", SC2WOL_LOC_ID_OFFSET + 1501, LocationType.BONUS), + LocationData("Breakout", "Breakout: Siege Tank Prison", SC2WOL_LOC_ID_OFFSET + 1502, LocationType.BONUS), + LocationData("Breakout", "Breakout: First Checkpoint", SC2WOL_LOC_ID_OFFSET + 1503, LocationType.MISSION_PROGRESS), + LocationData("Breakout", "Breakout: Second Checkpoint", SC2WOL_LOC_ID_OFFSET + 1504, LocationType.MISSION_PROGRESS), + LocationData("Ghost of a Chance", "Ghost of a Chance: Victory", SC2WOL_LOC_ID_OFFSET + 1600, LocationType.VICTORY), + LocationData("Ghost of a Chance", "Ghost of a Chance: Terrazine Tank", SC2WOL_LOC_ID_OFFSET + 1601, LocationType.MISSION_PROGRESS), + LocationData("Ghost of a Chance", "Ghost of a Chance: Jorium Stockpile", SC2WOL_LOC_ID_OFFSET + 1602, LocationType.MISSION_PROGRESS), + LocationData("Ghost of a Chance", "Ghost of a Chance: First Island Spectres", SC2WOL_LOC_ID_OFFSET + 1603, LocationType.BONUS), + LocationData("Ghost of a Chance", "Ghost of a Chance: Second Island Spectres", SC2WOL_LOC_ID_OFFSET + 1604, LocationType.BONUS), + LocationData("Ghost of a Chance", "Ghost of a Chance: Third Island Spectres", SC2WOL_LOC_ID_OFFSET + 1605, LocationType.BONUS), + LocationData("The Great Train Robbery", "The Great Train Robbery: Victory", SC2WOL_LOC_ID_OFFSET + 1700, LocationType.VICTORY, lambda state: state._sc2wol_has_train_killers(multiworld, player) and state._sc2wol_has_anti_air(multiworld, player)), - LocationData("The Great Train Robbery", "The Great Train Robbery: North Defiler", SC2WOL_LOC_ID_OFFSET + 1701), - LocationData("The Great Train Robbery", "The Great Train Robbery: Mid Defiler", SC2WOL_LOC_ID_OFFSET + 1702), - LocationData("The Great Train Robbery", "The Great Train Robbery: South Defiler", SC2WOL_LOC_ID_OFFSET + 1703), - LocationData("Cutthroat", "Cutthroat: Victory", SC2WOL_LOC_ID_OFFSET + 1800, + LocationData("The Great Train Robbery", "The Great Train Robbery: North Defiler", SC2WOL_LOC_ID_OFFSET + 1701, LocationType.BONUS), + LocationData("The Great Train Robbery", "The Great Train Robbery: Mid Defiler", SC2WOL_LOC_ID_OFFSET + 1702, LocationType.BONUS), + LocationData("The Great Train Robbery", "The Great Train Robbery: South Defiler", SC2WOL_LOC_ID_OFFSET + 1703, LocationType.BONUS), + LocationData("The Great Train Robbery", "The Great Train Robbery: Close Diamondback", SC2WOL_LOC_ID_OFFSET + 1704, LocationType.BONUS), + LocationData("The Great Train Robbery", "The Great Train Robbery: Northwest Diamondback", SC2WOL_LOC_ID_OFFSET + 1705, LocationType.BONUS), + LocationData("The Great Train Robbery", "The Great Train Robbery: North Diamondback", SC2WOL_LOC_ID_OFFSET + 1706, LocationType.BONUS), + LocationData("The Great Train Robbery", "The Great Train Robbery: Northeast Diamondback", SC2WOL_LOC_ID_OFFSET + 1707, LocationType.BONUS), + LocationData("The Great Train Robbery", "The Great Train Robbery: Southwest Diamondback", SC2WOL_LOC_ID_OFFSET + 1708, LocationType.BONUS), + LocationData("The Great Train Robbery", "The Great Train Robbery: Southeast Diamondback", SC2WOL_LOC_ID_OFFSET + 1709, LocationType.BONUS), + LocationData("The Great Train Robbery", "The Great Train Robbery: Kill Team", SC2WOL_LOC_ID_OFFSET + 1710, LocationType.CHALLENGE, + lambda state: (logic_level > 0 or state._sc2wol_has_common_unit(multiworld, player)) and + state._sc2wol_has_train_killers(multiworld, player) and + state._sc2wol_has_anti_air(multiworld, player)), + LocationData("Cutthroat", "Cutthroat: Victory", SC2WOL_LOC_ID_OFFSET + 1800, LocationType.VICTORY, lambda state: state._sc2wol_has_common_unit(multiworld, player) and (logic_level > 0 or state._sc2wol_has_anti_air)), - LocationData("Cutthroat", "Cutthroat: Mira Han", SC2WOL_LOC_ID_OFFSET + 1801, + LocationData("Cutthroat", "Cutthroat: Mira Han", SC2WOL_LOC_ID_OFFSET + 1801, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_has_common_unit(multiworld, player)), + LocationData("Cutthroat", "Cutthroat: North Relic", SC2WOL_LOC_ID_OFFSET + 1802, LocationType.BONUS, + lambda state: state._sc2wol_has_common_unit(multiworld, player)), + LocationData("Cutthroat", "Cutthroat: Mid Relic", SC2WOL_LOC_ID_OFFSET + 1803, LocationType.BONUS), + LocationData("Cutthroat", "Cutthroat: Southwest Relic", SC2WOL_LOC_ID_OFFSET + 1804, LocationType.BONUS, + lambda state: state._sc2wol_has_common_unit(multiworld, player)), + LocationData("Cutthroat", "Cutthroat: North Command Center", SC2WOL_LOC_ID_OFFSET + 1805, LocationType.MISSION_PROGRESS, lambda state: state._sc2wol_has_common_unit(multiworld, player)), - LocationData("Cutthroat", "Cutthroat: North Relic", SC2WOL_LOC_ID_OFFSET + 1802, + LocationData("Cutthroat", "Cutthroat: South Command Center", SC2WOL_LOC_ID_OFFSET + 1806, LocationType.MISSION_PROGRESS, lambda state: state._sc2wol_has_common_unit(multiworld, player)), - LocationData("Cutthroat", "Cutthroat: Mid Relic", SC2WOL_LOC_ID_OFFSET + 1803), - LocationData("Cutthroat", "Cutthroat: Southwest Relic", SC2WOL_LOC_ID_OFFSET + 1804, + LocationData("Cutthroat", "Cutthroat: West Command Center", SC2WOL_LOC_ID_OFFSET + 1807, LocationType.MISSION_PROGRESS, lambda state: state._sc2wol_has_common_unit(multiworld, player)), - LocationData("Engine of Destruction", "Engine of Destruction: Victory", SC2WOL_LOC_ID_OFFSET + 1900, + LocationData("Engine of Destruction", "Engine of Destruction: Victory", SC2WOL_LOC_ID_OFFSET + 1900, LocationType.VICTORY, lambda state: state._sc2wol_has_competent_anti_air(multiworld, player) and state._sc2wol_has_common_unit(multiworld, player) or state.has('Wraith', player)), - LocationData("Engine of Destruction", "Engine of Destruction: Odin", SC2WOL_LOC_ID_OFFSET + 1901), - LocationData("Engine of Destruction", "Engine of Destruction: Loki", SC2WOL_LOC_ID_OFFSET + 1902, + LocationData("Engine of Destruction", "Engine of Destruction: Odin", SC2WOL_LOC_ID_OFFSET + 1901, LocationType.MISSION_PROGRESS), + LocationData("Engine of Destruction", "Engine of Destruction: Loki", SC2WOL_LOC_ID_OFFSET + 1902, LocationType.OPTIONAL_BOSS, lambda state: state._sc2wol_has_competent_anti_air(multiworld, player) and state._sc2wol_has_common_unit(multiworld, player) or state.has('Wraith', player)), - LocationData("Engine of Destruction", "Engine of Destruction: Lab Devourer", SC2WOL_LOC_ID_OFFSET + 1903), - LocationData("Engine of Destruction", "Engine of Destruction: North Devourer", SC2WOL_LOC_ID_OFFSET + 1904, + LocationData("Engine of Destruction", "Engine of Destruction: Lab Devourer", SC2WOL_LOC_ID_OFFSET + 1903, LocationType.BONUS), + LocationData("Engine of Destruction", "Engine of Destruction: North Devourer", SC2WOL_LOC_ID_OFFSET + 1904, LocationType.BONUS, lambda state: state._sc2wol_has_competent_anti_air(multiworld, player) and state._sc2wol_has_common_unit(multiworld, player) or state.has('Wraith', player)), - LocationData("Engine of Destruction", "Engine of Destruction: Southeast Devourer", SC2WOL_LOC_ID_OFFSET + 1905, + LocationData("Engine of Destruction", "Engine of Destruction: Southeast Devourer", SC2WOL_LOC_ID_OFFSET + 1905, LocationType.BONUS, lambda state: state._sc2wol_has_competent_anti_air(multiworld, player) and state._sc2wol_has_common_unit(multiworld, player) or state.has('Wraith', player)), - LocationData("Media Blitz", "Media Blitz: Victory", SC2WOL_LOC_ID_OFFSET + 2000, + LocationData("Engine of Destruction", "Engine of Destruction: West Base", SC2WOL_LOC_ID_OFFSET + 1906, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_has_competent_anti_air(multiworld, player) and + state._sc2wol_has_common_unit(multiworld, player) or state.has('Wraith', player)), + LocationData("Engine of Destruction", "Engine of Destruction: Northwest Base", SC2WOL_LOC_ID_OFFSET + 1907, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_has_competent_anti_air(multiworld, player) and + state._sc2wol_has_common_unit(multiworld, player) or state.has('Wraith', player)), + LocationData("Engine of Destruction", "Engine of Destruction: Northeast Base", SC2WOL_LOC_ID_OFFSET + 1908, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_has_competent_anti_air(multiworld, player) and + state._sc2wol_has_common_unit(multiworld, player) or state.has('Wraith', player)), + LocationData("Engine of Destruction", "Engine of Destruction: Southeast Base", SC2WOL_LOC_ID_OFFSET + 1909, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_has_competent_anti_air(multiworld, player) and + state._sc2wol_has_common_unit(multiworld, player) or state.has('Wraith', player)), + LocationData("Media Blitz", "Media Blitz: Victory", SC2WOL_LOC_ID_OFFSET + 2000, LocationType.VICTORY, + lambda state: state._sc2wol_has_competent_comp(multiworld, player)), + LocationData("Media Blitz", "Media Blitz: Tower 1", SC2WOL_LOC_ID_OFFSET + 2001, LocationType.MISSION_PROGRESS, lambda state: state._sc2wol_has_competent_comp(multiworld, player)), - LocationData("Media Blitz", "Media Blitz: Tower 1", SC2WOL_LOC_ID_OFFSET + 2001, + LocationData("Media Blitz", "Media Blitz: Tower 2", SC2WOL_LOC_ID_OFFSET + 2002, LocationType.MISSION_PROGRESS, lambda state: state._sc2wol_has_competent_comp(multiworld, player)), - LocationData("Media Blitz", "Media Blitz: Tower 2", SC2WOL_LOC_ID_OFFSET + 2002, + LocationData("Media Blitz", "Media Blitz: Tower 3", SC2WOL_LOC_ID_OFFSET + 2003, LocationType.MISSION_PROGRESS, lambda state: state._sc2wol_has_competent_comp(multiworld, player)), - LocationData("Media Blitz", "Media Blitz: Tower 3", SC2WOL_LOC_ID_OFFSET + 2003, + LocationData("Media Blitz", "Media Blitz: Science Facility", SC2WOL_LOC_ID_OFFSET + 2004, LocationType.BONUS), + LocationData("Media Blitz", "Media Blitz: All Barracks", SC2WOL_LOC_ID_OFFSET + 2005, LocationType.MISSION_PROGRESS, lambda state: state._sc2wol_has_competent_comp(multiworld, player)), - LocationData("Media Blitz", "Media Blitz: Science Facility", SC2WOL_LOC_ID_OFFSET + 2004), - LocationData("Piercing the Shroud", "Piercing the Shroud: Victory", SC2WOL_LOC_ID_OFFSET + 2100, + LocationData("Media Blitz", "Media Blitz: All Factories", SC2WOL_LOC_ID_OFFSET + 2006, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_has_competent_comp(multiworld, player)), + LocationData("Media Blitz", "Media Blitz: All Starports", SC2WOL_LOC_ID_OFFSET + 2007, LocationType.MISSION_PROGRESS, + lambda state: logic_level > 0 or state._sc2wol_has_competent_comp(multiworld, player)), + LocationData("Media Blitz", "Media Blitz: Odin Not Trashed", SC2WOL_LOC_ID_OFFSET + 2008, LocationType.CHALLENGE, + lambda state: state._sc2wol_has_competent_comp(multiworld, player)), + LocationData("Piercing the Shroud", "Piercing the Shroud: Victory", SC2WOL_LOC_ID_OFFSET + 2100, LocationType.VICTORY, + lambda state: state._sc2wol_has_mm_upgrade(multiworld, player)), + LocationData("Piercing the Shroud", "Piercing the Shroud: Holding Cell Relic", SC2WOL_LOC_ID_OFFSET + 2101, LocationType.BONUS), + LocationData("Piercing the Shroud", "Piercing the Shroud: Brutalisk Relic", SC2WOL_LOC_ID_OFFSET + 2102, LocationType.BONUS, lambda state: state._sc2wol_has_mm_upgrade(multiworld, player)), - LocationData("Piercing the Shroud", "Piercing the Shroud: Holding Cell Relic", SC2WOL_LOC_ID_OFFSET + 2101), - LocationData("Piercing the Shroud", "Piercing the Shroud: Brutalisk Relic", SC2WOL_LOC_ID_OFFSET + 2102, + LocationData("Piercing the Shroud", "Piercing the Shroud: First Escape Relic", SC2WOL_LOC_ID_OFFSET + 2103,LocationType.BONUS, lambda state: state._sc2wol_has_mm_upgrade(multiworld, player)), - LocationData("Piercing the Shroud", "Piercing the Shroud: First Escape Relic", SC2WOL_LOC_ID_OFFSET + 2103, + LocationData("Piercing the Shroud", "Piercing the Shroud: Second Escape Relic", SC2WOL_LOC_ID_OFFSET + 2104, LocationType.BONUS, lambda state: state._sc2wol_has_mm_upgrade(multiworld, player)), - LocationData("Piercing the Shroud", "Piercing the Shroud: Second Escape Relic", SC2WOL_LOC_ID_OFFSET + 2104, + LocationData("Piercing the Shroud", "Piercing the Shroud: Brutalisk", SC2WOL_LOC_ID_OFFSET + 2105, LocationType.OPTIONAL_BOSS, lambda state: state._sc2wol_has_mm_upgrade(multiworld, player)), - LocationData("Piercing the Shroud", "Piercing the Shroud: Brutalisk", SC2WOL_LOC_ID_OFFSET + 2105, + LocationData("Piercing the Shroud", "Piercing the Shroud: Fusion Reactor", SC2WOL_LOC_ID_OFFSET + 2106, LocationType.MISSION_PROGRESS, lambda state: state._sc2wol_has_mm_upgrade(multiworld, player)), - LocationData("Whispers of Doom", "Whispers of Doom: Victory", SC2WOL_LOC_ID_OFFSET + 2200), - LocationData("Whispers of Doom", "Whispers of Doom: First Hatchery", SC2WOL_LOC_ID_OFFSET + 2201), - LocationData("Whispers of Doom", "Whispers of Doom: Second Hatchery", SC2WOL_LOC_ID_OFFSET + 2202), - LocationData("Whispers of Doom", "Whispers of Doom: Third Hatchery", SC2WOL_LOC_ID_OFFSET + 2203), - LocationData("A Sinister Turn", "A Sinister Turn: Victory", SC2WOL_LOC_ID_OFFSET + 2300, + LocationData("Whispers of Doom", "Whispers of Doom: Victory", SC2WOL_LOC_ID_OFFSET + 2200, LocationType.VICTORY), + LocationData("Whispers of Doom", "Whispers of Doom: First Hatchery", SC2WOL_LOC_ID_OFFSET + 2201, LocationType.BONUS), + LocationData("Whispers of Doom", "Whispers of Doom: Second Hatchery", SC2WOL_LOC_ID_OFFSET + 2202, LocationType.BONUS), + LocationData("Whispers of Doom", "Whispers of Doom: Third Hatchery", SC2WOL_LOC_ID_OFFSET + 2203, LocationType.BONUS), + LocationData("Whispers of Doom", "Whispers of Doom: First Prophecy Fragment", SC2WOL_LOC_ID_OFFSET + 2204, LocationType.MISSION_PROGRESS), + LocationData("Whispers of Doom", "Whispers of Doom: Second Prophecy Fragment", SC2WOL_LOC_ID_OFFSET + 2205, LocationType.MISSION_PROGRESS), + LocationData("Whispers of Doom", "Whispers of Doom: Third Prophecy Fragment", SC2WOL_LOC_ID_OFFSET + 2206, LocationType.MISSION_PROGRESS), + LocationData("A Sinister Turn", "A Sinister Turn: Victory", SC2WOL_LOC_ID_OFFSET + 2300, LocationType.VICTORY, lambda state: state._sc2wol_has_protoss_medium_units(multiworld, player)), - LocationData("A Sinister Turn", "A Sinister Turn: Robotics Facility", SC2WOL_LOC_ID_OFFSET + 2301, + LocationData("A Sinister Turn", "A Sinister Turn: Robotics Facility", SC2WOL_LOC_ID_OFFSET + 2301, LocationType.BONUS, lambda state: logic_level > 0 or state._sc2wol_has_protoss_common_units(multiworld, player)), - LocationData("A Sinister Turn", "A Sinister Turn: Dark Shrine", SC2WOL_LOC_ID_OFFSET + 2302, + LocationData("A Sinister Turn", "A Sinister Turn: Dark Shrine", SC2WOL_LOC_ID_OFFSET + 2302, LocationType.BONUS, lambda state: logic_level > 0 or state._sc2wol_has_protoss_common_units(multiworld, player)), - LocationData("A Sinister Turn", "A Sinister Turn: Templar Archives", SC2WOL_LOC_ID_OFFSET + 2303, - lambda state: state._sc2wol_has_protoss_common_units(multiworld, player)), - LocationData("Echoes of the Future", "Echoes of the Future: Victory", SC2WOL_LOC_ID_OFFSET + 2400, + LocationData("A Sinister Turn", "A Sinister Turn: Templar Archives", SC2WOL_LOC_ID_OFFSET + 2303, LocationType.BONUS, + lambda state: state._sc2wol_has_protoss_medium_units(multiworld, player)), + LocationData("A Sinister Turn", "A Sinister Turn: Northeast Base", SC2WOL_LOC_ID_OFFSET + 2304, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_has_protoss_medium_units(multiworld, player)), + LocationData("A Sinister Turn", "A Sinister Turn: Southeast Base", SC2WOL_LOC_ID_OFFSET + 2305, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_has_protoss_medium_units(multiworld, player)), + LocationData("A Sinister Turn", "A Sinister Turn: Maar", SC2WOL_LOC_ID_OFFSET + 2306, LocationType.MISSION_PROGRESS, lambda state: logic_level > 0 or state._sc2wol_has_protoss_medium_units(multiworld, player)), - LocationData("Echoes of the Future", "Echoes of the Future: Close Obelisk", SC2WOL_LOC_ID_OFFSET + 2401), - LocationData("Echoes of the Future", "Echoes of the Future: West Obelisk", SC2WOL_LOC_ID_OFFSET + 2402, + LocationData("A Sinister Turn", "A Sinister Turn: Northwest Preserver", SC2WOL_LOC_ID_OFFSET + 2307, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_has_protoss_medium_units(multiworld, player)), + LocationData("A Sinister Turn", "A Sinister Turn: Southwest Preserver", SC2WOL_LOC_ID_OFFSET + 2308, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_has_protoss_medium_units(multiworld, player)), + LocationData("A Sinister Turn", "A Sinister Turn: East Preserver", SC2WOL_LOC_ID_OFFSET + 2309, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_has_protoss_medium_units(multiworld, player)), + LocationData("Echoes of the Future", "Echoes of the Future: Victory", SC2WOL_LOC_ID_OFFSET + 2400, LocationType.VICTORY, + lambda state: logic_level > 0 or state._sc2wol_has_protoss_medium_units(multiworld, player)), + LocationData("Echoes of the Future", "Echoes of the Future: Close Obelisk", SC2WOL_LOC_ID_OFFSET + 2401, LocationType.BONUS), + LocationData("Echoes of the Future", "Echoes of the Future: West Obelisk", SC2WOL_LOC_ID_OFFSET + 2402, LocationType.BONUS, + lambda state: logic_level > 0 or state._sc2wol_has_protoss_common_units(multiworld, player)), + LocationData("Echoes of the Future", "Echoes of the Future: Base", SC2WOL_LOC_ID_OFFSET + 2403, LocationType.MISSION_PROGRESS), + LocationData("Echoes of the Future", "Echoes of the Future: Southwest Tendril", SC2WOL_LOC_ID_OFFSET + 2404, LocationType.MISSION_PROGRESS), + LocationData("Echoes of the Future", "Echoes of the Future: Southeast Tendril", SC2WOL_LOC_ID_OFFSET + 2405, LocationType.MISSION_PROGRESS, + lambda state: logic_level > 0 or state._sc2wol_has_protoss_common_units(multiworld, player)), + LocationData("Echoes of the Future", "Echoes of the Future: Northeast Tendril", SC2WOL_LOC_ID_OFFSET + 2406, LocationType.MISSION_PROGRESS, lambda state: logic_level > 0 or state._sc2wol_has_protoss_common_units(multiworld, player)), - LocationData("In Utter Darkness", "In Utter Darkness: Defeat", SC2WOL_LOC_ID_OFFSET + 2500), - LocationData("In Utter Darkness", "In Utter Darkness: Protoss Archive", SC2WOL_LOC_ID_OFFSET + 2501, + LocationData("Echoes of the Future", "Echoes of the Future: Northwest Tendril", SC2WOL_LOC_ID_OFFSET + 2407, LocationType.MISSION_PROGRESS, + lambda state: logic_level > 0 or state._sc2wol_has_protoss_common_units(multiworld, player)), + LocationData("In Utter Darkness", "In Utter Darkness: Defeat", SC2WOL_LOC_ID_OFFSET + 2500, LocationType.VICTORY), + LocationData("In Utter Darkness", "In Utter Darkness: Protoss Archive", SC2WOL_LOC_ID_OFFSET + 2501, LocationType.BONUS, lambda state: state._sc2wol_has_protoss_medium_units(multiworld, player)), - LocationData("In Utter Darkness", "In Utter Darkness: Kills", SC2WOL_LOC_ID_OFFSET + 2502, + LocationData("In Utter Darkness", "In Utter Darkness: Kills", SC2WOL_LOC_ID_OFFSET + 2502, LocationType.CHALLENGE, + lambda state: state._sc2wol_has_protoss_common_units(multiworld, player)), + LocationData("In Utter Darkness", "In Utter Darkness: Urun", SC2WOL_LOC_ID_OFFSET + 2503, LocationType.MISSION_PROGRESS), + LocationData("In Utter Darkness", "In Utter Darkness: Mohandar", SC2WOL_LOC_ID_OFFSET + 2504, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_has_protoss_common_units(multiworld, player)), + LocationData("In Utter Darkness", "In Utter Darkness: Selendis", SC2WOL_LOC_ID_OFFSET + 2505, LocationType.MISSION_PROGRESS, lambda state: state._sc2wol_has_protoss_common_units(multiworld, player)), - LocationData("Gates of Hell", "Gates of Hell: Victory", SC2WOL_LOC_ID_OFFSET + 2600, + LocationData("In Utter Darkness", "In Utter Darkness: Artanis", SC2WOL_LOC_ID_OFFSET + 2506, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_has_protoss_common_units(multiworld, player)), + LocationData("Gates of Hell", "Gates of Hell: Victory", SC2WOL_LOC_ID_OFFSET + 2600, LocationType.VICTORY, + lambda state: state._sc2wol_has_competent_comp(multiworld, player) and + state._sc2wol_defense_rating(multiworld, player, True) > 6), + LocationData("Gates of Hell", "Gates of Hell: Large Army", SC2WOL_LOC_ID_OFFSET + 2601, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_has_competent_comp(multiworld, player) and + state._sc2wol_defense_rating(multiworld, player, True) > 6), + LocationData("Gates of Hell", "Gates of Hell: 2 Drop Pods", SC2WOL_LOC_ID_OFFSET + 2602, LocationType.BONUS, + lambda state: state._sc2wol_has_competent_comp(multiworld, player) and + state._sc2wol_defense_rating(multiworld, player, True) > 6), + LocationData("Gates of Hell", "Gates of Hell: 4 Drop Pods", SC2WOL_LOC_ID_OFFSET + 2603, LocationType.BONUS, + lambda state: state._sc2wol_has_competent_comp(multiworld, player) and + state._sc2wol_defense_rating(multiworld, player, True) > 6), + LocationData("Gates of Hell", "Gates of Hell: 6 Drop Pods", SC2WOL_LOC_ID_OFFSET + 2604, LocationType.BONUS, lambda state: state._sc2wol_has_competent_comp(multiworld, player) and state._sc2wol_defense_rating(multiworld, player, True) > 6), - LocationData("Gates of Hell", "Gates of Hell: Large Army", SC2WOL_LOC_ID_OFFSET + 2601, + LocationData("Gates of Hell", "Gates of Hell: 8 Drop Pods", SC2WOL_LOC_ID_OFFSET + 2605, LocationType.BONUS, lambda state: state._sc2wol_has_competent_comp(multiworld, player) and state._sc2wol_defense_rating(multiworld, player, True) > 6), - LocationData("Belly of the Beast", "Belly of the Beast: Victory", SC2WOL_LOC_ID_OFFSET + 2700), - LocationData("Belly of the Beast", "Belly of the Beast: First Charge", SC2WOL_LOC_ID_OFFSET + 2701), - LocationData("Belly of the Beast", "Belly of the Beast: Second Charge", SC2WOL_LOC_ID_OFFSET + 2702), - LocationData("Belly of the Beast", "Belly of the Beast: Third Charge", SC2WOL_LOC_ID_OFFSET + 2703), - LocationData("Shatter the Sky", "Shatter the Sky: Victory", SC2WOL_LOC_ID_OFFSET + 2800, + LocationData("Belly of the Beast", "Belly of the Beast: Victory", SC2WOL_LOC_ID_OFFSET + 2700, LocationType.VICTORY), + LocationData("Belly of the Beast", "Belly of the Beast: First Charge", SC2WOL_LOC_ID_OFFSET + 2701, LocationType.MISSION_PROGRESS), + LocationData("Belly of the Beast", "Belly of the Beast: Second Charge", SC2WOL_LOC_ID_OFFSET + 2702, LocationType.MISSION_PROGRESS), + LocationData("Belly of the Beast", "Belly of the Beast: Third Charge", SC2WOL_LOC_ID_OFFSET + 2703, LocationType.MISSION_PROGRESS), + LocationData("Belly of the Beast", "Belly of the Beast: First Group Rescued", SC2WOL_LOC_ID_OFFSET + 2704, LocationType.BONUS), + LocationData("Belly of the Beast", "Belly of the Beast: Second Group Rescued", SC2WOL_LOC_ID_OFFSET + 2705, LocationType.BONUS), + LocationData("Belly of the Beast", "Belly of the Beast: Third Group Rescued", SC2WOL_LOC_ID_OFFSET + 2706, LocationType.BONUS), + LocationData("Shatter the Sky", "Shatter the Sky: Victory", SC2WOL_LOC_ID_OFFSET + 2800, LocationType.VICTORY, + lambda state: state._sc2wol_has_competent_comp(multiworld, player)), + LocationData("Shatter the Sky", "Shatter the Sky: Close Coolant Tower", SC2WOL_LOC_ID_OFFSET + 2801, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_has_competent_comp(multiworld, player)), + LocationData("Shatter the Sky", "Shatter the Sky: Northwest Coolant Tower", SC2WOL_LOC_ID_OFFSET + 2802, LocationType.MISSION_PROGRESS, + lambda state: state._sc2wol_has_competent_comp(multiworld, player)), + LocationData("Shatter the Sky", "Shatter the Sky: Southeast Coolant Tower", SC2WOL_LOC_ID_OFFSET + 2803, LocationType.MISSION_PROGRESS, lambda state: state._sc2wol_has_competent_comp(multiworld, player)), - LocationData("Shatter the Sky", "Shatter the Sky: Close Coolant Tower", SC2WOL_LOC_ID_OFFSET + 2801, + LocationData("Shatter the Sky", "Shatter the Sky: Southwest Coolant Tower", SC2WOL_LOC_ID_OFFSET + 2804, LocationType.MISSION_PROGRESS, lambda state: state._sc2wol_has_competent_comp(multiworld, player)), - LocationData("Shatter the Sky", "Shatter the Sky: Northwest Coolant Tower", SC2WOL_LOC_ID_OFFSET + 2802, + LocationData("Shatter the Sky", "Shatter the Sky: Leviathan", SC2WOL_LOC_ID_OFFSET + 2805, LocationType.OPTIONAL_BOSS, lambda state: state._sc2wol_has_competent_comp(multiworld, player)), - LocationData("Shatter the Sky", "Shatter the Sky: Southeast Coolant Tower", SC2WOL_LOC_ID_OFFSET + 2803, + LocationData("Shatter the Sky", "Shatter the Sky: East Hatchery", SC2WOL_LOC_ID_OFFSET + 2806, LocationType.MISSION_PROGRESS, lambda state: state._sc2wol_has_competent_comp(multiworld, player)), - LocationData("Shatter the Sky", "Shatter the Sky: Southwest Coolant Tower", SC2WOL_LOC_ID_OFFSET + 2804, + LocationData("Shatter the Sky", "Shatter the Sky: North Hatchery", SC2WOL_LOC_ID_OFFSET + 2807, LocationType.MISSION_PROGRESS, lambda state: state._sc2wol_has_competent_comp(multiworld, player)), - LocationData("Shatter the Sky", "Shatter the Sky: Leviathan", SC2WOL_LOC_ID_OFFSET + 2805, + LocationData("Shatter the Sky", "Shatter the Sky: Mid Hatchery", SC2WOL_LOC_ID_OFFSET + 2808, LocationType.MISSION_PROGRESS, lambda state: state._sc2wol_has_competent_comp(multiworld, player)), - LocationData("All-In", "All-In: Victory", None, + LocationData("All-In", "All-In: Victory", None, LocationType.VICTORY, lambda state: state._sc2wol_final_mission_requirements(multiworld, player)) ] diff --git a/worlds/sc2wol/LogicMixin.py b/worlds/sc2wol/LogicMixin.py index 8c7182e0e877..112302beb207 100644 --- a/worlds/sc2wol/LogicMixin.py +++ b/worlds/sc2wol/LogicMixin.py @@ -9,22 +9,38 @@ def _sc2wol_has_common_unit(self, multiworld: MultiWorld, player: int) -> bool: return self.has_any(get_basic_units(multiworld, player), player) def _sc2wol_has_air(self, multiworld: MultiWorld, player: int) -> bool: - return self.has_any({'Viking', 'Wraith', 'Banshee'}, player) or get_option_value(multiworld, player, 'required_tactics') > 0 \ + return self.has_any({'Viking', 'Wraith', 'Banshee', 'Battlecruiser'}, player) or get_option_value(multiworld, player, 'required_tactics') > 0 \ and self.has_any({'Hercules', 'Medivac'}, player) and self._sc2wol_has_common_unit(multiworld, player) def _sc2wol_has_air_anti_air(self, multiworld: MultiWorld, player: int) -> bool: return self.has('Viking', player) \ - or get_option_value(multiworld, player, 'required_tactics') > 0 and self.has('Wraith', player) + or self.has_all({'Wraith', 'Advanced Laser Technology (Wraith)'}, player) \ + or self.has_all({'Battlecruiser', 'ATX Laser Battery (Battlecruiser)'}, player) \ + or get_option_value(multiworld, player, 'required_tactics') > 0 and self.has_any({'Wraith', 'Valkyrie', 'Battlecruiser'}, player) - def _sc2wol_has_competent_anti_air(self, multiworld: MultiWorld, player: int) -> bool: + def _sc2wol_has_competent_ground_to_air(self, multiworld: MultiWorld, player: int) -> bool: return self.has('Goliath', player) \ or self.has('Marine', player) and self.has_any({'Medic', 'Medivac'}, player) \ + or get_option_value(multiworld, player, 'required_tactics') > 0 and self.has('Cyclone', player) + + def _sc2wol_has_competent_anti_air(self, multiworld: MultiWorld, player: int) -> bool: + return self._sc2wol_has_competent_ground_to_air(multiworld, player) \ or self._sc2wol_has_air_anti_air(multiworld, player) + def _sc2wol_welcome_to_the_jungle_requirement(self, multiworld: MultiWorld, player: int) -> bool: + return ( + self._sc2wol_has_common_unit(multiworld, player) + and self._sc2wol_has_competent_ground_to_air(multiworld, player) + ) or ( + get_option_value(multiworld, player, 'required_tactics') > 0 + and self.has_any({'Marine', 'Vulture'}, player) + and self._sc2wol_has_air_anti_air(multiworld, player) + ) + def _sc2wol_has_anti_air(self, multiworld: MultiWorld, player: int) -> bool: - return self.has_any({'Missile Turret', 'Thor', 'War Pigs', 'Spartan Company', "Hel's Angel", 'Battlecruiser', 'Marine', 'Wraith'}, player) \ + return self.has_any({'Missile Turret', 'Thor', 'War Pigs', 'Spartan Company', "Hel's Angel", 'Battlecruiser', 'Marine', 'Wraith', 'Valkyrie', 'Cyclone'}, player) \ or self._sc2wol_has_competent_anti_air(multiworld, player) \ - or get_option_value(multiworld, player, 'required_tactics') > 0 and self.has_any({'Ghost', 'Spectre'}, player) + or get_option_value(multiworld, player, 'required_tactics') > 0 and self.has_any({'Ghost', 'Spectre', 'Widow Mine', 'Liberator'}, player) def _sc2wol_defense_rating(self, multiworld: MultiWorld, player: int, zerg_enemy: bool, air_enemy: bool = True) -> bool: defense_score = sum((defense_ratings[item] for item in defense_ratings if self.has(item, player))) @@ -32,6 +48,10 @@ def _sc2wol_defense_rating(self, multiworld: MultiWorld, player: int, zerg_enemy defense_score += 3 if self.has_all({'Siege Tank', 'Maelstrom Rounds (Siege Tank)'}, player): defense_score += 2 + if self.has_all({'Siege Tank', 'Graduating Range (Siege Tank)'}, player): + defense_score += 1 + if self.has_all({'Widow Mine', 'Concealment (Widow Mine)'}, player): + defense_score += 1 if zerg_enemy: defense_score += sum((zerg_defense_ratings[item] for item in zerg_defense_ratings if self.has(item, player))) if self.has('Firebat', player) and self.has('Bunker', player): @@ -44,20 +64,27 @@ def _sc2wol_defense_rating(self, multiworld: MultiWorld, player: int, zerg_enemy return defense_score def _sc2wol_has_competent_comp(self, multiworld: MultiWorld, player: int) -> bool: - return (self.has('Marine', player) or self.has('Marauder', player) and - self._sc2wol_has_competent_anti_air(multiworld, player)) and self.has_any({'Medivac', 'Medic'}, player) or \ - self.has('Thor', player) or self.has("Banshee", player) and self._sc2wol_has_competent_anti_air(multiworld, player) or \ - self.has('Battlecruiser', player) and self._sc2wol_has_common_unit(multiworld, player) or \ - self.has('Siege Tank', player) and self._sc2wol_has_competent_anti_air(multiworld, player) + return \ + ( + ( + self.has_any({'Marine', 'Marauder'}, player) and self.has_any({'Medivac', 'Medic'}, player) + or self.has_any({'Thor', 'Banshee', 'Siege Tank'}, player) + or self.has_all({'Liberator', 'Raid Artillery (Liberator)'}, player) + ) and self._sc2wol_has_competent_anti_air(multiworld, player) + ) \ + or \ + ( + self.has('Battlecruiser', player) and self._sc2wol_has_common_unit(multiworld, player) + ) def _sc2wol_has_train_killers(self, multiworld: MultiWorld, player: int) -> bool: return ( - self.has_any({'Siege Tank', 'Diamondback', 'Marauder'}, player) + self.has_any({'Siege Tank', 'Diamondback', 'Marauder', 'Cyclone'}, player) or get_option_value(multiworld, player, 'required_tactics') > 0 and ( self.has_all({'Reaper', "G-4 Clusterbomb"}, player) or self.has_all({'Spectre', 'Psionic Lash'}, player) - or self.has('Vulture', player) + or self.has_any({'Vulture', 'Liberator'}, player) ) ) @@ -66,16 +93,18 @@ def _sc2wol_able_to_rescue(self, multiworld: MultiWorld, player: int) -> bool: def _sc2wol_has_protoss_common_units(self, multiworld: MultiWorld, player: int) -> bool: return self.has_any({'Zealot', 'Immortal', 'Stalker', 'Dark Templar'}, player) \ - or get_option_value(multiworld, player, 'required_tactics') > 0 and self.has_any({'High Templar', 'Dark Templar'}, player) + or get_option_value(multiworld, player, 'required_tactics') > 0 and self.has('High Templar', player) def _sc2wol_has_protoss_medium_units(self, multiworld: MultiWorld, player: int) -> bool: return self._sc2wol_has_protoss_common_units(multiworld, player) and \ - self.has_any({'Stalker', 'Void Ray', 'Phoenix', 'Carrier'}, player) \ - or get_option_value(multiworld, player, 'required_tactics') > 0 and self.has_any({'High Templar', 'Dark Templar'}, player) + self.has_any({'Stalker', 'Void Ray', 'Carrier'}, player) \ + or get_option_value(multiworld, player, 'required_tactics') > 0 and self.has('Dark Templar', player) def _sc2wol_beats_protoss_deathball(self, multiworld: MultiWorld, player: int) -> bool: - return self.has_any({'Banshee', 'Battlecruiser'}, player) and self._sc2wol_has_competent_anti_air(multiworld, player) or \ - self._sc2wol_has_competent_comp(multiworld, player) and self._sc2wol_has_air_anti_air(multiworld, player) + return (self.has_any({'Banshee', 'Battlecruiser'}, player) or + self.has_all({'Liberator', 'Raid Artillery (Liberator)'}, player)) \ + and self._sc2wol_has_competent_anti_air(multiworld, player) or \ + self._sc2wol_has_competent_comp(multiworld, player) and self._sc2wol_has_air_anti_air(multiworld, player) def _sc2wol_has_mm_upgrade(self, multiworld: MultiWorld, player: int) -> bool: return self.has_any({"Combat Shield (Marine)", "Stabilizer Medpacks (Medic)"}, player) @@ -89,6 +118,17 @@ def _sc2wol_survives_rip_field(self, multiworld: MultiWorld, player: int) -> boo def _sc2wol_has_nukes(self, multiworld: MultiWorld, player: int) -> bool: return get_option_value(multiworld, player, 'required_tactics') > 0 and self.has_any({'Ghost', 'Spectre'}, player) + def _sc2wol_can_respond_to_colony_infestations(self, multiworld: MultiWorld, player: int) -> bool: + return self._sc2wol_has_common_unit(multiworld, player) \ + and self._sc2wol_has_competent_anti_air(multiworld, player) \ + and \ + ( + self._sc2wol_has_air_anti_air(multiworld, player) or + self.has_any({'Battlecruiser', 'Valkyrie'}), player + ) \ + and \ + self._sc2wol_defense_rating(multiworld, player, True) >= 3 + def _sc2wol_final_mission_requirements(self, multiworld: MultiWorld, player: int): beats_kerrigan = self.has_any({'Marine', 'Banshee', 'Ghost'}, player) or get_option_value(multiworld, player, 'required_tactics') > 0 if get_option_value(multiworld, player, 'all_in_map') == 0: @@ -101,7 +141,7 @@ def _sc2wol_final_mission_requirements(self, multiworld: MultiWorld, player: int # Air defense_rating = self._sc2wol_defense_rating(multiworld, player, True, True) return defense_rating >= 8 and beats_kerrigan \ - and self.has_any({'Viking', 'Battlecruiser'}, player) \ + and self.has_any({'Viking', 'Battlecruiser', 'Valkyrie'}, player) \ and self.has_any({'Hive Mind Emulator', 'Psi Disruptor', 'Missile Turret'}, player) def _sc2wol_cleared_missions(self, multiworld: MultiWorld, player: int, mission_count: int) -> bool: diff --git a/worlds/sc2wol/MissionTables.py b/worlds/sc2wol/MissionTables.py index 6db93547689b..298cd7a978a6 100644 --- a/worlds/sc2wol/MissionTables.py +++ b/worlds/sc2wol/MissionTables.py @@ -49,8 +49,8 @@ class FillMission(NamedTuple): FillMission(MissionPools.EASY, [2], "Artifact", completion_critical=True), FillMission(MissionPools.MEDIUM, [7], "Artifact", number=8, completion_critical=True), FillMission(MissionPools.HARD, [8], "Artifact", number=11, completion_critical=True), - FillMission(MissionPools.HARD, [9], "Artifact", number=14, completion_critical=True), - FillMission(MissionPools.HARD, [10], "Artifact", completion_critical=True), + FillMission(MissionPools.HARD, [9], "Artifact", number=14, completion_critical=True, removal_priority=11), + FillMission(MissionPools.HARD, [10], "Artifact", completion_critical=True, removal_priority=10), FillMission(MissionPools.MEDIUM, [2], "Covert", number=4), FillMission(MissionPools.MEDIUM, [12], "Covert"), FillMission(MissionPools.HARD, [13], "Covert", number=8, removal_priority=3), @@ -58,7 +58,7 @@ class FillMission(NamedTuple): FillMission(MissionPools.MEDIUM, [2], "Rebellion", number=6), FillMission(MissionPools.HARD, [16], "Rebellion"), FillMission(MissionPools.HARD, [17], "Rebellion"), - FillMission(MissionPools.HARD, [18], "Rebellion"), + FillMission(MissionPools.HARD, [18], "Rebellion", removal_priority=12), FillMission(MissionPools.HARD, [19], "Rebellion", removal_priority=5), FillMission(MissionPools.MEDIUM, [8], "Prophecy", removal_priority=9), FillMission(MissionPools.HARD, [21], "Prophecy", removal_priority=8), @@ -98,6 +98,13 @@ class FillMission(NamedTuple): FillMission(MissionPools.FINAL, [5], "Final", completion_critical=True) ] +mini_gauntlet_order = [ + FillMission(MissionPools.STARTER, [-1], "I", completion_critical=True), + FillMission(MissionPools.EASY, [0], "II", completion_critical=True), + FillMission(MissionPools.MEDIUM, [1], "III", completion_critical=True), + FillMission(MissionPools.FINAL, [2], "Final", completion_critical=True) +] + grid_order = [ FillMission(MissionPools.STARTER, [-1], "_1"), FillMission(MissionPools.EASY, [0], "_1"), @@ -129,6 +136,13 @@ class FillMission(NamedTuple): FillMission(MissionPools.FINAL, [5, 7], "_3", or_requirements=True) ] +tiny_grid_order = [ + FillMission(MissionPools.STARTER, [-1], "_1"), + FillMission(MissionPools.MEDIUM, [0], "_1"), + FillMission(MissionPools.EASY, [0], "_2"), + FillMission(MissionPools.FINAL, [1, 2], "_2", or_requirements=True), +] + blitz_order = [ FillMission(MissionPools.STARTER, [-1], "I"), FillMission(MissionPools.EASY, [-1], "I"), @@ -144,7 +158,17 @@ class FillMission(NamedTuple): FillMission(MissionPools.FINAL, [0, 1], "Final", number=5, or_requirements=True) ] -mission_orders = [vanilla_shuffle_order, vanilla_shuffle_order, mini_campaign_order, grid_order, mini_grid_order, blitz_order, gauntlet_order] +mission_orders = [ + vanilla_shuffle_order, + vanilla_shuffle_order, + mini_campaign_order, + grid_order, + mini_grid_order, + blitz_order, + gauntlet_order, + mini_gauntlet_order, + tiny_grid_order +] vanilla_mission_req_table = { @@ -190,7 +214,7 @@ class FillMission(NamedTuple): "Whispers of Doom": "Whispers of Doom: Victory", "Belly of the Beast": "Belly of the Beast: Victory", "Zero Hour": "Zero Hour: First Group Rescued", - "Evacuation": "Evacuation: First Chysalis", + "Evacuation": "Evacuation: Reach Hanson", "Devil's Playground": "Devil's Playground: Tosh's Miners", "Smash and Grab": "Smash and Grab: First Relic", "The Great Train Robbery": "The Great Train Robbery: North Defiler" diff --git a/worlds/sc2wol/Options.py b/worlds/sc2wol/Options.py index 4f2032d662e8..13b01c42a22c 100644 --- a/worlds/sc2wol/Options.py +++ b/worlds/sc2wol/Options.py @@ -3,31 +3,49 @@ from Options import Choice, Option, Toggle, DefaultOnToggle, ItemSet, OptionSet, Range from .MissionTables import vanilla_mission_req_table +ORDER_VANILLA = 0 +ORDER_VANILLA_SHUFFLED = 1 class GameDifficulty(Choice): - """The difficulty of the campaign, affects enemy AI, starting units, and game speed.""" + """ + The difficulty of the campaign, affects enemy AI, starting units, and game speed. + + For those unfamiliar with the Archipelago randomizer, the recommended settings are one difficulty level + lower than the vanilla game + """ display_name = "Game Difficulty" option_casual = 0 option_normal = 1 option_hard = 2 option_brutal = 3 - - -class UpgradeBonus(Choice): - """Determines what lab upgrade to use, whether it is Ultra-Capacitors which boost attack speed with every weapon - upgrade or Vanadium Plating which boosts life with every armor upgrade.""" - display_name = "Upgrade Bonus" - option_ultra_capacitors = 0 - option_vanadium_plating = 1 - - -class BunkerUpgrade(Choice): - """Determines what bunker lab upgrade to use, whether it is Shrike Turret which outfits bunkers with an automated - turret or Fortified Bunker which boosts the life of bunkers.""" - display_name = "Bunker Upgrade" - option_shrike_turret = 0 - option_fortified_bunker = 1 - + default = 1 + +class GameSpeed(Choice): + """Optional setting to override difficulty-based game speed.""" + display_name = "Game Speed" + option_default = 0 + option_slower = 1 + option_slow = 2 + option_normal = 3 + option_fast = 4 + option_faster = 5 + default = option_default + +class FinalMap(Choice): + """ + Determines if the final map and goal of the campaign. + All in: You need to beat All-in map + Random Hard: A random hard mission is selected as a goal. + Beat this mission in order to complete the game. + All-in map won't be in the campaign + + Vanilla mission order always ends with All in mission! + + This option is short-lived. It may be changed in the future + """ + display_name = "Final Map" + option_all_in = 0 + option_random_hard = 1 class AllInMap(Choice): """Determines what version of All-In (final map) that will be generated for the campaign.""" @@ -37,14 +55,18 @@ class AllInMap(Choice): class MissionOrder(Choice): - """Determines the order the missions are played in. The last three mission orders end in a random mission. + """ + Determines the order the missions are played in. The last three mission orders end in a random mission. Vanilla (29): Keeps the standard mission order and branching from the WoL Campaign. Vanilla Shuffled (29): Keeps same branching paths from the WoL Campaign but randomizes the order of missions within. Mini Campaign (15): Shorter version of the campaign with randomized missions and optional branches. - Grid (16): A 4x4 grid of random missions. Start at the top-left and forge a path towards All-In. + Grid (16): A 4x4 grid of random missions. Start at the top-left and forge a path towards bottom-right mission to win. Mini Grid (9): A 3x3 version of Grid. Complete the bottom-right mission to win. Blitz (12): 12 random missions that open up very quickly. Complete the bottom-right mission to win. - Gauntlet (7): Linear series of 7 random missions to complete the campaign.""" + Gauntlet (7): Linear series of 7 random missions to complete the campaign. + Mini Gauntlet (4): Linear series of 4 random missions to complete the campaign. + Tiny Grid (4): A 2x2 version of Grid. Complete the bottom-right mission to win. + """ display_name = "Mission Order" option_vanilla = 0 option_vanilla_shuffled = 1 @@ -53,27 +75,53 @@ class MissionOrder(Choice): option_mini_grid = 4 option_blitz = 5 option_gauntlet = 6 + option_mini_gauntlet = 7 + option_tiny_grid = 8 + + +class PlayerColor(Choice): + """Determines in-game team color.""" + display_name = "Player Color" + option_white = 0 + option_red = 1 + option_blue = 2 + option_teal = 3 + option_purple = 4 + option_yellow = 5 + option_orange = 6 + option_green = 7 + option_light_pink = 8 + option_violet = 9 + option_light_grey = 10 + option_dark_green = 11 + option_brown = 12 + option_light_green = 13 + option_dark_grey = 14 + option_pink = 15 + option_rainbow = 16 + option_default = 17 + default = option_default class ShuffleProtoss(DefaultOnToggle): """Determines if the 3 protoss missions are included in the shuffle if Vanilla mission order is not enabled. - If turned off with Vanilla Shuffled, the 3 protoss missions will be in their normal position on the Prophecy chain - if not shuffled. - If turned off with reduced mission settings, the 3 protoss missions will not appear and Protoss units are removed - from the pool.""" + If turned off, the 3 protoss missions will not appear and Protoss units are removed from the pool.""" display_name = "Shuffle Protoss Missions" class ShuffleNoBuild(DefaultOnToggle): """Determines if the 5 no-build missions are included in the shuffle if Vanilla mission order is not enabled. - If turned off with Vanilla Shuffled, one no-build mission will be placed as the first mission and the rest will be - placed at the end of optional routes. - If turned off with reduced mission settings, the 5 no-build missions will not appear.""" + If turned off, the 5 no-build missions will not appear.""" display_name = "Shuffle No-Build Missions" class EarlyUnit(DefaultOnToggle): - """Guarantees that the first mission will contain a unit.""" + """ + Guarantees that the first mission will contain a unit. + + Each mission available to be the first mission has a pre-defined location where the unit should spawn. + This location gets overriden over any exclusion. It's guaranteed to be reachable with an empty inventory. + """ display_name = "Early Unit" @@ -91,11 +139,97 @@ class RequiredTactics(Choice): class UnitsAlwaysHaveUpgrades(DefaultOnToggle): - """If turned on, both upgrades will be present for each unit and structure in the seed. - This usually results in fewer units.""" + """ + If turned on, all upgrades will be present for each unit and structure in the seed. + This usually results in fewer units. + + See also: Max Number of Upgrades + """ display_name = "Units Always Have Upgrades" +class GenericUpgradeMissions(Range): + """Determines the percentage of missions in the mission order that must be completed before + level 1 of all weapon and armor upgrades is unlocked. Level 2 upgrades require double the amount of missions, + and level 3 requires triple the amount. The required amounts are always rounded down. + If set to 0, upgrades are instead added to the item pool and must be found to be used.""" + display_name = "Generic Upgrade Missions" + range_start = 0 + range_end = 100 + default = 0 + + +class GenericUpgradeResearch(Choice): + """Determines how weapon and armor upgrades affect missions once unlocked. + + Vanilla: Upgrades must be researched as normal. + Auto In No-Build: In No-Build missions, upgrades are automatically researched. + In all other missions, upgrades must be researched as normal. + Auto In Build: In No-Build missions, upgrades are unavailable as normal. + In all other missions, upgrades are automatically researched. + Always Auto: Upgrades are automatically researched in all missions.""" + display_name = "Generic Upgrade Research" + option_vanilla = 0 + option_auto_in_no_build = 1 + option_auto_in_build = 2 + option_always_auto = 3 + + +class GenericUpgradeItems(Choice): + """Determines how weapon and armor upgrades are split into items. All options produce 3 levels of each item. + Does nothing if upgrades are unlocked by completed mission counts. + + Individual Items: All weapon and armor upgrades are each an item, + resulting in 18 total upgrade items. + Bundle Weapon And Armor: All types of weapon upgrades are one item, + and all types of armor upgrades are one item, + resulting in 6 total items. + Bundle Unit Class: Weapon and armor upgrades are merged, + but Infantry, Vehicle, and Starship upgrades are bundled separately, + resulting in 9 total items. + Bundle All: All weapon and armor upgrades are one item, + resulting in 3 total items.""" + display_name = "Generic Upgrade Items" + option_individual_items = 0 + option_bundle_weapon_and_armor = 1 + option_bundle_unit_class = 2 + option_bundle_all = 3 + + +class NovaCovertOpsItems(Toggle): + """If turned on, the equipment upgrades from Nova Covert Ops may be present in the world.""" + display_name = "Nova Covert Ops Items" + default = Toggle.option_true + + +class BroodWarItems(Toggle): + """If turned on, returning items from StarCraft: Brood War may appear in the world.""" + display_name = "Brood War Items" + default = Toggle.option_true + + +class ExtendedItems(Toggle): + """If turned on, original items that did not appear in Campaign mode may appear in the world.""" + display_name = "Extended Items" + default = Toggle.option_true + + +class MaxNumberOfUpgrades(Range): + """ + Set a maximum to the number of upgrades a unit/structure can have. -1 is used to define unlimited. + Note that most unit have 4 or 6 upgrades. + + If used with Units Always Have Upgrades, each unit has this given amount of upgrades (if there enough upgrades exist) + + See also: Units Always Have Upgrades + """ + display_name = "Maximum number of upgrades per unit/structure" + range_start = -1 + # Do not know the maximum, but it is less than 123! + range_end = 123 + default = -1 + + class LockedItems(ItemSet): """Guarantees that these items will be unlockable""" display_name = "Locked Items" @@ -108,27 +242,114 @@ class ExcludedItems(ItemSet): class ExcludedMissions(OptionSet): """Guarantees that these missions will not appear in the campaign - Only applies on shortened mission orders. + Doesn't apply to vanilla mission order. It may be impossible to build a valid campaign if too many missions are excluded.""" display_name = "Excluded Missions" valid_keys = {mission_name for mission_name in vanilla_mission_req_table.keys() if mission_name != 'All-In'} +class LocationInclusion(Choice): + option_enabled = 0 + option_trash = 1 + option_nothing = 2 + + +class MissionProgressLocations(LocationInclusion): + """ + Enables or disables item rewards for progressing (not finishing) a mission. + Progressing a mission is usually a task of completing or progressing into a main objective. + Clearing an expansion base also counts here. + + Enabled: All locations fitting into this do their normal rewards + Trash: Forces a trash item in + Nothing: No rewards for this type of tasks, effectively disabling such locations + + Note: Individual locations subject to plando are always enabled, so the plando can be placed properly. + Warning: The generation may fail if too many locations are excluded by this way. + See also: Excluded Locations, Item Plando (https://archipelago.gg/tutorial/Archipelago/plando/en#item-plando) + """ + display_name = "Mission Progress Locations" + + +class BonusLocations(LocationInclusion): + """ + Enables or disables item rewards for completing bonus tasks. + Bonus tasks are those giving you a campaign-wide or mission-wide bonus in vanilla game: + Research, credits, bonus units or resources, etc. + + Enabled: All locations fitting into this do their normal rewards + Trash: Forces a trash item in + Nothing: No rewards for this type of tasks, effectively disabling such locations + + Note: Individual locations subject to plando are always enabled, so the plando can be placed properly. + Warning: The generation may fail if too many locations are excluded by this way. + See also: Excluded Locations, Item Plando (https://archipelago.gg/tutorial/Archipelago/plando/en#item-plando) + """ + display_name = "Bonus Locations" + + +class ChallengeLocations(LocationInclusion): + """ + Enables or disables item rewards for completing challenge tasks. + Challenges are tasks that have usually higher requirements to be completed + than to complete the mission they're in successfully. + You might be required to visit the same mission later when getting stronger in order to finish these tasks. + + Enabled: All locations fitting into this do their normal rewards + Trash: Forces a trash item in + Nothing: No rewards for this type of tasks, effectively disabling such locations + + Note: Individual locations subject to plando are always enabled, so the plando can be placed properly. + Warning: The generation may fail if too many locations are excluded by this way. + See also: Excluded Locations, Item Plando (https://archipelago.gg/tutorial/Archipelago/plando/en#item-plando) + """ + display_name = "Challenge Locations" + + +class OptionalBossLocations(LocationInclusion): + """ + Enables or disables item rewards for defeating optional bosses. + An optional boss is any boss that's not required to kill in order to finish the mission successfully. + All Brutalisks, Loki, etc. belongs here. + + Enabled: All locations fitting into this do their normal rewards + Trash: Forces a trash item in + Nothing: No rewards for this type of tasks, effectively disabling such locations + + Note: Individual locations subject to plando are always enabled, so the plando can be placed properly. + Warning: The generation may fail if too many locations are excluded by this way. + See also: Excluded Locations, Item Plando (https://archipelago.gg/tutorial/Archipelago/plando/en#item-plando) + """ + display_name = "Optional Boss Locations" + + # noinspection PyTypeChecker sc2wol_options: Dict[str, Option] = { "game_difficulty": GameDifficulty, - "upgrade_bonus": UpgradeBonus, - "bunker_upgrade": BunkerUpgrade, + "game_speed": GameSpeed, "all_in_map": AllInMap, + "final_map": FinalMap, "mission_order": MissionOrder, + "player_color": PlayerColor, "shuffle_protoss": ShuffleProtoss, "shuffle_no_build": ShuffleNoBuild, "early_unit": EarlyUnit, "required_tactics": RequiredTactics, "units_always_have_upgrades": UnitsAlwaysHaveUpgrades, + "max_number_of_upgrades": MaxNumberOfUpgrades, + "generic_upgrade_missions": GenericUpgradeMissions, + "generic_upgrade_research": GenericUpgradeResearch, + "generic_upgrade_items": GenericUpgradeItems, "locked_items": LockedItems, "excluded_items": ExcludedItems, - "excluded_missions": ExcludedMissions + "excluded_missions": ExcludedMissions, + "nco_items": NovaCovertOpsItems, + "bw_items": BroodWarItems, + "ext_items": ExtendedItems, + "mission_progress_locations": MissionProgressLocations, + "bonus_locations": BonusLocations, + "challenge_locations": ChallengeLocations, + "optional_boss_locations": OptionalBossLocations } diff --git a/worlds/sc2wol/PoolFilter.py b/worlds/sc2wol/PoolFilter.py index 16cc51f24372..4a19e2dbb305 100644 --- a/worlds/sc2wol/PoolFilter.py +++ b/worlds/sc2wol/PoolFilter.py @@ -1,22 +1,22 @@ from typing import Callable, Dict, List, Set from BaseClasses import MultiWorld, ItemClassification, Item, Location -from .Items import item_table +from .Items import get_full_item_list, spider_mine_sources, second_pass_placeable_items, filler_items from .MissionTables import no_build_regions_list, easy_regions_list, medium_regions_list, hard_regions_list,\ mission_orders, MissionInfo, alt_final_mission_locations, MissionPools -from .Options import get_option_value +from .Options import get_option_value, MissionOrder, FinalMap, MissionProgressLocations, LocationInclusion from .LogicMixin import SC2WoLLogic # Items with associated upgrades UPGRADABLE_ITEMS = [ "Marine", "Medic", "Firebat", "Marauder", "Reaper", "Ghost", "Spectre", - "Hellion", "Vulture", "Goliath", "Diamondback", "Siege Tank", "Thor", - "Medivac", "Wraith", "Viking", "Banshee", "Battlecruiser", + "Hellion", "Vulture", "Goliath", "Diamondback", "Siege Tank", "Thor", "Predator", "Widow Mine", "Cyclone", + "Medivac", "Wraith", "Viking", "Banshee", "Battlecruiser", "Raven", "Science Vessel", "Liberator", "Valkyrie", "Bunker", "Missile Turret" ] BARRACKS_UNITS = {"Marine", "Medic", "Firebat", "Marauder", "Reaper", "Ghost", "Spectre"} -FACTORY_UNITS = {"Hellion", "Vulture", "Goliath", "Diamondback", "Siege Tank", "Thor", "Predator"} -STARPORT_UNITS = {"Medivac", "Wraith", "Viking", "Banshee", "Battlecruiser", "Hercules", "Science Vessel", "Raven"} +FACTORY_UNITS = {"Hellion", "Vulture", "Goliath", "Diamondback", "Siege Tank", "Thor", "Predator", "Widow Mine"} +STARPORT_UNITS = {"Medivac", "Wraith", "Viking", "Banshee", "Battlecruiser", "Hercules", "Science Vessel", "Raven", "Liberator", "Valkyrie"} PROTOSS_REGIONS = {"A Sinister Turn", "Echoes of the Future", "In Utter Darkness"} @@ -30,7 +30,7 @@ def filter_missions(multiworld: MultiWorld, player: int) -> Dict[int, List[str]] shuffle_no_build = get_option_value(multiworld, player, "shuffle_no_build") shuffle_protoss = get_option_value(multiworld, player, "shuffle_protoss") excluded_missions = get_option_value(multiworld, player, "excluded_missions") - mission_count = len(mission_orders[mission_order_type]) - 1 + final_map = get_option_value(multiworld, player, "final_map") mission_pools = { MissionPools.STARTER: no_build_regions_list[:], MissionPools.EASY: easy_regions_list[:], @@ -38,21 +38,18 @@ def filter_missions(multiworld: MultiWorld, player: int) -> Dict[int, List[str]] MissionPools.HARD: hard_regions_list[:], MissionPools.FINAL: [] } - if mission_order_type == 0: + if mission_order_type == MissionOrder.option_vanilla: # Vanilla uses the entire mission pool mission_pools[MissionPools.FINAL] = ['All-In'] return mission_pools - elif mission_order_type == 1: - # Vanilla Shuffled ignores the player-provided excluded missions - excluded_missions = set() # Omitting No-Build missions if not shuffling no-build if not shuffle_no_build: excluded_missions = excluded_missions.union(no_build_regions_list) # Omitting Protoss missions if not shuffling protoss if not shuffle_protoss: excluded_missions = excluded_missions.union(PROTOSS_REGIONS) - # Replacing All-In on low mission counts - if mission_count < 14: + # Replacing All-In with alternate ending depending on option + if final_map == FinalMap.option_random_hard: final_mission = multiworld.random.choice([mission for mission in alt_final_mission_locations.keys() if mission not in excluded_missions]) excluded_missions.add(final_mission) else: @@ -92,10 +89,18 @@ def get_item_upgrades(inventory: List[Item], parent_item: Item or str): item_name = parent_item.name if isinstance(parent_item, Item) else parent_item return [ inv_item for inv_item in inventory - if item_table[inv_item.name].parent_item == item_name + if get_full_item_list()[inv_item.name].parent_item == item_name ] +def get_item_quantity(item): + return get_full_item_list()[item.name].quantity + + +def copy_item(item: Item): + return Item(item.name, item.classification, item.code, item.player) + + class ValidInventory: def has(self, item: str, player: int): @@ -124,22 +129,6 @@ def generate_reduced_inventory(self, inventory_size: int, mission_requirements: cascade_keys = self.cascade_removal_map.keys() units_always_have_upgrades = get_option_value(self.multiworld, self.player, "units_always_have_upgrades") - # Locking associated items for items that have already been placed when units_always_have_upgrades is on - if units_always_have_upgrades: - existing_items = self.existing_items[:] - while existing_items: - existing_item = existing_items.pop() - items_to_lock = self.cascade_removal_map.get(existing_item, [existing_item]) - for item in items_to_lock: - if item in inventory: - inventory.remove(item) - locked_items.append(item) - if item in existing_items: - existing_items.remove(item) - - if self.min_units_per_structure > 0 and self.has_units_per_structure(): - requirements.append(lambda state: state.has_units_per_structure()) - def attempt_removal(item: Item) -> bool: # If item can be removed and has associated items, remove them as well inventory.remove(item) @@ -149,9 +138,77 @@ def attempt_removal(item: Item) -> bool: if not all(requirement(self) for requirement in requirements): # If item cannot be removed, lock or revert self.logical_inventory.add(item.name) - locked_items.append(item) + for _ in range(get_item_quantity(item)): + locked_items.append(copy_item(item)) return False return True + + # Limit the maximum number of upgrades + maxUpgrad = get_option_value(self.multiworld, self.player, + "max_number_of_upgrades") + if maxUpgrad != -1: + unit_avail_upgrades = {} + # Needed to take into account locked/existing items + unit_nb_upgrades = {} + for item in inventory: + cItem = get_full_item_list()[item.name] + if cItem.type in UPGRADABLE_ITEMS and item.name not in unit_avail_upgrades: + unit_avail_upgrades[item.name] = [] + unit_nb_upgrades[item.name] = 0 + elif cItem.parent_item is not None: + if cItem.parent_item not in unit_avail_upgrades: + unit_avail_upgrades[cItem.parent_item] = [item] + unit_nb_upgrades[cItem.parent_item] = 1 + else: + unit_avail_upgrades[cItem.parent_item].append(item) + unit_nb_upgrades[cItem.parent_item] += 1 + # For those two categories, we count them but dont include them in removal + for item in locked_items + self.existing_items: + cItem = get_full_item_list()[item.name] + if cItem.type in UPGRADABLE_ITEMS and item.name not in unit_avail_upgrades: + unit_avail_upgrades[item.name] = [] + unit_nb_upgrades[item.name] = 0 + elif cItem.parent_item is not None: + if cItem.parent_item not in unit_avail_upgrades: + unit_nb_upgrades[cItem.parent_item] = 1 + else: + unit_nb_upgrades[cItem.parent_item] += 1 + # Making sure that the upgrades being removed is random + # Currently, only for combat shield vs Stabilizer Medpacks... + shuffled_unit_upgrade_list = list(unit_avail_upgrades.keys()) + self.multiworld.random.shuffle(shuffled_unit_upgrade_list) + for unit in shuffled_unit_upgrade_list: + while (unit_nb_upgrades[unit] > maxUpgrad) \ + and (len(unit_avail_upgrades[unit]) > 0): + itemCandidate = self.multiworld.random.choice(unit_avail_upgrades[unit]) + _ = attempt_removal(itemCandidate) + # Whatever it succeed to remove the iventory or it fails and thus + # lock it, the upgrade is no longer available for removal + unit_avail_upgrades[unit].remove(itemCandidate) + unit_nb_upgrades[unit] -= 1 + + # Locking associated items for items that have already been placed when units_always_have_upgrades is on + if units_always_have_upgrades: + existing_items = set(self.existing_items[:] + locked_items) + while existing_items: + existing_item = existing_items.pop() + items_to_lock = self.cascade_removal_map.get(existing_item, [existing_item]) + if get_full_item_list()[existing_item.name].type != "Upgrade": + # Don't process general upgrades, they may have been pre-locked per-level + for item in items_to_lock: + if item in inventory: + # Unit upgrades, lock all levels + for _ in range(inventory.count(item)): + inventory.remove(item) + if item not in locked_items: + # Lock all the associated items if not already locked + for _ in range(get_item_quantity(item)): + locked_items.append(copy_item(item)) + if item in existing_items: + existing_items.remove(item) + + if self.min_units_per_structure > 0 and self.has_units_per_structure(): + requirements.append(lambda state: state.has_units_per_structure()) # Determining if the full-size inventory can complete campaign if not all(requirement(self) for requirement in requirements): @@ -185,21 +242,47 @@ def attempt_removal(item: Item) -> bool: if cascade_failure: for transient_item in transient_items: if transient_item in inventory: - inventory.remove(transient_item) + for _ in range(inventory.count(transient_item)): + inventory.remove(transient_item) if transient_item not in locked_items: - locked_items.append(transient_item) + for _ in range(get_item_quantity(transient_item)): + locked_items.append(copy_item(transient_item)) if transient_item.classification in (ItemClassification.progression, ItemClassification.progression_skip_balancing): self.logical_inventory.add(transient_item.name) else: attempt_removal(item) - return inventory + locked_items + if not spider_mine_sources & self.logical_inventory: + inventory = [item for item in inventory if not item.name.endswith("(Spider Mine)")] + if not BARRACKS_UNITS & self.logical_inventory: + inventory = [item for item in inventory if + not (item.name.startswith("Progressive Infantry") or item.name == "Orbital Strike")] + if not FACTORY_UNITS & self.logical_inventory: + inventory = [item for item in inventory if not item.name.startswith("Progressive Vehicle")] + if not STARPORT_UNITS & self.logical_inventory: + inventory = [item for item in inventory if not item.name.startswith("Progressive Ship")] + + # Cull finished, adding locked items back into inventory + inventory += locked_items + + # Replacing empty space with generically useful items + replacement_items = [item for item in self.item_pool + if (item not in inventory + and item not in self.locked_items + and item.name in second_pass_placeable_items)] + self.multiworld.random.shuffle(replacement_items) + while len(inventory) < inventory_size and len(replacement_items) > 0: + item = replacement_items.pop() + inventory.append(item) + + return inventory def _read_logic(self): self._sc2wol_has_common_unit = lambda world, player: SC2WoLLogic._sc2wol_has_common_unit(self, world, player) self._sc2wol_has_air = lambda world, player: SC2WoLLogic._sc2wol_has_air(self, world, player) self._sc2wol_has_air_anti_air = lambda world, player: SC2WoLLogic._sc2wol_has_air_anti_air(self, world, player) self._sc2wol_has_competent_anti_air = lambda world, player: SC2WoLLogic._sc2wol_has_competent_anti_air(self, world, player) + self._sc2wol_has_competent_ground_to_air = lambda world, player: SC2WoLLogic._sc2wol_has_competent_ground_to_air(self, world, player) self._sc2wol_has_anti_air = lambda world, player: SC2WoLLogic._sc2wol_has_anti_air(self, world, player) self._sc2wol_defense_rating = lambda world, player, zerg_enemy, air_enemy=False: SC2WoLLogic._sc2wol_defense_rating(self, world, player, zerg_enemy, air_enemy) self._sc2wol_has_competent_comp = lambda world, player: SC2WoLLogic._sc2wol_has_competent_comp(self, world, player) @@ -210,6 +293,8 @@ def _read_logic(self): self._sc2wol_has_protoss_common_units = lambda world, player: SC2WoLLogic._sc2wol_has_protoss_common_units(self, world, player) self._sc2wol_has_protoss_medium_units = lambda world, player: SC2WoLLogic._sc2wol_has_protoss_medium_units(self, world, player) self._sc2wol_has_mm_upgrade = lambda world, player: SC2WoLLogic._sc2wol_has_mm_upgrade(self, world, player) + self._sc2wol_welcome_to_the_jungle_requirement = lambda world, player: SC2WoLLogic._sc2wol_welcome_to_the_jungle_requirement(self, world, player) + self._sc2wol_can_respond_to_colony_infestations = lambda world, player: SC2WoLLogic._sc2wol_can_respond_to_colony_infestations(self, world, player) self._sc2wol_final_mission_requirements = lambda world, player: SC2WoLLogic._sc2wol_final_mission_requirements(self, world, player) def __init__(self, multiworld: MultiWorld, player: int, @@ -230,7 +315,7 @@ def __init__(self, multiworld: MultiWorld, player: int, self.min_units_per_structure = int(mission_count / 7) min_upgrades = 1 if mission_count < 10 else 2 for item in item_pool: - item_info = item_table[item.name] + item_info = get_full_item_list()[item.name] if item_info.type == "Upgrade": # Locking upgrades based on mission duration if item.name not in item_quantities: diff --git a/worlds/sc2wol/Regions.py b/worlds/sc2wol/Regions.py index 033636662b59..f588ce7e982e 100644 --- a/worlds/sc2wol/Regions.py +++ b/worlds/sc2wol/Regions.py @@ -1,10 +1,14 @@ from typing import List, Set, Dict, Tuple, Optional, Callable from BaseClasses import MultiWorld, Region, Entrance, Location from .Locations import LocationData -from .Options import get_option_value -from .MissionTables import MissionInfo, mission_orders, vanilla_mission_req_table, alt_final_mission_locations, MissionPools +from .Options import get_option_value, MissionOrder +from .MissionTables import MissionInfo, mission_orders, vanilla_mission_req_table, alt_final_mission_locations, \ + MissionPools, vanilla_shuffle_order from .PoolFilter import filter_missions +PROPHECY_CHAIN_MISSION_COUNT = 4 + +VANILLA_SHUFFLED_FIRST_PROPHECY_MISSION = 21 def create_regions(multiworld: MultiWorld, player: int, locations: Tuple[LocationData, ...], location_cache: List[Location])\ -> Tuple[Dict[str, MissionInfo], int, str]: @@ -19,7 +23,7 @@ def create_regions(multiworld: MultiWorld, player: int, locations: Tuple[Locatio names: Dict[str, int] = {} - if mission_order_type == 0: + if mission_order_type == MissionOrder.option_vanilla: # Generating all regions and locations for region_name in vanilla_mission_req_table.keys(): @@ -108,12 +112,17 @@ def create_regions(multiworld: MultiWorld, player: int, locations: Tuple[Locatio removals = len(mission_order) - mission_pool_size # Removing entire Prophecy chain on vanilla shuffled when not shuffling protoss if remove_prophecy: - removals -= 4 + removals -= PROPHECY_CHAIN_MISSION_COUNT # Initial fill out of mission list and marking all-in mission for mission in mission_order: # Removing extra missions if mission pool is too small - if 0 < mission.removal_priority <= removals or mission.category == 'Prophecy' and remove_prophecy: + # Also handle lower removal priority than Prophecy + if 0 < mission.removal_priority <= removals or mission.category == 'Prophecy' and remove_prophecy \ + or (remove_prophecy and mission_order_type == MissionOrder.option_vanilla_shuffled + and mission.removal_priority > vanilla_shuffle_order[ + VANILLA_SHUFFLED_FIRST_PROPHECY_MISSION].removal_priority + and 0 < mission.removal_priority <= removals + PROPHECY_CHAIN_MISSION_COUNT): missions.append(None) elif mission.type == MissionPools.FINAL: missions.append(final_mission) @@ -191,22 +200,38 @@ def create_regions(multiworld: MultiWorld, player: int, locations: Tuple[Locatio # TODO: Handle 'and' connections mission_req_table = {} + def build_connection_rule(mission_names: List[str], missions_req: int) -> Callable: + if len(mission_names) > 1: + return lambda state: state.has_all({f"Beat {name}" for name in mission_names}, player) and \ + state._sc2wol_cleared_missions(multiworld, player, missions_req) + else: + return lambda state: state.has(f"Beat {mission_names[0]}", player) and \ + state._sc2wol_cleared_missions(multiworld, player, missions_req) + for i, mission in enumerate(missions): if mission is None: continue connections = [] + all_connections = [] + for connection in mission_order[i].connect_to: + if connection == -1: + continue + while missions[connection] is None: + connection -= 1 + all_connections.append(missions[connection]) for connection in mission_order[i].connect_to: required_mission = missions[connection] if connection == -1: connect(multiworld, player, names, "Menu", mission) - elif required_mission is None: - continue else: + if required_mission is None and not mission_order[i].completion_critical: # Drop non-critical null slots + continue + while required_mission is None: # Substituting null slot with prior slot + connection -= 1 + required_mission = missions[connection] + required_missions = [required_mission] if mission_order[i].or_requirements else all_connections connect(multiworld, player, names, required_mission, mission, - (lambda name, missions_req: (lambda state: state.has(f"Beat {name}", player) and - state._sc2wol_cleared_missions(multiworld, player, - missions_req))) - (missions[connection], mission_order[i].number)) + build_connection_rule(required_missions, mission_order[i].number)) connections.append(slot_map[connection]) mission_req_table.update({mission: MissionInfo( diff --git a/worlds/sc2wol/__init__.py b/worlds/sc2wol/__init__.py index 490524290c8a..93aebb7ad15a 100644 --- a/worlds/sc2wol/__init__.py +++ b/worlds/sc2wol/__init__.py @@ -3,11 +3,11 @@ from typing import List, Set, Tuple, Dict from BaseClasses import Item, MultiWorld, Location, Tutorial, ItemClassification from worlds.AutoWorld import WebWorld, World -from .Items import StarcraftWoLItem, item_table, filler_items, item_name_groups, get_full_item_list, \ - get_basic_units -from .Locations import get_locations +from .Items import StarcraftWoLItem, filler_items, item_name_groups, get_item_table, get_full_item_list, \ + get_basic_units, ItemData, upgrade_included_names, progressive_if_nco +from .Locations import get_locations, LocationType from .Regions import create_regions -from .Options import sc2wol_options, get_option_value +from .Options import sc2wol_options, get_option_value, LocationInclusion from .LogicMixin import SC2WoLLogic from .PoolFilter import filter_missions, filter_items, get_item_upgrades from .MissionTables import starting_mission_locations, MissionInfo @@ -36,7 +36,7 @@ class SC2WoLWorld(World): web = Starcraft2WoLWebWorld() data_version = 4 - item_name_to_id = {name: data.code for name, data in item_table.items()} + item_name_to_id = {name: data.code for name, data in get_full_item_list().items()} location_name_to_id = {location.name: location.code for location in get_locations(None, None)} option_definitions = sc2wol_options @@ -69,6 +69,8 @@ def create_items(self): starter_items = assign_starter_items(self.multiworld, self.player, excluded_items, self.locked_locations) + filter_locations(self.multiworld, self.player, self.locked_locations, self.location_cache) + pool = get_item_pool(self.multiworld, self.player, self.mission_req_table, starter_items, excluded_items, self.location_cache) fill_item_pool_with_dummy_items(self, self.multiworld, self.player, self.locked_locations, self.location_cache, pool) @@ -109,16 +111,6 @@ def setup_events(player: int, locked_locations: typing.List[str], location_cache def get_excluded_items(multiworld: MultiWorld, player: int) -> Set[str]: excluded_items: Set[str] = set() - if get_option_value(multiworld, player, "upgrade_bonus") == 1: - excluded_items.add("Ultra-Capacitors") - else: - excluded_items.add("Vanadium Plating") - - if get_option_value(multiworld, player, "bunker_upgrade") == 1: - excluded_items.add("Shrike Turret") - else: - excluded_items.add("Fortified Bunker") - for item in multiworld.precollected_items[player]: excluded_items.add(item.name) @@ -167,7 +159,7 @@ def assign_starter_item(multiworld: MultiWorld, player: int, excluded_items: Set def get_item_pool(multiworld: MultiWorld, player: int, mission_req_table: Dict[str, MissionInfo], - starter_items: List[str], excluded_items: Set[str], location_cache: List[Location]) -> List[Item]: + starter_items: List[Item], excluded_items: Set[str], location_cache: List[Location]) -> List[Item]: pool: List[Item] = [] # For the future: goal items like Artifact Shards go here @@ -176,17 +168,43 @@ def get_item_pool(multiworld: MultiWorld, player: int, mission_req_table: Dict[s # YAML items yaml_locked_items = get_option_value(multiworld, player, 'locked_items') - for name, data in item_table.items(): - if name not in excluded_items: - for _ in range(data.quantity): - item = create_item_with_correct_settings(player, name) - if name in yaml_locked_items: - locked_items.append(item) - else: - pool.append(item) + # Adjust generic upgrade availability based on options + include_upgrades = get_option_value(multiworld, player, 'generic_upgrade_missions') == 0 + upgrade_items = get_option_value(multiworld, player, 'generic_upgrade_items') + + # Include items from outside Wings of Liberty + item_sets = {'wol'} + if get_option_value(multiworld, player, 'nco_items'): + item_sets.add('nco') + if get_option_value(multiworld, player, 'bw_items'): + item_sets.add('bw') + if get_option_value(multiworld, player, 'ext_items'): + item_sets.add('ext') + + def allowed_quantity(name: str, data: ItemData) -> int: + if name in excluded_items \ + or data.type == "Upgrade" and (not include_upgrades or name not in upgrade_included_names[upgrade_items]) \ + or not data.origin.intersection(item_sets): + return 0 + elif name in progressive_if_nco and 'nco' not in item_sets: + return 1 + else: + return data.quantity + + for name, data in get_item_table(multiworld, player).items(): + for i in range(allowed_quantity(name, data)): + item = create_item_with_correct_settings(player, name) + if name in yaml_locked_items: + locked_items.append(item) + else: + pool.append(item) existing_items = starter_items + [item for item in multiworld.precollected_items[player]] existing_names = [item.name for item in existing_items] + + # Check the parent item integrity, exclude items + pool[:] = [item for item in pool if pool_contains_parent(item, pool + locked_items + existing_items)] + # Removing upgrades for excluded items for item_name in excluded_items: if item_name in existing_names: @@ -207,8 +225,100 @@ def fill_item_pool_with_dummy_items(self: SC2WoLWorld, multiworld: MultiWorld, p def create_item_with_correct_settings(player: int, name: str) -> Item: - data = item_table[name] + data = get_full_item_list()[name] item = Item(name, data.classification, data.code, player) return item + + +def pool_contains_parent(item: Item, pool: [Item]): + item_data = get_full_item_list().get(item.name) + if item_data.parent_item is None: + # The item has not associated parent, the item is valid + return True + parent_item = item_data.parent_item + # Check if the pool contains the parent item + return parent_item in [pool_item.name for pool_item in pool] + + +def filter_locations(multiworld: MultiWorld, player, locked_locations: List[str], location_cache: List[Location]): + """ + Filters the locations in the world using a trash or Nothing item + :param multiworld: + :param player: + :param locked_locations: + :param location_cache: + :return: + """ + open_locations = [location for location in location_cache if location.item is None] + plando_locations = get_plando_locations(multiworld, player) + mission_progress_locations = get_option_value(multiworld, player, "mission_progress_locations") + bonus_locations = get_option_value(multiworld, player, "bonus_locations") + challenge_locations = get_option_value(multiworld, player, "challenge_locations") + optional_boss_locations = get_option_value(multiworld, player, "optional_boss_locations") + location_data = get_locations(multiworld, player) + for location in open_locations: + # Go through the locations that aren't locked yet (early unit, etc) + if location.name not in plando_locations: + # The location is not plando'd + sc2_location = [sc2_location for sc2_location in location_data if sc2_location.name == location.name][0] + location_type = sc2_location.type + + if location_type == LocationType.MISSION_PROGRESS \ + and mission_progress_locations != LocationInclusion.option_enabled: + item_name = get_exclusion_item(multiworld, mission_progress_locations) + place_exclusion_item(item_name, location, locked_locations, player) + + if location_type == LocationType.BONUS \ + and bonus_locations != LocationInclusion.option_enabled: + item_name = get_exclusion_item(multiworld, bonus_locations) + place_exclusion_item(item_name, location, locked_locations, player) + + if location_type == LocationType.CHALLENGE \ + and challenge_locations != LocationInclusion.option_enabled: + item_name = get_exclusion_item(multiworld, challenge_locations) + place_exclusion_item(item_name, location, locked_locations, player) + + if location_type == LocationType.OPTIONAL_BOSS \ + and optional_boss_locations != LocationInclusion.option_enabled: + item_name = get_exclusion_item(multiworld, optional_boss_locations) + place_exclusion_item(item_name, location, locked_locations, player) + + +def place_exclusion_item(item_name, location, locked_locations, player): + item = create_item_with_correct_settings(player, item_name) + location.place_locked_item(item) + locked_locations.append(location.name) + + +def get_exclusion_item(multiworld: MultiWorld, option) -> str: + """ + Gets the exclusion item according to settings (trash/nothing) + :param multiworld: + :param option: + :return: Item used for location exclusion + """ + if option == LocationInclusion.option_nothing: + return "Nothing" + elif option == LocationInclusion.option_trash: + index = multiworld.random.randint(0, len(filler_items) - 1) + return filler_items[index] + raise Exception(f"Unsupported option type: {option}") + + +def get_plando_locations(multiworld: MultiWorld, player) -> List[str]: + """ + + :param multiworld: + :param player: + :return: A list of locations affected by a plando in a world + """ + plando_locations = [] + for plando_setting in multiworld.plando_items[player]: + plando_locations += plando_setting.get("locations", []) + plando_setting_location = plando_setting.get("location", None) + if plando_setting_location is not None: + plando_locations.append(plando_setting_location) + + return plando_locations diff --git a/worlds/sc2wol/docs/setup_en.md b/worlds/sc2wol/docs/setup_en.md index 13c7cb91e3aa..9bfeb3d235bc 100644 --- a/worlds/sc2wol/docs/setup_en.md +++ b/worlds/sc2wol/docs/setup_en.md @@ -7,7 +7,7 @@ to obtain a config file for StarCraft 2. - [StarCraft 2](https://starcraft2.com/en-us/) - [The most recent Archipelago release](https://github.com/ArchipelagoMW/Archipelago/releases) -- [StarCraft 2 AP Maps and Data](https://github.com/TheCondor07/Starcraft2ArchipelagoData) +- [StarCraft 2 AP Maps and Data](https://github.com/Ziktofel/Archipelago-SC2-data/releases) ## How do I install this randomizer? @@ -49,7 +49,7 @@ specific description of what's going wrong and attach your log file to your mess ## Running in macOS -To run StarCraft 2 through Archipelago in macOS, you will need to run the client via source as seen here: [macOS Guide](https://archipelago.gg/tutorial/Archipelago/mac/en). Note: when running the client, you will need to run the command `python3 Starcraft2Client.py`. This is done to make sure that `/download_data` works correctly. +To run StarCraft 2 through Archipelago in macOS, you will need to run the client via source as seen here: [macOS Guide](https://archipelago.gg/tutorial/Archipelago/mac/en). Note: when running the client, you will need to run the command `python3 Starcraft2Client.py`. ## Running in Linux diff --git a/worlds/sm/data/SMBasepatch_prebuilt/multiworld-basepatch.ips b/worlds/sm/data/SMBasepatch_prebuilt/multiworld-basepatch.ips index 0455364d8a7b..7854ca333287 100644 Binary files a/worlds/sm/data/SMBasepatch_prebuilt/multiworld-basepatch.ips and b/worlds/sm/data/SMBasepatch_prebuilt/multiworld-basepatch.ips differ diff --git a/worlds/sm/data/SMBasepatch_prebuilt/multiworld.sym b/worlds/sm/data/SMBasepatch_prebuilt/multiworld.sym index 2d231bc89961..0a76c2071484 100644 --- a/worlds/sm/data/SMBasepatch_prebuilt/multiworld.sym +++ b/worlds/sm/data/SMBasepatch_prebuilt/multiworld.sym @@ -2,7 +2,7 @@ ; generated by asar [labels] -82:FA23 :neg_1_1 +82:FA40 :neg_1_1 B8:83C1 :neg_1_2 B8:85DA :neg_1_3 B8:85F9 :neg_1_4 @@ -177,7 +177,7 @@ A0:FE00 setup_music A0:FE0B setup_music_quick A0:FE94 setup_samus A0:FEA5 setup_samus_normal -82:FA15 sm_fix_checksum +82:FA2A sm_fix_checksum B8:8800 sm_item_graphics B8:882E sm_item_plm_pickup_sequence_pointers B8:847B sm_save_done_hook @@ -192,7 +192,7 @@ B8:83F4 write_repeated_memory_loop [source files] 0000 fe019f55 main.asm -0001 91a745e3 ../common/fast_reload.asm +0001 62a29254 ../common/fast_reload.asm 0002 06780555 ../common/nofanfare.asm 0003 7a8904b6 ../common/multiworld.asm 0004 f7e9db95 ../common/itemextras.asm @@ -200,163 +200,177 @@ B8:83F4 write_repeated_memory_loop 0006 dbfcb38d ../common/startitem.asm [rom checksum] -f95f09a7 +e50233eb [addr-to-line mapping] ff:ffff 0000:00000001 -ce:ff0a 0001:00000012 -82:f990 0001:00000016 -82:f991 0001:00000017 -82:f993 0001:00000018 -82:f994 0001:00000019 -82:f996 0001:0000001a -82:f999 0001:0000001b -82:f99c 0001:0000001c -82:f99e 0001:0000001d -82:f9a2 0001:0000001e -82:f9a4 0001:0000001f -82:f9a7 0001:00000020 -82:f9a9 0001:00000021 -82:f9ac 0001:00000022 -82:f9af 0001:00000023 -82:f9b1 0001:00000024 -82:f9b5 0001:00000025 -82:f9b8 0001:00000026 -82:f9ba 0001:00000027 -82:f9bd 0001:00000028 -82:f9c1 0001:00000029 -82:f9c3 0001:0000002a -82:f9c4 0001:0000002b -82:f9c5 0001:0000002c -82:f9c8 0001:0000002d -82:f9c9 0001:0000002f -82:f9ca 0001:00000030 -82:f9cb 0001:00000031 -82:f9cc 0001:00000034 -82:f9cd 0001:00000035 -82:f9cf 0001:00000036 -82:f9d2 0001:00000037 -82:f9d5 0001:00000039 -82:f9d9 0001:0000003a -82:f9dc 0001:0000003a -82:f9de 0001:0000003b -82:f9e1 0001:0000003d -82:f9e2 0001:0000003e -82:f9e5 0001:0000003e -82:f9e6 0001:0000003e -82:f9e9 0001:0000003f -82:f9ea 0001:0000003f -82:f9eb 0001:00000040 -82:f9ee 0001:00000040 -82:f9f1 0001:00000041 -82:f9f2 0001:00000042 -82:f9f6 0001:00000043 -82:f9fa 0001:00000044 -82:f9fe 0001:00000046 -82:fa02 0001:00000047 -82:fa05 0001:00000048 -82:fa09 0001:00000049 -82:fa0d 0001:0000004a -82:fa10 0001:0000004b -82:fa13 0001:0000004c -82:fa14 0001:0000004d -82:fa15 0001:00000050 -82:fa16 0001:00000051 +ce:ff0a 0001:00000013 +82:f990 0001:00000017 +82:f991 0001:00000018 +82:f993 0001:00000019 +82:f994 0001:0000001a +82:f996 0001:0000001b +82:f999 0001:0000001c +82:f99c 0001:0000001d +82:f99e 0001:0000001e +82:f9a2 0001:0000001f +82:f9a4 0001:00000020 +82:f9a7 0001:00000021 +82:f9a9 0001:00000022 +82:f9ac 0001:00000023 +82:f9af 0001:00000024 +82:f9b1 0001:00000025 +82:f9b5 0001:00000026 +82:f9b8 0001:00000027 +82:f9ba 0001:00000028 +82:f9bd 0001:00000029 +82:f9c1 0001:0000002a +82:f9c3 0001:0000002b +82:f9c4 0001:0000002c +82:f9c5 0001:0000002d +82:f9c8 0001:0000002e +82:f9c9 0001:00000030 +82:f9ca 0001:00000031 +82:f9cb 0001:00000032 +82:f9cc 0001:00000035 +82:f9cd 0001:00000036 +82:f9cf 0001:00000037 +82:f9d2 0001:00000038 +82:f9d5 0001:0000003a +82:f9d9 0001:0000003b +82:f9dc 0001:0000003b +82:f9de 0001:0000003c +82:f9e1 0001:0000003e +82:f9e2 0001:0000003f +82:f9e6 0001:00000040 +82:f9ea 0001:00000042 +82:f9ee 0001:00000043 +82:f9f1 0001:00000044 +82:f9f2 0001:00000045 +82:f9f4 0001:00000046 +82:f9f5 0001:00000047 +82:f9f9 0001:00000048 +82:f9fc 0001:00000049 +82:f9fd 0001:0000004a +82:f9fe 0001:0000004b +82:f9ff 0001:0000004c +82:fa02 0001:0000004c +82:fa03 0001:0000004c +82:fa07 0001:0000004d +82:fa08 0001:0000004e +82:fa0b 0001:0000004e +82:fa0f 0001:0000004f +82:fa13 0001:00000051 82:fa17 0001:00000052 -82:fa18 0001:00000053 -82:fa19 0000:00000013 -82:fa1b 0001:00000057 -82:fa1d 0001:00000058 -82:fa1e 0001:00000059 -82:fa20 0001:0000005a -82:fa23 0001:0000005c -82:fa27 0001:0000005d -82:fa28 0001:0000005e -82:fa2a 0001:0000005f -82:fa2c 0001:00000060 -82:fa2d 0001:00000061 -82:fa2e 0001:00000062 -82:fa31 0001:00000063 -82:fa33 0001:00000065 -82:fa36 0001:00000066 +82:fa1a 0001:00000053 +82:fa1e 0001:00000054 +82:fa22 0001:00000055 +82:fa25 0001:00000056 +82:fa28 0001:00000057 +82:fa29 0001:00000058 +82:fa2a 0001:0000005b +82:fa2b 0001:0000005c +82:fa2c 0001:0000005d +82:fa2d 0001:0000005e +82:fa2e 0000:00000013 +82:fa30 0001:00000062 +82:fa32 0001:00000063 +82:fa33 0001:00000064 +82:fa35 0001:00000065 +82:fa37 0001:00000066 82:fa38 0001:00000067 82:fa3c 0001:00000068 -82:fa40 0001:00000069 -82:fa43 0001:0000006a -82:fa47 0001:0000006b -82:fa4b 0001:0000006c -82:fa4c 0001:0000006d -82:fa4e 0001:0000006f -82:fa4f 0001:00000070 -82:fa50 0001:00000071 -82:fa51 0001:00000072 -82:fa52 0001:00000073 -80:a088 0001:00000079 -80:a08c 0001:00000079 -80:a08d 0001:00000079 -80:a095 0001:0000007c -80:a0ce 0001:0000007f -80:a113 0001:00000082 -91:e164 0001:00000085 -91:e168 0001:00000085 -91:e169 0001:00000085 -a0:fe00 0001:0000008a -a0:fe03 0001:0000008b -a0:fe05 0001:0000008c -a0:fe08 0001:0000008d -a0:fe0b 0001:0000008f -a0:fe0c 0001:00000092 -a0:fe10 0001:00000093 -a0:fe13 0001:00000094 -a0:fe15 0001:00000095 -a0:fe18 0001:00000096 -a0:fe1b 0001:00000097 -a0:fe1f 0001:00000099 -a0:fe23 0001:0000009a -a0:fe27 0001:0000009b -a0:fe2b 0001:0000009c -a0:fe2f 0001:0000009f -a0:fe33 0001:000000a0 -a0:fe36 0001:000000a1 -a0:fe38 0001:000000a2 -a0:fe3c 0001:000000a3 -a0:fe40 0001:000000a5 -a0:fe44 0001:000000a8 -a0:fe48 0001:000000a9 -a0:fe49 0001:000000aa -a0:fe4c 0001:000000ab -a0:fe4e 0001:000000ac -a0:fe4f 0001:000000ad -a0:fe53 0001:000000ae -a0:fe57 0001:000000b0 -a0:fe58 0001:000000b1 -a0:fe5c 0001:000000b2 -a0:fe5f 0001:000000b3 -a0:fe62 0001:000000b4 -a0:fe65 0001:000000b5 -a0:fe67 0001:000000b6 -a0:fe6a 0001:000000b7 -a0:fe6d 0001:000000b8 -a0:fe6f 0001:000000b9 -a0:fe73 0001:000000bc -a0:fe76 0001:000000bd -a0:fe79 0001:000000be -a0:fe7c 0001:000000bf -a0:fe7f 0001:000000c1 -a0:fe82 0001:000000c2 -a0:fe85 0001:000000c3 -a0:fe89 0001:000000c4 -a0:fe8c 0001:000000c5 -a0:fe90 0001:000000c7 -a0:fe94 0001:000000ca -a0:fe97 0001:000000cb -a0:fe99 0001:000000cc -a0:fe9c 0001:000000cd -a0:fe9f 0001:000000ce -a0:fea2 0001:000000cf -a0:fea5 0001:000000d1 -a0:fea8 0001:000000d2 -a0:feab 0001:000000d3 +82:fa3d 0001:00000069 +82:fa40 0001:0000006b +82:fa44 0001:0000006c +82:fa45 0001:0000006d +82:fa47 0001:0000006e +82:fa49 0001:0000006f +82:fa4a 0001:00000070 +82:fa4b 0001:00000071 +82:fa4c 0001:00000072 +82:fa4d 0001:00000073 +82:fa50 0001:00000074 +82:fa52 0001:00000076 +82:fa54 0001:00000077 +82:fa56 0001:00000078 +82:fa5a 0001:00000079 +82:fa5e 0001:0000007a +82:fa61 0001:0000007b +82:fa65 0001:0000007c +82:fa69 0001:0000007d +82:fa6a 0001:0000007e +82:fa6c 0001:00000080 +82:fa6d 0001:00000081 +82:fa6e 0001:00000082 +82:fa6f 0001:00000083 +82:fa70 0001:00000084 +80:a088 0001:0000008a +80:a08c 0001:0000008a +80:a08d 0001:0000008a +80:a095 0001:0000008d +80:a0ce 0001:00000090 +80:a113 0001:00000093 +91:e164 0001:00000096 +91:e168 0001:00000096 +91:e169 0001:00000096 +a0:fe00 0001:0000009b +a0:fe03 0001:0000009c +a0:fe05 0001:0000009d +a0:fe08 0001:0000009e +a0:fe0b 0001:000000a0 +a0:fe0c 0001:000000a3 +a0:fe10 0001:000000a4 +a0:fe13 0001:000000a5 +a0:fe15 0001:000000a6 +a0:fe18 0001:000000a7 +a0:fe1b 0001:000000a8 +a0:fe1f 0001:000000aa +a0:fe23 0001:000000ab +a0:fe27 0001:000000ac +a0:fe2b 0001:000000ad +a0:fe2f 0001:000000b0 +a0:fe33 0001:000000b1 +a0:fe36 0001:000000b2 +a0:fe38 0001:000000b3 +a0:fe3c 0001:000000b4 +a0:fe40 0001:000000b6 +a0:fe44 0001:000000b9 +a0:fe48 0001:000000ba +a0:fe49 0001:000000bb +a0:fe4c 0001:000000bc +a0:fe4e 0001:000000bd +a0:fe4f 0001:000000be +a0:fe53 0001:000000bf +a0:fe57 0001:000000c1 +a0:fe58 0001:000000c2 +a0:fe5c 0001:000000c3 +a0:fe5f 0001:000000c4 +a0:fe62 0001:000000c5 +a0:fe65 0001:000000c6 +a0:fe67 0001:000000c7 +a0:fe6a 0001:000000c8 +a0:fe6d 0001:000000c9 +a0:fe6f 0001:000000ca +a0:fe73 0001:000000cd +a0:fe76 0001:000000ce +a0:fe79 0001:000000cf +a0:fe7c 0001:000000d0 +a0:fe7f 0001:000000d2 +a0:fe82 0001:000000d3 +a0:fe85 0001:000000d4 +a0:fe89 0001:000000d5 +a0:fe8c 0001:000000d6 +a0:fe90 0001:000000d8 +a0:fe94 0001:000000db +a0:fe97 0001:000000dc +a0:fe99 0001:000000dd +a0:fe9c 0001:000000de +a0:fe9f 0001:000000df +a0:fea2 0001:000000e0 +a0:fea5 0001:000000e2 +a0:fea8 0001:000000e3 +a0:feab 0001:000000e4 85:ff00 0002:0000010b 85:ff03 0002:0000010c 85:ff06 0002:0000010d diff --git a/worlds/sm/data/SMBasepatch_prebuilt/sm-basepatch-symbols.json b/worlds/sm/data/SMBasepatch_prebuilt/sm-basepatch-symbols.json index 1e5dcccb6efb..0be5a05715b1 100644 --- a/worlds/sm/data/SMBasepatch_prebuilt/sm-basepatch-symbols.json +++ b/worlds/sm/data/SMBasepatch_prebuilt/sm-basepatch-symbols.json @@ -158,7 +158,7 @@ "setup_music_quick": "A0:FE0B", "setup_samus": "A0:FE94", "setup_samus_normal": "A0:FEA5", - "sm_fix_checksum": "82:FA15", + "sm_fix_checksum": "82:FA2A", "sm_item_graphics": "B8:8800", "sm_item_plm_pickup_sequence_pointers": "B8:882E", "sm_save_done_hook": "B8:847B", @@ -172,10 +172,11 @@ "write_repeated_memory_loop": "B8:83F4", "deathhook82": "82:DDC7", "freespace82_start": "82:F990", - "freespace82_end": "82:FA53", + "freespace82_end": "82:FA71", "freespacea0": "a0:fe00", "SRAM_SAVING": "70:2604", "current_save_slot": "7e:0952", + "sram_save_slot_addresses": "81:812B", "ITEM_RAM": "7E:09A2", "SRAM_MW_ITEMS_RECV": "70:2000", "SRAM_MW_ITEMS_RECV_WCOUNT": "70:2602", diff --git a/worlds/sm/data/SMBasepatch_prebuilt/variapatches.ips b/worlds/sm/data/SMBasepatch_prebuilt/variapatches.ips index 37139ae5785d..efc38e262f19 100644 Binary files a/worlds/sm/data/SMBasepatch_prebuilt/variapatches.ips and b/worlds/sm/data/SMBasepatch_prebuilt/variapatches.ips differ diff --git a/worlds/sm/docs/en_Super Metroid.md b/worlds/sm/docs/en_Super Metroid.md index c177582e221e..5c87e026f634 100644 --- a/worlds/sm/docs/en_Super Metroid.md +++ b/worlds/sm/docs/en_Super Metroid.md @@ -34,5 +34,6 @@ When the player receives an item, a text box will appear to show which item was It can happen that a required item is in a place where you cant get back from. While in normal gameplay state, by holding Start+Select+L+R at the same time, the game will save your progress and put you back at your original starting position. -This can be required by the logic. +This can be required by the logic. Since the addition of that feature, VARIA's automatic backup saves are disabled since +you can't softlock anymore. diff --git a/worlds/sm/docs/multiworld_en.md b/worlds/sm/docs/multiworld_en.md index 20c055bc91bf..ce91e7a7e403 100644 --- a/worlds/sm/docs/multiworld_en.md +++ b/worlds/sm/docs/multiworld_en.md @@ -87,8 +87,7 @@ first time launching, you may be prompted to allow it to communicate through the 3. Click on **New Lua Script Window...** 4. In the new window, click **Browse...** 5. Select the connector lua file included with your client - - Look in the Archipelago folder for `/SNI/lua/x64` or `/SNI/lua/x86` depending on if the - emulator is 64-bit or 32-bit. + - Look in the Archipelago folder for `/SNI/lua/Connector.lua`. 6. If you see an error while loading the script that states `socket.dll missing` or similar, navigate to the folder of the lua you are using in your file explorer and copy the `socket.dll` to the base folder of your snes9x install. @@ -100,8 +99,7 @@ the lua you are using in your file explorer and copy the `socket.dll` to the bas 2. Load your ROM file if it hasn't already been loaded. If you changed your core preference after loading the ROM, don't forget to reload it (default hotkey: Ctrl+R). 3. Drag+drop the `Connector.lua` file included with your client onto the main EmuHawk window. - - Look in the Archipelago folder for `/SNI/lua/x64` or `/SNI/lua/x86` depending on if the - emulator is 64-bit or 32-bit. Please note the most recent versions of BizHawk are 64-bit only. + - Look in the Archipelago folder for `/SNI/lua/Connector.lua`. - You could instead open the Lua Console manually, click `Script` 〉 `Open Script`, and navigate to `Connector.lua` with the file picker. diff --git a/worlds/sm/variaRandomizer/rando/GraphBuilder.py b/worlds/sm/variaRandomizer/rando/GraphBuilder.py index 3577baff0880..7bee33ec82e0 100644 --- a/worlds/sm/variaRandomizer/rando/GraphBuilder.py +++ b/worlds/sm/variaRandomizer/rando/GraphBuilder.py @@ -150,7 +150,6 @@ def escapeTrigger(self, emptyContainer, graph, maxDiff, escapeTrigger): # update item% objectives accessibleItems = [il.Item for il in allItemLocs if ilCheck(il)] majorUpgrades = [item.Type for item in accessibleItems if item.BeamBits != 0 or item.ItemBits != 0] - sm.objectives.setItemPercentFuncs(len(accessibleItems), majorUpgrades) if split == "Scavenger": # update escape access for scav with last scav loc lastScavItemLoc = progItemLocs[-1] @@ -163,6 +162,7 @@ def escapeTrigger(self, emptyContainer, graph, maxDiff, escapeTrigger): if ilCheck(itemLoc) and (split.startswith("Full") or itemLoc.Location.isClass(split)): availLocsByArea[itemLoc.Location.GraphArea].append(itemLoc.Location.Name) self.log.debug("escapeTrigger. availLocsByArea="+str(availLocsByArea)) + sm.objectives.setItemPercentFuncs(len(accessibleItems), majorUpgrades, container) sm.objectives.setAreaFuncs({area:lambda sm,ap:SMBool(len(container.getLocs(lambda loc: loc.Name in availLocsByArea[area]))==0) for area in availLocsByArea}) self.log.debug("escapeTrigger. collect locs until G4 access") # collect all item/locations up until we can pass G4 (the escape triggers) diff --git a/worlds/sm/variaRandomizer/rom/rompatcher.py b/worlds/sm/variaRandomizer/rom/rompatcher.py index 913d0d8df8ef..2dcf554a0065 100644 --- a/worlds/sm/variaRandomizer/rom/rompatcher.py +++ b/worlds/sm/variaRandomizer/rom/rompatcher.py @@ -25,7 +25,9 @@ class RomPatcher: # faster MB cutscene transitions 'Mother_Brain_Cutscene_Edits', # "Balanced" suit mode - 'Removes_Gravity_Suit_heat_protection' + 'Removes_Gravity_Suit_heat_protection', + # new PLMs for indicating the color of the door on the other side + 'door_indicators_plms.ips' ], # VARIA tweaks 'VariaTweaks' : ['WS_Etank', 'LN_Chozo_SpaceJump_Check_Disable', 'ln_chozo_platform.ips', 'bomb_torizo.ips'], @@ -236,10 +238,12 @@ def applyIPSPatches(self): plms.append('WS_Save_Blinking_Door') if self.settings["boss"] == True: stdPatches.append("Phantoon_Eye_Door") - if (self.settings["area"] == True - or self.settings["doorsColorsRando"] == True - or not GraphUtils.isStandardStart(self.settings["startLocation"])): - stdPatches.append("Enable_Backup_Saves") + # rolling saves is not required anymore since the addition of fast_save_reload + # also, both arent completely compatible as-is + #if (self.settings["area"] == True + # or self.settings["doorsColorsRando"] == True + # or not GraphUtils.isStandardStart(self.settings["startLocation"])): + # stdPatches.append("Enable_Backup_Saves") if 'varia_hud.ips' in self.settings["optionalPatches"]: # varia hud can make demos glitch out self.applyIPSPatch("no_demo.ips") diff --git a/worlds/sm/variaRandomizer/utils/objectives.py b/worlds/sm/variaRandomizer/utils/objectives.py index 8c886674fd2c..67cdb9a1c132 100644 --- a/worlds/sm/variaRandomizer/utils/objectives.py +++ b/worlds/sm/variaRandomizer/utils/objectives.py @@ -511,16 +511,18 @@ def updateItemPercentEscapeAccess(self, collectedLocsAccessPoints): def setScavengerHuntFunc(self, scavClearFunc): self.goals["finish scavenger hunt"].clearFunc = scavClearFunc - def setItemPercentFuncs(self, totalItemsCount=None, allUpgradeTypes=None): - def getPctFunc(pct, totalItemsCount): + def setItemPercentFuncs(self, totalItemsCount=None, allUpgradeTypes=None, container=None): + def getPctFunc(total_needed, container): def f(sm, ap): - nonlocal pct, totalItemsCount - return sm.hasItemsPercent(pct, totalItemsCount) + nonlocal total_needed, container + locs_checked = len(container.getUsedLocs(lambda loc: True)) + return SMBool(locs_checked >= total_needed) return f + # AP: now based on location checks instead of local item for pct in [25,50,75,100]: goal = 'collect %d%% items' % pct - self.goals[goal].clearFunc = getPctFunc(pct, totalItemsCount) + self.goals[goal].clearFunc = getPctFunc(totalItemsCount * pct / 100, container) if allUpgradeTypes is not None: self.goals["collect all upgrades"].clearFunc = lambda sm, ap: sm.haveItems(allUpgradeTypes) diff --git a/worlds/sm64ex/docs/en_Super Mario 64.md b/worlds/sm64ex/docs/en_Super Mario 64.md index 4586369e5ec7..def6e2a37536 100644 --- a/worlds/sm64ex/docs/en_Super Mario 64.md +++ b/worlds/sm64ex/docs/en_Super Mario 64.md @@ -14,7 +14,7 @@ as different Items from within SM64. As in most Mario Games, save the Princess! ## Which items can be in another player's world? -Any of the 120 Stars, and the two Caste Keys. Additionally, Cap Switches are also considered "Items" and the "!"-Boxes will only be active +Any of the 120 Stars, and the two Castle Keys. Additionally, Cap Switches are also considered "Items" and the "!"-Boxes will only be active when someone collects the corresponding Cap Switch Item. ## What does another world's item look like in SM64EX? @@ -25,4 +25,4 @@ and who will receive it. When you receive an Item, a Message will pop up to inform you where you received the Item from, and which one it is. -NOTE: The Secret Star count in the Menu is broken. \ No newline at end of file +NOTE: The Secret Star count in the Menu is broken. diff --git a/worlds/smw/docs/setup_en.md b/worlds/smw/docs/setup_en.md index 2a9435c95d42..9ca8bdf58a16 100644 --- a/worlds/smw/docs/setup_en.md +++ b/worlds/smw/docs/setup_en.md @@ -77,8 +77,7 @@ first time launching, you may be prompted to allow it to communicate through the 3. Click on **New Lua Script Window...** 4. In the new window, click **Browse...** 5. Select the connector lua file included with your client - - Look in the Archipelago folder for `/SNI/lua/x64` or `/SNI/lua/x86` depending on if the - emulator is 64-bit or 32-bit. + - Look in the Archipelago folder for `/SNI/lua/Connector.lua`. 6. If you see an error while loading the script that states `socket.dll missing` or similar, navigate to the folder of the lua you are using in your file explorer and copy the `socket.dll` to the base folder of your snes9x install. @@ -90,8 +89,7 @@ the lua you are using in your file explorer and copy the `socket.dll` to the bas 2. Load your ROM file if it hasn't already been loaded. If you changed your core preference after loading the ROM, don't forget to reload it (default hotkey: Ctrl+R). 3. Drag+drop the `Connector.lua` file included with your client onto the main EmuHawk window. - - Look in the Archipelago folder for `/SNI/lua/x64` or `/SNI/lua/x86` depending on if the - emulator is 64-bit or 32-bit. Please note the most recent versions of BizHawk are 64-bit only. + - Look in the Archipelago folder for `/SNI/lua/Connector.lua`. - You could instead open the Lua Console manually, click `Script` 〉 `Open Script`, and navigate to `Connector.lua` with the file picker. diff --git a/worlds/smz3/TotalSMZ3/Item.py b/worlds/smz3/TotalSMZ3/Item.py index b4fc9d592550..28e9658ce1d0 100644 --- a/worlds/smz3/TotalSMZ3/Item.py +++ b/worlds/smz3/TotalSMZ3/Item.py @@ -181,6 +181,7 @@ class Item: keycard = re.compile("^Card") smMap = re.compile("^SmMap") + def IsNameDungeonItem(item_name): return Item.dungeon.match(item_name) def IsDungeonItem(self): return self.dungeon.match(self.Type.name) def IsBigKey(self): return self.bigKey.match(self.Type.name) def IsKey(self): return self.key.match(self.Type.name) diff --git a/worlds/smz3/__init__.py b/worlds/smz3/__init__.py index 79ba17db82b1..e2eb2ac80a13 100644 --- a/worlds/smz3/__init__.py +++ b/worlds/smz3/__init__.py @@ -221,7 +221,9 @@ def create_items(self): if (self.smz3World.Config.Keysanity): progressionItems = self.progression + self.dungeon + self.keyCardsItems + self.SmMapsItems else: - progressionItems = self.progression + progressionItems = self.progression + # Dungeons items here are not in the itempool and will be prefilled locally so they must stay local + self.multiworld.non_local_items[self.player].value -= frozenset(item_name for item_name in self.item_names if TotalSMZ3Item.Item.IsNameDungeonItem(item_name)) for item in self.keyCardsItems: self.multiworld.push_precollected(SMZ3Item(item.Type.name, ItemClassification.filler, item.Type, self.item_name_to_id[item.Type.name], self.player, item)) @@ -548,11 +550,8 @@ def write_spoiler(self, spoiler_handle: TextIO): def JunkFillGT(self, factor): poolLength = len(self.multiworld.itempool) - playerGroups = self.multiworld.get_player_groups(self.player) - playerGroups.add(self.player) junkPoolIdx = [i for i in range(0, poolLength) - if self.multiworld.itempool[i].classification in (ItemClassification.filler, ItemClassification.trap) and - self.multiworld.itempool[i].player in playerGroups] + if self.multiworld.itempool[i].classification in (ItemClassification.filler, ItemClassification.trap)] toRemove = [] for loc in self.locations.values(): # commenting this for now since doing a partial GT pre fill would allow for non SMZ3 progression in GT @@ -563,6 +562,7 @@ def JunkFillGT(self, factor): poolLength = len(junkPoolIdx) # start looking at a random starting index and loop at start if no match found start = self.multiworld.random.randint(0, poolLength) + itemFromPool = None for off in range(0, poolLength): i = (start + off) % poolLength candidate = self.multiworld.itempool[junkPoolIdx[i]] @@ -570,6 +570,7 @@ def JunkFillGT(self, factor): itemFromPool = candidate toRemove.append(junkPoolIdx[i]) break + assert itemFromPool is not None, "Can't find anymore item(s) to pre fill GT" self.multiworld.push_item(loc, itemFromPool, False) loc.event = False toRemove.sort(reverse = True) diff --git a/worlds/smz3/docs/multiworld_en.md b/worlds/smz3/docs/multiworld_en.md index 27c8a507e3df..da6e29ab6923 100644 --- a/worlds/smz3/docs/multiworld_en.md +++ b/worlds/smz3/docs/multiworld_en.md @@ -85,8 +85,7 @@ first time launching, you may be prompted to allow it to communicate through the 3. Click on **New Lua Script Window...** 4. In the new window, click **Browse...** 5. Select the connector lua file included with your client - - Look in the Archipelago folder for `/SNI/lua/x64` or `/SNI/lua/x86` depending on if the - emulator is 64-bit or 32-bit. + - Look in the Archipelago folder for `/SNI/lua/Connector.lua`. 6. If you see an error while loading the script that states `socket.dll missing` or similar, navigate to the folder of the lua you are using in your file explorer and copy the `socket.dll` to the base folder of your snes9x install. @@ -98,8 +97,7 @@ the lua you are using in your file explorer and copy the `socket.dll` to the bas 2. Load your ROM file if it hasn't already been loaded. If you changed your core preference after loading the ROM, don't forget to reload it (default hotkey: Ctrl+R). 3. Drag+drop the `Connector.lua` file included with your client onto the main EmuHawk window. - - Look in the Archipelago folder for `/SNI/lua/x64` or `/SNI/lua/x86` depending on if the - emulator is 64-bit or 32-bit. Please note the most recent versions of BizHawk are 64-bit only. + - Look in the Archipelago folder for `/SNI/lua/Connector.lua`. - You could instead open the Lua Console manually, click `Script` 〉 `Open Script`, and navigate to `Connector.lua` with the file picker. diff --git a/worlds/stardew_valley/data/villagers_data.py b/worlds/stardew_valley/data/villagers_data.py index 330d5eb955fb..e858d46f34a3 100644 --- a/worlds/stardew_valley/data/villagers_data.py +++ b/worlds/stardew_valley/data/villagers_data.py @@ -39,6 +39,7 @@ def __repr__(self): sewers = (Region.sewer,) island = (Region.island_east,) secret_woods = (Region.secret_woods,) +wizard_tower = (Region.wizard_tower,) golden_pumpkin = ("Golden Pumpkin",) # magic_rock_candy = ("Magic Rock Candy",) @@ -314,7 +315,7 @@ def villager(name: str, bachelor: bool, locations: Tuple[str, ...], birthday: st sandy = villager(NPC.sandy, False, oasis, Season.fall, universal_loves + sandy_loves, False) vincent = villager(NPC.vincent, False, town, Season.spring, universal_loves + vincent_loves, True) willy = villager(NPC.willy, False, beach, Season.summer, universal_loves + willy_loves, True) -wizard = villager(NPC.wizard, False, forest, Season.winter, universal_loves + wizard_loves, True) +wizard = villager(NPC.wizard, False, wizard_tower, Season.winter, universal_loves + wizard_loves, True) # Custom NPCs alec = villager(ModNPC.alec, True, forest, Season.winter, universal_loves + trilobite, True, ModNames.alec) diff --git a/worlds/stardew_valley/docs/en_Stardew Valley.md b/worlds/stardew_valley/docs/en_Stardew Valley.md index 042163343eb9..a880a40b971a 100644 --- a/worlds/stardew_valley/docs/en_Stardew Valley.md +++ b/worlds/stardew_valley/docs/en_Stardew Valley.md @@ -7,7 +7,7 @@ config file. ## What does randomization do to this game? -A vast number of optional objectives in stardew valley can be shuffled around the multiworld. Most of these are optional, and the player can customize their experience in their YAML file. +A vast number of objectives in Stardew Valley can be shuffled around the multiworld. Most of these are optional, and the player can customize their experience in their YAML file. For these objectives, if they have a vanilla reward, this reward will instead be an item in the multiworld. For the remaining number of such objectives, there are a number of "Resource Pack" items, which are simply an item or a stack of items that may be useful to the player. @@ -28,24 +28,24 @@ The player can choose from a number of goals, using their YAML settings. Location checks in Stardew Valley always include: - [Community Center Bundles](https://stardewvalleywiki.com/Bundles) -- [Mineshaft chest rewards](https://stardewvalleywiki.com/The_Mines#Remixed_Rewards) +- [Mineshaft Chest Rewards](https://stardewvalleywiki.com/The_Mines#Remixed_Rewards) - [Story Quests](https://stardewvalleywiki.com/Quests#List_of_Story_Quests) -- [Traveling Merchant items](https://stardewvalleywiki.com/Traveling_Cart) +- [Traveling Merchant Items](https://stardewvalleywiki.com/Traveling_Cart) - Isolated objectives such as the [beach bridge](https://stardewvalleywiki.com/The_Beach#Tide_Pools), [Old Master Cannoli](https://stardewvalleywiki.com/Secret_Woods#Old_Master_Cannoli), [Grim Reaper Statue](https://stardewvalleywiki.com/Golden_Scythe), etc There also are a number of location checks that are optional, and individual players choose to include them or not in their shuffling: - Tools and Fishing Rod Upgrades - Carpenter Buildings - Backpack Upgrades -- Mine elevator levels +- Mine Elevator Levels - Skill Levels - Arcade Machines -- Help Wanted quests +- Help Wanted Quests - Participating in Festivals - Special Orders from the town board, or from Mr Qi -- Cropsanity: Growing and harvesting individual crop types +- Cropsanity: Growing and Harvesting individual crop types - Fishsanity: Catching individual fish -- Museumsanity: Donating individual items to the museum, or reaching the museum milestones for donations +- Museumsanity: Donating individual items, or reaching milestones for museum donations - Friendsanity: Reaching specific friendship levels with NPCs ## Which items can be in another player's world? diff --git a/worlds/stardew_valley/logic.py b/worlds/stardew_valley/logic.py index b12e02fff989..377fa0d03ba3 100644 --- a/worlds/stardew_valley/logic.py +++ b/worlds/stardew_valley/logic.py @@ -492,35 +492,39 @@ def __post_init__(self): }) self.special_order_rules.update({ - SpecialOrder.island_ingredients: self.has_island_transport() & self.can_farm_perfectly() & - self.has(Vegetable.taro_root) & self.has(Fruit.pineapple) & self.has(Forageable.ginger), - SpecialOrder.cave_patrol: self.can_mine_perfectly() & self.can_mine_to_floor(120), - SpecialOrder.aquatic_overpopulation: self.can_fish_perfectly(), - SpecialOrder.biome_balance: self.can_fish_perfectly(), - SpecialOrder.rock_rejuivenation: self.has(Mineral.ruby) & self.has(Mineral.topaz) & self.has(Mineral.emerald) & - self.has(Mineral.jade) & self.has(Mineral.amethyst) & self.has_relationship(NPC.emily, 4) & - self.has(ArtisanGood.cloth) & self.can_reach_region(Region.haley_house), - SpecialOrder.gifts_for_george: self.has_season(Season.spring) & self.has(Forageable.leek), - SpecialOrder.fragments_of_the_past: self.can_reach_region(Region.dig_site), - SpecialOrder.gus_famous_omelet: self.has(AnimalProduct.any_egg), - SpecialOrder.crop_order: self.can_farm_perfectly(), - SpecialOrder.community_cleanup: self.can_crab_pot(), - SpecialOrder.the_strong_stuff: self.can_keg(Vegetable.potato), - SpecialOrder.pierres_prime_produce: self.can_farm_perfectly(), - SpecialOrder.robins_project: self.can_chop_perfectly() & self.has(Material.hardwood), - SpecialOrder.robins_resource_rush: self.can_chop_perfectly() & self.has(Fertilizer.tree) & self.can_mine_perfectly(), - SpecialOrder.juicy_bugs_wanted_yum: self.has(Loot.bug_meat), - SpecialOrder.tropical_fish: self.has_island_transport() & self.has(Fish.stingray) & self.has(Fish.blue_discus) & self.has(Fish.lionfish), - SpecialOrder.a_curious_substance: self.can_mine_perfectly() & self.can_mine_to_floor(80), - SpecialOrder.prismatic_jelly: self.can_mine_perfectly() & self.can_mine_to_floor(40), + SpecialOrder.island_ingredients: self.can_meet(NPC.caroline) & self.has_island_transport() & self.can_farm_perfectly() & + self.can_ship(Vegetable.taro_root) & self.can_ship(Fruit.pineapple) & self.can_ship(Forageable.ginger), + SpecialOrder.cave_patrol: self.can_meet(NPC.clint) & self.can_mine_perfectly() & self.can_mine_to_floor(120), + SpecialOrder.aquatic_overpopulation: self.can_meet(NPC.demetrius) & self.can_fish_perfectly(), + SpecialOrder.biome_balance: self.can_meet(NPC.demetrius) & self.can_fish_perfectly(), + SpecialOrder.rock_rejuivenation: self.has_relationship(NPC.emily, 4) & self.has(Mineral.ruby) & self.has(Mineral.topaz) & + self.has(Mineral.emerald) & self.has(Mineral.jade) & self.has(Mineral.amethyst) & + self.has(ArtisanGood.cloth) & self.can_reach_region(Region.haley_house), + SpecialOrder.gifts_for_george: self.can_reach_region(Region.alex_house) & self.has_season(Season.spring) & self.has(Forageable.leek), + SpecialOrder.fragments_of_the_past: self.can_reach_region(Region.museum) & self.can_reach_region(Region.dig_site) & self.has_tool(Tool.pickaxe), + SpecialOrder.gus_famous_omelet: self.can_reach_region(Region.saloon) & self.has(AnimalProduct.any_egg), + SpecialOrder.crop_order: self.can_farm_perfectly() & self.can_ship(), + SpecialOrder.community_cleanup: self.can_reach_region(Region.railroad) & self.can_crab_pot(), + SpecialOrder.the_strong_stuff: self.can_reach_region(Region.trailer) & self.can_keg(Vegetable.potato), + SpecialOrder.pierres_prime_produce: self.can_reach_region(Region.pierre_store) & self.can_farm_perfectly(), + SpecialOrder.robins_project: self.can_meet(NPC.robin) & self.can_reach_region(Region.carpenter) & self.can_chop_perfectly() & + self.has(Material.hardwood), + SpecialOrder.robins_resource_rush: self.can_meet(NPC.robin) & self.can_reach_region(Region.carpenter) & self.can_chop_perfectly() & + self.has(Fertilizer.tree) & self.can_mine_perfectly(), + SpecialOrder.juicy_bugs_wanted_yum: self.can_reach_region(Region.beach) & self.has(Loot.bug_meat), + SpecialOrder.tropical_fish: self.can_meet(NPC.willy) & self.received("Island Resort") & self.has_island_transport() & + self.has(Fish.stingray) & self.has(Fish.blue_discus) & self.has(Fish.lionfish), + SpecialOrder.a_curious_substance: self.can_reach_region(Region.wizard_tower) & self.can_mine_perfectly() & self.can_mine_to_floor(80), + SpecialOrder.prismatic_jelly: self.can_reach_region(Region.wizard_tower) & self.can_mine_perfectly() & self.can_mine_to_floor(40), SpecialOrder.qis_crop: self.can_farm_perfectly() & self.can_reach_region(Region.greenhouse) & self.can_reach_region(Region.island_west) & self.has_total_skill_level(50) & - self.has(Machine.seed_maker), + self.has(Machine.seed_maker) & self.has_building(Building.shipping_bin), SpecialOrder.lets_play_a_game: self.has_junimo_kart_max_level(), SpecialOrder.four_precious_stones: self.has_lived_months(MAX_MONTHS) & self.has("Prismatic Shard") & self.can_mine_perfectly_in_the_skull_cavern(), SpecialOrder.qis_hungry_challenge: self.can_mine_perfectly_in_the_skull_cavern() & self.has_max_buffs(), - SpecialOrder.qis_cuisine: self.can_cook() & (self.can_spend_money_at(Region.saloon, 205000) | self.can_spend_money_at(Region.pierre_store, 170000)), + SpecialOrder.qis_cuisine: self.can_cook() & (self.can_spend_money_at(Region.saloon, 205000) | self.can_spend_money_at(Region.pierre_store, 170000)) & + self.can_ship(), SpecialOrder.qis_kindness: self.can_give_loved_gifts_to_everyone(), SpecialOrder.extended_family: self.can_fish_perfectly() & self.has(Fish.angler) & self.has(Fish.glacierfish) & self.has(Fish.crimsonfish) & self.has(Fish.mutant_carp) & self.has(Fish.legend), @@ -1039,11 +1043,12 @@ def can_reproduce(self, number_children: int = 1) -> StardewRule: def has_relationship(self, npc: str, hearts: int = 1) -> StardewRule: if hearts <= 0: return True_() - if self.options[options.Friendsanity] == options.Friendsanity.option_none: + friendsanity = self.options[options.Friendsanity] + if friendsanity == options.Friendsanity.option_none: return self.can_earn_relationship(npc, hearts) if npc not in all_villagers_by_name: if npc == NPC.pet: - if self.options[options.Friendsanity] == options.Friendsanity.option_bachelors: + if friendsanity == options.Friendsanity.option_bachelors: return self.can_befriend_pet(hearts) return self.received_hearts(NPC.pet, hearts) if npc == Generic.any or npc == Generic.bachelor: @@ -1073,12 +1078,12 @@ def has_relationship(self, npc: str, hearts: int = 1) -> StardewRule: if not self.npc_is_in_current_slot(npc): return True_() villager = all_villagers_by_name[npc] - if self.options[options.Friendsanity] == options.Friendsanity.option_bachelors and not villager.bachelor: + if friendsanity == options.Friendsanity.option_bachelors and not villager.bachelor: return self.can_earn_relationship(npc, hearts) - if self.options[options.Friendsanity] == options.Friendsanity.option_starting_npcs and not villager.available: + if friendsanity == options.Friendsanity.option_starting_npcs and not villager.available: return self.can_earn_relationship(npc, hearts) - if self.options[ - options.Friendsanity] != options.Friendsanity.option_all_with_marriage and villager.bachelor and hearts > 8: + is_capped_at_8 = villager.bachelor and friendsanity != options.Friendsanity.option_all_with_marriage + if is_capped_at_8 and hearts > 8: return self.received_hearts(villager, 8) & self.can_earn_relationship(npc, hearts) return self.received_hearts(villager, hearts) @@ -1132,11 +1137,22 @@ def can_earn_relationship(self, npc: str, hearts: int = 0) -> StardewRule: rule_if_birthday = self.has_season(villager.birthday) & self.has_any_universal_love() & self.has_lived_months(hearts // 2) rule_if_not_birthday = self.has_lived_months(hearts) earn_rule = self.can_meet(npc) & (rule_if_birthday | rule_if_not_birthday) + if villager.bachelor: + if hearts > 8: + earn_rule = earn_rule & self.can_date(npc) + if hearts > 10: + earn_rule = earn_rule & self.can_marry(npc) else: earn_rule = self.has_lived_months(min(hearts // 2, 8)) return previous_heart_rule & earn_rule + def can_date(self, npc: str) -> StardewRule: + return self.has_relationship(npc, 8) & self.has(Gift.bouquet) + + def can_marry(self, npc: str) -> StardewRule: + return self.has_relationship(npc, 10) & self.has(Gift.mermaid_pendant) + def can_befriend_pet(self, hearts: int): if hearts <= 0: return True_() @@ -1150,14 +1166,15 @@ def can_befriend_pet(self, hearts: int): def can_complete_bundle(self, bundle_requirements: List[BundleItem], number_required: int) -> StardewRule: item_rules = [] highest_quality_yet = 0 + can_speak_junimo = self.can_reach_region(Region.wizard_tower) for bundle_item in bundle_requirements: if bundle_item.item.item_id == -1: - return self.can_spend_money(bundle_item.amount) + return can_speak_junimo & self.can_spend_money(bundle_item.amount) else: item_rules.append(bundle_item.item.name) if bundle_item.quality > highest_quality_yet: highest_quality_yet = bundle_item.quality - return self.has(item_rules, number_required) & self.can_grow_gold_quality(highest_quality_yet) + return can_speak_junimo & self.has(item_rules, number_required) & self.can_grow_gold_quality(highest_quality_yet) def can_grow_gold_quality(self, quality: int) -> StardewRule: if quality <= 0: @@ -1605,3 +1622,9 @@ def has_all_rarecrows(self) -> StardewRule: rules.append(self.received(f"Rarecrow #{rarecrow_number}")) return And(rules) + def can_ship(self, item: str = "") -> StardewRule: + shipping_bin_rule = self.has_building(Building.shipping_bin) + if item == "": + return shipping_bin_rule + return shipping_bin_rule & self.has(item) + diff --git a/worlds/stardew_valley/test/TestGeneration.py b/worlds/stardew_valley/test/TestGeneration.py index a80f334d45d3..0142ad007908 100644 --- a/worlds/stardew_valley/test/TestGeneration.py +++ b/worlds/stardew_valley/test/TestGeneration.py @@ -214,7 +214,7 @@ def test_minimal_location_maximal_items_still_valid(self): self.assertGreaterEqual(len(valid_locations), len(multiworld.itempool)) def test_allsanity_without_mods_has_at_least_locations(self): - expected_locations = 993 + expected_locations = 994 allsanity_options = self.allsanity_options_without_mods() multiworld = setup_solo_multiworld(allsanity_options) number_locations = len(get_real_locations(self, multiworld)) @@ -227,7 +227,7 @@ def test_allsanity_without_mods_has_at_least_locations(self): f"\n\t\tActual: {number_locations}") def test_allsanity_with_mods_has_at_least_locations(self): - expected_locations = 1245 + expected_locations = 1246 allsanity_options = self.allsanity_options_with_mods() multiworld = setup_solo_multiworld(allsanity_options) number_locations = len(get_real_locations(self, multiworld)) diff --git a/worlds/stardew_valley/test/TestRules.py b/worlds/stardew_valley/test/TestRules.py index 8556dac1d89a..0847d8a63b95 100644 --- a/worlds/stardew_valley/test/TestRules.py +++ b/worlds/stardew_valley/test/TestRules.py @@ -444,3 +444,63 @@ def collect_all_except(multiworld, item_to_not_collect: str): for item in multiworld.get_items(): if item.name != item_to_not_collect: multiworld.state.collect(item) + + +class TestFriendsanityDatingRules(SVTestBase): + options = { + options.SeasonRandomization.internal_name: options.SeasonRandomization.option_randomized_not_winter, + options.Friendsanity.internal_name: options.Friendsanity.option_all_with_marriage, + options.FriendsanityHeartSize.internal_name: 3 + } + + def test_earning_dating_heart_requires_dating(self): + month_name = "Month End" + for i in range(12): + month_item = self.world.create_item(month_name) + self.multiworld.state.collect(month_item, event=True) + self.multiworld.state.collect(self.world.create_item("Beach Bridge"), event=False) + self.multiworld.state.collect(self.world.create_item("Progressive House"), event=False) + self.multiworld.state.collect(self.world.create_item("Adventurer's Guild"), event=False) + self.multiworld.state.collect(self.world.create_item("Galaxy Hammer"), event=False) + for i in range(3): + self.multiworld.state.collect(self.world.create_item("Progressive Pickaxe"), event=False) + self.multiworld.state.collect(self.world.create_item("Progressive Axe"), event=False) + self.multiworld.state.collect(self.world.create_item("Progressive Barn"), event=False) + for i in range(10): + self.multiworld.state.collect(self.world.create_item("Foraging Level"), event=False) + self.multiworld.state.collect(self.world.create_item("Farming Level"), event=False) + self.multiworld.state.collect(self.world.create_item("Mining Level"), event=False) + self.multiworld.state.collect(self.world.create_item("Combat Level"), event=False) + self.multiworld.state.collect(self.world.create_item("Progressive Mine Elevator"), event=False) + self.multiworld.state.collect(self.world.create_item("Progressive Mine Elevator"), event=False) + + npc = "Abigail" + heart_name = f"{npc} <3" + step = 3 + + self.assert_can_reach_heart_up_to(npc, 3, step) + self.multiworld.state.collect(self.world.create_item(heart_name), event=False) + self.assert_can_reach_heart_up_to(npc, 6, step) + self.multiworld.state.collect(self.world.create_item(heart_name), event=False) + self.assert_can_reach_heart_up_to(npc, 8, step) + self.multiworld.state.collect(self.world.create_item(heart_name), event=False) + self.assert_can_reach_heart_up_to(npc, 10, step) + self.multiworld.state.collect(self.world.create_item(heart_name), event=False) + self.assert_can_reach_heart_up_to(npc, 14, step) + + def assert_can_reach_heart_up_to(self, npc: str, max_reachable: int, step: int): + prefix = "Friendsanity: " + suffix = " <3" + for i in range(1, max_reachable + 1): + if i % step != 0 and i != 14: + continue + location = f"{prefix}{npc} {i}{suffix}" + can_reach = self.world.logic.can_reach_location(location)(self.multiworld.state) + self.assertTrue(can_reach, f"Should be able to earn relationship up to {i} hearts") + for i in range(max_reachable + 1, 14 + 1): + if i % step != 0 and i != 14: + continue + location = f"{prefix}{npc} {i}{suffix}" + can_reach = self.world.logic.can_reach_location(location)(self.multiworld.state) + self.assertFalse(can_reach, f"Should not be able to earn relationship up to {i} hearts") + diff --git a/worlds/subnautica/__init__.py b/worlds/subnautica/__init__.py index 1684260cc1a0..2d4cf2faf602 100644 --- a/worlds/subnautica/__init__.py +++ b/worlds/subnautica/__init__.py @@ -116,15 +116,16 @@ def create_items(self): # list of high-count important fragments as priority filler [ "Cyclops Engine Fragment", - "Modification Station Fragment", - "Mobile Vehicle Bay Fragment", - "Seamoth Fragment", "Cyclops Hull Fragment", "Cyclops Bridge Fragment", + "Seamoth Fragment", "Prawn Suit Fragment", + "Mobile Vehicle Bay Fragment", + "Modification Station Fragment", "Moonpool Fragment", + "Laser Cutter Fragment", ], - k=min(extras, 8)): + k=min(extras, 9)): item = self.create_item(item_name) pool.append(item) extras -= 1 diff --git a/worlds/subnautica/docs/en_Subnautica.md b/worlds/subnautica/docs/en_Subnautica.md index 9a112aa596ec..5e99208b5f44 100644 --- a/worlds/subnautica/docs/en_Subnautica.md +++ b/worlds/subnautica/docs/en_Subnautica.md @@ -12,7 +12,7 @@ awarded from scanning those items have been shuffled into location checks throug ## What is the goal of Subnautica when randomized? -The goal remains unchanged. Cure the plague, build the Neptune Escape Rocket, and escape into space. +There are four goals currently available. The Launch goal has you leave the planet. The Free goal has you cure the plague. Infected is achieved at maximum infection level. Drive asks you to repair the Aurora Drive Core. ## What items and locations get shuffled? @@ -34,5 +34,5 @@ player's world. ## When the player receives a technology, what happens? -When the player receives a technology, the chat log displays a notification the technology has been received. +When the player receives a technology, the chat log displays a notification that the technology has been received. diff --git a/worlds/terraria/__init__.py b/worlds/terraria/__init__.py index a56f47608b87..306a65ef9186 100644 --- a/worlds/terraria/__init__.py +++ b/worlds/terraria/__init__.py @@ -338,5 +338,6 @@ def check(state: CollectionState, location=location): def fill_slot_data(self) -> Dict[str, object]: return { "goal": list(self.goal_locations), + "achievements": self.multiworld.achievements[self.player].value, "deathlink": bool(self.multiworld.death_link[self.player]), } diff --git a/worlds/terraria/docs/setup_en.md b/worlds/terraria/docs/setup_en.md index d0833b748452..84744a4a337c 100644 --- a/worlds/terraria/docs/setup_en.md +++ b/worlds/terraria/docs/setup_en.md @@ -3,11 +3,23 @@ ## Required Software Download and install [Terraria](https://store.steampowered.com/app/105600/Terraria/) -and [TModLoader](https://store.steampowered.com/app/1281930/tModLoader/) on Steam +and [tModLoader](https://store.steampowered.com/app/1281930/tModLoader/) on Steam ## Installing the Archipelago Mod -Subscribe to [the mod](https://steamcommunity.com/sharedfiles/filedetails/?id=2922217554) on Steam. +1. Subscribe to [the mod](https://steamcommunity.com/sharedfiles/filedetails/?id=2922217554) on Steam +2. Open tModLoader +3. Go to **Workshop -> Manage Mods** and enable the Archipelago mod + - If tModLoader states that you need version 1.4.3, follow the following steps + 1. Close tModLoader + 2. Right-Click tModLoader in Steam and select **Properties** + 3. Navigate to **Betas -> Beta Participation** + 4. Select **1.4.3-legacy - Legacy - Stable tModLoader for Terraria 1.4.3** + 5. Update tModLoader through Steam + 6. Open tModLoader and navigate back to the **Manage Mods** menu +4. tModLoader will say that it needs to refresh; exit this menu, and it will do this automatically +5. Once tModLoader finishes loading, the Archipelago mod is finished installing; you can now +[connect to an Archipelago game](#joining-an-archipelago-game-in-terraria). This mod might not work with mods that significantly alter progression or vanilla features. It is highly recommended to use utility mods and features to speed up gameplay, such as: @@ -16,7 +28,7 @@ highly recommended to use utility mods and features to speed up gameplay, such a - Ore Excavator - Magic Storage - Alchemist NPC Lite - - (May be used to break progression) + - (Can be used to break progression) - Reduced Grinding - Upgraded Research @@ -24,8 +36,8 @@ highly recommended to use utility mods and features to speed up gameplay, such a ### What is a YAML and why do I need one? -You can see the [basic multiworld setup guide](/tutorial/Archipelago/setup/en) here -on the Archipelago website to learn about why Archipelago uses YAML files and what they're for. +The [basic multiworld setup guide](/tutorial/Archipelago/setup/en) can be found on Archipelago's website. Among other things, it explains what .yaml +files are, and how they are used. ### Where do I get a YAML? @@ -34,17 +46,15 @@ on the Archipelago website to generate a YAML using a graphical interface. ## Joining an Archipelago Game in Terraria -1. Launch TModLoader -2. In Workshop > Manage Mods, edit Archipelago Randomizer's settings - - "Name" should be the player name you set when creating your YAML file - - "Port" should be the port number associated with the Archipelago server. It will be a 4 or 5 - digit number. - - If you're not hosting your game on the Archipelago website, change "Address" to the server's - URL or IP address -3. Create a new character and world as normal (or use an existing one if you prefer). Terraria is -usually significantly more difficult with this mod, so it is recommended to choose a lower -difficulty than you normally would. -4. Open the world in single player or multiplayer +1. Launch tModLoader +2. In **Workshop > Manage Mods**, edit Archipelago Randomizer's settings + - **Name** should be the player name you set when creating your YAML file. + - **Port** should be the port number associated with the Archipelago server. It will be a 4 or 5-digit number. + - If you're not hosting your game on the Archipelago website, change **Address** to the server's URL or IP address +3. Create a new character and world as normal (or use an existing one if you prefer). Terraria usually becomes +significantly more difficult with this mod, so it is recommended to choose a lower difficulty than you normally would +play on. +4. Open the world in single player or multiplayer. 5. When you're ready, open chat, and enter `/apstart` to start the game. ## Commands diff --git a/worlds/timespinner/Options.py b/worlds/timespinner/Options.py index 5f4d230688bd..8b111849442c 100644 --- a/worlds/timespinner/Options.py +++ b/worlds/timespinner/Options.py @@ -60,7 +60,7 @@ class BossRando(Toggle): class BossScaling(DefaultOnToggle): - "When Boss Rando is enabled, scales the bosses' HP, XP, and ATK to the stats of the location they replace (Reccomended)" + "When Boss Rando is enabled, scales the bosses' HP, XP, and ATK to the stats of the location they replace (Recommended)" display_name = "Scale Random Boss Stats" diff --git a/worlds/undertale/Rules.py b/worlds/undertale/Rules.py index 02c21f53f73c..648152c50414 100644 --- a/worlds/undertale/Rules.py +++ b/worlds/undertale/Rules.py @@ -113,7 +113,7 @@ def set_rules(multiworld: MultiWorld, player: int): set_rule(multiworld.get_location("Hush Trade", player), lambda state: state.can_reach("News Show", "Region", player) and state.has("Hot Dog...?", player, 1)) set_rule(multiworld.get_location("Letter Quest", player), - lambda state: state.can_reach("New Home Exit", "Entrance", player)) + lambda state: state.can_reach("New Home Exit", "Entrance", player) and state.has("Undyne Date", player)) if (not _undertale_is_route(multiworld.state, player, 2)) or _undertale_is_route(multiworld.state, player, 3): set_rule(multiworld.get_location("Nicecream Punch Card", player), lambda state: state.has("Punch Card", player, 3) and state.can_reach("Waterfall", "Region", player)) diff --git a/worlds/undertale/__init__.py b/worlds/undertale/__init__.py index 3a34a162c478..5e3634470394 100644 --- a/worlds/undertale/__init__.py +++ b/worlds/undertale/__init__.py @@ -33,8 +33,8 @@ class UndertaleWeb(WebWorld): "A guide to setting up the Archipelago Undertale software on your computer. This guide covers " "single-player, multiworld, and related software.", "English", - "undertale_en.md", - "undertale/en", + "setup_en.md", + "setup/en", ["Mewlif"] )] diff --git a/worlds/undertale/data/patch.bsdiff b/worlds/undertale/data/patch.bsdiff index 8d7dcbf43a96..5d137537be83 100644 Binary files a/worlds/undertale/data/patch.bsdiff and b/worlds/undertale/data/patch.bsdiff differ diff --git a/worlds/undertale/docs/en_Undertale.md b/worlds/undertale/docs/en_Undertale.md index 79ca21681e58..3905d3bc3ead 100644 --- a/worlds/undertale/docs/en_Undertale.md +++ b/worlds/undertale/docs/en_Undertale.md @@ -24,24 +24,29 @@ every major route in the game, those being `Pacifist`, `Neutral`, and `Genocide` There are some major differences between vanilla and the randomizer. -There are now doors to every major area in the underground located in the flower room (The first room of the game), those being Ruins, Snowdin, Waterfall, Hotland, and Core. +There are now doors to every major area in the underground located in the flower room (the first room of the game.) +These doors lead to Ruins, Snowdin, Waterfall, Hotland, and Core from left to right. Each door needs their respective key from the pool to enter. -You start with one key for a random door. (Core will never be given to start with.) -The rest of the keys will be in the item pool. +You start with one key for a random door and the rest of the keys will be in the item pool to be found by other players. +(Core will never be given to start with, unless otherwise specified.) -Genocide works a little differently in terms of the requirements. -You now only need to get through Core and fight Mettaton NEO, and then beat Sans, to win. -If you choose to fight other major bosses, you will still need to grind out the area before fighting them like normal. -Pacifist is mostly the same, except you are not required to go to the Ruins to spare Toriel, -you only need to spare Papyrus, Undyne, and Mettaton EX. Although you still cannot kill anyone. -You are also still required to do the date/hangout with Papyrus, the hangout with Undyne, and the date with Alphys, -in that order, before entering the True Lab. +**Genocide** works a little differently in terms of the requirements. -You now require custom items to Hangout with Papyrus, Undyne, to enter the True Lab, and to fight Mettaton EX/NEO. -Those being `Complete Skeleton`, `Fish`, `DT Extractor`, and `Mettaton Plush`. +In order to win with the genocide route, you only need to get through Core, fight Mettaton NEO, and beat Sans to win. +If you choose to fight other major bosses, you will still need to progress the area like normal before fighting them. -The Riverperson will only take you to locations you have actually seen the Riverperson at. -Meaning they will only take you to, for example, Waterfall, if you have seen the Riverperson at Waterfall at least once. +**Pacifist** remains mostly the same. + +In the Pacifist run, you are not required to go to the Ruins to spare Toriel. The only necessary spares are Papyrus, +Undyne, and Mettaton EX. Just as it is in the vanilla game, you cannot kill anyone. You are also required to complete +the date/hangout with Papyrus, Undyne, and Alphys, in that order, before entering the True Lab. + +Additionally, custom items are required to hang out with Papyrus, Undyne, to enter the True Lab, and to fight +Mettaton EX/NEO. The respective items for each interaction are `Complete Skeleton`, `Fish`, `DT Extractor`, +and `Mettaton Plush`. + +The Riverperson will only take you to locations you have seen them at, meaning they will only take you to +Waterfall if you have seen them at Waterfall at least once. If you press `W` while in the save menu, you will teleport back to the flower room, for quick access to the other areas. \ No newline at end of file diff --git a/worlds/undertale/docs/setup_en.md b/worlds/undertale/docs/setup_en.md new file mode 100644 index 000000000000..f82105c26916 --- /dev/null +++ b/worlds/undertale/docs/setup_en.md @@ -0,0 +1,65 @@ +# Undertale Randomizer Setup Guide + +### Required Software + +- Undertale from the [Steam page](https://store.steampowered.com/app/391540) +- Archipelago from the [Archipelago Releases Page](https://github.com/ArchipelagoMW/Archipelago/releases) + - (select `Undertale Client` during installation.) + +### First time setup + +Start the Undertale client from your Archipelago folder and input `/auto_patch ` at the bottom. + +This directory is usually located at `C:\Program Files\Steam\steamapps\Undertale`, but it can be different depending on +your installation. You can easily find the directory by opening the Undertale directory through Steam by right-clicking +Undertale in your library and selecting `Manage -> Browse local files`. Then, on Windows you can see the directory that +you need at the top of the window that opens. + +After using the `/auto_patch` command, **Archipelago will make an Undertale folder within the Archipelago install +location.** That folder contains the version of Undertale you will use for Archipelago. (If you update Archipelago, +you will need to redo this set-up.) + +**Linux Users**: The Linux installation is mostly similar, however, Undertale will be installed on Steam as the Linux +variant. Since this randomizer only supports the Windows version, we must fix this, by right-click the game in Steam, +going to `Properties -> Compatibility`, and checking `Force the use of a specific Steam Play compatibility tool`. This +downloads the Windows version of Undertale to use instead of the Linux version. If the play button is greyed out in +Steam, be sure to go to `Settings -> Compatibility` and toggle `Enable Steam Play for all other titles`. + +### Connect to the MultiServer + +Make sure both Undertale **from the Archipelago folder** and its client are running. (Undertale will ask for a save slot +to play on. Archipelago Undertale does not overwrite vanilla saves, but you may want to back up your save as a precaution.) + +In the top text box of the client, type the `IP Address` (or `Hostname`) and `Port` separated with a `:` symbol. +(Ex. `archipelago.gg:38281`) + +The client will then ask for the slot name, input your slot name chosen during YAML creation in the text box at the +bottom of the client. + +**Linux Users**: When you start the client, it is likely that the save data path is incorrect, and how the game +is played depends on where the save data folder is located. + +**On Steam (via Proton)**: This assumes the game is in a Steam Library folder. Right-click Undertale, go to `Manage -> +Browse Local Files`. Go up the directories to the `steamapps` folder, open `compatdata/391540` (391540 is the "magic number" for +Undertale in Steam). Save data from here is at `/pfx/drive_c/users/steamuser/AppData/Local/UNDERTALE`. + +**Through WINE directly**: This depends on the prefix used. If it is default, then the save data is located at +`/home/USERNAME/.wine/drive_c/users/USERNAME/AppData/Local/UNDERTALE`. + +Once the save data folder is located, run the `/savepath` command to redirect the client to the correct save data folder +before connecting. + +### Play the game + +When the console tells you that you have joined the room, you're all set. Congratulations on successfully joining a +multi-world game! + +### PLEASE READ! + +Please read this page in its entirety before asking questions! Most importantly, there is a list of +gameplay differences at the bottom. +[Undertale Game Info Page](/games/Undertale/info/en) + +### Where do I get a YAML file? + +You can customize your settings by visiting the [Undertale Player Settings Page](/games/Undertale/player-settings) diff --git a/worlds/undertale/docs/undertale_en.md b/worlds/undertale/docs/undertale_en.md deleted file mode 100644 index a2f3d2579a94..000000000000 --- a/worlds/undertale/docs/undertale_en.md +++ /dev/null @@ -1,59 +0,0 @@ -# Undertale Randomizer Setup Guide - -### Required Software - -- Undertale from the [Steam page](https://store.steampowered.com/app/391540) -- Archipelago from the [Archipelago Releases Page](https://github.com/ArchipelagoMW/Archipelago/releases) - - (select `Undertale Client` during installation.) - -### First time setup - -Start the Undertale client, and in the bottom text box, input `/auto_patch (Input your Undertale install directory here)` (It is usually located at `C:\Program Files\Steam\steamapps\Undertale`, but it can be different, you can more easily find the directory -by opening the Undertale directory through Steam), it will then make an Undertale folder that will be created in the -Archipelago install location. That contains the version of Undertale you will use for Archipelago. (You will need to -redo this step when updating Archipelago.) - -**Linux Users**: This guide is mostly similar; however, when Undertale is installed on Steam, it defaults to a Linux -supported variant; this randomizer only supports the Windows version. To fix this, right-click the game in Steam, go to -Properties -> Compatibility, and check "Force the use of a specific Steam Play compatibility tool". This -downloads the Windows version instead. If the play button is greyed out in Steam, be sure to go to -Settings -> Compatibility and toggle "Enable Steam Play for all other titles". - -### Connect to the MultiServer - -Make sure both Undertale and its client are running. (Undertale will ask for a saveslot, it can be 1 through 99, none -of the slots will overwrite your vanilla save, although you may want to make a backup just in case.) - -In the top text box of the client, type the -`Ip Address` (or `Hostname`) and `Port` separated with a `:` symbol. (Ex. `archipelago.gg:38281`) - -The client will then ask for the slot name, input that in the text box at the bottom of the client. - -**Linux Users**: When you start the client, it is likely that the save data path is incorrect, and how the game -is played depends on where the save data folder is located. - -*On Steam (via Proton)*: This assumes the game is in a Steam Library folder. Right-click Undertale, go to Manage -> -Browse Local Files. Move back to the steamapps folder, open compatdata/391540 (391540 is the "magic number" for -Undertale in Steam and can be confirmed by visiting its store page and looking at the URL). Save data from here is at -/pfx/drive_c/users/steamuser/AppData/Local/UNDERTALE. - -*Through WINE directly*: This depends on the prefix used. If it is default, then the save data is located at -/home/USERNAME/.wine/drive_c/users/USERNAME/AppData/Local/UNDERTALE. - -Once the save data folder is located, run the /savepath command to redirect the client to the correct save data folder -before connecting. - -### Play the game - -When the console tells you that you have joined the room, you're all set. Congratulations on successfully joining a -multiworld game! - -### PLEASE READ! - -Please read this page in its entirety before asking questions! Most importantly, there is a list of -gameplay differences at the bottom. -[Undertale Game Info Page](/games/Undertale/info/en) - -### Where do I get a YAML file? - -You can customize your settings by visiting the [Undertale Player Settings Page](/games/Undertale/player-settings) diff --git a/worlds/witness/WitnessLogicExpert.txt b/worlds/witness/WitnessLogicExpert.txt index a55c22441e28..581167cc450d 100644 --- a/worlds/witness/WitnessLogicExpert.txt +++ b/worlds/witness/WitnessLogicExpert.txt @@ -14,7 +14,7 @@ Tutorial (Tutorial) - Outside Tutorial - True: 158005 - 0x0A3B5 (Back Left) - True - Dots & Full Dots 158006 - 0x0A3B2 (Back Right) - True - Dots & Full Dots 158007 - 0x03629 (Gate Open) - 0x002C2 - Symmetry & Dots -158008 - 0x03505 (Gate Close) - 0x2FAF6 & 0x03629 - False +158008 - 0x03505 (Gate Close) - 0x2FAF6 & 0x03629 - True 158009 - 0x0C335 (Pillar) - True - Triangles 158010 - 0x0C373 (Patio Floor) - 0x0C335 - Dots 159512 - 0x33530 (Cloud EP) - True - True @@ -703,7 +703,7 @@ Swamp Between Bridges Far (Swamp) - Swamp Red Underwater - 0x183F2 - Swamp Rotat 158322 - 0x0000A (Between Bridges Far Row 4) - 0x00009 - Rotated Shapers & Shapers & Dots & Full Dots Door - 0x183F2 (Red Water Pump) - 0x00596 -Swamp Red Underwater (Swamp) - Swamp Maze - 0x014D1: +Swamp Red Underwater (Swamp) - Swamp Maze - 0x305D5: 158323 - 0x00001 (Red Underwater 1) - True - Shapers & Negative Shapers & Dots & Full Dots 158324 - 0x014D2 (Red Underwater 2) - True - Shapers & Negative Shapers & Dots & Full Dots 158325 - 0x014D4 (Red Underwater 3) - True - Shapers & Negative Shapers & Dots & Full Dots diff --git a/worlds/witness/__init__.py b/worlds/witness/__init__.py index f05202751fdb..faaafd598b51 100644 --- a/worlds/witness/__init__.py +++ b/worlds/witness/__init__.py @@ -248,6 +248,9 @@ def create_item(self, item_name: str) -> Item: return WitnessItem(item_name, item_data.classification, item_data.ap_code, player=self.player) + def get_filler_item_name(self) -> str: + return "Speed Boost" + class WitnessLocation(Location): """ diff --git a/worlds/witness/items.py b/worlds/witness/items.py index 7e083534c9c6..82c79047f3fb 100644 --- a/worlds/witness/items.py +++ b/worlds/witness/items.py @@ -152,7 +152,7 @@ def get_mandatory_items(self) -> Dict[str, int]: """ Returns the list of items that must be in the pool for the game to successfully generate. """ - return self._mandatory_items + return self._mandatory_items.copy() def get_filler_items(self, quantity: int) -> Dict[str, int]: """ diff --git a/worlds/zillion/__init__.py b/worlds/zillion/__init__.py index 2ea7ffdea5cd..7c927c10eb92 100644 --- a/worlds/zillion/__init__.py +++ b/worlds/zillion/__init__.py @@ -10,6 +10,7 @@ from BaseClasses import ItemClassification, LocationProgressType, \ MultiWorld, Item, CollectionState, Entrance, Tutorial +from .config import detect_test from .logic import cs_to_zz_locs from .region import ZillionLocation, ZillionRegion from .options import ZillionStartChar, zillion_options, validate @@ -145,8 +146,7 @@ def generate_early(self) -> None: self._item_counts = item_counts - import __main__ - rom_dir_name = "" if "test" in __main__.__file__ else os.path.dirname(get_base_rom_path()) + rom_dir_name = "" if detect_test() else os.path.dirname(get_base_rom_path()) with redirect_stdout(self.lsi): # type: ignore self.zz_system.make_patcher(rom_dir_name) self.zz_system.make_randomizer(zz_op) diff --git a/worlds/zillion/config.py b/worlds/zillion/config.py index ca02f9a99f41..db61d0c45347 100644 --- a/worlds/zillion/config.py +++ b/worlds/zillion/config.py @@ -2,3 +2,20 @@ base_id = 8675309 zillion_map = os.path.join(os.path.dirname(__file__), "empty-zillion-map-row-col-labels-281.png") + + +def detect_test() -> bool: + """ + Parts of generation that are in unit tests need the rom. + This is to detect whether we are running unit tests + so we can work around the need for the rom. + """ + import __main__ + try: + if "test" in __main__.__file__: + return True + except AttributeError: + # In some environments, __main__ doesn't have __file__ + # We'll assume that's not unit tests. + pass + return False diff --git a/worlds/zillion/requirements.txt b/worlds/zillion/requirements.txt index 626579ab1139..2af057decef9 100644 --- a/worlds/zillion/requirements.txt +++ b/worlds/zillion/requirements.txt @@ -1 +1 @@ -zilliandomizer @ git+https://github.com/beauxq/zilliandomizer@cd6a940ad7b585c75a560b91468d6b9eee030559#0.5.2 +zilliandomizer @ git+https://github.com/beauxq/zilliandomizer@4b27d115269db25fe73b0471b73495f41df1323c#0.5.3
+ Starting Resources
+ Weapon & Armor Upgrades
+ Base
 
+ Infantry + Vehicles +
- Vehicles -
- Starships -
+ Starships +
- Dominion -
+ Mercenaries
- Lab Upgrades + + General Upgrades
+ Protoss Units