From 17e90ce12c9b8f4f20aa90a9fb3945318f61f692 Mon Sep 17 00:00:00 2001 From: Magnemania <89949176+Magnemania@users.noreply.github.com> Date: Tue, 7 Mar 2023 08:14:49 -0500 Subject: [PATCH] SC2: Greater variety on short generations (#1367) Originally, short generations used an artificial cull to create balanced mission distributions. This resulted in campaigns that were somewhat too consistent, and on some standard settings combinations, this resulted in campaigns having The Outlaws as the second mission 100% of the time. It also caused generation to fail a bit too easily if the player excluded too many missions. This removes the cull and adds an additional early Easy mission slot to all of the reduced sized campaigns. When playing on No Build settings, this also pushes many of the missions down a difficulty level to ensure greater variety, and pushes additional missions down on Advanced Tactics. Additional small fixes: The in-world Excluded Missions validation check is replaced by the core OptionSet check. Fixed issue with Existing Items not getting their upgrades locked with Units Always Have Upgrades on. --- Starcraft2Client.py | 8 +- worlds/sc2wol/Items.py | 6 +- worlds/sc2wol/LogicMixin.py | 8 +- worlds/sc2wol/MissionTables.py | 217 ++++++++++++++++----------------- worlds/sc2wol/Options.py | 23 ++-- worlds/sc2wol/PoolFilter.py | 130 ++++++++++---------- worlds/sc2wol/Regions.py | 138 +++++++++++---------- worlds/sc2wol/__init__.py | 7 +- 8 files changed, 273 insertions(+), 264 deletions(-) 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: