From e7862437381d01be11e748c5b97f8f803c72cc47 Mon Sep 17 00:00:00 2001 From: TheCondor07 Date: Thu, 26 May 2022 13:28:10 -0400 Subject: [PATCH] SC2: Option for random mission order (#569) --- Starcraft2Client.py | 155 +++++++++++++++------- worlds/sc2wol/Locations.py | 38 +++--- worlds/sc2wol/MissionTables.py | 96 ++++++++++++++ worlds/sc2wol/Options.py | 18 ++- worlds/sc2wol/Regions.py | 230 +++++++++++++++++++++++---------- worlds/sc2wol/__init__.py | 25 +++- 6 files changed, 422 insertions(+), 140 deletions(-) create mode 100644 worlds/sc2wol/MissionTables.py diff --git a/Starcraft2Client.py b/Starcraft2Client.py index 51a04ea4..1a23fe60 100644 --- a/Starcraft2Client.py +++ b/Starcraft2Client.py @@ -11,6 +11,8 @@ from sc2.main import run_game from sc2.data import Race from sc2.bot_ai import BotAI from sc2.player import Bot +from worlds.sc2wol.Regions import MissionInfo +from worlds.sc2wol.MissionTables import lookup_id_to_mission from worlds.sc2wol.Items import lookup_id_to_name, item_table from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET @@ -32,6 +34,13 @@ nest_asyncio.apply() class StarcraftClientProcessor(ClientCommandProcessor): ctx: Context + missions_unlocked = 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.missions_unlocked = True + sc2_logger.info("Mission check has been disabled") def _cmd_play(self, mission_id: str = "") -> bool: """Start a Starcraft 2 mission""" @@ -42,7 +51,8 @@ class StarcraftClientProcessor(ClientCommandProcessor): if num_options > 0: mission_number = int(options[0]) - if is_mission_available(mission_number, self.ctx.checked_locations, mission_req_table): + if self.missions_unlocked or \ + is_mission_available(mission_number, self.ctx.checked_locations, self.ctx.mission_req_table): if self.ctx.sc2_run_task: if not self.ctx.sc2_run_task.done(): sc2_logger.warning("Starcraft 2 Client is still running!") @@ -65,13 +75,13 @@ class StarcraftClientProcessor(ClientCommandProcessor): def _cmd_available(self) -> bool: """Get what missions are currently available to play""" - request_available_missions(self.ctx.checked_locations, mission_req_table, self.ctx.ui) + request_available_missions(self.ctx.checked_locations, self.ctx.mission_req_table, self.ctx.ui) 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.checked_locations, mission_req_table, self.ctx.ui) + request_unfinished_missions(self.ctx.checked_locations, self.ctx.mission_req_table, self.ctx.ui, self.ctx) return True @@ -81,6 +91,7 @@ class Context(CommonContext): items_handling = 0b111 difficulty = -1 all_in_choice = 0 + mission_req_table = None items_rec_to_announce = [] rec_announce_pos = 0 items_sent_to_announce = [] @@ -102,6 +113,11 @@ class Context(CommonContext): 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"] + self.mission_req_table = {} + for mission in slot_req_table: + self.mission_req_table[mission] = MissionInfo(**slot_req_table[mission]) + if cmd in {"PrintJSON"}: noted = False if "receiving" in args: @@ -224,8 +240,8 @@ async def starcraft_launch(ctx: Context, mission_id): sc2_logger.info(f"Launching {lookup_id_to_mission[mission_id]}. If game does not launch check log file for errors.") - run_game(sc2.maps.get(maps_table[mission_id - 1]), [ - Bot(Race.Terran, ArchipelagoBot(ctx, mission_id), name="Archipelago", fullscreen=True)], realtime=True) + run_game(sc2.maps.get(maps_table[mission_id - 1]), [Bot(Race.Terran, ArchipelagoBot(ctx, mission_id), + name="Archipelago", fullscreen=True)], realtime=True) class ArchipelagoBot(sc2.bot_ai.BotAI): @@ -302,7 +318,7 @@ class ArchipelagoBot(sc2.bot_ai.BotAI): game_state = int(38281 - unit.health) self.can_read_game = True - if iteration == 80 and not game_state & 1: + 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)") @@ -390,15 +406,6 @@ class ArchipelagoBot(sc2.bot_ai.BotAI): await self.chat_send("LostConnection - Lost connection to game.") -class MissionInfo(typing.NamedTuple): - id: int - extra_locations: int - required_world: list[int] - number: int = 0 # number of worlds need beaten - completion_critical: bool = False # missions needed to beat game - or_requirements: bool = False # true if the requirements should be or-ed instead of and-ed - - mission_req_table = { "Liberation Day": MissionInfo(1, 7, [], completion_critical=True), "The Outlaws": MissionInfo(2, 2, [1], completion_critical=True), @@ -431,17 +438,17 @@ mission_req_table = { "All-In": MissionInfo(29, -1, [27, 28], completion_critical=True, or_requirements=True) } -lookup_id_to_mission: typing.Dict[int, str] = { - data.id: mission_name for mission_name, data in mission_req_table.items() if data.id} - -def calc_objectives_completed(mission, missions_info, locations_done): +def calc_objectives_completed(mission, missions_info, locations_done, unfinished_locations, ctx): objectives_complete = 0 if missions_info[mission].extra_locations > 0: for i in range(missions_info[mission].extra_locations): if (missions_info[mission].id * 100 + SC2WOL_LOC_ID_OFFSET + i) in locations_done: objectives_complete += 1 + else: + unfinished_locations[mission].append(ctx.location_name_getter( + missions_info[mission].id * 100 + SC2WOL_LOC_ID_OFFSET + i)) return objectives_complete @@ -449,31 +456,37 @@ def calc_objectives_completed(mission, missions_info, locations_done): return -1 -def request_unfinished_missions(locations_done, location_table, ui): - message = "Unfinished Missions: " +def request_unfinished_missions(locations_done, location_table, ui, ctx): + if location_table: + message = "Unfinished Missions: " + unlocks = initialize_blank_mission_dict(location_table) + unfinished_locations = initialize_blank_mission_dict(location_table) - unfinished_missions = calc_unfinished_missions(locations_done, location_table) + unfinished_missions = calc_unfinished_missions(locations_done, location_table, unlocks, unfinished_locations, ctx) + message += ", ".join(f"{mark_up_mission_name(mission, location_table, ui,unlocks)}[{location_table[mission].id}] " + + mark_up_objectives( + f"[{unfinished_missions[mission]}/{location_table[mission].extra_locations}]", + ctx, unfinished_locations, mission) + for mission in unfinished_missions) - message += ", ".join(f"{mark_critical(mission,location_table, ui)}[{location_table[mission].id}] " - f"({unfinished_missions[mission]}/{location_table[mission].extra_locations})" - for mission in unfinished_missions) - - if ui: - ui.log_panels['All'].on_message_markup(message) - ui.log_panels['Starcraft2'].on_message_markup(message) + if ui: + ui.log_panels['All'].on_message_markup(message) + ui.log_panels['Starcraft2'].on_message_markup(message) + else: + sc2_logger.info(message) else: - sc2_logger.info(message) + sc2_logger.warning("No mission table found, you are likely not connected to a server.") -def calc_unfinished_missions(locations_done, locations): +def calc_unfinished_missions(locations_done, locations, unlocks, unfinished_locations, ctx): unfinished_missions = [] locations_completed = [] - available_missions = calc_available_missions(locations_done, locations) + available_missions = calc_available_missions(locations_done, locations, unlocks) for name in available_missions: if not locations[name].extra_locations == -1: - objectives_completed = calc_objectives_completed(name, locations, locations_done) + objectives_completed = calc_objectives_completed(name, locations, locations_done, unfinished_locations, ctx) if objectives_completed < locations[name].extra_locations: unfinished_missions.append(name) @@ -492,31 +505,65 @@ def is_mission_available(mission_id_to_check, locations_done, locations): return any(mission_id_to_check == locations[mission].id for mission in unfinished_missions) -def mark_critical(mission, location_table, ui): +def mark_up_mission_name(mission, location_table, ui, unlock_table): """Checks if the mission is required for game completion and adds '*' to the name to mark that.""" + if location_table[mission].completion_critical: if ui: - return "[color=AF99EF]" + mission + "[/color]" + message = "[color=AF99EF]" + mission + "[/color]" else: - return "*" + mission + "*" + message = "*" + mission + "*" else: - return mission + message = mission + + if ui: + unlocks = unlock_table[mission] + + if len(unlocks) > 0: + pre_message = f"[ref={list(location_table).index(mission)}|Unlocks: " + pre_message += ", ".join(f"{unlock}({location_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(locations_done, location_table, ui): - message = "Available Missions: " + if location_table: + message = "Available Missions: " - missions = calc_available_missions(locations_done, location_table) - message += ", ".join(f"{mark_critical(mission,location_table, ui)}[{location_table[mission].id}]" for mission in missions) + # Initialize mission unlock table + unlocks = initialize_blank_mission_dict(location_table) - if ui: - ui.log_panels['All'].on_message_markup(message) - ui.log_panels['Starcraft2'].on_message_markup(message) + missions = calc_available_missions(locations_done, location_table, unlocks) + message += \ + ", ".join(f"{mark_up_mission_name(mission, location_table, ui, unlocks)}[{location_table[mission].id}]" + for mission in missions) + + if ui: + ui.log_panels['All'].on_message_markup(message) + ui.log_panels['Starcraft2'].on_message_markup(message) + else: + sc2_logger.info(message) else: - sc2_logger.info(message) + sc2_logger.warning("No mission table found, you are likely not connected to a server.") -def calc_available_missions(locations_done, locations): +def calc_available_missions(locations_done, locations, unlocks=None): available_missions = [] missions_complete = 0 @@ -526,6 +573,11 @@ def calc_available_missions(locations_done, locations): missions_complete += 1 for name in locations: + # Go through the required missions for each mission and fill up unlock table used later for hover-over tooltips + if unlocks: + for unlock in locations[name].required_world: + unlocks[list(locations)[unlock-1]].append(name) + if mission_reqs_completed(name, missions_complete, locations_done, locations): available_missions.append(name) @@ -549,14 +601,14 @@ def mission_reqs_completed(location_to_check, missions_complete, locations_done, req_success = True # Check if required mission has been completed - if not (req_mission * 100 + SC2WOL_LOC_ID_OFFSET) in locations_done: + if not (locations[list(locations)[req_mission-1]].id * 100 + SC2WOL_LOC_ID_OFFSET) in locations_done: if not locations[location_to_check].or_requirements: return False else: req_success = False # Recursively check required mission to see if it's requirements are met, in case !collect has been done - if not mission_reqs_completed(lookup_id_to_mission[req_mission], missions_complete, locations_done, + if not mission_reqs_completed(list(locations)[req_mission-1], missions_complete, locations_done, locations): if not locations[location_to_check].or_requirements: return False @@ -581,6 +633,15 @@ def mission_reqs_completed(location_to_check, missions_complete, locations_done, return True +def initialize_blank_mission_dict(location_table): + unlocks = {} + + for mission in list(location_table): + unlocks[mission] = [] + + return unlocks + + if __name__ == '__main__': colorama.init() asyncio.run(main()) diff --git a/worlds/sc2wol/Locations.py b/worlds/sc2wol/Locations.py index 402d2124..dc2ec74a 100644 --- a/worlds/sc2wol/Locations.py +++ b/worlds/sc2wol/Locations.py @@ -100,7 +100,7 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L lambda state: state._sc2wol_has_common_unit(world, player) and state._sc2wol_has_anti_air(world, player) and state._sc2wol_has_heavy_defense(world, player)), - LocationData("The Moebius Factor", "The Moebius Factor: 3rd Data Core", SC2WOL_LOC_ID_OFFSET + 1000, + LocationData("The Moebius Factor", "The Moebius Factor: Victory", SC2WOL_LOC_ID_OFFSET + 1000, lambda state: state._sc2wol_has_air(world, player)), LocationData("The Moebius Factor", "The Moebius Factor: 1st Data Core ", SC2WOL_LOC_ID_OFFSET + 1001, lambda state: state._sc2wol_has_air(world, player) or True), @@ -131,7 +131,7 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L lambda state: state._sc2wol_has_common_unit(world, player)), LocationData("Supernova", "Beat Supernova", None, lambda state: state._sc2wol_has_common_unit(world, player)), - LocationData("Maw of the Void", "Maw of the Void: Xel'Naga Vault", SC2WOL_LOC_ID_OFFSET + 1200, + LocationData("Maw of the Void", "Maw of the Void: Victory", SC2WOL_LOC_ID_OFFSET + 1200, lambda state: state.has('Battlecruiser', player) or state.has('Science Vessel', player) and state._sc2wol_has_air(world, player)), LocationData("Maw of the Void", "Maw of the Void: Landing Zone Cleared", SC2WOL_LOC_ID_OFFSET + 1201), @@ -148,14 +148,14 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L LocationData("Maw of the Void", "Beat Maw of the Void", None, lambda state: state.has('Battlecruiser', player) or state.has('Science Vessel', player) and state._sc2wol_has_air(world, player)), - LocationData("Devil's Playground", "Devil's Playground: 8000 Minerals", SC2WOL_LOC_ID_OFFSET + 1300, + LocationData("Devil's Playground", "Devil's Playground: Victory", SC2WOL_LOC_ID_OFFSET + 1300, lambda state: state._sc2wol_has_common_unit(world, 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, lambda state: state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player)), LocationData("Devil's Playground", "Beat Devil's Playground", None, lambda state: state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player)), - LocationData("Welcome to the Jungle", "Welcome to the Jungle: 7 Canisters", SC2WOL_LOC_ID_OFFSET + 1400, + LocationData("Welcome to the Jungle", "Welcome to the Jungle: Victory", SC2WOL_LOC_ID_OFFSET + 1400, lambda state: state._sc2wol_has_common_unit(world, player) and state._sc2wol_has_mobile_anti_air(world, player)), LocationData("Welcome to the Jungle", "Welcome to the Jungle: Close Relic", SC2WOL_LOC_ID_OFFSET + 1401), @@ -168,25 +168,25 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L LocationData("Welcome to the Jungle", "Beat Welcome to the Jungle", None, lambda state: state._sc2wol_has_common_unit(world, player) and state._sc2wol_has_mobile_anti_air(world, player)), - LocationData("Breakout", "Breakout: Main Prison", SC2WOL_LOC_ID_OFFSET + 1500), + 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("Breakout", "Beat Breakout", None), - LocationData("Ghost of a Chance", "Ghost of a Chance: Psi-Indoctrinator", SC2WOL_LOC_ID_OFFSET + 1600), + 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("Ghost of a Chance", "Beat Ghost of a Chance", None), - LocationData("The Great Train Robbery", "The Great Train Robbery: 8 Trains", SC2WOL_LOC_ID_OFFSET + 1700, + LocationData("The Great Train Robbery", "The Great Train Robbery: Victory", SC2WOL_LOC_ID_OFFSET + 1700, lambda state: state._sc2wol_has_train_killers(world, 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("The Great Train Robbery", "Beat The Great Train Robbery", None, lambda state: state._sc2wol_has_train_killers(world, player)), - LocationData("Cutthroat", "Cutthroat: Orlan's Planetary", SC2WOL_LOC_ID_OFFSET + 1800, + LocationData("Cutthroat", "Cutthroat: Victory", SC2WOL_LOC_ID_OFFSET + 1800, lambda state: state._sc2wol_has_common_unit(world, player)), LocationData("Cutthroat", "Cutthroat: Mira Han", SC2WOL_LOC_ID_OFFSET + 1801, lambda state: state._sc2wol_has_common_unit(world, player)), @@ -197,7 +197,7 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L lambda state: state._sc2wol_has_common_unit(world, player)), LocationData("Cutthroat", "Beat Cutthroat", None, lambda state: state._sc2wol_has_common_unit(world, player)), - LocationData("Engine of Destruction", "Engine of Destruction: Dominion Bases", SC2WOL_LOC_ID_OFFSET + 1900, + LocationData("Engine of Destruction", "Engine of Destruction: Victory", SC2WOL_LOC_ID_OFFSET + 1900, lambda state: state._sc2wol_has_mobile_anti_air(world, 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, @@ -213,7 +213,7 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L LocationData("Engine of Destruction", "Beat Engine of Destruction", None, lambda state: state._sc2wol_has_mobile_anti_air(world, player) and state._sc2wol_has_common_unit(world, player) or state.has('Wraith', player)), - LocationData("Media Blitz", "Media Blitz: Full Upload", SC2WOL_LOC_ID_OFFSET + 2000, + LocationData("Media Blitz", "Media Blitz: Victory", SC2WOL_LOC_ID_OFFSET + 2000, lambda state: state._sc2wol_has_competent_comp(world, player)), LocationData("Media Blitz", "Media Blitz: Tower 1", SC2WOL_LOC_ID_OFFSET + 2001, lambda state: state._sc2wol_has_competent_comp(world, player)), @@ -224,19 +224,19 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L LocationData("Media Blitz", "Media Blitz: Science Facility", SC2WOL_LOC_ID_OFFSET + 2004), LocationData("Media Blitz", "Beat Media Blitz", None, lambda state: state._sc2wol_has_competent_comp(world, player)), - LocationData("Piercing the Shroud", "Piercing the Shroud: Facility Escape", SC2WOL_LOC_ID_OFFSET + 2100), + LocationData("Piercing the Shroud", "Piercing the Shroud: Victory", SC2WOL_LOC_ID_OFFSET + 2100), 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), 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), LocationData("Piercing the Shroud", "Beat Piercing the Shroud", None), - LocationData("Whispers of Doom", "Whispers of Doom: Void Seeker Escape", SC2WOL_LOC_ID_OFFSET + 2200), + 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("Whispers of Doom", "Beat Whispers of Doom", None), - LocationData("A Sinister Turn", "A Sinister Turn: Preservers Freed", SC2WOL_LOC_ID_OFFSET + 2300, + LocationData("A Sinister Turn", "A Sinister Turn: Victory", SC2WOL_LOC_ID_OFFSET + 2300, lambda state: state._sc2wol_has_protoss_medium_units(world, player)), LocationData("A Sinister Turn", "A Sinister Turn: Robotics Facility", SC2WOL_LOC_ID_OFFSET + 2301), LocationData("A Sinister Turn", "A Sinister Turn: Dark Shrine", SC2WOL_LOC_ID_OFFSET + 2302), @@ -244,31 +244,31 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L lambda state: state._sc2wol_has_protoss_common_units(world, player)), LocationData("A Sinister Turn", "Beat A Sinister Turn", None, lambda state: state._sc2wol_has_protoss_medium_units(world, player)), - LocationData("Echoes of the Future", "Echoes of the Future: Overmind", SC2WOL_LOC_ID_OFFSET + 2400, + LocationData("Echoes of the Future", "Echoes of the Future: Victory", SC2WOL_LOC_ID_OFFSET + 2400, lambda state: state._sc2wol_has_protoss_medium_units(world, 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, lambda state: state._sc2wol_has_protoss_common_units(world, player)), LocationData("Echoes of the Future", "Beat Echoes of the Future", None, lambda state: state._sc2wol_has_protoss_medium_units(world, player)), - LocationData("In Utter Darkness", "In Utter Darkness: Kills", SC2WOL_LOC_ID_OFFSET + 2500, + LocationData("In Utter Darkness", "In Utter Darkness: Defeat", SC2WOL_LOC_ID_OFFSET + 2500, lambda state: state._sc2wol_has_protoss_medium_units(world, player)), LocationData("In Utter Darkness", "In Utter Darkness: Protoss Archive", SC2WOL_LOC_ID_OFFSET + 2501, lambda state: state._sc2wol_has_protoss_medium_units(world, player)), - LocationData("In Utter Darkness", "In Utter Darkness: Defeat", SC2WOL_LOC_ID_OFFSET + 2502), + LocationData("In Utter Darkness", "In Utter Darkness: Kills", SC2WOL_LOC_ID_OFFSET + 2502), LocationData("In Utter Darkness", "Beat In Utter Darkness", None, lambda state: state._sc2wol_has_protoss_medium_units(world, player)), - LocationData("Gates of Hell", "Gates of Hell: Nydus Worms", SC2WOL_LOC_ID_OFFSET + 2600, + LocationData("Gates of Hell", "Gates of Hell: Victory", SC2WOL_LOC_ID_OFFSET + 2600, lambda state: state._sc2wol_has_competent_comp(world, player)), LocationData("Gates of Hell", "Gates of Hell: Large Army", SC2WOL_LOC_ID_OFFSET + 2601, lambda state: state._sc2wol_has_competent_comp(world, player)), LocationData("Gates of Hell", "Beat Gates of Hell", None), - LocationData("Belly of the Beast", "Belly of the Beast: Extract", SC2WOL_LOC_ID_OFFSET + 2700), + 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("Belly of the Beast", "Beat Belly of the Beast", None), - LocationData("Shatter the Sky", "Shatter the Sky: Platform Destroyed", SC2WOL_LOC_ID_OFFSET + 2800, + LocationData("Shatter the Sky", "Shatter the Sky: Victory", SC2WOL_LOC_ID_OFFSET + 2800, lambda state: state._sc2wol_has_competent_comp(world, player)), LocationData("Shatter the Sky", "Shatter the Sky: Close Coolant Tower", SC2WOL_LOC_ID_OFFSET + 2801, lambda state: state._sc2wol_has_competent_comp(world, player)), diff --git a/worlds/sc2wol/MissionTables.py b/worlds/sc2wol/MissionTables.py new file mode 100644 index 00000000..d2906fd8 --- /dev/null +++ b/worlds/sc2wol/MissionTables.py @@ -0,0 +1,96 @@ +from typing import NamedTuple, Dict, List + +no_build_regions_list = ["Liberation Day", "Breakout", "Ghost of a Chance", "Piercing the Shroud", "Whispers of Doom", + "Belly of the Beast"] +easy_regions_list = ["The Outlaws", "Zero Hour", "Evacuation", "Outbreak", "Smash and Grab", "Devil's Playground"] +medium_regions_list = ["Safe Haven", "Haven's Fall", "The Dig", "The Moebius Factor", "Supernova", + "Welcome to the Jungle", "The Great Train Robbery", "Cutthroat", "Media Blitz", + "A Sinister Turn", "Echoes of the Future"] +hard_regions_list = ["Maw of the Void", "Engine of Destruction", "In Utter Darkness", "Gates of Hell", + "Shatter the Sky"] + + +class MissionInfo(NamedTuple): + id: int + extra_locations: int + required_world: List[int] + number: int = 0 # number of worlds need beaten + completion_critical: bool = False # missions needed to beat game + or_requirements: bool = False # true if the requirements should be or-ed instead of and-ed + + +class FillMission(NamedTuple): + type: str + connect_to: List[int] # -1 connects to Menu + number: int = 0 # number of worlds need beaten + completion_critical: bool = False # missions needed to beat game + or_requirements: bool = False # true if the requirements should be or-ed instead of and-ed + + +vanilla_shuffle_order = [ + FillMission("no_build", [-1], completion_critical=True), + FillMission("easy", [0], completion_critical=True), + FillMission("easy", [1], completion_critical=True), + FillMission("easy", [2]), + FillMission("medium", [3]), + FillMission("hard", [4], number=7), + FillMission("hard", [4], number=7), + FillMission("easy", [2], completion_critical=True), + FillMission("medium", [7], number=8, completion_critical=True), + FillMission("hard", [8], number=11, completion_critical=True), + FillMission("hard", [9], number=14, completion_critical=True), + FillMission("hard", [10], completion_critical=True), + FillMission("medium", [2], number=4), + FillMission("medium", [12]), + FillMission("hard", [13], number=8), + FillMission("hard", [13], number=8), + FillMission("medium", [2], number=6), + FillMission("hard", [16]), + FillMission("hard", [17]), + FillMission("hard", [18]), + FillMission("hard", [19]), + FillMission("medium", [8]), + FillMission("hard", [21]), + FillMission("hard", [22]), + FillMission("hard", [23]), + FillMission("hard", [11], completion_critical=True), + FillMission("hard", [25], completion_critical=True), + FillMission("hard", [25], completion_critical=True), + FillMission("all_in", [26, 27], completion_critical=True, or_requirements=True) +] + + +vanilla_mission_req_table = { + "Liberation Day": MissionInfo(1, 7, [], completion_critical=True), + "The Outlaws": MissionInfo(2, 2, [1], completion_critical=True), + "Zero Hour": MissionInfo(3, 4, [2], completion_critical=True), + "Evacuation": MissionInfo(4, 4, [3]), + "Outbreak": MissionInfo(5, 3, [4]), + "Safe Haven": MissionInfo(6, 1, [5], number=7), + "Haven's Fall": MissionInfo(7, 1, [5], number=7), + "Smash and Grab": MissionInfo(8, 5, [3], completion_critical=True), + "The Dig": MissionInfo(9, 4, [8], number=8, completion_critical=True), + "The Moebius Factor": MissionInfo(10, 9, [9], number=11, completion_critical=True), + "Supernova": MissionInfo(11, 5, [10], number=14, completion_critical=True), + "Maw of the Void": MissionInfo(12, 6, [11], completion_critical=True), + "Devil's Playground": MissionInfo(13, 3, [3], number=4), + "Welcome to the Jungle": MissionInfo(14, 4, [13]), + "Breakout": MissionInfo(15, 3, [14], number=8), + "Ghost of a Chance": MissionInfo(16, 6, [14], number=8), + "The Great Train Robbery": MissionInfo(17, 4, [3], number=6), + "Cutthroat": MissionInfo(18, 5, [17]), + "Engine of Destruction": MissionInfo(19, 6, [18]), + "Media Blitz": MissionInfo(20, 5, [19]), + "Piercing the Shroud": MissionInfo(21, 6, [20]), + "Whispers of Doom": MissionInfo(22, 4, [9]), + "A Sinister Turn": MissionInfo(23, 4, [22]), + "Echoes of the Future": MissionInfo(24, 3, [23]), + "In Utter Darkness": MissionInfo(25, 3, [24]), + "Gates of Hell": MissionInfo(26, 2, [12], completion_critical=True), + "Belly of the Beast": MissionInfo(27, 4, [26], completion_critical=True), + "Shatter the Sky": MissionInfo(28, 5, [26], completion_critical=True), + "All-In": MissionInfo(29, -1, [27, 28], completion_critical=True, or_requirements=True) +} + +lookup_id_to_mission: Dict[int, str] = { + data.id: mission_name for mission_name, data in vanilla_mission_req_table.items() if data.id} diff --git a/worlds/sc2wol/Options.py b/worlds/sc2wol/Options.py index 8772aa60..fe05af28 100644 --- a/worlds/sc2wol/Options.py +++ b/worlds/sc2wol/Options.py @@ -1,6 +1,6 @@ from typing import Dict from BaseClasses import MultiWorld -from Options import Choice, Option +from Options import Choice, Option, DefaultOnToggle class GameDifficulty(Choice): @@ -35,12 +35,28 @@ class AllInMap(Choice): option_air = 1 +class MissionOrder(Choice): + """Determines the order the missions are played in. + Vanilla: Keeps the standard mission order and branching from the WoL Campaign. + Vanilla Shuffled: Keeps same branching paths from the WoL Campaign but randomizes the order of missions within""" + display_name = "Mission Order" + option_vanilla = 0 + option_vanilla_shuffled = 1 + +class ShuffleProtoss(DefaultOnToggle): + """Determines if the 3 protoss missions are included in the shuffle if Vanilla Shuffled is enabled. If this is + not the 3 protoss missions will stay in their vanilla order in the mission order making them optional to complete + the game.""" + display_name = "Shuffle Protoss Missions" + # noinspection PyTypeChecker sc2wol_options: Dict[str, Option] = { "game_difficulty": GameDifficulty, "upgrade_bonus": UpgradeBonus, "bunker_upgrade": BunkerUpgrade, "all_in_map": AllInMap, + "mission_order": MissionOrder, + "shuffle_protoss": ShuffleProtoss } diff --git a/worlds/sc2wol/Regions.py b/worlds/sc2wol/Regions.py index e0d8415b..bca841f2 100644 --- a/worlds/sc2wol/Regions.py +++ b/worlds/sc2wol/Regions.py @@ -1,6 +1,10 @@ -from typing import List, Set, Dict, Tuple, Optional, Callable +from typing import List, Set, Dict, Tuple, Optional, Callable, NamedTuple from BaseClasses import MultiWorld, Region, Entrance, Location, RegionType from .Locations import LocationData +from .Options import get_option_value +from worlds.sc2wol.MissionTables import MissionInfo, vanilla_shuffle_order, vanilla_mission_req_table, \ + no_build_regions_list, easy_regions_list, medium_regions_list, hard_regions_list +import random def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData, ...], location_cache: List[Location]): @@ -46,73 +50,163 @@ def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData names: Dict[str, int] = {} - connect(world, player, names, 'Menu', 'Liberation Day'), - connect(world, player, names, 'Liberation Day', 'The Outlaws', - lambda state: state.has("Beat Liberation Day", player)), - connect(world, player, names, 'The Outlaws', 'Zero Hour', - lambda state: state.has("Beat The Outlaws", player)), - connect(world, player, names, 'Zero Hour', 'Evacuation', - lambda state: state.has("Beat Zero Hour", player)), - connect(world, player, names, 'Evacuation', 'Outbreak', - lambda state: state.has("Beat Evacuation", player)), - connect(world, player, names, "Outbreak", "Safe Haven", - lambda state: state._sc2wol_cleared_missions(world, player, 7) and - state.has("Beat Outbreak", player)), - connect(world, player, names, "Outbreak", "Haven's Fall", - lambda state: state._sc2wol_cleared_missions(world, player, 7) and - state.has("Beat Outbreak", player)), - connect(world, player, names, 'Zero Hour', 'Smash and Grab', - lambda state: state.has("Beat Zero Hour", player)), - connect(world, player, names, 'Smash and Grab', 'The Dig', - lambda state: state._sc2wol_cleared_missions(world, player, 8) and - state.has("Beat Smash and Grab", player)), - connect(world, player, names, 'The Dig', 'The Moebius Factor', - lambda state: state._sc2wol_cleared_missions(world, player, 11) and - state.has("Beat The Dig", player)), - connect(world, player, names, 'The Moebius Factor', 'Supernova', - lambda state: state._sc2wol_cleared_missions(world, player, 14) and - state.has("Beat The Moebius Factor", player)), - connect(world, player, names, 'Supernova', 'Maw of the Void', - lambda state: state.has("Beat Supernova", player)), - connect(world, player, names, 'Zero Hour', "Devil's Playground", - lambda state: state._sc2wol_cleared_missions(world, player, 4) and - state.has("Beat Zero Hour", player)), - connect(world, player, names, "Devil's Playground", 'Welcome to the Jungle', - lambda state: state.has("Beat Devil's Playground", player)), - connect(world, player, names, "Welcome to the Jungle", 'Breakout', - lambda state: state._sc2wol_cleared_missions(world, player, 8) and - state.has("Beat Welcome to the Jungle", player)), - connect(world, player, names, "Welcome to the Jungle", 'Ghost of a Chance', - lambda state: state._sc2wol_cleared_missions(world, player, 8) and - state.has("Beat Welcome to the Jungle", player)), - connect(world, player, names, "Zero Hour", 'The Great Train Robbery', - lambda state: state._sc2wol_cleared_missions(world, player, 6) and - state.has("Beat Zero Hour", player)), - connect(world, player, names, 'The Great Train Robbery', 'Cutthroat', - lambda state: state.has("Beat The Great Train Robbery", player)), - connect(world, player, names, 'Cutthroat', 'Engine of Destruction', - lambda state: state.has("Beat Cutthroat", player)), - connect(world, player, names, 'Engine of Destruction', 'Media Blitz', - lambda state: state.has("Beat Engine of Destruction", player)), - connect(world, player, names, 'Media Blitz', 'Piercing the Shroud', - lambda state: state.has("Beat Media Blitz", player)), - connect(world, player, names, 'The Dig', 'Whispers of Doom', - lambda state: state.has("Beat The Dig", player)), - connect(world, player, names, 'Whispers of Doom', 'A Sinister Turn', - lambda state: state.has("Beat Whispers of Doom", player)), - connect(world, player, names, 'A Sinister Turn', 'Echoes of the Future', - lambda state: state.has("Beat A Sinister Turn", player)), - connect(world, player, names, 'Echoes of the Future', 'In Utter Darkness', - lambda state: state.has("Beat Echoes of the Future", player)), - connect(world, player, names, 'Maw of the Void', 'Gates of Hell', - lambda state: state.has("Beat Maw of the Void", player)), - connect(world, player, names, 'Gates of Hell', 'Belly of the Beast', - lambda state: state.has("Beat Gates of Hell", player)), - connect(world, player, names, 'Gates of Hell', 'Shatter the Sky', - lambda state: state.has("Beat Gates of Hell", player)), - connect(world, player, names, 'Gates of Hell', 'All-In', - lambda state: state.has('Beat Gates of Hell', player) and ( - state.has('Beat Shatter the Sky', player) or state.has('Beat Belly of the Beast', player))) + if get_option_value(world, player, "mission_order") == 0: + connect(world, player, names, 'Menu', 'Liberation Day'), + connect(world, player, names, 'Liberation Day', 'The Outlaws', + lambda state: state.has("Beat Liberation Day", player)), + connect(world, player, names, 'The Outlaws', 'Zero Hour', + lambda state: state.has("Beat The Outlaws", player)), + connect(world, player, names, 'Zero Hour', 'Evacuation', + lambda state: state.has("Beat Zero Hour", player)), + connect(world, player, names, 'Evacuation', 'Outbreak', + lambda state: state.has("Beat Evacuation", player)), + connect(world, player, names, "Outbreak", "Safe Haven", + lambda state: state._sc2wol_cleared_missions(world, player, 7) and + state.has("Beat Outbreak", player)), + connect(world, player, names, "Outbreak", "Haven's Fall", + lambda state: state._sc2wol_cleared_missions(world, player, 7) and + state.has("Beat Outbreak", player)), + connect(world, player, names, 'Zero Hour', 'Smash and Grab', + lambda state: state.has("Beat Zero Hour", player)), + connect(world, player, names, 'Smash and Grab', 'The Dig', + lambda state: state._sc2wol_cleared_missions(world, player, 8) and + state.has("Beat Smash and Grab", player)), + connect(world, player, names, 'The Dig', 'The Moebius Factor', + lambda state: state._sc2wol_cleared_missions(world, player, 11) and + state.has("Beat The Dig", player)), + connect(world, player, names, 'The Moebius Factor', 'Supernova', + lambda state: state._sc2wol_cleared_missions(world, player, 14) and + state.has("Beat The Moebius Factor", player)), + connect(world, player, names, 'Supernova', 'Maw of the Void', + lambda state: state.has("Beat Supernova", player)), + connect(world, player, names, 'Zero Hour', "Devil's Playground", + lambda state: state._sc2wol_cleared_missions(world, player, 4) and + state.has("Beat Zero Hour", player)), + connect(world, player, names, "Devil's Playground", 'Welcome to the Jungle', + lambda state: state.has("Beat Devil's Playground", player)), + connect(world, player, names, "Welcome to the Jungle", 'Breakout', + lambda state: state._sc2wol_cleared_missions(world, player, 8) and + state.has("Beat Welcome to the Jungle", player)), + connect(world, player, names, "Welcome to the Jungle", 'Ghost of a Chance', + lambda state: state._sc2wol_cleared_missions(world, player, 8) and + state.has("Beat Welcome to the Jungle", player)), + connect(world, player, names, "Zero Hour", 'The Great Train Robbery', + lambda state: state._sc2wol_cleared_missions(world, player, 6) and + state.has("Beat Zero Hour", player)), + connect(world, player, names, 'The Great Train Robbery', 'Cutthroat', + lambda state: state.has("Beat The Great Train Robbery", player)), + connect(world, player, names, 'Cutthroat', 'Engine of Destruction', + lambda state: state.has("Beat Cutthroat", player)), + connect(world, player, names, 'Engine of Destruction', 'Media Blitz', + lambda state: state.has("Beat Engine of Destruction", player)), + connect(world, player, names, 'Media Blitz', 'Piercing the Shroud', + lambda state: state.has("Beat Media Blitz", player)), + connect(world, player, names, 'The Dig', 'Whispers of Doom', + lambda state: state.has("Beat The Dig", player)), + connect(world, player, names, 'Whispers of Doom', 'A Sinister Turn', + lambda state: state.has("Beat Whispers of Doom", player)), + connect(world, player, names, 'A Sinister Turn', 'Echoes of the Future', + lambda state: state.has("Beat A Sinister Turn", player)), + connect(world, player, names, 'Echoes of the Future', 'In Utter Darkness', + lambda state: state.has("Beat Echoes of the Future", player)), + connect(world, player, names, 'Maw of the Void', 'Gates of Hell', + lambda state: state.has("Beat Maw of the Void", player)), + connect(world, player, names, 'Gates of Hell', 'Belly of the Beast', + lambda state: state.has("Beat Gates of Hell", player)), + connect(world, player, names, 'Gates of Hell', 'Shatter the Sky', + lambda state: state.has("Beat Gates of Hell", player)), + connect(world, player, names, 'Gates of Hell', 'All-In', + lambda state: state.has('Beat Gates of Hell', player) and ( + state.has('Beat Shatter the Sky', player) or state.has('Beat Belly of the Beast', player))) + + return vanilla_mission_req_table + + elif get_option_value(world, player, "mission_order") == 1: + missions = [] + no_build_pool = no_build_regions_list[:] + easy_pool = easy_regions_list[:] + medium_pool = medium_regions_list[:] + hard_pool = hard_regions_list[:] + + # Initial fill out of mission list and marking all-in mission + for mission in vanilla_shuffle_order: + if mission.type == "all_in": + missions.append("All-In") + else: + missions.append(mission.type) + + # Place Protoss Missions if we are not using ShuffleProtoss + if get_option_value(world, player, "shuffle_protoss") == 0: + missions[22] = "A Sinister Turn" + medium_pool.remove("A Sinister Turn") + missions[23] = "Echoes of the Future" + medium_pool.remove("Echoes of the Future") + missions[24] = "In Utter Darkness" + hard_pool.remove("In Utter Darkness") + + no_build_slots = [] + easy_slots = [] + medium_slots = [] + hard_slots = [] + + # Search through missions to find slots needed to fill + for i in range(len(missions)): + if missions[i] == "no_build": + no_build_slots.append(i) + elif missions[i] == "easy": + easy_slots.append(i) + elif missions[i] == "medium": + medium_slots.append(i) + elif missions[i] == "hard": + hard_slots.append(i) + + # Add no_build missions to the pool and fill in no_build slots + missions_to_add = no_build_pool + for slot in no_build_slots: + filler = random.randint(0, len(missions_to_add)-1) + + missions[slot] = missions_to_add.pop(filler) + + # Add easy missions into pool and fill in easy slots + missions_to_add = missions_to_add + easy_pool + for slot in easy_slots: + filler = random.randint(0, len(missions_to_add) - 1) + + missions[slot] = missions_to_add.pop(filler) + + # Add medium missions into pool and fill in medium slots + missions_to_add = missions_to_add + medium_pool + for slot in medium_slots: + filler = random.randint(0, len(missions_to_add) - 1) + + missions[slot] = missions_to_add.pop(filler) + + # Add hard missions into pool and fill in hard slots + missions_to_add = missions_to_add + hard_pool + for slot in hard_slots: + filler = random.randint(0, len(missions_to_add) - 1) + + missions[slot] = missions_to_add.pop(filler) + + # Loop through missions to create requirements table and connect regions + # TODO: Handle 'and' connections + mission_req_table = {} + for i in range(len(missions)): + connections = [] + for connection in vanilla_shuffle_order[i].connect_to: + if connection == -1: + connect(world, player, names, "Menu", missions[i]) + else: + connect(world, player, names, missions[connection], missions[i], + (lambda name: (lambda state: state.has(f"Beat {name}", player)))(missions[connection])) + connections.append(connection + 1) + + mission_req_table.update({missions[i]: MissionInfo( + vanilla_mission_req_table[missions[i]].id, vanilla_mission_req_table[missions[i]].extra_locations, + connections, completion_critical=vanilla_shuffle_order[i].completion_critical, + or_requirements=vanilla_shuffle_order[i].or_requirements)}) + + return mission_req_table def throwIfAnyLocationIsNotAssignedToARegion(regions: List[Region], regionNames: Set[str]): diff --git a/worlds/sc2wol/__init__.py b/worlds/sc2wol/__init__.py index a06a37ee..31b759d8 100644 --- a/worlds/sc2wol/__init__.py +++ b/worlds/sc2wol/__init__.py @@ -1,6 +1,6 @@ import typing -from typing import List, Set, Tuple +from typing import List, Set, Tuple, NamedTuple from BaseClasses import Item, MultiWorld, Location, Tutorial from ..AutoWorld import World, WebWorld from .Items import StarcraftWoLItem, item_table, filler_items, item_name_groups, get_full_item_list, \ @@ -24,6 +24,7 @@ class Starcraft2WoLWebWorld(WebWorld): tutorials = [setup] + class SC2WoLWorld(World): """ StarCraft II: Wings of Liberty is a science fiction real-time strategy video game developed and published by Blizzard Entertainment. @@ -40,6 +41,7 @@ class SC2WoLWorld(World): item_name_groups = item_name_groups locked_locations: typing.List[str] location_cache: typing.List[Location] + mission_req_table = {} def __init__(self, world: MultiWorld, player: int): super(SC2WoLWorld, self).__init__(world, player) @@ -55,8 +57,8 @@ class SC2WoLWorld(World): return StarcraftWoLItem(name, data.progression, data.code, self.player) def create_regions(self): - create_regions(self.world, self.player, get_locations(self.world, self.player), - self.location_cache) + self.mission_req_table = create_regions(self.world, self.player, get_locations(self.world, self.player), + self.location_cache) def generate_basic(self): excluded_items = get_excluded_items(self, self.world, self.player) @@ -83,6 +85,11 @@ class SC2WoLWorld(World): option = getattr(self.world, option_name)[self.player] if type(option.value) in {str, int}: slot_data[option_name] = int(option.value) + slot_req_table = {} + for mission in self.mission_req_table: + slot_req_table[mission] = self.mission_req_table[mission]._asdict() + + slot_data["mission_req"] = slot_req_table return slot_data @@ -122,7 +129,15 @@ def assign_starter_items(world: MultiWorld, player: int, excluded_items: Set[str if not local_basic_unit: raise Exception("At least one basic unit must be local") - assign_starter_item(world, player, excluded_items, locked_locations, 'Liberation Day: First Statue', + # The first world should also be the starting world + first_location = list(world.worlds[player].mission_req_table)[0] + + if first_location == "In Utter Darkness": + first_location = first_location + ": Defeat" + else: + first_location = first_location + ": Victory" + + assign_starter_item(world, player, excluded_items, locked_locations, first_location, local_basic_unit) @@ -168,4 +183,4 @@ def create_item_with_correct_settings(world: MultiWorld, player: int, name: str) if not item.advancement: return item - return item \ No newline at end of file + return item