import typing from dataclasses import fields from typing import List, Set, Iterable, Sequence, Dict, Callable, Union from math import floor, ceil from BaseClasses import Item, MultiWorld, Location, Tutorial, ItemClassification from worlds.AutoWorld import WebWorld, World from . import ItemNames from .Items import StarcraftItem, filler_items, get_item_table, get_full_item_list, \ get_basic_units, ItemData, upgrade_included_names, progressive_if_nco, kerrigan_actives, kerrigan_passives, \ kerrigan_only_passives, progressive_if_ext, not_balanced_starting_units, spear_of_adun_calldowns, \ spear_of_adun_castable_passives, nova_equipment from .ItemGroups import item_name_groups from .Locations import get_locations, LocationType, get_location_types, get_plando_locations from .Regions import create_regions from .Options import get_option_value, LocationInclusion, KerriganLevelItemDistribution, \ KerriganPresence, KerriganPrimalStatus, RequiredTactics, kerrigan_unit_available, StarterUnit, SpearOfAdunPresence, \ get_enabled_campaigns, SpearOfAdunAutonomouslyCastAbilityPresence, Starcraft2Options from .PoolFilter import filter_items, get_item_upgrades, UPGRADABLE_ITEMS, missions_in_mission_table, get_used_races from .MissionTables import MissionInfo, SC2Campaign, lookup_name_to_mission, SC2Mission, \ SC2Race class Starcraft2WebWorld(WebWorld): setup_en = Tutorial( "Multiworld Setup Guide", "A guide to setting up the Starcraft 2 randomizer connected to an Archipelago Multiworld", "English", "setup_en.md", "setup/en", ["TheCondor", "Phaneros"] ) setup_fr = Tutorial( setup_en.tutorial_name, setup_en.description, "Français", "setup_fr.md", "setup/fr", ["Neocerber"] ) tutorials = [setup_en, setup_fr] class SC2World(World): """ StarCraft II is a science fiction real-time strategy video game developed and published by Blizzard Entertainment. Play as one of three factions across four campaigns in a battle for supremacy of the Koprulu Sector. """ game = "Starcraft 2" web = Starcraft2WebWorld() item_name_to_id = {name: data.code for name, data in get_full_item_list().items()} location_name_to_id = {location.name: location.code for location in get_locations(None)} options_dataclass = Starcraft2Options options: Starcraft2Options item_name_groups = item_name_groups locked_locations: typing.List[str] location_cache: typing.List[Location] mission_req_table: Dict[SC2Campaign, Dict[str, MissionInfo]] = {} final_mission_id: int victory_item: str required_client_version = 0, 4, 5 def __init__(self, multiworld: MultiWorld, player: int): super(SC2World, self).__init__(multiworld, player) self.location_cache = [] self.locked_locations = [] def create_item(self, name: str) -> Item: data = get_full_item_list()[name] return StarcraftItem(name, data.classification, data.code, self.player) def create_regions(self): self.mission_req_table, self.final_mission_id, self.victory_item = create_regions( self, get_locations(self), self.location_cache ) def create_items(self): setup_events(self.player, self.locked_locations, self.location_cache) excluded_items = get_excluded_items(self) starter_items = assign_starter_items(self, excluded_items, self.locked_locations, self.location_cache) fill_resource_locations(self, self.locked_locations, self.location_cache) pool = get_item_pool(self, self.mission_req_table, starter_items, excluded_items, self.location_cache) fill_item_pool_with_dummy_items(self, self.locked_locations, self.location_cache, pool) self.multiworld.itempool += pool def set_rules(self): self.multiworld.completion_condition[self.player] = lambda state: state.has(self.victory_item, self.player) def get_filler_item_name(self) -> str: return self.random.choice(filler_items) def fill_slot_data(self): slot_data = {} for option_name in [field.name for field in fields(Starcraft2Options)]: option = get_option_value(self, option_name) if type(option) in {str, int}: slot_data[option_name] = int(option) slot_req_table = {} # Serialize data for campaign in self.mission_req_table: slot_req_table[campaign.id] = {} for mission in self.mission_req_table[campaign]: slot_req_table[campaign.id][mission] = self.mission_req_table[campaign][mission]._asdict() # Replace mission objects with mission IDs slot_req_table[campaign.id][mission]["mission"] = slot_req_table[campaign.id][mission]["mission"].id for index in range(len(slot_req_table[campaign.id][mission]["required_world"])): # TODO this is a band-aid, sometimes the mission_req_table already contains dicts # as far as I can tell it's related to having multiple vanilla mission orders if not isinstance(slot_req_table[campaign.id][mission]["required_world"][index], dict): slot_req_table[campaign.id][mission]["required_world"][index] = slot_req_table[campaign.id][mission]["required_world"][index]._asdict() enabled_campaigns = get_enabled_campaigns(self) slot_data["plando_locations"] = get_plando_locations(self) slot_data["nova_covert_ops_only"] = (enabled_campaigns == {SC2Campaign.NCO}) slot_data["mission_req"] = slot_req_table slot_data["final_mission"] = self.final_mission_id slot_data["version"] = 3 if SC2Campaign.HOTS not in enabled_campaigns: slot_data["kerrigan_presence"] = KerriganPresence.option_not_present return slot_data def setup_events(player: int, locked_locations: typing.List[str], location_cache: typing.List[Location]): for location in location_cache: if location.address is None: item = Item(location.name, ItemClassification.progression, None, player) locked_locations.append(location.name) location.place_locked_item(item) def get_excluded_items(world: World) -> Set[str]: excluded_items: Set[str] = set(get_option_value(world, 'excluded_items')) for item in world.multiworld.precollected_items[world.player]: excluded_items.add(item.name) locked_items: Set[str] = set(get_option_value(world, 'locked_items')) # Starter items are also excluded items starter_items: Set[str] = set(get_option_value(world, 'start_inventory')) item_table = get_full_item_list() soa_presence = get_option_value(world, "spear_of_adun_presence") soa_autocast_presence = get_option_value(world, "spear_of_adun_autonomously_cast_ability_presence") enabled_campaigns = get_enabled_campaigns(world) # Ensure no item is both guaranteed and excluded invalid_items = excluded_items.intersection(locked_items) invalid_count = len(invalid_items) # Don't count starter items that can appear multiple times invalid_count -= len([item for item in starter_items.intersection(locked_items) if item_table[item].quantity != 1]) if invalid_count > 0: raise Exception(f"{invalid_count} item{'s are' if invalid_count > 1 else ' is'} both locked and excluded from generation. Please adjust your excluded items and locked items.") def smart_exclude(item_choices: Set[str], choices_to_keep: int): expected_choices = len(item_choices) if expected_choices == 0: return item_choices = set(item_choices) starter_choices = item_choices.intersection(starter_items) excluded_choices = item_choices.intersection(excluded_items) item_choices.difference_update(excluded_choices) item_choices.difference_update(locked_items) candidates = sorted(item_choices) exclude_amount = min(expected_choices - choices_to_keep - len(excluded_choices) + len(starter_choices), len(candidates)) if exclude_amount > 0: excluded_items.update(world.random.sample(candidates, exclude_amount)) # Nova gear exclusion if NCO not in campaigns if SC2Campaign.NCO not in enabled_campaigns: excluded_items = excluded_items.union(nova_equipment) kerrigan_presence = get_option_value(world, "kerrigan_presence") # Exclude Primal Form item if option is not set or Kerrigan is unavailable if get_option_value(world, "kerrigan_primal_status") != KerriganPrimalStatus.option_item or \ (kerrigan_presence in {KerriganPresence.option_not_present, KerriganPresence.option_not_present_and_no_passives}): excluded_items.add(ItemNames.KERRIGAN_PRIMAL_FORM) # no Kerrigan & remove all passives => remove all abilities if kerrigan_presence == KerriganPresence.option_not_present_and_no_passives: for tier in range(7): smart_exclude(kerrigan_actives[tier].union(kerrigan_passives[tier]), 0) else: # no Kerrigan, but keep non-Kerrigan passives if kerrigan_presence == KerriganPresence.option_not_present: smart_exclude(kerrigan_only_passives, 0) for tier in range(7): smart_exclude(kerrigan_actives[tier], 0) # SOA exclusion, other cases are handled by generic race logic if (soa_presence == SpearOfAdunPresence.option_lotv_protoss and SC2Campaign.LOTV not in enabled_campaigns) \ or soa_presence == SpearOfAdunPresence.option_not_present: excluded_items.update(spear_of_adun_calldowns) if (soa_autocast_presence == SpearOfAdunAutonomouslyCastAbilityPresence.option_lotv_protoss \ and SC2Campaign.LOTV not in enabled_campaigns) \ or soa_autocast_presence == SpearOfAdunAutonomouslyCastAbilityPresence.option_not_present: excluded_items.update(spear_of_adun_castable_passives) return excluded_items def assign_starter_items(world: World, excluded_items: Set[str], locked_locations: List[str], location_cache: typing.List[Location]) -> List[Item]: starter_items: List[Item] = [] non_local_items = get_option_value(world, "non_local_items") starter_unit = get_option_value(world, "starter_unit") enabled_campaigns = get_enabled_campaigns(world) first_mission = get_first_mission(world.mission_req_table) # Ensuring that first mission is completable if starter_unit == StarterUnit.option_off: starter_mission_locations = [location.name for location in location_cache if location.parent_region.name == first_mission and location.access_rule == Location.access_rule] if not starter_mission_locations: # Force early unit if first mission is impossible without one starter_unit = StarterUnit.option_any_starter_unit if starter_unit != StarterUnit.option_off: first_race = lookup_name_to_mission[first_mission].race if first_race == SC2Race.ANY: # If the first mission is a logic-less no-build mission_req_table: Dict[SC2Campaign, Dict[str, MissionInfo]] = world.mission_req_table races = get_used_races(mission_req_table, world) races.remove(SC2Race.ANY) if lookup_name_to_mission[first_mission].race in races: # The campaign's race is in (At least one mission that's not logic-less no-build exists) first_race = lookup_name_to_mission[first_mission].campaign.race elif len(races) > 0: # The campaign only has logic-less no-build missions. Find any other valid race first_race = world.random.choice(list(races)) if first_race != SC2Race.ANY: # The race of the early unit has been chosen basic_units = get_basic_units(world, first_race) if starter_unit == StarterUnit.option_balanced: basic_units = basic_units.difference(not_balanced_starting_units) if first_mission == SC2Mission.DARK_WHISPERS.mission_name: # Special case - you don't have a logicless location but need an AA basic_units = basic_units.difference( {ItemNames.ZEALOT, ItemNames.CENTURION, ItemNames.SENTINEL, ItemNames.BLOOD_HUNTER, ItemNames.AVENGER, ItemNames.IMMORTAL, ItemNames.ANNIHILATOR, ItemNames.VANGUARD}) if first_mission == SC2Mission.SUDDEN_STRIKE.mission_name: # Special case - cliffjumpers basic_units = {ItemNames.REAPER, ItemNames.GOLIATH, ItemNames.SIEGE_TANK, ItemNames.VIKING, ItemNames.BANSHEE} local_basic_unit = sorted(item for item in basic_units if item not in non_local_items and item not in excluded_items) if not local_basic_unit: # Drop non_local_items constraint local_basic_unit = sorted(item for item in basic_units if item not in excluded_items) if not local_basic_unit: raise Exception("Early Unit: At least one basic unit must be included") unit: Item = add_starter_item(world, excluded_items, local_basic_unit) starter_items.append(unit) # NCO-only specific rules if first_mission == SC2Mission.SUDDEN_STRIKE.mission_name: support_item: Union[str, None] = None if unit.name == ItemNames.REAPER: support_item = ItemNames.REAPER_SPIDER_MINES elif unit.name == ItemNames.GOLIATH: support_item = ItemNames.GOLIATH_JUMP_JETS elif unit.name == ItemNames.SIEGE_TANK: support_item = ItemNames.SIEGE_TANK_JUMP_JETS elif unit.name == ItemNames.VIKING: support_item = ItemNames.VIKING_SMART_SERVOS if support_item is not None: starter_items.append(add_starter_item(world, excluded_items, [support_item])) starter_items.append(add_starter_item(world, excluded_items, [ItemNames.NOVA_JUMP_SUIT_MODULE])) starter_items.append( add_starter_item(world, excluded_items, [ ItemNames.NOVA_HELLFIRE_SHOTGUN, ItemNames.NOVA_PLASMA_RIFLE, ItemNames.NOVA_PULSE_GRENADES ])) if enabled_campaigns == {SC2Campaign.NCO}: starter_items.append(add_starter_item(world, excluded_items, [ItemNames.LIBERATOR_RAID_ARTILLERY])) starter_abilities = get_option_value(world, 'start_primary_abilities') assert isinstance(starter_abilities, int) if starter_abilities: ability_count = starter_abilities ability_tiers = [0, 1, 3] world.random.shuffle(ability_tiers) if ability_count > 3: ability_tiers.append(6) for tier in ability_tiers: abilities = kerrigan_actives[tier].union(kerrigan_passives[tier]).difference(excluded_items, non_local_items) if not abilities: abilities = kerrigan_actives[tier].union(kerrigan_passives[tier]).difference(excluded_items) if abilities: ability_count -= 1 starter_items.append(add_starter_item(world, excluded_items, list(abilities))) if ability_count == 0: break return starter_items def get_first_mission(mission_req_table: Dict[SC2Campaign, Dict[str, MissionInfo]]) -> str: # The first world should also be the starting world campaigns = mission_req_table.keys() lowest_id = min([campaign.id for campaign in campaigns]) first_campaign = [campaign for campaign in campaigns if campaign.id == lowest_id][0] first_mission = list(mission_req_table[first_campaign])[0] return first_mission def add_starter_item(world: World, excluded_items: Set[str], item_list: Sequence[str]) -> Item: item_name = world.random.choice(sorted(item_list)) excluded_items.add(item_name) item = create_item_with_correct_settings(world.player, item_name) world.multiworld.push_precollected(item) return item def get_item_pool(world: World, mission_req_table: Dict[SC2Campaign, Dict[str, MissionInfo]], starter_items: List[Item], excluded_items: Set[str], location_cache: List[Location]) -> List[Item]: pool: List[Item] = [] # For the future: goal items like Artifact Shards go here locked_items = [] # YAML items yaml_locked_items = get_option_value(world, 'locked_items') assert not isinstance(yaml_locked_items, int) # Adjust generic upgrade availability based on options include_upgrades = get_option_value(world, 'generic_upgrade_missions') == 0 upgrade_items = get_option_value(world, 'generic_upgrade_items') assert isinstance(upgrade_items, int) # Include items from outside main campaigns item_sets = {'wol', 'hots', 'lotv'} if get_option_value(world, 'nco_items') \ or SC2Campaign.NCO in get_enabled_campaigns(world): item_sets.add('nco') if get_option_value(world, 'bw_items'): item_sets.add('bw') if get_option_value(world, 'ext_items'): item_sets.add('ext') def allowed_quantity(name: str, data: ItemData) -> int: if name in excluded_items \ or data.type == "Upgrade" and (not include_upgrades or name not in upgrade_included_names[upgrade_items]) \ or not data.origin.intersection(item_sets): return 0 elif name in progressive_if_nco and 'nco' not in item_sets: return 1 elif name in progressive_if_ext and 'ext' not in item_sets: return 1 else: return data.quantity for name, data in get_item_table().items(): for _ in range(allowed_quantity(name, data)): item = create_item_with_correct_settings(world.player, name) if name in yaml_locked_items: locked_items.append(item) else: pool.append(item) existing_items = starter_items + [item for item in world.multiworld.precollected_items[world.player] if item not in starter_items] existing_names = [item.name for item in existing_items] # Check the parent item integrity, exclude items pool[:] = [item for item in pool if pool_contains_parent(item, pool + locked_items + existing_items)] # Removing upgrades for excluded items for item_name in excluded_items: if item_name in existing_names: continue invalid_upgrades = get_item_upgrades(pool, item_name) for invalid_upgrade in invalid_upgrades: pool.remove(invalid_upgrade) fill_pool_with_kerrigan_levels(world, pool) filtered_pool = filter_items(world, mission_req_table, location_cache, pool, existing_items, locked_items) return filtered_pool def fill_item_pool_with_dummy_items(self: SC2World, locked_locations: List[str], location_cache: List[Location], pool: List[Item]): for _ in range(len(location_cache) - len(locked_locations) - len(pool)): item = create_item_with_correct_settings(self.player, self.get_filler_item_name()) pool.append(item) def create_item_with_correct_settings(player: int, name: str) -> Item: data = get_full_item_list()[name] item = Item(name, data.classification, data.code, player) return item def pool_contains_parent(item: Item, pool: Iterable[Item]): item_data = get_full_item_list().get(item.name) if item_data.parent_item is None: # The item has not associated parent, the item is valid return True parent_item = item_data.parent_item # Check if the pool contains the parent item return parent_item in [pool_item.name for pool_item in pool] def fill_resource_locations(world: World, locked_locations: List[str], location_cache: List[Location]): """ Filters the locations in the world using a trash or Nothing item :param multiworld: :param player: :param locked_locations: :param location_cache: :return: """ open_locations = [location for location in location_cache if location.item is None] plando_locations = get_plando_locations(world) resource_location_types = get_location_types(world, LocationInclusion.option_resources) location_data = {sc2_location.name: sc2_location for sc2_location in get_locations(world)} for location in open_locations: # Go through the locations that aren't locked yet (early unit, etc) if location.name not in plando_locations: # The location is not plando'd sc2_location = location_data[location.name] if sc2_location.type in resource_location_types: item_name = world.random.choice(filler_items) item = create_item_with_correct_settings(world.player, item_name) location.place_locked_item(item) locked_locations.append(location.name) def place_exclusion_item(item_name, location, locked_locations, player): item = create_item_with_correct_settings(player, item_name) location.place_locked_item(item) locked_locations.append(location.name) def fill_pool_with_kerrigan_levels(world: World, item_pool: List[Item]): total_levels = get_option_value(world, "kerrigan_level_item_sum") if get_option_value(world, "kerrigan_presence") not in kerrigan_unit_available \ or total_levels == 0 \ or SC2Campaign.HOTS not in get_enabled_campaigns(world): return def add_kerrigan_level_items(level_amount: int, item_amount: int): name = f"{level_amount} Kerrigan Level" if level_amount > 1: name += "s" for _ in range(item_amount): item_pool.append(create_item_with_correct_settings(world.player, name)) sizes = [70, 35, 14, 10, 7, 5, 2, 1] option = get_option_value(world, "kerrigan_level_item_distribution") assert isinstance(option, int) assert isinstance(total_levels, int) if option in (KerriganLevelItemDistribution.option_vanilla, KerriganLevelItemDistribution.option_smooth): distribution = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] if option == KerriganLevelItemDistribution.option_vanilla: distribution = [32, 0, 0, 1, 3, 0, 0, 0, 1, 1] else: # Smooth distribution = [0, 0, 0, 1, 1, 2, 2, 2, 1, 1] for tier in range(len(distribution)): add_kerrigan_level_items(tier + 1, distribution[tier]) else: size = sizes[option - 2] round_func: Callable[[float], int] = round if total_levels > 70: round_func = floor else: round_func = ceil add_kerrigan_level_items(size, round_func(float(total_levels) / size))