diff --git a/worlds/sc2/__init__.py b/worlds/sc2/__init__.py index 5b9134821b11..1470fab620fb 100644 --- a/worlds/sc2/__init__.py +++ b/worlds/sc2/__init__.py @@ -19,7 +19,7 @@ from .options import (get_option_value, LocationInclusion, KerriganLevelItemDistribution, KerriganPresence, KerriganPrimalStatus, kerrigan_unit_available, StarterUnit, SpearOfAdunPresence, get_enabled_campaigns, SpearOfAdunAutonomouslyCastAbilityPresence, Starcraft2Options, - GrantStoryTech, GenericUpgradeResearch, + GrantStoryTech, GenericUpgradeResearch, get_enabled_races ) from .pool_filter import filter_items from .mission_tables import ( @@ -246,6 +246,7 @@ def resolve_count(count: Optional[int], max_count: int) -> int: def flag_excludes_by_faction_presence(world: SC2World, item_list: List[FilterItem]) -> None: """Excludes items based on if their faction has a mission present where they can be used""" missions = get_all_missions(world.mission_req_table) + races = get_enabled_races(world) if world.options.take_over_ai_allies.value: terran_missions = [mission for mission in missions if (MissionFlag.Terran|MissionFlag.AiTerranAlly) & mission.flags] zerg_missions = [mission for mission in missions if (MissionFlag.Zerg|MissionFlag.AiZergAlly) & mission.flags] @@ -264,13 +265,13 @@ def flag_excludes_by_faction_presence(world: SC2World, item_list: List[FilterIte for item in item_list: # Catch-all for all of a faction's items - if (not terran_missions and item.data.race == SC2Race.TERRAN): + if (not terran_missions or SC2Race.TERRAN not in races) and item.data.race == SC2Race.TERRAN: item.flags |= ItemFilterFlags.Excluded continue - if (not zerg_missions and item.data.race == SC2Race.ZERG): + if (not zerg_missions or SC2Race.ZERG not in races) and item.data.race == SC2Race.ZERG: item.flags |= ItemFilterFlags.Excluded continue - if (not protoss_missions and item.data.race == SC2Race.PROTOSS): + if (not protoss_missions or SC2Race.PROTOSS not in races) and item.data.race == SC2Race.PROTOSS: if item.name not in item_groups.soa_items: item.flags |= ItemFilterFlags.Excluded continue diff --git a/worlds/sc2/client.py b/worlds/sc2/client.py index de50079def6a..d82420df3443 100644 --- a/worlds/sc2/client.py +++ b/worlds/sc2/client.py @@ -34,6 +34,7 @@ SpearOfAdunPresence, SpearOfAdunPresentInNoBuild, SpearOfAdunAutonomouslyCastAbilityPresence, SpearOfAdunAutonomouslyCastPresentInNoBuild, LEGACY_GRID_ORDERS, ) +from .mission_tables import MissionFlag if __name__ == "__main__": @@ -1013,6 +1014,21 @@ def kerrigan_primal(ctx: SC2Context, kerrigan_level: int) -> bool: return get_full_item_list()[item_names.KERRIGAN_PRIMAL_FORM].code in codes return False + +def get_mission_variant(mission_id): + mission_flags = lookup_id_to_mission[mission_id].flags + faction_variant = 0 + if MissionFlag.RaceSwap not in mission_flags: + return faction_variant + if MissionFlag.Terran in mission_flags: + faction_variant = 1 + elif MissionFlag.Zerg in mission_flags: + faction_variant = 2 + elif MissionFlag.Protoss in mission_flags: + faction_variant = 3 + return faction_variant + + async def starcraft_launch(ctx: SC2Context, mission_id: int): sc2_logger.info(f"Launching {lookup_id_to_mission[mission_id].mission_name}. If game does not launch check log file for errors.") @@ -1061,6 +1077,7 @@ async def on_step(self, iteration: int): kerrigan_level = get_kerrigan_level(self.ctx, start_items, missions_beaten) kerrigan_options = calculate_kerrigan_options(self.ctx) soa_options = caclulate_soa_options(self.ctx) + mission_variant = get_mission_variant(self.mission_id) uncollected_objectives: typing.List[int] = self.get_uncollected_objectives() if self.ctx.difficulty_override >= 0: difficulty = calc_difficulty(self.ctx.difficulty_override) @@ -1070,7 +1087,7 @@ async def on_step(self, iteration: int): game_speed = self.ctx.game_speed_override else: game_speed = self.ctx.game_speed - await self.chat_send("?SetOptions {} {} {} {} {} {} {} {} {} {} {} {} {} {}".format( + await self.chat_send("?SetOptions {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}".format( difficulty, self.ctx.generic_upgrade_research, self.ctx.all_in_choice, @@ -1084,7 +1101,8 @@ async def on_step(self, iteration: int): self.ctx.mission_order, 1 if self.ctx.nova_covert_ops_only else 0, self.ctx.grant_story_levels, - self.ctx.enable_morphling + self.ctx.enable_morphling, + mission_variant )) await self.chat_send("?GiveResources {} {} {}".format( start_items[SC2Race.ANY][0], diff --git a/worlds/sc2/locations.py b/worlds/sc2/locations.py index e0046c380c1e..1048b0f241db 100644 --- a/worlds/sc2/locations.py +++ b/worlds/sc2/locations.py @@ -17,6 +17,7 @@ SC2HOTS_LOC_ID_OFFSET = 20000000 # Avoid clashes with The Legend of Zelda SC2LOTV_LOC_ID_OFFSET = SC2HOTS_LOC_ID_OFFSET + 2000 SC2NCO_LOC_ID_OFFSET = SC2LOTV_LOC_ID_OFFSET + 2500 +SC2_RACESWAP_LOC_ID_OFFSET = SC2NCO_LOC_ID_OFFSET + 900 class SC2Location(Location): @@ -1619,6 +1620,52 @@ def get_locations(world: Optional['SC2World']) -> Tuple[LocationData, ...]: lambda state: logic.end_game_requirement(state) and logic.nova_any_weapon(state)), LocationData("End Game", "End Game: Xanthos", SC2NCO_LOC_ID_OFFSET + 901, LocationType.VANILLA, lambda state: logic.end_game_requirement(state)), + + # Mission Variants + LocationData("Smash and Grab (Z)", "Smash and Grab (Zerg): Victory", SC2_RACESWAP_LOC_ID_OFFSET + 100, LocationType.VICTORY, + lambda state: logic.zerg_common_unit(state) and + (adv_tactics and logic.zerg_basic_anti_air(state) + or logic.zerg_competent_anti_air(state))), + LocationData("Smash and Grab (Z)", "Smash and Grab (Zerg): First Relic", SC2_RACESWAP_LOC_ID_OFFSET + 101, LocationType.VANILLA), + LocationData("Smash and Grab (Z)", "Smash and Grab (Zerg): Second Relic", SC2_RACESWAP_LOC_ID_OFFSET + 102, LocationType.VANILLA), + LocationData("Smash and Grab (Z)", "Smash and Grab (Zerg): Third Relic", SC2_RACESWAP_LOC_ID_OFFSET + 103, LocationType.VANILLA, + lambda state: logic.zerg_common_unit(state) and + (adv_tactics and logic.zerg_basic_kerriganless_anti_air(state) + or logic.zerg_competent_anti_air(state))), + LocationData("Smash and Grab (Z)", "Smash and Grab (Zerg): Fourth Relic", SC2_RACESWAP_LOC_ID_OFFSET + 104, LocationType.VANILLA, + lambda state: logic.zerg_common_unit(state) and + (adv_tactics and logic.zerg_basic_kerriganless_anti_air(state) + or logic.zerg_competent_anti_air(state))), + LocationData("Smash and Grab (Z)", "Smash and Grab (Zerg): First Forcefield Area Busted", SC2_RACESWAP_LOC_ID_OFFSET + 105, LocationType.EXTRA, + lambda state: logic.zerg_common_unit(state) and + (adv_tactics and logic.zerg_basic_kerriganless_anti_air(state) + or logic.zerg_competent_anti_air(state))), + LocationData("Smash and Grab (Z)", "Smash and Grab (Zerg): Second Forcefield Area Busted", SC2_RACESWAP_LOC_ID_OFFSET + 106, LocationType.EXTRA, + lambda state: logic.zerg_common_unit(state) and + (adv_tactics and logic.zerg_basic_kerriganless_anti_air(state) + or logic.zerg_competent_anti_air(state))), + LocationData("Smash and Grab (P)", "Smash and Grab (Protoss): Victory", SC2_RACESWAP_LOC_ID_OFFSET + 200, LocationType.VICTORY, + lambda state: logic.protoss_common_unit(state) and + (adv_tactics and logic.protoss_basic_anti_air(state) + or logic.protoss_competent_anti_air(state))), + LocationData("Smash and Grab (P)", "Smash and Grab (Protoss): First Relic", SC2_RACESWAP_LOC_ID_OFFSET + 201, LocationType.VANILLA), + LocationData("Smash and Grab (P)", "Smash and Grab (Protoss): Second Relic", SC2_RACESWAP_LOC_ID_OFFSET + 202, LocationType.VANILLA), + LocationData("Smash and Grab (P)", "Smash and Grab (Protoss): Third Relic", SC2_RACESWAP_LOC_ID_OFFSET + 203, LocationType.VANILLA, + lambda state: logic.protoss_common_unit(state) and + (adv_tactics and logic.protoss_basic_anti_air(state) + or logic.protoss_competent_anti_air(state))), + LocationData("Smash and Grab (P)", "Smash and Grab (Protoss): Fourth Relic", SC2_RACESWAP_LOC_ID_OFFSET + 204, LocationType.VANILLA, + lambda state: logic.protoss_common_unit(state) and + (adv_tactics and logic.protoss_basic_anti_air(state) + or logic.protoss_competent_anti_air(state))), + LocationData("Smash and Grab (P)", "Smash and Grab (Protoss): First Forcefield Area Busted", SC2_RACESWAP_LOC_ID_OFFSET + 205, LocationType.EXTRA, + lambda state: logic.protoss_common_unit(state) and + (adv_tactics and logic.protoss_basic_anti_air(state) + or logic.protoss_competent_anti_air(state))), + LocationData("Smash and Grab (P)", "Smash and Grab (Protoss): Second Forcefield Area Busted", SC2_RACESWAP_LOC_ID_OFFSET + 206, LocationType.EXTRA, + lambda state: logic.protoss_common_unit(state) and + (adv_tactics and logic.protoss_basic_anti_air(state) + or logic.protoss_competent_anti_air(state))), ] beat_events = [] diff --git a/worlds/sc2/mission_tables.py b/worlds/sc2/mission_tables.py index f047c18dccd2..f1c9978b88da 100644 --- a/worlds/sc2/mission_tables.py +++ b/worlds/sc2/mission_tables.py @@ -36,6 +36,7 @@ class MissionFlag(IntFlag): VsTerran = auto() VsZerg = auto() VsProtoss = auto() + RaceSwap = auto() # The mission uses a faction other than the one it uses in vanilla AiAlly = AiTerranAlly|AiZergAlly|AiProtossAlly TimedDefense = AutoScroller|Defense @@ -194,6 +195,10 @@ def __init__(self, mission_id: int, name: str, campaign: SC2Campaign, area: str, DARK_SKIES = 82, "Dark Skies", SC2Campaign.NCO, "_3", SC2Race.TERRAN, MissionPools.HARD, "ap_dark_skies", MissionFlag.Terran|MissionFlag.Nova|MissionFlag.TimedDefense|MissionFlag.VsProtoss END_GAME = 83, "End Game", SC2Campaign.NCO, "_3", SC2Race.TERRAN, MissionPools.VERY_HARD, "ap_end_game", MissionFlag.Terran|MissionFlag.Nova|MissionFlag.Defense|MissionFlag.VsTerran + # Race-Swapped Variants + SMASH_AND_GRAB_Z = 84, "Smash and Grab (Z)", SC2Campaign.WOL, "Artifact", SC2Race.ZERG, MissionPools.EASY, "ap_smash_and_grab", MissionFlag.Zerg|MissionFlag.Countdown|MissionFlag.VsPZ|MissionFlag.RaceSwap + SMASH_AND_GRAB_P = 85, "Smash and Grab (P)", SC2Campaign.WOL, "Artifact", SC2Race.PROTOSS, MissionPools.EASY, "ap_smash_and_grab", MissionFlag.Protoss|MissionFlag.Countdown|MissionFlag.VsPZ|MissionFlag.RaceSwap + class MissionConnection: campaign: SC2Campaign @@ -486,5 +491,5 @@ def get_campaign_potential_goal_missions(campaign: SC2Campaign) -> List[SC2Missi return missions -def get_no_build_missions() -> List[SC2Mission]: - return [mission for mission in SC2Mission if MissionFlag.NoBuild in mission.flags] +def get_missions_with_flags(flags: MissionFlag) -> List[SC2Mission]: + return [mission for mission in SC2Mission if flags & mission.flags] diff --git a/worlds/sc2/options.py b/worlds/sc2/options.py index cb061c241fdf..ac810bb79203 100644 --- a/worlds/sc2/options.py +++ b/worlds/sc2/options.py @@ -6,8 +6,8 @@ PerGameCommonOptions, Option, VerifyKeys) from Utils import get_fuzzy_results from BaseClasses import PlandoOptions -from .mission_tables import SC2Campaign, SC2Mission, lookup_name_to_mission, MissionPools, get_no_build_missions, \ - campaign_mission_table +from .mission_tables import SC2Campaign, SC2Mission, lookup_name_to_mission, MissionPools, get_missions_with_flags, \ + campaign_mission_table, SC2Race, MissionFlag from .mission_orders import vanilla_shuffle_order, mini_campaign_order from .mission_groups import mission_groups, MissionGroupNames @@ -133,7 +133,7 @@ class MaximumCampaignSize(Range): """ display_name = "Maximum Campaign Size" range_start = 1 - range_end = 83 + range_end = 85 default = 83 @@ -188,6 +188,23 @@ class PlayerColorZergPrimal(ColorChoice): display_name = "Zerg Player Color (Primal)" +class SelectRaces(Choice): + """ + Pick which factions' missions can be shuffled into the world. + """ + # bit 0: terran, bit 1: zerg, bit 2: protoss. all disabled means plando only + display_name = "Select Playable Races" + option_all = 7 + option_terran = 1 + option_zerg = 2 + option_protoss = 4 + option_terran_and_zerg = 3 + option_terran_and_protoss = 5 + option_zerg_and_protoss = 6 + option_plando = 0 + default = option_all + + class EnableWolMissions(DefaultOnToggle): """ Enables missions from main Wings of Liberty campaign. @@ -244,6 +261,22 @@ class EnableNCOMissions(DefaultOnToggle): display_name = "Enable Nova Covert Ops missions" +class EnableRaceSwapVariants(Choice): + """ + Allow mission variants where you play a faction other than the one the map was initially + designed for. + + Disabled: Don't shuffle any non-vanilla map variants into the pool. + Pick One: Only allow one version of each map at a time + Shuffle All: Each version of a map can appear in the same pool (so a map can appear up to 3 times as different races) + """ + display_name = "Enable Race-Swapped Mission Variants" + option_disabled = 0 + option_pick_one = 1 + option_shuffle_all = 2 + default = option_disabled + + class ShuffleCampaigns(DefaultOnToggle): """ Shuffles the missions between campaigns if enabled. @@ -886,6 +919,7 @@ class Starcraft2Options(PerGameCommonOptions): player_color_protoss: PlayerColorProtoss player_color_zerg: PlayerColorZerg player_color_zerg_primal: PlayerColorZergPrimal + selected_races: SelectRaces enable_wol_missions: EnableWolMissions enable_prophecy_missions: EnableProphecyMissions enable_hots_missions: EnableHotsMissions @@ -893,6 +927,7 @@ class Starcraft2Options(PerGameCommonOptions): enable_lotv_missions: EnableLotVMissions enable_epilogue_missions: EnableEpilogueMissions enable_nco_missions: EnableNCOMissions + enable_race_swap: EnableRaceSwapVariants shuffle_campaigns: ShuffleCampaigns shuffle_no_build: ShuffleNoBuild starter_unit: StarterUnit @@ -944,6 +979,21 @@ def get_option_value(world: 'SC2World', name: str) -> Union[int, FrozenSet]: return player_option.value +def get_enabled_races(world: 'SC2World') -> Set[SC2Race]: + selection = world.options.selected_races.value + enabled = set() + # if bit 0, enable terran + if selection & 1: + enabled.add(SC2Race.TERRAN) + # if bit 1, enable zerg + if selection & (1 << 1): + enabled.add(SC2Race.ZERG) + # if bit 2, enable protoss + if selection & (1 << 2): + enabled.add(SC2Race.PROTOSS) + return enabled + + def get_enabled_campaigns(world: 'SC2World') -> Set[SC2Campaign]: enabled_campaigns = set() if get_option_value(world, "enable_wol_missions"): @@ -971,11 +1021,30 @@ def get_disabled_campaigns(world: 'SC2World') -> Set[SC2Campaign]: return disabled_campaigns +def get_disabled_flags(world: 'SC2World') -> MissionFlag: + excluded = 0 + races = get_enabled_races(world) + # filter out missions based on disabled races + if SC2Race.TERRAN not in races: + excluded |= MissionFlag.Terran + if SC2Race.ZERG not in races: + excluded |= MissionFlag.Zerg + if SC2Race.PROTOSS not in races: + excluded |= MissionFlag.Protoss + # filter out race-swapped mission variants + if world.options.enable_race_swap == EnableRaceSwapVariants.option_disabled: + excluded |= MissionFlag.RaceSwap + # filter out no-build missions + if not world.options.shuffle_no_build: + excluded |= MissionFlag.NoBuild + return MissionFlag(excluded) + + def get_excluded_missions(world: 'SC2World') -> Set[SC2Mission]: mission_order_type = world.options.mission_order.value excluded_mission_names = world.options.excluded_missions.value - shuffle_no_build = world.options.shuffle_no_build.value disabled_campaigns = get_disabled_campaigns(world) + disabled_flags = get_disabled_flags(world) excluded_missions: Set[SC2Mission] = set([lookup_name_to_mission[name] for name in excluded_mission_names]) @@ -991,8 +1060,8 @@ def get_excluded_missions(world: 'SC2World') -> Set[SC2Mission]: mission.pool == MissionPools.VERY_HARD and mission.campaign != SC2Campaign.EPILOGUE] ) # Omitting No-Build missions if not shuffling no-build - if not shuffle_no_build: - excluded_missions = excluded_missions.union(get_no_build_missions()) + if disabled_flags: + excluded_missions = excluded_missions.union(get_missions_with_flags(disabled_flags)) # Omitting missions not in enabled campaigns for campaign in disabled_campaigns: excluded_missions = excluded_missions.union(campaign_mission_table[campaign]) diff --git a/worlds/sc2/rules.py b/worlds/sc2/rules.py index d4fa0b412226..cb12c2d47dee 100644 --- a/worlds/sc2/rules.py +++ b/worlds/sc2/rules.py @@ -3,7 +3,7 @@ from BaseClasses import CollectionState from .options import get_option_value, RequiredTactics, kerrigan_unit_available, AllInMap, \ GrantStoryTech, GrantStoryLevels, TakeOverAIAllies, SpearOfAdunAutonomouslyCastAbilityPresence, \ - get_enabled_campaigns, MissionOrder, EnableMorphling + get_enabled_campaigns, MissionOrder, EnableMorphling, get_enabled_races from .items import get_basic_units, defense_ratings, zerg_defense_ratings, kerrigan_actives, air_defense_ratings, \ kerrigan_levels, get_full_item_list from .mission_tables import SC2Race, SC2Campaign @@ -340,9 +340,12 @@ def zerg_competent_anti_air(self, state: CollectionState) -> bool: or (self.advanced_tactics and state.has(item_names.INFESTOR, self.player)) def zerg_basic_anti_air(self, state: CollectionState) -> bool: - return self.zerg_competent_anti_air(state) or self.kerrigan_unit_available in kerrigan_unit_available or \ - state.has_any({item_names.SWARM_QUEEN, item_names.SCOURGE}, self.player) or (self.advanced_tactics and state.has(item_names.SPORE_CRAWLER, self.player)) - + return self.zerg_basic_kerriganless_anti_air(state) or self.kerrigan_unit_available in kerrigan_unit_available + + def zerg_basic_kerriganless_anti_air(self, state: CollectionState) -> bool: + return self.zerg_competent_anti_air(state) or state.has_any({item_names.SWARM_QUEEN, item_names.SCOURGE}, self.player) \ + or (self.advanced_tactics and state.has(item_names.SPORE_CRAWLER, self.player)) + def morph_brood_lord(self, state: CollectionState) -> bool: return (state.has_any({item_names.MUTALISK, item_names.CORRUPTOR}, self.player) or self.morphling_enabled) \ and state.has(item_names.MUTALISK_CORRUPTOR_BROOD_LORD_ASPECT, self.player)