diff --git a/worlds/sc2/__init__.py b/worlds/sc2/__init__.py index 0097d1154031..7e100a58ae9a 100644 --- a/worlds/sc2/__init__.py +++ b/worlds/sc2/__init__.py @@ -7,7 +7,7 @@ from Options import Accessibility from worlds.AutoWorld import WebWorld, World from .item.item_tables import ( - filler_items, get_full_item_list, ProtossItemType, + get_full_item_list, ProtossItemType, ItemData, kerrigan_actives, kerrigan_passives, not_balanced_starting_units, WEAPON_ARMOR_UPGRADE_MAX_LEVEL, ZergItemType, ) @@ -19,8 +19,8 @@ get_option_value, LocationInclusion, KerriganLevelItemDistribution, KerriganPresence, KerriganPrimalStatus, kerrigan_unit_available, StarterUnit, SpearOfAdunPresence, get_enabled_campaigns, SpearOfAdunAutonomouslyCastAbilityPresence, Starcraft2Options, - GrantStoryTech, GenericUpgradeResearch, GenericUpgradeItems, RequiredTactics, - upgrade_included_names, EnableVoidTrade + GrantStoryTech, GenericUpgradeResearch, RequiredTactics, + upgrade_included_names, EnableVoidTrade, FillerRatio, ) from .rules import get_basic_units from . import settings @@ -95,11 +95,13 @@ class SC2World(World): has_zerg_air_unit: bool = True has_protoss_ground_unit: bool = True has_protoss_air_unit: bool = True + filler_ratio: Dict[str, int] def __init__(self, multiworld: MultiWorld, player: int): super(SC2World, self).__init__(multiworld, player) self.location_cache = [] self.locked_locations = [] + self.filler_ratio = FillerRatio.default def create_item(self, name: str) -> Item: data = get_full_item_list()[name] @@ -119,6 +121,7 @@ def create_items(self): # * If the item pool is less than the location count, add some filler items setup_events(self.player, self.locked_locations, self.location_cache) + set_up_filler_ratio(self) item_list: List[FilterItem] = create_and_flag_explicit_item_locks_and_excludes(self) flag_excludes_by_faction_presence(self, item_list) @@ -166,21 +169,18 @@ def set_rules(self) -> None: # Forcing completed goal and minimal accessibility on no logic self.options.accessibility.value = Accessibility.option_minimal required_items = self.custom_mission_order.get_items_to_lock() - self.multiworld.completion_condition[self.player] = lambda state, required_items=required_items: all( + self.multiworld.completion_condition[self.player] = lambda state, required_items=required_items: all( # type: ignore state.has(item, self.player, amount) for (item, amount) in required_items.items() ) else: self.multiworld.completion_condition[self.player] = self.custom_mission_order.get_completion_condition(self.player) def get_filler_item_name(self) -> str: - available_filler_items: list[str] = list(filler_items) - if self.has_protoss_ground_unit or self.has_protoss_air_unit: - available_filler_items.append(item_names.SHIELD_REGENERATION) - available_filler_items.sort() - return self.random.choice(available_filler_items) - - def fill_slot_data(self): - slot_data = {} + # Assume `self.filler_ratio` is validated and has at least one non-zero entry + return self.random.choices(tuple(self.filler_ratio), weights=self.filler_ratio.values())[0] # type: ignore + + def fill_slot_data(self) -> Mapping[str, Any]: + slot_data: Dict[str, Any] = {} for option_name in [field.name for field in fields(Starcraft2Options)]: option = get_option_value(self, option_name) if type(option) in {str, int}: @@ -232,11 +232,10 @@ def pre_fill(self) -> None: item = self.multiworld.create_item(item_name, self.player) self.multiworld.push_precollected(item) - def extend_hint_information(self, hint_data: Dict[int, Dict[int, str]]): + def extend_hint_information(self, hint_data: Dict[int, Dict[int, str]]) -> None: """ - Generate information to hints where each mission is actually located in the mission order + Generate information to hint where each mission is actually located in the mission order :param hint_data: - :return: """ hint_data[self.player] = {} for campaign in self.custom_mission_order.campaigns: @@ -269,7 +268,7 @@ def extend_hint_information(self, hint_data: Dict[int, Dict[int, str]]): hint_data[self.player][location.address] = mission_position_name -def _get_column_display(index, single_row_layout): +def _get_column_display(index: int, single_row_layout: bool) -> str: """ Helper function to display column name :param index: @@ -280,11 +279,11 @@ def _get_column_display(index, single_row_layout): return str(index + 1) else: # Convert column name to a letter, from Z continue with AA and so on - f = lambda x: "" if x == 0 else f((x - 1) // 26) + chr((x - 1) % 26 + ord("A")) + f: Callable[[int], str] = lambda x: "" if x == 0 else f((x - 1) // 26) + chr((x - 1) % 26 + ord("A")) return f(index + 1) -def setup_events(player: int, locked_locations: List[str], location_cache: List[Location]): +def setup_events(player: int, locked_locations: List[str], location_cache: List[Location]) -> None: for location in location_cache: if location.address is None: item = Item(location.name, ItemClassification.progression, None, player) @@ -741,7 +740,7 @@ def flag_and_add_resource_locations(world: SC2World, item_list: List[FilterItem] if (sc2_location.type in resource_location_types or (sc2_location.flags & resource_location_flags) ): - item_name = world.random.choice(filler_items) + item_name = world.get_filler_item_name() item = create_item_with_correct_settings(world.player, item_name) location.place_locked_item(item) world.locked_locations.append(location.name) @@ -796,12 +795,27 @@ def item_list_contains_parent(world: SC2World, item_data: ItemData, item_name_li return item_parents.parent_present[item_data.parent](item_name_list, world.options) -def pad_item_pool_with_filler(self: SC2World, num_items: int, pool: List[Item]): +def pad_item_pool_with_filler(world: SC2World, num_items: int, pool: List[Item]): for _ in range(num_items): - item = create_item_with_correct_settings(self.player, self.get_filler_item_name()) + item = create_item_with_correct_settings(world.player, world.get_filler_item_name()) pool.append(item) +def set_up_filler_ratio(world: SC2World) -> None: + world.filler_ratio = world.options.filler_ratio.value.copy() + mission_flags = world.custom_mission_order.get_used_flags() + include_protoss = ( + MissionFlag.Protoss in mission_flags + or (world.options.take_over_ai_allies and (MissionFlag.AiProtossAlly in mission_flags)) + ) + if not include_protoss: + world.filler_ratio.pop(item_names.SHIELD_REGENERATION, 0) + if sum(world.filler_ratio.values()) == 0: + world.filler_ratio = FillerRatio.default.copy() + if not include_protoss: + world.filler_ratio.pop(item_names.SHIELD_REGENERATION, 0) + + def get_random_first_mission(world: SC2World, mission_order: SC2MissionOrder) -> SC2Mission: # Pick an arbitrary lowest-difficulty starer mission starting_missions = mission_order.get_starting_missions() @@ -882,4 +896,4 @@ def push_precollected_items_to_multiworld(world: SC2World, item_list: List[Starc for item in item_list: if ItemFilterFlags.StartInventory not in item.filter_flags: continue - world.multiworld.push_precollected(create_item_with_correct_settings(world.player, item.name)) + world.multiworld.push_precollected(create_item_with_correct_settings(world.player, item.name, item.filter_flags)) diff --git a/worlds/sc2/client.py b/worlds/sc2/client.py index c17c2284d664..c09b02c20f00 100644 --- a/worlds/sc2/client.py +++ b/worlds/sc2/client.py @@ -241,6 +241,9 @@ def _cmd_received(self, filter_search: str = "") -> bool: """List received items. Pass in a parameter to filter the search by partial item name or exact item group. Use '/received recent ' to list the last 'number' items received (default 20).""" + if self.ctx.slot is None: + self.formatted_print("Connect to a slot to view what items are received.") + return True if filter_search.casefold().startswith('recent'): return self._received_recent(filter_search[len('recent'):].strip()) # Groups must be matched case-sensitively, so we properly capitalize the search term @@ -314,6 +317,7 @@ def display_tree( ) -> None: if not should_display: return + assert self.ctx.slot is not None indent_str = " " * indent if isinstance(element, SC2Race): self.formatted_print(f" [u]{name}[/u] ") @@ -353,7 +357,8 @@ def display_tree( self.formatted_print(f"[b]Filter \"{filter_search}\" found {items_obtained_matching_filter} out of {item_types_obtained} obtained item types[/b]") return True - def _received_recent(self, amount: str) -> None: + def _received_recent(self, amount: str) -> bool: + assert self.ctx.slot is not None try: display_amount = int(amount) except ValueError: @@ -391,6 +396,9 @@ def _cmd_option(self, option_name: str = "", option_value: str = "") -> None: ConfigurableOptionInfo('minerals_per_item', 'minerals_per_item', options.MineralsPerItem, ConfigurableOptionType.INTEGER), ConfigurableOptionInfo('gas_per_item', 'vespene_per_item', options.VespenePerItem, ConfigurableOptionType.INTEGER), ConfigurableOptionInfo('supply_per_item', 'starting_supply_per_item', options.StartingSupplyPerItem, ConfigurableOptionType.INTEGER), + ConfigurableOptionInfo('max_supply_per_item', 'maximum_supply_per_item', options.MaximumSupplyPerItem, ConfigurableOptionType.INTEGER), + ConfigurableOptionInfo('reduced_supply_per_item', 'maximum_supply_reduction_per_item', options.MaximumSupplyReductionPerItem, ConfigurableOptionType.INTEGER), + ConfigurableOptionInfo('lowest_max_supply', 'lowest_maximum_supply', options.LowestMaximumSupply, ConfigurableOptionType.INTEGER), ConfigurableOptionInfo('no_forced_camera', 'disable_forced_camera', options.DisableForcedCamera), ConfigurableOptionInfo('skip_cutscenes', 'skip_cutscenes', options.SkipCutscenes), ConfigurableOptionInfo('enable_morphling', 'enable_morphling', options.EnableMorphling, can_break_logic=True), @@ -622,6 +630,8 @@ def __init__(self, *args, **kwargs) -> None: self.vespene_per_item: int = 15 # For backwards compat with games generated pre-0.4.5 self.starting_supply_per_item: int = 2 # For backwards compat with games generated pre-0.4.5 self.maximum_supply_per_item: int = 2 + self.maximum_supply_reduction_per_item: int = options.MaximumSupplyReductionPerItem.default + self.lowest_maximum_supply: int = options.LowestMaximumSupply.default self.nova_covert_ops_only = False self.kerrigan_levels_per_mission_completed = 0 self.trade_enabled: int = EnableVoidTrade.default @@ -629,7 +639,7 @@ def __init__(self, *args, **kwargs) -> None: self.trade_latest_reply: typing.Optional[dict] = None self.trade_reply_event = asyncio.Event() self.trade_lock_wait: int = 0 - self.trade_lock_start: typing.Optional[int] = None + self.trade_lock_start: typing.Optional[float] = None self.trade_response: typing.Optional[str] = None self.difficulty_damage_modifier: int = DifficultyDamageModifier.default @@ -780,7 +790,9 @@ def on_package(self, cmd: str, args: dict) -> None: self.minerals_per_item = args["slot_data"].get("minerals_per_item", 15) self.vespene_per_item = args["slot_data"].get("vespene_per_item", 15) self.starting_supply_per_item = args["slot_data"].get("starting_supply_per_item", 2) - self.maximum_supply_per_item = args["slot_data"].get("maximum_supply_per_item", 2) + self.maximum_supply_per_item = args["slot_data"].get("maximum_supply_per_item", options.MaximumSupplyPerItem.default) + self.maximum_supply_reduction_per_item = args["slot_data"].get("maximum_supply_reduction_per_item", options.MaximumSupplyReductionPerItem.default) + self.lowest_maximum_supply = args["slot_data"].get("lowest_maximum_supply", options.LowestMaximumSupply.default) self.nova_covert_ops_only = args["slot_data"].get("nova_covert_ops_only", False) self.trade_enabled = args["slot_data"].get("enable_void_trade", EnableVoidTrade.option_false) self.difficulty_damage_modifier = args["slot_data"].get("difficulty_damage_modifier", DifficultyDamageModifier.option_true) @@ -861,6 +873,7 @@ def parse_mission_req_table(mission_req_table: typing.Dict[SC2Campaign, typing.D categories[mission.category] = [] mission_id = mission.mission.id sub_rules: typing.List[CountMissionsRuleData] = [] + missions: typing.List[int] if mission.number: amount = mission.number missions = [ @@ -870,7 +883,7 @@ def parse_mission_req_table(mission_req_table: typing.Dict[SC2Campaign, typing.D sub_rules.append(CountMissionsRuleData(missions, amount, [campaign_name])) prev_missions: typing.List[int] = [] if len(mission.required_world) > 0: - missions: typing.List[int] = [] + missions = [] for connection in mission.required_world: if isinstance(connection, dict): required_campaign = {} @@ -1025,7 +1038,8 @@ async def trade_acquire_storage(self, keep_trying: bool = False) -> typing.Optio if not keep_trying: return None continue - + + assert self.trade_latest_reply is not None reply = copy.deepcopy(self.trade_latest_reply) # Make sure the most recently received update was triggered by our lock attempt @@ -1278,10 +1292,8 @@ def calculate_items(ctx: SC2Context) -> typing.Dict[SC2Race, typing.List[int]]: accumulators[item_data.race][item_data.type.flag_word] += ctx.vespene_per_item elif name == item_names.STARTING_SUPPLY: accumulators[item_data.race][item_data.type.flag_word] += ctx.starting_supply_per_item - elif name == item_names.MAX_SUPPLY: - accumulators[item_data.race][item_data.type.flag_word] += ctx.maximum_supply_per_item else: - accumulators[item_data.race][item_data.type.flag_word] += item_data.number + accumulators[item_data.race][item_data.type.flag_word] += 1 # Fix Shields from generic upgrades by unit class (Maximum of ground/air upgrades) if shields_from_ground_upgrade > 0 or shields_from_air_upgrade > 0: @@ -1487,6 +1499,8 @@ class ArchipelagoBot(bot.bot_ai.BotAI): 'last_supply_used' ] ctx: SC2Context + # defined in bot_ai_internal.py; seems to be mis-annotated as a float and later re-annotated as an int + supply_used: int def __init__(self, ctx: SC2Context, mission_id: int): self.game_running = False @@ -1693,7 +1707,7 @@ def get_uncollected_objectives(self) -> typing.List[int]: result.append(0) return result - def missions_beaten_count(self): + def missions_beaten_count(self) -> int: return len([location for location in self.ctx.checked_locations if location % VICTORY_MODULO == 0]) async def update_colors(self): @@ -1704,30 +1718,43 @@ async def update_colors(self): await self.chat_send("?SetColor nova " + str(self.ctx.player_color_nova)) self.ctx.pending_color_update = False - async def update_resources(self, current_items): + async def update_resources(self, current_items: typing.Dict[SC2Race, typing.List[int]]): + DEFAULT_MAX_SUPPLY = 200 + max_supply_amount = max( + DEFAULT_MAX_SUPPLY + + ( + current_items[SC2Race.ANY][get_item_flag_word(item_names.MAX_SUPPLY)] + * self.ctx.maximum_supply_per_item + ) + - ( + current_items[SC2Race.ANY][get_item_flag_word(item_names.REDUCED_MAX_SUPPLY)] + * self.ctx.maximum_supply_reduction_per_item + ), + self.ctx.lowest_maximum_supply, + ) await self.chat_send("?GiveResources {} {} {} {}".format( current_items[SC2Race.ANY][get_item_flag_word(item_names.STARTING_MINERALS)], current_items[SC2Race.ANY][get_item_flag_word(item_names.STARTING_VESPENE)], current_items[SC2Race.ANY][get_item_flag_word(item_names.STARTING_SUPPLY)], - current_items[SC2Race.ANY][get_item_flag_word(item_names.MAX_SUPPLY)] + max_supply_amount - DEFAULT_MAX_SUPPLY, )) - async def update_terran_tech(self, current_items): + async def update_terran_tech(self, current_items: typing.Dict[SC2Race, typing.List[int]]): terran_items = current_items[SC2Race.TERRAN] await self.chat_send("?GiveTerranTech " + " ".join(map(str, terran_items))) - async def update_zerg_tech(self, current_items, kerrigan_level): + async def update_zerg_tech(self, current_items: typing.Dict[SC2Race, typing.List[int]], kerrigan_level: int): zerg_items = current_items[SC2Race.ZERG] zerg_items = [value for index, value in enumerate(zerg_items) if index not in [ZergItemType.Level.flag_word, ZergItemType.Primal_Form.flag_word]] kerrigan_primal_by_items = kerrigan_primal(self.ctx, kerrigan_level) kerrigan_primal_bot_value = 1 if kerrigan_primal_by_items else 0 await self.chat_send(f"?GiveZergTech {kerrigan_level} {kerrigan_primal_bot_value} " + ' '.join(map(str, zerg_items))) - async def update_protoss_tech(self, current_items): + async def update_protoss_tech(self, current_items: typing.Dict[SC2Race, typing.List[int]]): protoss_items = current_items[SC2Race.PROTOSS] await self.chat_send("?GiveProtossTech " + " ".join(map(str, protoss_items))) - async def update_misc_tech(self, current_items): + async def update_misc_tech(self, current_items: typing.Dict[SC2Race, typing.List[int]]): await self.chat_send("?GiveMiscTech {}".format( current_items[SC2Race.ANY][get_item_flag_word(item_names.BUILDING_CONSTRUCTION_SPEED)], )) diff --git a/worlds/sc2/gui_config.py b/worlds/sc2/gui_config.py index 7925c665a9cb..b3039da19ebb 100644 --- a/worlds/sc2/gui_config.py +++ b/worlds/sc2/gui_config.py @@ -59,7 +59,7 @@ def validate_color(color: Any, default: Tuple[float, float, float]) -> Tuple[Tup return (f'Invalid type {type(color)}; expected 3-element list or integer',), default elif len(color) != 3: return (f'Wrong number of elements in color; expected 3, got {len(color)}',), default - result: list[float] = [0.0, 0.0, 0.0] + result: List[float] = [0.0, 0.0, 0.0] errors: List[str] = [] expected = 'expected a number from 0 to 1' for index, element in enumerate(color): @@ -83,7 +83,7 @@ def get_button_color(race: str) -> Tuple[Tuple[str, ...], Tuple[float, float, fl from . import SC2World baseline_color = 0.345 # the button graphic is grey, with this value in each color channel if race == 'TERRAN': - user_color = SC2World.settings.terran_button_color + user_color: list = SC2World.settings.terran_button_color default_color = (0.0838, 0.2898, 0.2346) elif race == 'PROTOSS': user_color = SC2World.settings.protoss_button_color diff --git a/worlds/sc2/item/item_descriptions.py b/worlds/sc2/item/item_descriptions.py index 24b51b9ecb28..55dc4524afd1 100644 --- a/worlds/sc2/item/item_descriptions.py +++ b/worlds/sc2/item/item_descriptions.py @@ -563,7 +563,8 @@ def _ability_desc(unit_name_plural: str, ability_name: str, ability_description: item_names.STARTING_VESPENE: "Increases the starting vespene for all missions.", item_names.STARTING_SUPPLY: "Increases the starting supply for all missions.", item_names.NOTHING: "Does nothing. Used to remove a location from the game.", - item_names.MAX_SUPPLY: "Increases the maximum supply for all missions.", + item_names.MAX_SUPPLY: "Increases the maximum supply cap for all missions.", + item_names.REDUCED_MAX_SUPPLY: "Trap Item. Decreases the maximum supply cap for all missions.", item_names.SHIELD_REGENERATION: "Increases shield regeneration of all own units.", item_names.BUILDING_CONSTRUCTION_SPEED: "Increases building construction speed.", item_names.NOVA_GHOST_VISOR: "Reveals the locations of enemy units in the fog of war around Nova. Can detect cloaked units.", diff --git a/worlds/sc2/item/item_names.py b/worlds/sc2/item/item_names.py index 0206b1035e92..919ce212ab9b 100644 --- a/worlds/sc2/item/item_names.py +++ b/worlds/sc2/item/item_names.py @@ -876,6 +876,9 @@ MAX_SUPPLY = "Additional Maximum Supply" SHIELD_REGENERATION = "Increased Shield Regeneration" BUILDING_CONSTRUCTION_SPEED = "Increased Building Construction Speed" + +# Trap +REDUCED_MAX_SUPPLY = "Decreased Maximum Supply" NOTHING = "Nothing" # Deprecated diff --git a/worlds/sc2/item/item_tables.py b/worlds/sc2/item/item_tables.py index 16498686b0ce..2c0aa96dd80a 100644 --- a/worlds/sc2/item/item_tables.py +++ b/worlds/sc2/item/item_tables.py @@ -92,6 +92,7 @@ class FactionlessItemType(ItemTypeEnum): BuildingSpeed = "Building Speed", 4 Nothing = "Nothing Group", 5 Deprecated = "Deprecated", 6 + MaxSupplyTrap = "Max Supply Trap", 7 Keys = "Keys", -1 @@ -1084,6 +1085,11 @@ def get_full_item_list(): ItemData(806 + SC2WOL_ITEM_ID_OFFSET, FactionlessItemType.BuildingSpeed, 1, SC2Race.ANY, quantity=0, classification=ItemClassification.filler), + # Trap Filler + item_names.REDUCED_MAX_SUPPLY: + ItemData(850 + SC2WOL_ITEM_ID_OFFSET, FactionlessItemType.MaxSupplyTrap, -1, SC2Race.ANY, quantity=0, + classification=ItemClassification.trap), + # Nova gear item_names.NOVA_GHOST_VISOR: @@ -2137,13 +2143,6 @@ def get_item_table(): } -filler_items: typing.Tuple[str, ...] = ( - item_names.STARTING_MINERALS, - item_names.STARTING_VESPENE, - item_names.STARTING_SUPPLY, - item_names.MAX_SUPPLY, -) - # Defense rating table # Commented defense ratings are handled in LogicMixin tvx_defense_ratings = { diff --git a/worlds/sc2/options.py b/worlds/sc2/options.py index 234d2dbc8cd8..7db9182f0d35 100644 --- a/worlds/sc2/options.py +++ b/worlds/sc2/options.py @@ -1024,18 +1024,110 @@ class StartingSupplyPerItem(Range): class MaximumSupplyPerItem(Range): """ - Configures how much maximum supply per is given per item. + Configures how much the maximum supply limit increases per item. """ display_name = "Maximum Supply Per Item" range_start = 0 - range_end = 16 - default = 2 + range_end = 10 + default = 1 + + +class MaximumSupplyReductionPerItem(Range): + """ + Configures how much maximum supply is reduced per trap item. + """ + display_name = "Maximum Supply Reduction Per Item" + range_start = 1 + range_end = 10 + default = 1 + + +class LowestMaximumSupply(Range): + """Controls how far max supply reduction traps can reduce maximum supply.""" + display_name = "Lowest Maximum Supply" + range_start = 100 + range_end = 200 + default = 180 + + +class FillerRatio(Option[Dict[str, int]], VerifyKeys, Mapping[str, int]): + """Controls the relative probability of each filler item being generated over others.""" + default = { + item_names.STARTING_MINERALS: 1, + item_names.STARTING_VESPENE: 1, + item_names.STARTING_SUPPLY: 1, + item_names.MAX_SUPPLY: 1, + item_names.SHIELD_REGENERATION: 1, + item_names.BUILDING_CONSTRUCTION_SPEED: 1, + item_names.REDUCED_MAX_SUPPLY: 0, + } + simple_names = { + "minerals": item_names.STARTING_MINERALS, + "gas": item_names.STARTING_VESPENE, + "vespene": item_names.STARTING_VESPENE, + "supply": item_names.STARTING_SUPPLY, + "max supply": item_names.MAX_SUPPLY, + "maximum supply": item_names.MAX_SUPPLY, + "shields": item_names.SHIELD_REGENERATION, + "shield regen": item_names.SHIELD_REGENERATION, + "shield regeneration": item_names.SHIELD_REGENERATION, + "build speed": item_names.BUILDING_CONSTRUCTION_SPEED, + "construction speed": item_names.BUILDING_CONSTRUCTION_SPEED, + "supply trap": item_names.REDUCED_MAX_SUPPLY, + "reduced max supply": item_names.REDUCED_MAX_SUPPLY, + } + supports_weighting = False + display_name = "Filler Item Ratios" + minimum_value: int = 0 + + def __init__(self, value: Dict[str, int]) -> None: + self.value = {key: val for key, val in value.items()} + + @classmethod + def from_any(cls, data: Union[List[str], Dict[str, int]]) -> 'FillerRatio': + if isinstance(data, dict): + for key, value in data.items(): + if not isinstance(value, int): + raise ValueError(f"Invalid type in '{cls.display_name}': element '{key}' maps to '{value}', expected an integer") + if value < cls.minimum_value: + raise ValueError(f"Invalid value for '{cls.display_name}': element '{key}' maps to {value}, which is less than the minimum ({cls.minimum_value})") + return cls(data) + else: + raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}") + + def verify(self, world: Type['World'], player_name: str, plando_options: PlandoOptions) -> None: + """Overridden version of function from Options.VerifyKeys for a better error message""" + new_value: dict[str, int] = {} + name_mapping = { + item_name.casefold(): item_name for item_name in self.default + } + name_mapping.update(self.simple_names) + for key_name in self.value: + item_name = name_mapping.get(key_name.casefold()) + if item_name is None: + raise ValueError( + f"Unknown key {key_name} in option filler_ratio. " + f"Valid names are ({', '.join(list(self.default) + list(self.simple_names))})" + ) + new_value[item_name] = new_value.get(item_name, 0) + self.value[key_name] + self.value = new_value + def get_option_name(self, value): + return ", ".join(f"{key}: {v}" for key, v in value.items()) + + def __getitem__(self, item: str) -> int: + return self.value.__getitem__(item) + + def __iter__(self) -> Iterator[str]: + return self.value.__iter__() + + def __len__(self) -> int: + return self.value.__len__() @dataclass class Starcraft2Options(PerGameCommonOptions): - start_inventory: Sc2StartInventory + start_inventory: Sc2StartInventory # type: ignore game_difficulty: GameDifficulty difficulty_damage_modifier: DifficultyDamageModifier game_speed: GameSpeed @@ -1105,6 +1197,9 @@ class Starcraft2Options(PerGameCommonOptions): vespene_per_item: VespenePerItem starting_supply_per_item: StartingSupplyPerItem maximum_supply_per_item: MaximumSupplyPerItem + maximum_supply_reduction_per_item: MaximumSupplyReductionPerItem + lowest_maximum_supply: LowestMaximumSupply + filler_ratio: FillerRatio custom_mission_order: CustomMissionOrder @@ -1289,3 +1384,4 @@ def get_excluded_missions(world: 'SC2World') -> Set[SC2Mission]: item_names.PROGRESSIVE_PROTOSS_WEAPON_ARMOR_UPGRADE, } } + diff --git a/worlds/sc2/test/test_generation.py b/worlds/sc2/test/test_generation.py index ed5c91993742..7c1bc7002dcd 100644 --- a/worlds/sc2/test/test_generation.py +++ b/worlds/sc2/test/test_generation.py @@ -9,6 +9,20 @@ from .. import get_all_missions, get_random_first_mission +EXCLUDE_ALL_CAMPAIGNS = { + 'enable_wol_missions': False, + 'enable_prophecy_missions': False, + 'enable_hots_missions': False, + 'enable_lotv_prologue_missions': False, + 'enable_lotv_missions': False, + 'enable_epilogue_missions': False, + 'enable_nco_missions': False, +} +INCLUDE_ALL_CAMPAIGNS = { + option_name: True for option_name in EXCLUDE_ALL_CAMPAIGNS +} + + class TestItemFiltering(Sc2SetupTestBase): def test_explicit_locks_excludes_interact_and_set_flags(self): world_options = { @@ -79,11 +93,9 @@ def test_unexcludes_cancel_out_excludes(self): item_names.SCIENCE_VESSEL: 0, }, # Terran-only - 'enable_prophecy_missions': False, - 'enable_hots_missions': False, - 'enable_lotv_missions': False, - 'enable_lotv_prologue_missions': False, - 'enable_epilogue_missions': False, + **EXCLUDE_ALL_CAMPAIGNS, + 'enable_wol_missions': True, + 'enable_nco_missions': True, } self.generate_world(world_options) self.assertTrue(self.multiworld.itempool) @@ -127,13 +139,8 @@ def test_excluding_mission_groups_excludes_all_missions_in_group(self): def test_excluding_campaigns_excludes_campaign_specific_items(self) -> None: world_options = { + **EXCLUDE_ALL_CAMPAIGNS, 'enable_wol_missions': True, - 'enable_prophecy_missions': False, - 'enable_hots_missions': False, - 'enable_lotv_prologue_missions': False, - 'enable_lotv_missions': False, - 'enable_epilogue_missions': False, - 'enable_nco_missions': False, } self.generate_world(world_options) self.assertTrue(self.multiworld.itempool) @@ -146,13 +153,8 @@ def test_excluding_campaigns_excludes_campaign_specific_items(self) -> None: def test_starter_unit_populates_start_inventory(self): world_options = { + **EXCLUDE_ALL_CAMPAIGNS, 'enable_wol_missions': True, - 'enable_nco_missions': False, - 'enable_prophecy_missions': False, - 'enable_hots_missions': False, - 'enable_lotv_prologue_missions': False, - 'enable_lotv_missions': False, - 'enable_epilogue_missions': False, 'shuffle_no_build': options.ShuffleNoBuild.option_false, 'mission_order': options.MissionOrder.option_grid, 'starter_unit': options.StarterUnit.option_any_starter_unit, @@ -268,11 +270,9 @@ def test_excluding_all_protoss_build_missions_excludes_protoss_units(self) -> No def test_vanilla_items_only_excludes_terran_progressives(self) -> None: world_options = { - 'enable_prophecy_missions': False, - 'enable_hots_missions': False, - 'enable_lotv_prologue_missions': False, - 'enable_lotv_missions': False, - 'enable_epilogue_missions': False, + **EXCLUDE_ALL_CAMPAIGNS, + 'enable_wol_missions': True, + 'enable_nco_missions': True, 'mission_order': options.MissionOrder.option_grid, 'maximum_campaign_size': options.MaximumCampaignSize.range_end, 'accessibility': 'locations', @@ -326,11 +326,9 @@ def test_evil_awoken_with_vanilla_items_only_generates(self) -> None: def test_enemy_within_and_no_zerg_build_missions_generates(self) -> None: world_options = { # including WoL to allow for valid goal missions - 'enable_nco_missions': False, - 'enable_prophecy_missions': False, - 'enable_lotv_prologue_missions': False, - 'enable_lotv_missions': False, - 'enable_epilogue_missions': False, + **EXCLUDE_ALL_CAMPAIGNS, + 'enable_wol_missions': True, + 'enable_hots_missions': True, 'excluded_missions': [ mission.mission_name for mission in mission_tables.SC2Mission if mission_tables.MissionFlag.Zerg in mission.flags @@ -353,12 +351,8 @@ def test_enemy_within_and_no_zerg_build_missions_generates(self) -> None: def test_soa_items_are_included_in_wol_when_presence_set_to_everywhere(self) -> None: world_options = { - 'enable_nco_missions': False, - 'enable_prophecy_missions': False, - 'enable_lotv_prologue_missions': False, - 'enable_lotv_missions': False, - 'enable_hots_missions': False, - 'enable_epilogue_missions': False, + **EXCLUDE_ALL_CAMPAIGNS, + 'enable_wol_missions': True, 'spear_of_adun_presence': options.SpearOfAdunPresence.option_everywhere, 'mission_order': options.MissionOrder.option_grid, 'maximum_campaign_size': options.MaximumCampaignSize.range_end, @@ -373,13 +367,8 @@ def test_soa_items_are_included_in_wol_when_presence_set_to_everywhere(self) -> def test_lotv_only_doesnt_include_kerrigan_items_with_grant_story_tech(self) -> None: world_options = { - 'enable_wol_missions': False, - 'enable_nco_missions': False, - 'enable_prophecy_missions': False, - 'enable_lotv_prologue_missions': False, + **EXCLUDE_ALL_CAMPAIGNS, 'enable_lotv_missions': True, - 'enable_hots_missions': False, - 'enable_epilogue_missions': False, 'mission_order': options.MissionOrder.option_grid, 'maximum_campaign_size': options.MaximumCampaignSize.range_end, 'accessibility': 'locations', @@ -397,13 +386,8 @@ def test_lotv_only_doesnt_include_kerrigan_items_with_grant_story_tech(self) -> def test_excluding_zerg_units_with_morphling_enabled_doesnt_exclude_aspects(self) -> None: world_options = { - 'enable_wol_missions': False, - 'enable_prophecy_missions': False, + **EXCLUDE_ALL_CAMPAIGNS, 'enable_hots_missions': True, - 'enable_lotv_prologue_missions': False, - 'enable_lotv_missions': False, - 'enable_epilogue_missions': False, - 'enable_nco_missions': False, 'required_tactics': options.RequiredTactics.option_no_logic, 'enable_morphling': options.EnableMorphling.option_true, 'excluded_items': [ @@ -424,13 +408,8 @@ def test_excluding_zerg_units_with_morphling_enabled_doesnt_exclude_aspects(self def test_excluding_zerg_units_with_morphling_disabled_should_exclude_aspects(self) -> None: world_options = { - 'enable_wol_missions': False, - 'enable_prophecy_missions': False, + **EXCLUDE_ALL_CAMPAIGNS, 'enable_hots_missions': True, - 'enable_lotv_prologue_missions': False, - 'enable_lotv_missions': False, - 'enable_epilogue_missions': False, - 'enable_nco_missions': False, 'required_tactics': options.RequiredTactics.option_no_logic, 'enable_morphling': options.EnableMorphling.option_false, 'excluded_items': [ @@ -456,7 +435,6 @@ def test_deprecated_orbital_command_not_present(self) -> None: """ Orbital command got replaced. The item is still there for backwards compatibility. It shouldn't be generated. - :return: """ world_options = {} @@ -487,13 +465,10 @@ def test_planetary_orbital_module_not_present_without_cc_spells(self) -> None: def test_disabling_unit_nerfs_start_inventories_war_council_upgrades(self) -> None: world_options = { - 'enable_wol_missions': False, + **EXCLUDE_ALL_CAMPAIGNS, 'enable_prophecy_missions': True, - 'enable_hots_missions': False, 'enable_lotv_prologue_missions': True, 'enable_lotv_missions': True, - 'enable_epilogue_missions': False, - 'enable_nco_missions': False, 'mission_order': options.MissionOrder.option_grid, 'nerf_unit_baselines': options.NerfUnitBaselines.option_false, } @@ -511,13 +486,8 @@ def test_disabling_unit_nerfs_start_inventories_war_council_upgrades(self) -> No def test_disabling_speedrun_locations_removes_them_from_the_pool(self) -> None: world_options = { - 'enable_wol_missions': False, - 'enable_prophecy_missions': False, + **EXCLUDE_ALL_CAMPAIGNS, 'enable_hots_missions': True, - 'enable_lotv_prologue_missions': False, - 'enable_lotv_missions': False, - 'enable_epilogue_missions': False, - 'enable_nco_missions': False, 'mission_order': options.MissionOrder.option_grid, 'speedrun_locations': options.SpeedrunLocations.option_disabled, 'preventative_locations': options.PreventativeLocations.option_resources, @@ -533,11 +503,9 @@ def test_disabling_speedrun_locations_removes_them_from_the_pool(self) -> None: def test_nco_and_wol_picks_correct_starting_mission(self): world_options = { - 'enable_prophecy_missions': False, - 'enable_hots_missions': False, - 'enable_lotv_prologue_missions': False, - 'enable_lotv_missions': False, - 'enable_epilogue_missions': False, + **EXCLUDE_ALL_CAMPAIGNS, + 'enable_wol_missions': True, + 'enable_nco_missions': True, } self.generate_world(world_options) self.assertEqual(get_random_first_mission(self.world, self.world.custom_mission_order), mission_tables.SC2Mission.LIBERATION_DAY) @@ -550,12 +518,8 @@ def test_excluding_mission_short_name_excludes_all_variants_of_mission(self): 'mission_order': options.MissionOrder.option_grid, 'selected_races': options.SelectRaces.option_all, 'enable_race_swap': options.EnableRaceSwapVariants.option_shuffle_all, - 'enable_prophecy_missions': False, - 'enable_hots_missions': False, - 'enable_lotv_prologue_missions': False, - 'enable_lotv_missions': False, - 'enable_epilogue_missions': False, - 'enable_nco_missions': False, + **EXCLUDE_ALL_CAMPAIGNS, + 'enable_wol_missions': True, } self.generate_world(world_options) missions = get_all_missions(self.world.custom_mission_order) @@ -572,12 +536,8 @@ def test_excluding_mission_variant_excludes_just_that_variant(self): 'mission_order': options.MissionOrder.option_grid, 'selected_races': options.SelectRaces.option_all, 'enable_race_swap': options.EnableRaceSwapVariants.option_shuffle_all, - 'enable_prophecy_missions': False, - 'enable_hots_missions': False, - 'enable_lotv_prologue_missions': False, - 'enable_lotv_missions': False, - 'enable_epilogue_missions': False, - 'enable_nco_missions': False, + **EXCLUDE_ALL_CAMPAIGNS, + 'enable_wol_missions': True, } self.generate_world(world_options) missions = get_all_missions(self.world.custom_mission_order) @@ -591,12 +551,8 @@ def test_weapon_armor_upgrades(self): # Vanilla WoL with all missions 'mission_order': options.MissionOrder.option_vanilla, 'starter_unit': options.StarterUnit.option_off, - 'enable_prophecy_missions': False, - 'enable_hots_missions': False, - 'enable_lotv_prologue_missions': False, - 'enable_lotv_missions': False, - 'enable_epilogue_missions': False, - 'enable_nco_missions': False, + **EXCLUDE_ALL_CAMPAIGNS, + 'enable_wol_missions': True, 'start_inventory': { item_names.GOLIATH: 1 # Don't fail with early item placement }, @@ -632,12 +588,8 @@ def test_weapon_armor_upgrades_with_bundles(self): # Vanilla WoL with all missions 'mission_order': options.MissionOrder.option_vanilla, 'starter_unit': options.StarterUnit.option_off, - 'enable_prophecy_missions': False, - 'enable_hots_missions': False, - 'enable_lotv_prologue_missions': False, - 'enable_lotv_missions': False, - 'enable_epilogue_missions': False, - 'enable_nco_missions': False, + **EXCLUDE_ALL_CAMPAIGNS, + 'enable_wol_missions': True, 'start_inventory': { item_names.GOLIATH: 1 # Don't fail with early item placement }, @@ -673,12 +625,8 @@ def test_weapon_armor_upgrades_all_in_air(self): # Vanilla WoL with all missions 'mission_order': options.MissionOrder.option_vanilla, 'starter_unit': options.StarterUnit.option_off, - 'enable_prophecy_missions': False, - 'enable_hots_missions': False, - 'enable_lotv_prologue_missions': False, - 'enable_lotv_missions': False, - 'enable_epilogue_missions': False, - 'enable_nco_missions': False, + **EXCLUDE_ALL_CAMPAIGNS, + 'enable_wol_missions': True, 'all_in_map': options.AllInMap.option_air, # All-in air forces an air unit 'start_inventory': { item_names.GOLIATH: 1 # Don't fail with early item placement @@ -715,12 +663,8 @@ def test_weapon_armor_upgrades_generic_upgrade_missions(self): 'mission_order': options.MissionOrder.option_vanilla, 'required_tactics': options.RequiredTactics.option_standard, 'starter_unit': options.StarterUnit.option_off, - 'enable_prophecy_missions': False, - 'enable_hots_missions': False, - 'enable_lotv_prologue_missions': False, - 'enable_lotv_missions': False, - 'enable_epilogue_missions': False, - 'enable_nco_missions': False, + **EXCLUDE_ALL_CAMPAIGNS, + 'enable_wol_missions': True, 'all_in_map': options.AllInMap.option_air, # All-in air forces an air unit 'start_inventory': { item_names.GOLIATH: 1 # Don't fail with early item placement @@ -749,12 +693,8 @@ def test_weapon_armor_upgrades_generic_upgrade_missions_no_logic(self): 'mission_order': options.MissionOrder.option_vanilla, 'required_tactics': options.RequiredTactics.option_no_logic, 'starter_unit': options.StarterUnit.option_off, - 'enable_prophecy_missions': False, - 'enable_hots_missions': False, - 'enable_lotv_prologue_missions': False, - 'enable_lotv_missions': False, - 'enable_epilogue_missions': False, - 'enable_nco_missions': False, + **EXCLUDE_ALL_CAMPAIGNS, + 'enable_wol_missions': True, 'all_in_map': options.AllInMap.option_air, # All-in air forces an air unit 'start_inventory': { item_names.GOLIATH: 1 # Don't fail with early item placement @@ -776,12 +716,8 @@ def test_weapon_armor_upgrades_generic_upgrade_missions_no_countermeasure_needed 'mission_order': options.MissionOrder.option_vanilla, 'required_tactics': options.RequiredTactics.option_standard, 'starter_unit': options.StarterUnit.option_off, - 'enable_prophecy_missions': False, - 'enable_hots_missions': False, - 'enable_lotv_prologue_missions': False, - 'enable_lotv_missions': False, - 'enable_epilogue_missions': False, - 'enable_nco_missions': False, + **EXCLUDE_ALL_CAMPAIGNS, + 'enable_wol_missions': True, 'all_in_map': options.AllInMap.option_air, # All-in air forces an air unit 'start_inventory': { item_names.GOLIATH: 1 # Don't fail with early item placement @@ -844,13 +780,7 @@ def test_fully_balanced_mission_races(self): # Reasonably large grid with enough missions to balance races 'mission_order': options.MissionOrder.option_grid, 'maximum_campaign_size': campaign_size, - 'enable_wol_missions': True, - 'enable_prophecy_missions': True, - 'enable_hots_missions': True, - 'enable_lotv_prologue_missions': True, - 'enable_lotv_missions': True, - 'enable_epilogue_missions': True, - 'enable_nco_missions': True, + **INCLUDE_ALL_CAMPAIGNS, 'selected_races': options.SelectRaces.option_all, 'enable_race_swap': options.EnableRaceSwapVariants.option_shuffle_all, 'mission_race_balancing': options.EnableMissionRaceBalancing.option_fully_balanced, @@ -865,3 +795,71 @@ def test_fully_balanced_mission_races(self): self.assertEqual(race_counts[mission_tables.MissionFlag.Terran], race_counts[mission_tables.MissionFlag.Zerg]) self.assertEqual(race_counts[mission_tables.MissionFlag.Zerg], race_counts[mission_tables.MissionFlag.Protoss]) + + def test_setting_filter_weight_to_zero_excludes_that_item(self) -> None: + world_options = { + 'filler_ratio': { + 'minerals': 0, + 'gas': 1, + 'supply': 0, + 'max supply': 0, + 'supply trap': 0, + 'shields': 0, + 'build speed': 0, + }, + # Exclude many items to get filler to generate + 'excluded_items': { + item_groups.ItemGroupNames.TERRAN_VETERANCY_UNITS: 0, + }, + 'max_number_of_upgrades': 2, + 'mission_order': options.MissionOrder.option_grid, + 'selected_races': options.SelectRaces.option_terran, + 'enable_race_swap': options.EnableRaceSwapVariants.option_shuffle_all, + } + + self.generate_world(world_options) + itempool = [item.name for item in self.multiworld.itempool] + + self.assertNotIn(item_names.STARTING_MINERALS, itempool) + self.assertNotIn(item_names.STARTING_SUPPLY, itempool) + self.assertNotIn(item_names.MAX_SUPPLY, itempool) + self.assertNotIn(item_names.REDUCED_MAX_SUPPLY, itempool) + self.assertNotIn(item_names.SHIELD_REGENERATION, itempool) + self.assertNotIn(item_names.BUILDING_CONSTRUCTION_SPEED, itempool) + + self.assertIn(item_names.STARTING_VESPENE, itempool) + + def test_shields_filler_doesnt_appear_if_no_protoss_missions_appear(self) -> None: + world_options = { + 'filler_ratio': { + 'minerals': 1, + 'gas': 0, + 'supply': 0, + 'max supply': 0, + 'supply trap': 1, + 'shields': 1, + 'build speed': 0, + }, + # Exclude many items to get filler to generate + 'excluded_items': { + item_groups.ItemGroupNames.TERRAN_VETERANCY_UNITS: 0, + item_groups.ItemGroupNames.ZERG_MORPHS: 0, + }, + 'max_number_of_upgrades': 2, + 'mission_order': options.MissionOrder.option_grid, + 'selected_races': options.SelectRaces.option_terran_and_zerg, + 'enable_race_swap': options.EnableRaceSwapVariants.option_shuffle_all, + } + + self.generate_world(world_options) + itempool = [item.name for item in self.multiworld.itempool] + + self.assertNotIn(item_names.SHIELD_REGENERATION, itempool) + + self.assertNotIn(item_names.STARTING_VESPENE, itempool) + self.assertNotIn(item_names.STARTING_SUPPLY, itempool) + self.assertNotIn(item_names.MAX_SUPPLY, itempool) + self.assertNotIn(item_names.BUILDING_CONSTRUCTION_SPEED, itempool) + + self.assertIn(item_names.STARTING_MINERALS, itempool) + self.assertIn(item_names.REDUCED_MAX_SUPPLY, itempool) diff --git a/worlds/sc2/test/test_item_filtering.py b/worlds/sc2/test/test_item_filtering.py index 5091b969444b..ff006f29d667 100644 --- a/worlds/sc2/test/test_item_filtering.py +++ b/worlds/sc2/test/test_item_filtering.py @@ -45,6 +45,10 @@ def test_excluding_one_item_of_multi_parent_doesnt_filter_children(self) -> None item_names.ENERGIZER: 1, item_names.AVENGER: 1, item_names.ARBITER: 1, + item_names.VOID_RAY: 1, + item_names.WARP_RAY: 1, + item_names.DESTROYER: 1, + item_names.DAWNBRINGER: 1, }, 'min_number_of_upgrades': 2, 'required_tactics': 'standard',