diff --git a/Starcraft2Client.py b/Starcraft2Client.py index 3b05f5aa..cf164057 100644 --- a/Starcraft2Client.py +++ b/Starcraft2Client.py @@ -52,9 +52,9 @@ class StarcraftClientProcessor(ClientCommandProcessor): """Overrides the current difficulty set for the seed. Takes the argument casual, normal, hard, or brutal""" options = difficulty.split() num_options = len(options) - difficulty_choice = options[0].lower() if num_options > 0: + difficulty_choice = options[0].lower() if difficulty_choice == "casual": self.ctx.difficulty_override = 0 elif difficulty_choice == "normal": @@ -71,7 +71,11 @@ class StarcraftClientProcessor(ClientCommandProcessor): return True else: - self.output("Difficulty needs to be specified in the command.") + 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: diff --git a/worlds/sc2wol/Items.py b/worlds/sc2wol/Items.py index aae83f50..9776e4fe 100644 --- a/worlds/sc2wol/Items.py +++ b/worlds/sc2wol/Items.py @@ -182,9 +182,11 @@ filler_items: typing.Tuple[str, ...] = ( '+15 Starting Vespene' ) +# Defense rating table +# Commented defense ratings are handled in LogicMixin defense_ratings = { "Siege Tank": 5, - "Maelstrom Rounds": 2, + # "Maelstrom Rounds": 2, "Planetary Fortress": 3, # Bunker w/ Marine/Marauder: 3, "Perdition Turret": 2, @@ -193,7 +195,7 @@ defense_ratings = { } zerg_defense_ratings = { "Perdition Turret": 2, - # Bunker w/ Firebat: 2 + # Bunker w/ Firebat: 2, "Hive Mind Emulator": 3, "Psi Disruptor": 3 } diff --git a/worlds/sc2wol/LogicMixin.py b/worlds/sc2wol/LogicMixin.py index dac9d856..c803835f 100644 --- a/worlds/sc2wol/LogicMixin.py +++ b/worlds/sc2wol/LogicMixin.py @@ -17,10 +17,12 @@ class SC2WoLLogic(LogicMixin): or get_option_value(multiworld, player, 'required_tactics') > 0 and self.has('Wraith', player) def _sc2wol_has_competent_anti_air(self, multiworld: MultiWorld, player: int) -> bool: - return self.has_any({'Marine', 'Goliath'}, player) or self._sc2wol_has_air_anti_air(multiworld, player) + return self.has('Goliath', player) \ + or self.has('Marine', player) and self.has_any({'Medic', 'Medivac'}, player) \ + or 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', 'Wraith'}, player) \ + return self.has_any({'Missile Turret', 'Thor', 'War Pigs', 'Spartan Company', "Hel's Angel", 'Battlecruiser', 'Marine', 'Wraith'}, 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) @@ -28,6 +30,8 @@ class SC2WoLLogic(LogicMixin): defense_score = sum((defense_ratings[item] for item in defense_ratings if self.has(item, player))) if self.has_any({'Marine', 'Marauder'}, player) and self.has('Bunker', player): defense_score += 3 + if self.has_all({'Siege Tank', 'Maelstrom Rounds'}, player): + defense_score += 2 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): diff --git a/worlds/sc2wol/MissionTables.py b/worlds/sc2wol/MissionTables.py index d926ea62..6db93547 100644 --- a/worlds/sc2wol/MissionTables.py +++ b/worlds/sc2wol/MissionTables.py @@ -1,7 +1,5 @@ -from typing import NamedTuple, Dict, List, Set - -from BaseClasses import MultiWorld -from .Options import get_option_value +from typing import NamedTuple, Dict, List +from enum import IntEnum no_build_regions_list = ["Liberation Day", "Breakout", "Ghost of a Chance", "Piercing the Shroud", "Whispers of Doom", "Belly of the Beast"] @@ -13,6 +11,14 @@ hard_regions_list = ["Maw of the Void", "Engine of Destruction", "In Utter Darkn "Shatter the Sky"] +class MissionPools(IntEnum): + STARTER = 0 + EASY = 1 + MEDIUM = 2 + HARD = 3 + FINAL = 4 + + class MissionInfo(NamedTuple): id: int required_world: List[int] @@ -23,119 +29,119 @@ class MissionInfo(NamedTuple): class FillMission(NamedTuple): - type: str + type: int connect_to: List[int] # -1 connects to Menu category: str 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 - relegate: bool = False # true if this is a slot no build missions should be relegated to. + removal_priority: int = 0 # how many missions missing from the pool required to remove this mission vanilla_shuffle_order = [ - FillMission("no_build", [-1], "Mar Sara", completion_critical=True), - FillMission("easy", [0], "Mar Sara", completion_critical=True), - FillMission("easy", [1], "Mar Sara", completion_critical=True), - FillMission("easy", [2], "Colonist"), - FillMission("medium", [3], "Colonist"), - FillMission("hard", [4], "Colonist", number=7), - FillMission("hard", [4], "Colonist", number=7, relegate=True), - FillMission("easy", [2], "Artifact", completion_critical=True), - FillMission("medium", [7], "Artifact", number=8, completion_critical=True), - FillMission("hard", [8], "Artifact", number=11, completion_critical=True), - FillMission("hard", [9], "Artifact", number=14, completion_critical=True), - FillMission("hard", [10], "Artifact", completion_critical=True), - FillMission("medium", [2], "Covert", number=4), - FillMission("medium", [12], "Covert"), - FillMission("hard", [13], "Covert", number=8, relegate=True), - FillMission("hard", [13], "Covert", number=8, relegate=True), - FillMission("medium", [2], "Rebellion", number=6), - FillMission("hard", [16], "Rebellion"), - FillMission("hard", [17], "Rebellion"), - FillMission("hard", [18], "Rebellion"), - FillMission("hard", [19], "Rebellion", relegate=True), - FillMission("medium", [8], "Prophecy"), - FillMission("hard", [21], "Prophecy"), - FillMission("hard", [22], "Prophecy"), - FillMission("hard", [23], "Prophecy", relegate=True), - FillMission("hard", [11], "Char", completion_critical=True), - FillMission("hard", [25], "Char", completion_critical=True), - FillMission("hard", [25], "Char", completion_critical=True), - FillMission("all_in", [26, 27], "Char", completion_critical=True, or_requirements=True) + FillMission(MissionPools.STARTER, [-1], "Mar Sara", completion_critical=True), + FillMission(MissionPools.EASY, [0], "Mar Sara", completion_critical=True), + FillMission(MissionPools.EASY, [1], "Mar Sara", completion_critical=True), + FillMission(MissionPools.EASY, [2], "Colonist"), + FillMission(MissionPools.MEDIUM, [3], "Colonist"), + FillMission(MissionPools.HARD, [4], "Colonist", number=7), + FillMission(MissionPools.HARD, [4], "Colonist", number=7, removal_priority=1), + 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.MEDIUM, [2], "Covert", number=4), + FillMission(MissionPools.MEDIUM, [12], "Covert"), + FillMission(MissionPools.HARD, [13], "Covert", number=8, removal_priority=3), + FillMission(MissionPools.HARD, [13], "Covert", number=8, removal_priority=2), + 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, [19], "Rebellion", removal_priority=5), + FillMission(MissionPools.MEDIUM, [8], "Prophecy", removal_priority=9), + FillMission(MissionPools.HARD, [21], "Prophecy", removal_priority=8), + FillMission(MissionPools.HARD, [22], "Prophecy", removal_priority=7), + FillMission(MissionPools.HARD, [23], "Prophecy", removal_priority=6), + FillMission(MissionPools.HARD, [11], "Char", completion_critical=True), + FillMission(MissionPools.HARD, [25], "Char", completion_critical=True, removal_priority=4), + FillMission(MissionPools.HARD, [25], "Char", completion_critical=True), + FillMission(MissionPools.FINAL, [26, 27], "Char", completion_critical=True, or_requirements=True) ] mini_campaign_order = [ - FillMission("no_build", [-1], "Mar Sara", completion_critical=True), - FillMission("easy", [0], "Colonist"), - FillMission("medium", [1], "Colonist"), - FillMission("medium", [0], "Artifact", completion_critical=True), - FillMission("medium", [3], "Artifact", number=4, completion_critical=True), - FillMission("hard", [4], "Artifact", number=8, completion_critical=True), - FillMission("medium", [0], "Covert", number=2), - FillMission("hard", [6], "Covert"), - FillMission("medium", [0], "Rebellion", number=3), - FillMission("hard", [8], "Rebellion"), - FillMission("medium", [4], "Prophecy"), - FillMission("hard", [10], "Prophecy"), - FillMission("hard", [5], "Char", completion_critical=True), - FillMission("hard", [5], "Char", completion_critical=True), - FillMission("all_in", [12, 13], "Char", completion_critical=True, or_requirements=True) + FillMission(MissionPools.STARTER, [-1], "Mar Sara", completion_critical=True), + FillMission(MissionPools.EASY, [0], "Colonist"), + FillMission(MissionPools.MEDIUM, [1], "Colonist"), + FillMission(MissionPools.EASY, [0], "Artifact", completion_critical=True), + FillMission(MissionPools.MEDIUM, [3], "Artifact", number=4, completion_critical=True), + FillMission(MissionPools.HARD, [4], "Artifact", number=8, completion_critical=True), + FillMission(MissionPools.MEDIUM, [0], "Covert", number=2), + FillMission(MissionPools.HARD, [6], "Covert"), + FillMission(MissionPools.MEDIUM, [0], "Rebellion", number=3), + FillMission(MissionPools.HARD, [8], "Rebellion"), + FillMission(MissionPools.MEDIUM, [4], "Prophecy"), + FillMission(MissionPools.HARD, [10], "Prophecy"), + FillMission(MissionPools.HARD, [5], "Char", completion_critical=True), + FillMission(MissionPools.HARD, [5], "Char", completion_critical=True), + FillMission(MissionPools.FINAL, [12, 13], "Char", completion_critical=True, or_requirements=True) ] gauntlet_order = [ - FillMission("no_build", [-1], "I", completion_critical=True), - FillMission("easy", [0], "II", completion_critical=True), - FillMission("medium", [1], "III", completion_critical=True), - FillMission("medium", [2], "IV", completion_critical=True), - FillMission("hard", [3], "V", completion_critical=True), - FillMission("hard", [4], "VI", completion_critical=True), - FillMission("all_in", [5], "Final", completion_critical=True) + FillMission(MissionPools.STARTER, [-1], "I", completion_critical=True), + FillMission(MissionPools.EASY, [0], "II", completion_critical=True), + FillMission(MissionPools.EASY, [1], "III", completion_critical=True), + FillMission(MissionPools.MEDIUM, [2], "IV", completion_critical=True), + FillMission(MissionPools.MEDIUM, [3], "V", completion_critical=True), + FillMission(MissionPools.HARD, [4], "VI", completion_critical=True), + FillMission(MissionPools.FINAL, [5], "Final", completion_critical=True) ] grid_order = [ - FillMission("no_build", [-1], "_1"), - FillMission("medium", [0], "_1"), - FillMission("medium", [1, 6, 3], "_1", or_requirements=True), - FillMission("hard", [2, 7], "_1", or_requirements=True), - FillMission("easy", [0], "_2"), - FillMission("medium", [1, 4], "_2", or_requirements=True), - FillMission("hard", [2, 5, 10, 7], "_2", or_requirements=True), - FillMission("hard", [3, 6, 11], "_2", or_requirements=True), - FillMission("medium", [4, 9, 12], "_3", or_requirements=True), - FillMission("hard", [5, 8, 10, 13], "_3", or_requirements=True), - FillMission("hard", [6, 9, 11, 14], "_3", or_requirements=True), - FillMission("hard", [7, 10], "_3", or_requirements=True), - FillMission("hard", [8, 13], "_4", or_requirements=True), - FillMission("hard", [9, 12, 14], "_4", or_requirements=True), - FillMission("hard", [10, 13], "_4", or_requirements=True), - FillMission("all_in", [11, 14], "_4", or_requirements=True) + FillMission(MissionPools.STARTER, [-1], "_1"), + FillMission(MissionPools.EASY, [0], "_1"), + FillMission(MissionPools.MEDIUM, [1, 6, 3], "_1", or_requirements=True), + FillMission(MissionPools.HARD, [2, 7], "_1", or_requirements=True), + FillMission(MissionPools.EASY, [0], "_2"), + FillMission(MissionPools.MEDIUM, [1, 4], "_2", or_requirements=True), + FillMission(MissionPools.HARD, [2, 5, 10, 7], "_2", or_requirements=True), + FillMission(MissionPools.HARD, [3, 6, 11], "_2", or_requirements=True), + FillMission(MissionPools.MEDIUM, [4, 9, 12], "_3", or_requirements=True), + FillMission(MissionPools.HARD, [5, 8, 10, 13], "_3", or_requirements=True), + FillMission(MissionPools.HARD, [6, 9, 11, 14], "_3", or_requirements=True), + FillMission(MissionPools.HARD, [7, 10], "_3", or_requirements=True), + FillMission(MissionPools.HARD, [8, 13], "_4", or_requirements=True), + FillMission(MissionPools.HARD, [9, 12, 14], "_4", or_requirements=True), + FillMission(MissionPools.HARD, [10, 13], "_4", or_requirements=True), + FillMission(MissionPools.FINAL, [11, 14], "_4", or_requirements=True) ] mini_grid_order = [ - FillMission("no_build", [-1], "_1"), - FillMission("medium", [0], "_1"), - FillMission("medium", [1, 5], "_1", or_requirements=True), - FillMission("easy", [0], "_2"), - FillMission("medium", [1, 3], "_2", or_requirements=True), - FillMission("hard", [2, 4], "_2", or_requirements=True), - FillMission("medium", [3, 7], "_3", or_requirements=True), - FillMission("hard", [4, 6], "_3", or_requirements=True), - FillMission("all_in", [5, 7], "_3", or_requirements=True) + FillMission(MissionPools.STARTER, [-1], "_1"), + FillMission(MissionPools.EASY, [0], "_1"), + FillMission(MissionPools.MEDIUM, [1, 5], "_1", or_requirements=True), + FillMission(MissionPools.EASY, [0], "_2"), + FillMission(MissionPools.MEDIUM, [1, 3], "_2", or_requirements=True), + FillMission(MissionPools.HARD, [2, 4], "_2", or_requirements=True), + FillMission(MissionPools.MEDIUM, [3, 7], "_3", or_requirements=True), + FillMission(MissionPools.HARD, [4, 6], "_3", or_requirements=True), + FillMission(MissionPools.FINAL, [5, 7], "_3", or_requirements=True) ] blitz_order = [ - FillMission("no_build", [-1], "I"), - FillMission("easy", [-1], "I"), - FillMission("medium", [0, 1], "II", number=1, or_requirements=True), - FillMission("medium", [0, 1], "II", number=1, or_requirements=True), - FillMission("medium", [0, 1], "III", number=2, or_requirements=True), - FillMission("medium", [0, 1], "III", number=2, or_requirements=True), - FillMission("hard", [0, 1], "IV", number=3, or_requirements=True), - FillMission("hard", [0, 1], "IV", number=3, or_requirements=True), - FillMission("hard", [0, 1], "V", number=4, or_requirements=True), - FillMission("hard", [0, 1], "V", number=4, or_requirements=True), - FillMission("hard", [0, 1], "Final", number=5, or_requirements=True), - FillMission("all_in", [0, 1], "Final", number=5, or_requirements=True) + FillMission(MissionPools.STARTER, [-1], "I"), + FillMission(MissionPools.EASY, [-1], "I"), + FillMission(MissionPools.MEDIUM, [0, 1], "II", number=1, or_requirements=True), + FillMission(MissionPools.MEDIUM, [0, 1], "II", number=1, or_requirements=True), + FillMission(MissionPools.MEDIUM, [0, 1], "III", number=2, or_requirements=True), + FillMission(MissionPools.MEDIUM, [0, 1], "III", number=2, or_requirements=True), + FillMission(MissionPools.HARD, [0, 1], "IV", number=3, or_requirements=True), + FillMission(MissionPools.HARD, [0, 1], "IV", number=3, or_requirements=True), + FillMission(MissionPools.HARD, [0, 1], "V", number=4, or_requirements=True), + FillMission(MissionPools.HARD, [0, 1], "V", number=4, or_requirements=True), + FillMission(MissionPools.HARD, [0, 1], "Final", number=5, or_requirements=True), + 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] @@ -176,40 +182,21 @@ vanilla_mission_req_table = { lookup_id_to_mission: Dict[int, str] = { data.id: mission_name for mission_name, data in vanilla_mission_req_table.items() if data.id} -no_build_starting_mission_locations = { +starting_mission_locations = { "Liberation Day": "Liberation Day: Victory", "Breakout": "Breakout: Victory", "Ghost of a Chance": "Ghost of a Chance: Victory", "Piercing the Shroud": "Piercing the Shroud: Victory", "Whispers of Doom": "Whispers of Doom: Victory", "Belly of the Beast": "Belly of the Beast: Victory", -} - -build_starting_mission_locations = { "Zero Hour": "Zero Hour: First Group Rescued", "Evacuation": "Evacuation: First Chysalis", - "Devil's Playground": "Devil's Playground: Tosh's Miners" -} - -advanced_starting_mission_locations = { + "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" } -def get_starting_mission_locations(multiworld: MultiWorld, player: int) -> Set[str]: - if get_option_value(multiworld, player, 'shuffle_no_build') or get_option_value(multiworld, player, 'mission_order') < 2: - # Always start with a no-build mission unless explicitly relegating them - # Vanilla and Vanilla Shuffled always start with a no-build even when relegated - return no_build_starting_mission_locations - elif get_option_value(multiworld, player, 'required_tactics') > 0: - # Advanced Tactics/No Logic add more starting missions to the pool - return {**build_starting_mission_locations, **advanced_starting_mission_locations} - else: - # Standard starting missions when relegate is on - return build_starting_mission_locations - - alt_final_mission_locations = { "Maw of the Void": "Maw of the Void: Victory", "Engine of Destruction": "Engine of Destruction: Victory", diff --git a/worlds/sc2wol/Options.py b/worlds/sc2wol/Options.py index 4526328f..4f2032d6 100644 --- a/worlds/sc2wol/Options.py +++ b/worlds/sc2wol/Options.py @@ -1,6 +1,7 @@ -from typing import Dict +from typing import Dict, FrozenSet, Union from BaseClasses import MultiWorld from Options import Choice, Option, Toggle, DefaultOnToggle, ItemSet, OptionSet, Range +from .MissionTables import vanilla_mission_req_table class GameDifficulty(Choice): @@ -110,6 +111,7 @@ class ExcludedMissions(OptionSet): Only applies on shortened mission orders. 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'} # noinspection PyTypeChecker @@ -130,19 +132,10 @@ sc2wol_options: Dict[str, Option] = { } -def get_option_value(multiworld: MultiWorld, player: int, name: str) -> int: - option = getattr(multiworld, name, None) +def get_option_value(multiworld: MultiWorld, player: int, name: str) -> Union[int, FrozenSet]: + if multiworld is None: + return sc2wol_options[name].default - if option is None: - return 0 + player_option = getattr(multiworld, name)[player] - return int(option[player].value) - - -def get_option_set_value(multiworld: MultiWorld, player: int, name: str) -> set: - option = getattr(multiworld, name, None) - - if option is None: - return set() - - return option[player].value + return player_option.value diff --git a/worlds/sc2wol/PoolFilter.py b/worlds/sc2wol/PoolFilter.py index c4aa1098..16cc51f2 100644 --- a/worlds/sc2wol/PoolFilter.py +++ b/worlds/sc2wol/PoolFilter.py @@ -2,8 +2,8 @@ from typing import Callable, Dict, List, Set from BaseClasses import MultiWorld, ItemClassification, Item, Location from .Items import item_table from .MissionTables import no_build_regions_list, easy_regions_list, medium_regions_list, hard_regions_list,\ - mission_orders, get_starting_mission_locations, MissionInfo, vanilla_mission_req_table, alt_final_mission_locations -from .Options import get_option_value, get_option_set_value + mission_orders, MissionInfo, alt_final_mission_locations, MissionPools +from .Options import get_option_value from .LogicMixin import SC2WoLLogic # Items with associated upgrades @@ -21,34 +21,33 @@ STARPORT_UNITS = {"Medivac", "Wraith", "Viking", "Banshee", "Battlecruiser", "He PROTOSS_REGIONS = {"A Sinister Turn", "Echoes of the Future", "In Utter Darkness"} -def filter_missions(multiworld: MultiWorld, player: int) -> Dict[str, List[str]]: +def filter_missions(multiworld: MultiWorld, player: int) -> Dict[int, List[str]]: """ Returns a semi-randomly pruned tuple of no-build, easy, medium, and hard mission sets """ mission_order_type = get_option_value(multiworld, player, "mission_order") + shuffle_no_build = get_option_value(multiworld, player, "shuffle_no_build") shuffle_protoss = get_option_value(multiworld, player, "shuffle_protoss") - excluded_missions = set(get_option_set_value(multiworld, player, "excluded_missions")) - invalid_mission_names = excluded_missions.difference(vanilla_mission_req_table.keys()) - if invalid_mission_names: - raise Exception("Error in locked_missions - the following are not valid mission names: " + ", ".join(invalid_mission_names)) + excluded_missions = get_option_value(multiworld, player, "excluded_missions") mission_count = len(mission_orders[mission_order_type]) - 1 - # Vanilla and Vanilla Shuffled use the entire mission pool - if mission_count == 28: - return { - "no_build": no_build_regions_list[:], - "easy": easy_regions_list[:], - "medium": medium_regions_list[:], - "hard": hard_regions_list[:], - "all_in": ["All-In"] - } - - mission_pools = [ - [], - easy_regions_list, - medium_regions_list, - hard_regions_list - ] + mission_pools = { + MissionPools.STARTER: no_build_regions_list[:], + MissionPools.EASY: easy_regions_list[:], + MissionPools.MEDIUM: medium_regions_list[:], + MissionPools.HARD: hard_regions_list[:], + MissionPools.FINAL: [] + } + if mission_order_type == 0: + # 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) @@ -58,46 +57,35 @@ def filter_missions(multiworld: MultiWorld, player: int) -> Dict[str, List[str]] excluded_missions.add(final_mission) else: final_mission = 'All-In' - # Yaml settings determine which missions can be placed in the first slot - mission_pools[0] = [mission for mission in get_starting_mission_locations(multiworld, player).keys() if mission not in excluded_missions] - # Removing the new no-build missions from their original sets - for i in range(1, len(mission_pools)): - mission_pools[i] = [mission for mission in mission_pools[i] if mission not in excluded_missions.union(mission_pools[0])] - # If the first mission is a build mission, there may not be enough locations to reach Outbreak as a second mission + # Excluding missions + for difficulty, mission_pool in mission_pools.items(): + mission_pools[difficulty] = [mission for mission in mission_pool if mission not in excluded_missions] + mission_pools[MissionPools.FINAL].append(final_mission) + # Mission pool changes on Build-Only if not get_option_value(multiworld, player, 'shuffle_no_build'): - # Swapping Outbreak and The Great Train Robbery - if "Outbreak" in mission_pools[1]: - mission_pools[1].remove("Outbreak") - mission_pools[2].append("Outbreak") - if "The Great Train Robbery" in mission_pools[2]: - mission_pools[2].remove("The Great Train Robbery") - mission_pools[1].append("The Great Train Robbery") - # Removing random missions from each difficulty set in a cycle - set_cycle = 0 - current_count = sum(len(mission_pool) for mission_pool in mission_pools) + def move_mission(mission_name, current_pool, new_pool): + if mission_name in mission_pools[current_pool]: + mission_pools[current_pool].remove(mission_name) + mission_pools[new_pool].append(mission_name) + # Replacing No Build missions with Easy missions + move_mission("Zero Hour", MissionPools.EASY, MissionPools.STARTER) + move_mission("Evacuation", MissionPools.EASY, MissionPools.STARTER) + move_mission("Devil's Playground", MissionPools.EASY, MissionPools.STARTER) + # Pushing Outbreak to Normal, as it cannot be placed as the second mission on Build-Only + move_mission("Outbreak", MissionPools.EASY, MissionPools.MEDIUM) + # Pushing extra Normal missions to Easy + move_mission("The Great Train Robbery", MissionPools.MEDIUM, MissionPools.EASY) + move_mission("Echoes of the Future", MissionPools.MEDIUM, MissionPools.EASY) + move_mission("Cutthroat", MissionPools.MEDIUM, MissionPools.EASY) + # Additional changes on Advanced Tactics + if get_option_value(multiworld, player, "required_tactics") > 0: + move_mission("The Great Train Robbery", MissionPools.EASY, MissionPools.STARTER) + move_mission("Smash and Grab", MissionPools.EASY, MissionPools.STARTER) + move_mission("Moebius Factor", MissionPools.MEDIUM, MissionPools.EASY) + move_mission("Welcome to the Jungle", MissionPools.MEDIUM, MissionPools.EASY) + move_mission("Engine of Destruction", MissionPools.HARD, MissionPools.MEDIUM) - if current_count < mission_count: - raise Exception("Not enough missions available to fill the campaign on current settings. Please exclude fewer missions.") - while current_count > mission_count: - if set_cycle == 4: - set_cycle = 0 - # Must contain at least one mission per set - mission_pool = mission_pools[set_cycle] - if len(mission_pool) <= 1: - if all(len(mission_pool) <= 1 for mission_pool in mission_pools): - raise Exception("Not enough missions available to fill the campaign on current settings. Please exclude fewer missions.") - else: - mission_pool.remove(multiworld.random.choice(mission_pool)) - current_count -= 1 - set_cycle += 1 - - return { - "no_build": mission_pools[0], - "easy": mission_pools[1], - "medium": mission_pools[2], - "hard": mission_pools[3], - "all_in": [final_mission] - } + return mission_pools def get_item_upgrades(inventory: List[Item], parent_item: Item or str): @@ -135,7 +123,21 @@ class ValidInventory: requirements = mission_requirements cascade_keys = self.cascade_removal_map.keys() units_always_have_upgrades = get_option_value(self.multiworld, self.player, "units_always_have_upgrades") - if self.min_units_per_structure > 0: + + # 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: @@ -151,6 +153,10 @@ class ValidInventory: return False return True + # Determining if the full-size inventory can complete campaign + if not all(requirement(self) for requirement in requirements): + raise Exception("Too many items excluded - campaign is impossible to complete.") + while len(inventory) + len(locked_items) > inventory_size: if len(inventory) == 0: raise Exception("Reduced item pool generation failed - not enough locations available to place items.") diff --git a/worlds/sc2wol/Regions.py b/worlds/sc2wol/Regions.py index bcf6434a..03363666 100644 --- a/worlds/sc2wol/Regions.py +++ b/worlds/sc2wol/Regions.py @@ -2,7 +2,7 @@ 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 +from .MissionTables import MissionInfo, mission_orders, vanilla_mission_req_table, alt_final_mission_locations, MissionPools from .PoolFilter import filter_missions @@ -14,34 +14,18 @@ def create_regions(multiworld: MultiWorld, player: int, locations: Tuple[Locatio mission_order = mission_orders[mission_order_type] mission_pools = filter_missions(multiworld, player) - final_mission = mission_pools['all_in'][0] - used_regions = [mission for mission_pool in mission_pools.values() for mission in mission_pool] regions = [create_region(multiworld, player, locations_per_region, location_cache, "Menu")] - for region_name in used_regions: - regions.append(create_region(multiworld, player, locations_per_region, location_cache, region_name)) - # Changing the completion condition for alternate final missions into an event - if final_mission != 'All-In': - final_location = alt_final_mission_locations[final_mission] - # Final location should be near the end of the cache - for i in range(len(location_cache) - 1, -1, -1): - if location_cache[i].name == final_location: - location_cache[i].locked = True - location_cache[i].event = True - location_cache[i].address = None - break - else: - final_location = 'All-In: Victory' - - if __debug__: - if mission_order_type in (0, 1): - throwIfAnyLocationIsNotAssignedToARegion(regions, locations_per_region.keys()) - - multiworld.regions += regions names: Dict[str, int] = {} if mission_order_type == 0: + + # Generating all regions and locations + for region_name in vanilla_mission_req_table.keys(): + regions.append(create_region(multiworld, player, locations_per_region, location_cache, region_name)) + multiworld.regions += regions + connect(multiworld, player, names, 'Menu', 'Liberation Day'), connect(multiworld, player, names, 'Liberation Day', 'The Outlaws', lambda state: state.has("Beat Liberation Day", player)), @@ -110,31 +94,32 @@ def create_regions(multiworld: MultiWorld, player: int, locations: Tuple[Locatio 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, 29, final_location + return vanilla_mission_req_table, 29, 'All-In: Victory' else: missions = [] + remove_prophecy = mission_order_type == 1 and not get_option_value(multiworld, player, "shuffle_protoss") + + final_mission = mission_pools[MissionPools.FINAL][0] + + # Determining if missions must be removed + mission_pool_size = sum(len(mission_pool) for mission_pool in mission_pools.values()) + removals = len(mission_order) - mission_pool_size + # Removing entire Prophecy chain on vanilla shuffled when not shuffling protoss + if remove_prophecy: + removals -= 4 + # Initial fill out of mission list and marking all-in mission for mission in mission_order: - if mission is None: + # Removing extra missions if mission pool is too small + if 0 < mission.removal_priority <= removals or mission.category == 'Prophecy' and remove_prophecy: missions.append(None) - elif mission.type == "all_in": + elif mission.type == MissionPools.FINAL: missions.append(final_mission) - elif mission.relegate and not get_option_value(multiworld, player, "shuffle_no_build"): - missions.append("no_build") else: missions.append(mission.type) - # Place Protoss Missions if we are not using ShuffleProtoss and are in Vanilla Shuffled - if get_option_value(multiworld, player, "shuffle_protoss") == 0 and mission_order_type == 1: - missions[22] = "A Sinister Turn" - mission_pools['medium'].remove("A Sinister Turn") - missions[23] = "Echoes of the Future" - mission_pools['medium'].remove("Echoes of the Future") - missions[24] = "In Utter Darkness" - mission_pools['hard'].remove("In Utter Darkness") - no_build_slots = [] easy_slots = [] medium_slots = [] @@ -144,79 +129,108 @@ def create_regions(multiworld: MultiWorld, player: int, locations: Tuple[Locatio for i in range(len(missions)): if missions[i] is None: continue - if missions[i] == "no_build": + if missions[i] == MissionPools.STARTER: no_build_slots.append(i) - elif missions[i] == "easy": + elif missions[i] == MissionPools.EASY: easy_slots.append(i) - elif missions[i] == "medium": + elif missions[i] == MissionPools.MEDIUM: medium_slots.append(i) - elif missions[i] == "hard": + elif missions[i] == MissionPools.HARD: hard_slots.append(i) # Add no_build missions to the pool and fill in no_build slots - missions_to_add = mission_pools['no_build'] + missions_to_add = mission_pools[MissionPools.STARTER] + if len(no_build_slots) > len(missions_to_add): + raise Exception("There are no valid No-Build missions. Please exclude fewer missions.") for slot in no_build_slots: filler = multiworld.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 + mission_pools['easy'] + missions_to_add = missions_to_add + mission_pools[MissionPools.EASY] + if len(easy_slots) > len(missions_to_add): + raise Exception("There are not enough Easy missions to fill the campaign. Please exclude fewer missions.") for slot in easy_slots: filler = multiworld.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 + mission_pools['medium'] + missions_to_add = missions_to_add + mission_pools[MissionPools.MEDIUM] + if len(medium_slots) > len(missions_to_add): + raise Exception("There are not enough Easy and Medium missions to fill the campaign. Please exclude fewer missions.") for slot in medium_slots: filler = multiworld.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 + mission_pools['hard'] + missions_to_add = missions_to_add + mission_pools[MissionPools.HARD] + if len(hard_slots) > len(missions_to_add): + raise Exception("There are not enough missions to fill the campaign. Please exclude fewer missions.") for slot in hard_slots: filler = multiworld.random.randint(0, len(missions_to_add) - 1) missions[slot] = missions_to_add.pop(filler) + # Generating regions and locations from selected missions + for region_name in missions: + regions.append(create_region(multiworld, player, locations_per_region, location_cache, region_name)) + multiworld.regions += regions + + # Mapping original mission slots to shifted mission slots when missions are removed + slot_map = [] + slot_offset = 0 + for position, mission in enumerate(missions): + slot_map.append(position - slot_offset + 1) + if mission is None: + slot_offset += 1 + # Loop through missions to create requirements table and connect regions # TODO: Handle 'and' connections mission_req_table = {} - for i in range(len(missions)): + + for i, mission in enumerate(missions): + if mission is None: + continue connections = [] for connection in mission_order[i].connect_to: + required_mission = missions[connection] if connection == -1: - connect(multiworld, player, names, "Menu", missions[i]) + connect(multiworld, player, names, "Menu", mission) + elif required_mission is None: + continue else: - connect(multiworld, player, names, missions[connection], missions[i], + 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)) - connections.append(connection + 1) + connections.append(slot_map[connection]) - mission_req_table.update({missions[i]: MissionInfo( - vanilla_mission_req_table[missions[i]].id, connections, mission_order[i].category, + mission_req_table.update({mission: MissionInfo( + vanilla_mission_req_table[mission].id, connections, mission_order[i].category, number=mission_order[i].number, completion_critical=mission_order[i].completion_critical, or_requirements=mission_order[i].or_requirements)}) final_mission_id = vanilla_mission_req_table[final_mission].id - return mission_req_table, final_mission_id, final_mission + ': Victory' + # Changing the completion condition for alternate final missions into an event + if final_mission != 'All-In': + final_location = alt_final_mission_locations[final_mission] + # Final location should be near the end of the cache + for i in range(len(location_cache) - 1, -1, -1): + if location_cache[i].name == final_location: + location_cache[i].locked = True + location_cache[i].event = True + location_cache[i].address = None + break + else: + final_location = 'All-In: Victory' -def throwIfAnyLocationIsNotAssignedToARegion(regions: List[Region], regionNames: Set[str]): - existingRegions = set() - - for region in regions: - existingRegions.add(region.name) - - if (regionNames - existingRegions): - raise Exception("Starcraft: the following regions are used in locations: {}, but no such region exists".format( - regionNames - existingRegions)) - + return mission_req_table, final_mission_id, final_location def create_location(player: int, location_data: LocationData, region: Region, location_cache: List[Location]) -> Location: diff --git a/worlds/sc2wol/__init__.py b/worlds/sc2wol/__init__.py index 878f3882..60de2008 100644 --- a/worlds/sc2wol/__init__.py +++ b/worlds/sc2wol/__init__.py @@ -7,10 +7,10 @@ from .Items import StarcraftWoLItem, item_table, filler_items, item_name_groups, get_basic_units from .Locations import get_locations from .Regions import create_regions -from .Options import sc2wol_options, get_option_value, get_option_set_value +from .Options import sc2wol_options, get_option_value from .LogicMixin import SC2WoLLogic from .PoolFilter import filter_missions, filter_items, get_item_upgrades -from .MissionTables import get_starting_mission_locations, MissionInfo +from .MissionTables import starting_mission_locations, MissionInfo class Starcraft2WoLWebWorld(WebWorld): @@ -137,7 +137,6 @@ def assign_starter_items(multiworld: MultiWorld, player: int, excluded_items: Se # The first world should also be the starting world first_mission = list(multiworld.worlds[player].mission_req_table)[0] - starting_mission_locations = get_starting_mission_locations(multiworld, player) if first_mission in starting_mission_locations: first_location = starting_mission_locations[first_mission] elif first_mission == "In Utter Darkness": @@ -174,7 +173,7 @@ def get_item_pool(multiworld: MultiWorld, player: int, mission_req_table: Dict[s locked_items = [] # YAML items - yaml_locked_items = get_option_set_value(multiworld, player, 'locked_items') + yaml_locked_items = get_option_value(multiworld, player, 'locked_items') for name, data in item_table.items(): if name not in excluded_items: