diff --git a/worlds/sc2/__init__.py b/worlds/sc2/__init__.py index 0df315c629..c57f54aa70 100644 --- a/worlds/sc2/__init__.py +++ b/worlds/sc2/__init__.py @@ -12,8 +12,10 @@ from .item.item_tables import ( get_full_item_list, not_balanced_starting_units, WEAPON_ARMOR_UPGRADE_MAX_LEVEL, ) -from .item import FilterItem, ItemFilterFlags, StarcraftItem, item_groups, item_names, item_tables, item_parents, \ +from .item import ( + FilterItem, ItemFilterFlags, StarcraftItem, item_groups, item_names, item_tables, item_parents, ZergItemType, ProtossItemType, ItemData +) from .locations import ( get_locations, DEFAULT_LOCATION_LIST, get_location_types, get_location_flags, get_plando_locations, LocationType, lookup_location_id_to_type @@ -117,17 +119,48 @@ class SC2World(World): data = get_full_item_list()[name] return StarcraftItem(name, data.classification, data.code, self.player) - def create_regions(self): + def generate_early(self) -> None: + # Do some options validation/recovery here + if not self.options.selected_races.value: + self.options.selected_races.value = set(options.SelectedRaces.default) + if not self.options.enabled_campaigns.value: + self.options.enabled_campaigns.value = set(options.EnabledCampaigns.default) + + # Disable campaigns on vanilla-like mission orders if their race is disabled + if self.options.mission_order.value in options.static_mission_orders: + enabled_campaigns = set(self.options.enabled_campaigns.value) + if self.options.enable_race_swap.value == options.EnableRaceSwapVariants.option_disabled: + if SC2Race.TERRAN.get_title() not in self.options.selected_races.value: + enabled_campaigns.discard(SC2Campaign.WOL.campaign_name) + if SC2Race.ZERG.get_title() not in self.options.selected_races.value: + enabled_campaigns.discard(SC2Campaign.HOTS.campaign_name) + if SC2Race.PROTOSS.get_title() not in self.options.selected_races.value: + enabled_campaigns.discard(SC2Campaign.PROPHECY.campaign_name) + enabled_campaigns.discard(SC2Campaign.PROLOGUE.campaign_name) + enabled_campaigns.discard(SC2Campaign.LOTV.campaign_name) + # Epilogue and NCO don't have raceswaps currently + if SC2Race.TERRAN.get_title() not in self.options.selected_races.value: + enabled_campaigns.discard(SC2Campaign.NCO.campaign_name) + if len(self.options.selected_races.value) < 3: + enabled_campaigns.discard(SC2Campaign.EPILOGUE.campaign_name) + if not enabled_campaigns: + raise OptionError( + "Campaign and race exclusions remove all possible missions from the pool. " + "Either include more campaigns, include more races, or enable race swap." + ) + self.options.enabled_campaigns.value = enabled_campaigns + + def create_regions(self) -> None: self.logic = SC2Logic(self) self.custom_mission_order = create_mission_order( self, get_locations(self), self.location_cache ) self.logic.nova_used = ( - MissionFlag.Nova in self.custom_mission_order.get_used_flags() - or ( - MissionFlag.WoLNova in self.custom_mission_order.get_used_flags() - and self.options.nova_ghost_of_a_chance_variant == NovaGhostOfAChanceVariant.option_nco - ) + MissionFlag.Nova in self.custom_mission_order.get_used_flags() + or ( + MissionFlag.WoLNova in self.custom_mission_order.get_used_flags() + and self.options.nova_ghost_of_a_chance_variant == NovaGhostOfAChanceVariant.option_nco + ) ) def create_items(self) -> None: @@ -208,14 +241,14 @@ class SC2World(World): enabled_campaigns = get_enabled_campaigns(self) slot_data["plando_locations"] = get_plando_locations(self) slot_data["use_nova_nco_fallback"] = ( - enabled_campaigns == {SC2Campaign.NCO} - and self.options.mission_order == MissionOrder.option_vanilla + enabled_campaigns == {SC2Campaign.NCO} + and self.options.mission_order == MissionOrder.option_vanilla ) if (self.options.nova_ghost_of_a_chance_variant == NovaGhostOfAChanceVariant.option_nco - or ( - self.options.nova_ghost_of_a_chance_variant == NovaGhostOfAChanceVariant.option_auto - and MissionFlag.Nova in self.custom_mission_order.get_used_flags().keys() - ) + or ( + self.options.nova_ghost_of_a_chance_variant == NovaGhostOfAChanceVariant.option_auto + and MissionFlag.Nova in self.custom_mission_order.get_used_flags().keys() + ) ): slot_data["use_nova_wol_fallback"] = False else: @@ -224,7 +257,9 @@ class SC2World(World): slot_data["custom_mission_order"] = self.custom_mission_order.get_slot_data() slot_data["version"] = 4 - if SC2Campaign.HOTS not in enabled_campaigns: + if (SC2Campaign.HOTS not in enabled_campaigns + or SC2Race.ZERG.get_title() not in self.options.selected_races.value + ): slot_data["kerrigan_presence"] = KerriganPresence.option_not_present if self.options.mission_order_scouting != MissionOrderScouting.option_none: @@ -259,8 +294,8 @@ class SC2World(World): assert self.logic is not None self.logic.total_mission_count = self.custom_mission_order.get_mission_count() if ( - self.options.generic_upgrade_missions > 0 - and self.options.required_tactics != RequiredTactics.option_no_logic + self.options.generic_upgrade_missions > 0 + and self.options.required_tactics != RequiredTactics.option_no_logic ): # Attempt to resolve a situation when the option is too high for the mission order rolled weapon_armor_item_names = [ @@ -277,8 +312,8 @@ class SC2World(World): self._fill_needed_items(state_with_kerrigan_levels, weapon_armor_item_names, WEAPON_ARMOR_UPGRADE_MAX_LEVEL) if ( - self.options.kerrigan_levels_per_mission_completed > 0 - and self.options.required_tactics != RequiredTactics.option_no_logic + self.options.kerrigan_levels_per_mission_completed > 0 + and self.options.required_tactics != RequiredTactics.option_no_logic ): # Attempt to solve being locked by Kerrigan level requirements self._fill_needed_items(lambda: self.multiworld.get_all_state(False), [item_names.KERRIGAN_LEVELS_1], 70) @@ -518,9 +553,9 @@ def flag_excludes_by_faction_presence(world: SC2World, item_list: List[FilterIte item.flags |= ItemFilterFlags.FilterExcluded if (not protoss_build_missions and item.data.type in ( - item_tables.ProtossItemType.Unit, - item_tables.ProtossItemType.Unit_2, - item_tables.ProtossItemType.Building, + item_tables.ProtossItemType.Unit, + item_tables.ProtossItemType.Unit_2, + item_tables.ProtossItemType.Building, ) ): # Note(mm): This doesn't exclude things like automated assimilators or warp gate improvements @@ -528,9 +563,9 @@ def flag_excludes_by_faction_presence(world: SC2World, item_list: List[FilterIte if (SC2Mission.TEMPLAR_S_RETURN not in missions or world.options.grant_story_tech.value == GrantStoryTech.option_grant or item.name not in ( - item_names.IMMORTAL, item_names.ANNIHILATOR, - item_names.COLOSSUS, item_names.VANGUARD, item_names.REAVER, item_names.DARK_TEMPLAR, - item_names.SENTRY, item_names.HIGH_TEMPLAR, + item_names.IMMORTAL, item_names.ANNIHILATOR, + item_names.COLOSSUS, item_names.VANGUARD, item_names.REAVER, item_names.DARK_TEMPLAR, + item_names.SENTRY, item_names.HIGH_TEMPLAR, ) ): item.flags |= ItemFilterFlags.FilterExcluded @@ -565,15 +600,16 @@ def flag_mission_based_item_excludes(world: SC2World, item_list: List[FilterItem mission for mission in missions if MissionFlag.Nova in mission.flags or ( - world.options.nova_ghost_of_a_chance_variant == NovaGhostOfAChanceVariant.option_nco - and MissionFlag.WoLNova in mission.flags + world.options.nova_ghost_of_a_chance_variant == NovaGhostOfAChanceVariant.option_nco + and MissionFlag.WoLNova in mission.flags ) ] kerrigan_is_present = ( - len(kerrigan_missions) > 0 - and world.options.kerrigan_presence in kerrigan_unit_available - and SC2Campaign.HOTS in get_enabled_campaigns(world) # TODO: Kerrigan available all Zerg/Everywhere + len(kerrigan_missions) > 0 + and world.options.kerrigan_presence in kerrigan_unit_available + and SC2Campaign.HOTS in get_enabled_campaigns(world) # TODO: Kerrigan available all Zerg/Everywhere + and SC2Race.ZERG.get_title() in world.options.selected_races.value ) # TvX build missions -- check flags diff --git a/worlds/sc2/item/item_groups.py b/worlds/sc2/item/item_groups.py index 7a41b98659..c842731906 100644 --- a/worlds/sc2/item/item_groups.py +++ b/worlds/sc2/item/item_groups.py @@ -600,7 +600,7 @@ item_name_groups[ItemGroupNames.KERRIGAN_LOGIC_ACTIVE_ABILITIES] = kerrigan_logi item_name for item_name in kerrigan_active_abilities if item_name != item_names.KERRIGAN_ASSIMILATION_AURA ] item_name_groups[ItemGroupNames.KERRIGAN_TIER_1] = kerrigan_tier_1 = [ - item_names.KERRIGAN_CRUSHING_GRIP, item_names.KERRIGAN_HEROIC_FORTITUDE, item_names.KERRIGAN_LEAPING_STRIKE + item_names.KERRIGAN_KINETIC_BLAST, item_names.KERRIGAN_HEROIC_FORTITUDE, item_names.KERRIGAN_LEAPING_STRIKE ] item_name_groups[ItemGroupNames.KERRIGAN_TIER_2] = kerrigan_tier_2= [ item_names.KERRIGAN_CRUSHING_GRIP, item_names.KERRIGAN_CHAIN_REACTION, item_names.KERRIGAN_PSIONIC_SHIFT diff --git a/worlds/sc2/options.py b/worlds/sc2/options.py index 63f9af94f5..c8cc244dd3 100644 --- a/worlds/sc2/options.py +++ b/worlds/sc2/options.py @@ -1576,7 +1576,7 @@ def get_option_value(world: Union['SC2World', None], name: str) -> int: def get_enabled_races(world: Optional['SC2World']) -> Set[SC2Race]: - race_names = world.options.selected_races.value if world and len(world.options.selected_races.value) > 0 else SelectedRaces.valid_keys + race_names = world.options.selected_races.value if world else SelectedRaces.default return {race for race in SC2Race if race.get_title() in race_names} @@ -1584,16 +1584,7 @@ def get_enabled_campaigns(world: Optional['SC2World']) -> Set[SC2Campaign]: if world is None: return {campaign for campaign in SC2Campaign if campaign.campaign_name in EnabledCampaigns.default} campaign_names = world.options.enabled_campaigns - campaigns = {campaign for campaign in SC2Campaign if campaign.campaign_name in campaign_names} - if (world.options.mission_order.value == MissionOrder.option_vanilla - and get_enabled_races(world) != {SC2Race.TERRAN, SC2Race.ZERG, SC2Race.PROTOSS} - and SC2Campaign.EPILOGUE in campaigns - ): - campaigns.remove(SC2Campaign.EPILOGUE) - if len(campaigns) == 0: - # Everything is disabled, roll as everything enabled - return {campaign for campaign in SC2Campaign if campaign != SC2Campaign.GLOBAL} - return campaigns + return {campaign for campaign in SC2Campaign if campaign.campaign_name in campaign_names} def get_disabled_campaigns(world: 'SC2World') -> Set[SC2Campaign]: @@ -1606,8 +1597,8 @@ def get_disabled_campaigns(world: 'SC2World') -> Set[SC2Campaign]: def get_disabled_flags(world: 'SC2World') -> MissionFlag: excluded = ( - (MissionFlag.Terran | MissionFlag.Zerg | MissionFlag.Protoss) - ^ functools.reduce(lambda a, b: a | b, [race.get_mission_flag() for race in get_enabled_races(world)]) + (MissionFlag.Terran | MissionFlag.Zerg | MissionFlag.Protoss) + ^ functools.reduce(lambda a, b: a | b, [race.get_mission_flag() for race in get_enabled_races(world)]) ) # filter out no-build missions if not world.options.shuffle_no_build.value: diff --git a/worlds/sc2/rules.py b/worlds/sc2/rules.py index 52511f1523..28a8804e5e 100644 --- a/worlds/sc2/rules.py +++ b/worlds/sc2/rules.py @@ -1142,7 +1142,10 @@ class SC2Logic: return levels >= target def basic_kerrigan(self, state: CollectionState, story_tech_available=True) -> bool: - if story_tech_available and self.grant_story_tech == GrantStoryTech.option_grant: + if story_tech_available and ( + self.grant_story_tech == GrantStoryTech.option_grant + or not self.kerrigan_unit_available + ): return True # One active ability that can be used to defeat enemies directly if not state.has_any( @@ -1166,7 +1169,10 @@ class SC2Logic: return False def two_kerrigan_actives(self, state: CollectionState, story_tech_available=True) -> bool: - if story_tech_available and self.grant_story_tech == GrantStoryTech.option_grant: + if story_tech_available and ( + self.grant_story_tech == GrantStoryTech.option_grant + or not self.kerrigan_unit_available + ): return True return state.count_from_list(item_groups.kerrigan_logic_active_abilities, self.player) >= 2 @@ -2361,6 +2367,7 @@ class SC2Logic: # Note(mm): This check isn't necessary as self.kerrigan_levels cover it, # and it's not fully desirable in future when we support non-grant story tech + kerriganless. # or not self.kerrigan_presence + or not self.kerrigan_unit_available or state.has_any(( # Cases tested by Snarky item_names.KERRIGAN_KINETIC_BLAST, @@ -2399,22 +2406,23 @@ class SC2Logic: def supreme_requirement(self, state: CollectionState) -> bool: return ( self.grant_story_tech == GrantStoryTech.option_grant - or not self.kerrigan_unit_available or (self.grant_story_tech == GrantStoryTech.option_allow_substitutes - and state.has_any(( - item_names.KERRIGAN_LEAPING_STRIKE, - item_names.OVERLORD_VENTRAL_SACS, - item_names.YGGDRASIL, - item_names.MUTALISK_CORRUPTOR_VIPER_ASPECT, - item_names.NYDUS_WORM, - item_names.BULLFROG, - ), self.player) - and state.has_any(( - item_names.KERRIGAN_MEND, - item_names.SWARM_QUEEN, - item_names.INFESTED_MEDICS, - ), self.player) - and self.kerrigan_levels(state, 35) - ) + or not self.kerrigan_unit_available + or (self.grant_story_tech == GrantStoryTech.option_allow_substitutes + and state.has_any(( + item_names.KERRIGAN_LEAPING_STRIKE, + item_names.OVERLORD_VENTRAL_SACS, + item_names.YGGDRASIL, + item_names.MUTALISK_CORRUPTOR_VIPER_ASPECT, + item_names.NYDUS_WORM, + item_names.BULLFROG, + ), self.player) + and state.has_any(( + item_names.KERRIGAN_MEND, + item_names.SWARM_QUEEN, + item_names.INFESTED_MEDICS, + ), self.player) + and self.kerrigan_levels(state, 35) + ) or (state.has_all((item_names.KERRIGAN_LEAPING_STRIKE, item_names.KERRIGAN_MEND), self.player) and self.kerrigan_levels(state, 35)) ) @@ -2502,15 +2510,12 @@ class SC2Logic: self.grant_story_tech == GrantStoryTech.option_grant or not self.kerrigan_unit_available or ( - state.has_any( - ( - item_names.KERRIGAN_KINETIC_BLAST, - item_names.KERRIGAN_SPAWN_BANELINGS, - item_names.KERRIGAN_LEAPING_STRIKE, - item_names.KERRIGAN_SPAWN_LEVIATHAN, - ), - self.player, - ) + state.has_any(( + item_names.KERRIGAN_KINETIC_BLAST, + item_names.KERRIGAN_SPAWN_BANELINGS, + item_names.KERRIGAN_LEAPING_STRIKE, + item_names.KERRIGAN_SPAWN_LEVIATHAN, + ), self.player) and self.basic_kerrigan(state) ) ) diff --git a/worlds/sc2/test/test_usecases.py b/worlds/sc2/test/test_usecases.py index 08d0cb4cc8..a18d6280a4 100644 --- a/worlds/sc2/test/test_usecases.py +++ b/worlds/sc2/test/test_usecases.py @@ -271,9 +271,19 @@ class TestSupportedUseCases(Sc2SetupTestBase): world_regions.remove('Menu') for region in world_regions: - self.assertNotIn(mission_tables.lookup_name_to_mission[region].campaign, - ([mission_tables.SC2Campaign.EPILOGUE]), - f"{region} is an epilogue mission!") + self.assertNotIn( + mission_tables.lookup_name_to_mission[region].campaign, + ([mission_tables.SC2Campaign.EPILOGUE]), + f"{region} is an epilogue mission!" + ) + + def test_excluding_all_factions_and_campaigns_still_generates(self) -> None: + world_options = { + 'selected_races': set(), + 'enabled_campaigns': set(), + } + # asserting no exception is thrown + self.generate_world(world_options) def test_race_swap_pick_one_has_correct_length_and_includes_swaps(self) -> None: world_options = {