diff --git a/worlds/sc2/__init__.py b/worlds/sc2/__init__.py index e13a487c0e51..7a460a2552b7 100644 --- a/worlds/sc2/__init__.py +++ b/worlds/sc2/__init__.py @@ -11,7 +11,7 @@ from . import item_names from .items import ( StarcraftItem, filler_items, get_full_item_list, ProtossItemType, - get_basic_units, ItemData, upgrade_included_names, kerrigan_actives, kerrigan_passives, + ItemData, kerrigan_actives, kerrigan_passives, not_balanced_starting_units, ) from . import items @@ -23,7 +23,9 @@ KerriganPresence, KerriganPrimalStatus, kerrigan_unit_available, StarterUnit, SpearOfAdunPresence, get_enabled_campaigns, SpearOfAdunAutonomouslyCastAbilityPresence, Starcraft2Options, GrantStoryTech, GenericUpgradeResearch, GenericUpgradeItems, RequiredTactics, + upgrade_included_names ) +from .rules import get_basic_units from . import settings from .pool_filter import filter_items from .mission_tables import ( @@ -45,8 +47,12 @@ class ItemFilterFlags(enum.IntFlag): Excluded = enum.auto() AllowedOrphan = enum.auto() """Used to flag items that shouldn't be filtered out with their parents""" + ForceProgression = enum.auto() + """Used to flag items that aren't classified as progression by default""" + Necessary = enum.auto() + """Used to flag items that are never allowed to be culled""" - Unremovable = Locked|StartInventory|Plando + Unremovable = Locked|StartInventory|Plando|Necessary @dataclass @@ -145,6 +151,7 @@ def create_items(self): flag_user_excluded_item_sets(self, item_list) flag_war_council_items(self, item_list) flag_and_add_resource_locations(self, item_list) + flag_mission_order_required_items(self, item_list) pool: List[Item] = prune_item_pool(self, item_list) pad_item_pool_with_filler(self, len(self.location_cache) - len(self.locked_locations) - len(pool), pool) @@ -156,7 +163,10 @@ def set_rules(self) -> None: if self.options.required_tactics == RequiredTactics.option_no_logic: # Forcing completed goal and minimal accessibility on no logic self.options.accessibility.value = Accessibility.option_minimal - self.multiworld.completion_condition[self.player] = lambda state: True + required_items = self.custom_mission_order.get_items_to_lock() + self.multiworld.completion_condition[self.player] = lambda state, required_items=required_items: all( + 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) @@ -631,6 +641,17 @@ def flag_and_add_resource_locations(world: SC2World, item_list: List[FilterItem] world.locked_locations.append(location.name) +def flag_mission_order_required_items(world: SC2World, item_list: List[FilterItem]) -> None: + """Marks items that are necessary for item rules in the mission order and forces them to be progression.""" + locks_required = world.custom_mission_order.get_items_to_lock() + locks_done = {item: 0 for item in locks_required} + for item in item_list: + if item.name in locks_required and locks_done[item.name] < locks_required[item.name]: + item.flags |= ItemFilterFlags.Necessary + item.flags |= ItemFilterFlags.ForceProgression + locks_done[item.name] += 1 + + def prune_item_pool(world: SC2World, item_list: List[FilterItem]) -> List[Item]: """Prunes the item pool size to be less than the number of available locations""" @@ -648,17 +669,22 @@ def prune_item_pool(world: SC2World, item_list: List[FilterItem]) -> List[Item]: pool: List[Item] = [] locked_items: List[Item] = [] existing_items: List[Item] = [] + necessary_items: List[Item] = [] for item in item_list: ap_item = create_item_with_correct_settings(world.player, item.name) + if ItemFilterFlags.ForceProgression in item.flags: + ap_item.classification = ItemClassification.progression if ItemFilterFlags.StartInventory in item.flags: existing_items.append(ap_item) + elif ItemFilterFlags.Necessary in item.flags: + necessary_items.append(ap_item) elif ItemFilterFlags.Locked in item.flags: locked_items.append(ap_item) else: pool.append(ap_item) fill_pool_with_kerrigan_levels(world, pool) - filtered_pool = filter_items(world, world.location_cache, pool, existing_items, locked_items) + filtered_pool = filter_items(world, world.location_cache, pool, existing_items, locked_items, necessary_items) return filtered_pool diff --git a/worlds/sc2/client.py b/worlds/sc2/client.py index e767e3dbb84d..5f58bb4a0932 100644 --- a/worlds/sc2/client.py +++ b/worlds/sc2/client.py @@ -54,7 +54,7 @@ from worlds._sc2common.bot.player import Bot from .items import ( lookup_id_to_name, get_full_item_list, ItemData, - race_to_item_type, ZergItemType, ProtossItemType, upgrade_bundles, upgrade_included_names, + race_to_item_type, ZergItemType, ProtossItemType, upgrade_bundles, WEAPON_ARMOR_UPGRADE_MAX_LEVEL, ) from .locations import SC2WOL_LOC_ID_OFFSET, LocationType, LocationFlag, SC2HOTS_LOC_ID_OFFSET @@ -64,7 +64,7 @@ ) import colorama -from .options import Option +from .options import Option, upgrade_included_names from NetUtils import ClientStatus, NetworkItem, JSONtoTextParser, JSONMessagePart, add_json_item, add_json_location, add_json_text, JSONTypes from MultiServer import mark_raw @@ -1389,20 +1389,24 @@ def calc_available_nodes(ctx: SC2Context) -> typing.Tuple[typing.List[int], typi available_campaigns: typing.List[int] = [] beaten_missions = {mission_id for mission_id in ctx.mission_id_to_entry_rules if ctx.is_mission_completed(mission_id)} + received_items: typing.Dict[int, int] = {} + for network_item in ctx.items_received: + received_items.setdefault(network_item.item, 0) + received_items[network_item.item] += 1 accessible_rules: typing.Set[int] = set() for campaign_idx, campaign in enumerate(ctx.custom_mission_order): available_layouts[campaign_idx] = [] - if campaign.entry_rule.is_accessible(beaten_missions, ctx.mission_id_to_entry_rules, accessible_rules, set()): + if campaign.entry_rule.is_accessible(beaten_missions, received_items, ctx.mission_id_to_entry_rules, accessible_rules, set()): available_campaigns.append(campaign_idx) for layout_idx, layout in enumerate(campaign.layouts): - if layout.entry_rule.is_accessible(beaten_missions, ctx.mission_id_to_entry_rules, accessible_rules, set()): + if layout.entry_rule.is_accessible(beaten_missions, received_items, ctx.mission_id_to_entry_rules, accessible_rules, set()): available_layouts[campaign_idx].append(layout_idx) for column in layout.missions: for mission in column: if mission.mission_id == -1: continue - if mission.entry_rule.is_accessible(beaten_missions, ctx.mission_id_to_entry_rules, accessible_rules, set()): + if mission.entry_rule.is_accessible(beaten_missions, received_items, ctx.mission_id_to_entry_rules, accessible_rules, set()): available_missions.append(mission.mission_id) return available_missions, available_layouts, available_campaigns diff --git a/worlds/sc2/docs/en_Custom Mission Orders.md b/worlds/sc2/docs/en_Custom Mission Orders.md index db85ae3812be..1ee56086b73a 100644 --- a/worlds/sc2/docs/en_Custom Mission Orders.md +++ b/worlds/sc2/docs/en_Custom Mission Orders.md @@ -262,7 +262,7 @@ entry_rules: [] ``` This defines access restrictions for parts of the mission order. -There are three available rules: +These are the available rules: ```yaml entry_rules: # Beat these things ("Beat rule") @@ -270,10 +270,14 @@ entry_rules: # Beat X amount of missions from these things ("Count rule") - scope: [] amount: -1 + # Find these items ("Item rule") + - items: {} # Fulfill X amount of other conditions ("Subrule rule") - rules: [] amount: -1 ``` +Note that Item rules take both a name and amount for each item (see the example below). In general this rule treats items like the `locked_items` option, but as a notable difference all items required for Item rules are marked as progression. If multiple Item rules require the same item, the largest required amount will be locked, **not** the sum of all amounts. + The Beat and Count rules both require a list of scopes. This list accepts addresses towards other parts of the mission order. The basic form of an address is `//`, where `` and `` are the definition names (not `display_names`!) of a campaign and a layout within that campaign, and `` is the index of a mission slot in that layout. The indices of mission slots are determined by the layout's type. @@ -296,6 +300,12 @@ Below are examples of the available entry rules: Some Missions: type: grid size: 9 + entry_rules: + # Item rule: + # To access the Some Missions layout, + # you have to find or receive your Marine + - items: + Marine: 1 Wings of Liberty: Mar Sara: diff --git a/worlds/sc2/items.py b/worlds/sc2/items.py index c51c6632a6a4..77696177b770 100644 --- a/worlds/sc2/items.py +++ b/worlds/sc2/items.py @@ -4,7 +4,6 @@ import typing import enum -from .options import get_option_value, RequiredTactics, GenericUpgradeItems from .mission_tables import SC2Mission, SC2Race, SC2Campaign, campaign_mission_table from . import item_names from worlds.AutoWorld import World @@ -1964,16 +1963,6 @@ def get_item_table(): } -def get_basic_units(world: 'SC2World', race: SC2Race) -> typing.Set[str]: - logic_level = get_option_value(world, 'required_tactics') - if logic_level == RequiredTactics.option_no_logic: - return no_logic_basic_units[race] - elif logic_level == RequiredTactics.option_advanced: - return advanced_basic_units[race] - else: - return basic_units[race] - - # Items that can be placed before resources if not already in # General upgrades and Mercs second_pass_placeable_items: typing.Tuple[str, ...] = ( @@ -2289,50 +2278,6 @@ def get_basic_units(world: 'SC2World', race: SC2Race) -> typing.Set[str]: ], } -# Names of upgrades to be included for different options -upgrade_included_names: Dict[GenericUpgradeItems, Set[str]] = { - GenericUpgradeItems.option_individual_items: { - item_names.PROGRESSIVE_TERRAN_INFANTRY_WEAPON, - item_names.PROGRESSIVE_TERRAN_INFANTRY_ARMOR, - item_names.PROGRESSIVE_TERRAN_VEHICLE_WEAPON, - item_names.PROGRESSIVE_TERRAN_VEHICLE_ARMOR, - item_names.PROGRESSIVE_TERRAN_SHIP_WEAPON, - item_names.PROGRESSIVE_TERRAN_SHIP_ARMOR, - item_names.PROGRESSIVE_ZERG_MELEE_ATTACK, - item_names.PROGRESSIVE_ZERG_MISSILE_ATTACK, - item_names.PROGRESSIVE_ZERG_GROUND_CARAPACE, - item_names.PROGRESSIVE_ZERG_FLYER_ATTACK, - item_names.PROGRESSIVE_ZERG_FLYER_CARAPACE, - item_names.PROGRESSIVE_PROTOSS_GROUND_WEAPON, - item_names.PROGRESSIVE_PROTOSS_GROUND_ARMOR, - item_names.PROGRESSIVE_PROTOSS_SHIELDS, - item_names.PROGRESSIVE_PROTOSS_AIR_WEAPON, - item_names.PROGRESSIVE_PROTOSS_AIR_ARMOR, - }, - GenericUpgradeItems.option_bundle_weapon_and_armor: { - item_names.PROGRESSIVE_TERRAN_WEAPON_UPGRADE, - item_names.PROGRESSIVE_TERRAN_ARMOR_UPGRADE, - item_names.PROGRESSIVE_ZERG_WEAPON_UPGRADE, - item_names.PROGRESSIVE_ZERG_ARMOR_UPGRADE, - item_names.PROGRESSIVE_PROTOSS_WEAPON_UPGRADE, - item_names.PROGRESSIVE_PROTOSS_ARMOR_UPGRADE, - }, - GenericUpgradeItems.option_bundle_unit_class: { - item_names.PROGRESSIVE_TERRAN_INFANTRY_UPGRADE, - item_names.PROGRESSIVE_TERRAN_VEHICLE_UPGRADE, - item_names.PROGRESSIVE_TERRAN_SHIP_UPGRADE, - item_names.PROGRESSIVE_ZERG_GROUND_UPGRADE, - item_names.PROGRESSIVE_ZERG_FLYER_UPGRADE, - item_names.PROGRESSIVE_PROTOSS_GROUND_UPGRADE, - item_names.PROGRESSIVE_PROTOSS_AIR_UPGRADE, - }, - GenericUpgradeItems.option_bundle_all: { - item_names.PROGRESSIVE_TERRAN_WEAPON_ARMOR_UPGRADE, - item_names.PROGRESSIVE_ZERG_WEAPON_ARMOR_UPGRADE, - item_names.PROGRESSIVE_PROTOSS_WEAPON_ARMOR_UPGRADE, - } -} - lookup_id_to_name: typing.Dict[int, str] = {data.code: item_name for item_name, data in get_full_item_list().items() if data.code} diff --git a/worlds/sc2/mission_order/entry_rules.py b/worlds/sc2/mission_order/entry_rules.py index d78613e96e87..1b03f337fbf8 100644 --- a/worlds/sc2/mission_order/entry_rules.py +++ b/worlds/sc2/mission_order/entry_rules.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from ..mission_tables import SC2Mission +from ..items import item_table from BaseClasses import CollectionState if TYPE_CHECKING: @@ -67,7 +68,8 @@ def shows_single_rule(self) -> bool: @abstractmethod def is_accessible( - self, beaten_missions: Set[int], mission_id_to_entry_rules: Dict[int, MissionEntryRules], + self, beaten_missions: Set[int], received_items: Dict[int, int], + mission_id_to_entry_rules: Dict[int, MissionEntryRules], accessible_rules: Set[int], seen_rules: Set[int] ) -> bool: return False @@ -117,7 +119,8 @@ def shows_single_rule(self) -> bool: return len(self.visual_reqs) == 1 def is_accessible( - self, beaten_missions: Set[int], mission_id_to_entry_rules: Dict[int, MissionEntryRules], + self, beaten_missions: Set[int], received_items: Dict[int, int], + mission_id_to_entry_rules: Dict[int, MissionEntryRules], accessible_rules: Set[int], seen_rules: Set[int] ) -> bool: # Beat rules are accessible if all their missions are beaten and accessible @@ -125,7 +128,7 @@ def is_accessible( return False for mission_id in self.mission_ids: for rule in mission_id_to_entry_rules[mission_id]: - if not rule.is_accessible(beaten_missions, mission_id_to_entry_rules, accessible_rules, seen_rules): + if not rule.is_accessible(beaten_missions, received_items, mission_id_to_entry_rules, accessible_rules, seen_rules): return False return True @@ -193,13 +196,14 @@ def shows_single_rule(self) -> bool: return len(self.visual_reqs) == 1 def is_accessible( - self, beaten_missions: Set[int], mission_id_to_entry_rules: Dict[int, MissionEntryRules], + self, beaten_missions: Set[int], received_items: Dict[int, int], + mission_id_to_entry_rules: Dict[int, MissionEntryRules], accessible_rules: Set[int], seen_rules: Set[int] ) -> bool: # Count rules are accessible if enough of their missions are beaten and accessible return self.amount <= sum( all( - rule.is_accessible(beaten_missions, mission_id_to_entry_rules, accessible_rules, seen_rules) + rule.is_accessible(beaten_missions, received_items, mission_id_to_entry_rules, accessible_rules, seen_rules) for rule in mission_id_to_entry_rules[mission_id] ) for mission_id in beaten_missions.intersection(self.mission_ids) @@ -259,6 +263,13 @@ def parse_from_dict(data: Dict[str, Any]) -> SubRuleRuleData: for rule_data in data["sub_rules"]: if "sub_rules" in rule_data: rule = SubRuleRuleData.parse_from_dict(rule_data) + elif "item_ids" in rule_data: + # Slot data converts Dict[int, int] to Dict[str, int] for some reason + item_ids = {int(item): item_amount for (item, item_amount) in rule_data["item_ids"].items()} + rule = ItemRuleData( + item_ids, + rule_data["visual_reqs"] + ) elif "amount" in rule_data: rule = CountMissionsRuleData( **{field: value for field, value in rule_data.items()} @@ -296,7 +307,8 @@ def shows_single_rule(self) -> bool: return self.amount == len(self.sub_rules) == 1 and self.sub_rules[0].shows_single_rule() def is_accessible( - self, beaten_missions: Set[int], mission_id_to_entry_rules: Dict[int, MissionEntryRules], + self, beaten_missions: Set[int], received_items: Dict[int, int], + mission_id_to_entry_rules: Dict[int, MissionEntryRules], accessible_rules: Set[int], seen_rules: Set[int] ) -> bool: # Early exit check for top-level entry rules @@ -310,10 +322,64 @@ def is_accessible( seen_rules.add(self.rule_id) # Sub-rule rules are accessible if enough of their child rules are accessible if self.amount <= sum( - rule.is_accessible(beaten_missions, mission_id_to_entry_rules, accessible_rules, seen_rules) + rule.is_accessible(beaten_missions, received_items, mission_id_to_entry_rules, accessible_rules, seen_rules) for rule in self.sub_rules ): if self.rule_id >= 0: accessible_rules.add(self.rule_id) return True return False + +class ItemEntryRule(EntryRule): + items_to_check: Dict[str, int] + + def __init__(self, items_to_check: Dict[str, int]) -> None: + super().__init__() + self.items_to_check = items_to_check + + def _is_fulfilled(self, beaten_missions: Set[SC2MOGenMission]) -> bool: + # Region creation should assume items can be placed + return True + + def _get_depth(self, beaten_missions: Set[SC2MOGenMission]) -> int: + # Depth 0 means this rule requires 0 prior beaten missions + return 0 + + def to_lambda(self, player: int) -> Callable[[CollectionState], bool]: + return lambda state: state.has_all_counts(self.items_to_check, player) + + def to_slot_data(self) -> RuleData: + item_ids = {item_table[item].code: amount for (item, amount) in self.items_to_check.items()} + visual_reqs = [item if amount == 1 else str(amount) + "x " + item for (item, amount) in self.items_to_check.items()] + return ItemRuleData( + item_ids, + visual_reqs + ) + +@dataclass +class ItemRuleData(RuleData): + item_ids: Dict[int, int] + visual_reqs: List[str] + + def tooltip(self, indents: int, missions: Dict[int, SC2Mission]) -> str: + indent = " ".join("" for _ in range(indents)) + if len(self.visual_reqs) == 1: + return f"Find {self.visual_reqs[0]}" + tooltip = f"Find all of these:\n{indent}- " + tooltip += f"\n{indent}- ".join(req for req in self.visual_reqs) + return tooltip + + def shows_single_rule(self) -> bool: + return len(self.visual_reqs) == 1 + + def is_accessible( + self, beaten_missions: Set[int], received_items: Dict[int, int], + mission_id_to_entry_rules: Dict[int, MissionEntryRules], + accessible_rules: Set[int], seen_rules: Set[int] + ) -> bool: + return all( + item in received_items and received_items[item] >= amount + for (item, amount) in self.item_ids.items() + ) + + diff --git a/worlds/sc2/mission_order/options.py b/worlds/sc2/mission_order/options.py index 638adb0e257d..9821a74dac31 100644 --- a/worlds/sc2/mission_order/options.py +++ b/worlds/sc2/mission_order/options.py @@ -9,6 +9,8 @@ from ..mission_tables import lookup_name_to_mission from ..mission_groups import mission_groups +from ..items import item_table +from ..item_groups import item_name_groups from .structs import Difficulty, LayoutType from .layout_types import Column, Grid, Hopscotch, Gauntlet, Blitz from .presets_static import ( @@ -73,7 +75,10 @@ BeatMissionsEntryRule = { "scope": [str], } -EntryRule = Or(SubRuleEntryRule, MissionCountEntryRule, BeatMissionsEntryRule) +ItemEntryRule = { + "items": {str: int} +} +EntryRule = Or(SubRuleEntryRule, MissionCountEntryRule, BeatMissionsEntryRule, ItemEntryRule) class CustomMissionOrder(OptionDict): """ @@ -211,7 +216,6 @@ def __init__(self, yaml_value: Dict[str, Dict[str, Any]]): ordered_layouts[key].update(layouts[key]) else: ordered_layouts[key] = layouts[key] - # Campaign values = default options (except for default layouts) + preset options (except for layouts) + campaign options self.value[campaign] = {key: value for (key, value) in self.default["Default Campaign"].items() if type(value) != dict} @@ -220,8 +224,6 @@ def __init__(self, yaml_value: Dict[str, Dict[str, Any]]): _resolve_special_options(self.value[campaign]) for layout in ordered_layouts: - # if type(value[campaign][layout]) != dict: - # continue # Layout values = default options + campaign's global options + layout options self.value[campaign][layout] = copy.deepcopy(self.default["Default Campaign"][GLOBAL_ENTRY]) self.value[campaign][layout].update(global_dict) @@ -301,16 +303,32 @@ def _resolve_entry_rule(option_value: Dict[str, Any]) -> Dict[str, Any]: resolved["amount"] = _resolve_potential_range(option_value["amount"]) if "scope" in option_value: # A scope may be a list or a single address - # Since addresses can be a single index, they may be ranges if type(option_value["scope"]) == list: - resolved["scope"] = [_resolve_potential_range(str(subscope)) for subscope in option_value["scope"]] + resolved["scope"] = [str(subscope) for subscope in option_value["scope"]] else: - resolved["scope"] = [_resolve_potential_range(str(option_value["scope"]))] + resolved["scope"] = [str(option_value["scope"])] if "rules" in option_value: resolved["rules"] = [_resolve_entry_rule(subrule) for subrule in option_value["rules"]] # Make sure sub-rule rules have a specified amount if not "amount" in option_value: resolved["amount"] = -1 + if "items" in option_value: + option_items: Dict[str, Any] = option_value["items"] + resolved_items = {item: int(_resolve_potential_range(str(amount))) for (item, amount) in option_items.items()} + resolved_items = _resolve_item_names(resolved_items) + resolved["items"] = {} + for item in resolved_items: + if item not in item_table: + raise ValueError(f"Item rule contains \"{item}\", which is not a valid item name.") + amount = max(0, resolved_items[item]) + quantity = item_table[item].quantity + if amount == 0: + final_amount = quantity + elif quantity == 0: + final_amount = amount + else: + final_amount = min(quantity, amount) + resolved["items"][item] = final_amount return resolved def _resolve_potential_range(option_value: Union[Any, str]) -> Union[Any, int]: @@ -382,3 +400,17 @@ def _custom_range(text: str) -> int: def _triangular(lower: int, end: int, tri: typing.Optional[int] = None) -> int: return int(round(random.triangular(lower, end, tri), 0)) + +# Version of options.Sc2ItemDict.verify without World +def _resolve_item_names(value: Dict[str, int]) -> Dict[str, int]: + new_value: dict[str, int] = {} + case_insensitive_group_mapping = { + group_name.casefold(): group_value for group_name, group_value in item_name_groups.items() + } + case_insensitive_group_mapping.update({item.casefold(): {item} for item in item_table}) + for group_name in value: + item_names = case_insensitive_group_mapping.get(group_name.casefold(), {group_name}) + for item_name in item_names: + new_value[item_name] = new_value.get(item_name, 0) + value[group_name] + return new_value + \ No newline at end of file diff --git a/worlds/sc2/mission_order/structs.py b/worlds/sc2/mission_order/structs.py index b911f786e653..fa2cf9c9d5f6 100644 --- a/worlds/sc2/mission_order/structs.py +++ b/worlds/sc2/mission_order/structs.py @@ -8,7 +8,7 @@ from BaseClasses import Region, Location, CollectionState, Entrance from ..mission_tables import SC2Mission, lookup_name_to_mission, MissionFlag, lookup_id_to_mission, get_goal_location from .layout_types import LayoutType -from .entry_rules import EntryRule, SubRuleEntryRule, CountMissionsEntryRule, BeatMissionsEntryRule, SubRuleRuleData +from .entry_rules import EntryRule, SubRuleEntryRule, CountMissionsEntryRule, BeatMissionsEntryRule, SubRuleRuleData, ItemEntryRule from .mission_pools import SC2MOGenMissionPools, Difficulty, modified_difficulty_thresholds from worlds.AutoWorld import World @@ -54,6 +54,7 @@ class SC2MissionOrder(MissionOrderNode): campaigns: List[SC2MOGenCampaign] sorted_missions: Dict[Difficulty, List[SC2MOGenMission]] fixed_missions: List[SC2MOGenMission] + items_to_lock: Dict[str, int] mission_pools: SC2MOGenMissionPools goal_missions: List[SC2MOGenMission] max_depth: int @@ -62,6 +63,7 @@ def __init__(self, world: World, mission_pools: SC2MOGenMissionPools, data: Dict self.campaigns = [] self.sorted_missions = {diff: [] for diff in Difficulty if diff != Difficulty.RELATIVE} self.fixed_missions = [] + self.items_to_lock = {} self.mission_pools = mission_pools self.goal_missions = [] self.parent = None @@ -143,6 +145,10 @@ def get_final_missions(self) -> List[SC2MOGenMission]: """Returns the slots of all missions that are required to beat the mission order.""" return self.goal_missions + def get_items_to_lock(self) -> Dict[str, int]: + """Returns a dict of item names and amounts that are required by Item entry rules.""" + return self.items_to_lock + def get_slot_data(self) -> List[Dict[str, Any]]: """Parses the mission order into a format usable for slot data.""" # [(campaign data, [(layout data, [[(mission data)]] )] )] @@ -300,6 +306,15 @@ def resolve_unlocks(self): mission.entry_rule.rules_to_check.append(CountMissionsEntryRule(mission.prev, 1, mission.prev)) def dict_to_entry_rule(self, data: Dict[str, Any], searcher: MissionOrderNode, rule_id: int = -1) -> EntryRule: + if "items" in data: + items: Dict[str, int] = data["items"] + for (item, amount) in items.items(): + if item in self.items_to_lock: + # Lock the greatest required amount of each item + self.items_to_lock[item] = max(self.items_to_lock[item, amount]) + else: + self.items_to_lock[item] = amount + return ItemEntryRule(items) if "rules" in data: rules = [self.dict_to_entry_rule(subrule, searcher) for subrule in data["rules"]] return SubRuleEntryRule(rules, data["amount"], rule_id) diff --git a/worlds/sc2/options.py b/worlds/sc2/options.py index 09caea241c2f..dba0dfe597a8 100644 --- a/worlds/sc2/options.py +++ b/worlds/sc2/options.py @@ -9,6 +9,7 @@ campaign_mission_table, SC2Race, MissionFlag from .mission_groups import mission_groups, MissionGroupNames from .mission_order.options import CustomMissionOrder +from . import item_names if TYPE_CHECKING: from worlds.AutoWorld import World @@ -1151,3 +1152,47 @@ def get_excluded_missions(world: 'SC2World') -> Set[SC2Mission]: kerrigan_unit_available = [ KerriganPresence.option_vanilla, ] + +# Names of upgrades to be included for different options +upgrade_included_names: Dict[GenericUpgradeItems, Set[str]] = { + GenericUpgradeItems.option_individual_items: { + item_names.PROGRESSIVE_TERRAN_INFANTRY_WEAPON, + item_names.PROGRESSIVE_TERRAN_INFANTRY_ARMOR, + item_names.PROGRESSIVE_TERRAN_VEHICLE_WEAPON, + item_names.PROGRESSIVE_TERRAN_VEHICLE_ARMOR, + item_names.PROGRESSIVE_TERRAN_SHIP_WEAPON, + item_names.PROGRESSIVE_TERRAN_SHIP_ARMOR, + item_names.PROGRESSIVE_ZERG_MELEE_ATTACK, + item_names.PROGRESSIVE_ZERG_MISSILE_ATTACK, + item_names.PROGRESSIVE_ZERG_GROUND_CARAPACE, + item_names.PROGRESSIVE_ZERG_FLYER_ATTACK, + item_names.PROGRESSIVE_ZERG_FLYER_CARAPACE, + item_names.PROGRESSIVE_PROTOSS_GROUND_WEAPON, + item_names.PROGRESSIVE_PROTOSS_GROUND_ARMOR, + item_names.PROGRESSIVE_PROTOSS_SHIELDS, + item_names.PROGRESSIVE_PROTOSS_AIR_WEAPON, + item_names.PROGRESSIVE_PROTOSS_AIR_ARMOR, + }, + GenericUpgradeItems.option_bundle_weapon_and_armor: { + item_names.PROGRESSIVE_TERRAN_WEAPON_UPGRADE, + item_names.PROGRESSIVE_TERRAN_ARMOR_UPGRADE, + item_names.PROGRESSIVE_ZERG_WEAPON_UPGRADE, + item_names.PROGRESSIVE_ZERG_ARMOR_UPGRADE, + item_names.PROGRESSIVE_PROTOSS_WEAPON_UPGRADE, + item_names.PROGRESSIVE_PROTOSS_ARMOR_UPGRADE, + }, + GenericUpgradeItems.option_bundle_unit_class: { + item_names.PROGRESSIVE_TERRAN_INFANTRY_UPGRADE, + item_names.PROGRESSIVE_TERRAN_VEHICLE_UPGRADE, + item_names.PROGRESSIVE_TERRAN_SHIP_UPGRADE, + item_names.PROGRESSIVE_ZERG_GROUND_UPGRADE, + item_names.PROGRESSIVE_ZERG_FLYER_UPGRADE, + item_names.PROGRESSIVE_PROTOSS_GROUND_UPGRADE, + item_names.PROGRESSIVE_PROTOSS_AIR_UPGRADE, + }, + GenericUpgradeItems.option_bundle_all: { + item_names.PROGRESSIVE_TERRAN_WEAPON_ARMOR_UPGRADE, + item_names.PROGRESSIVE_ZERG_WEAPON_ARMOR_UPGRADE, + item_names.PROGRESSIVE_PROTOSS_WEAPON_ARMOR_UPGRADE, + } +} diff --git a/worlds/sc2/pool_filter.py b/worlds/sc2/pool_filter.py index ad4adf1542a8..8e4ffc8241e0 100644 --- a/worlds/sc2/pool_filter.py +++ b/worlds/sc2/pool_filter.py @@ -59,10 +59,11 @@ def generate_reduced_inventory(self, inventory_size: int, mission_requirements: """Attempts to generate a reduced inventory that can fulfill the mission requirements.""" inventory: List[Item] = list(self.item_pool) locked_items: List[Item] = list(self.locked_items) + necessary_items: List[Item] = list(self.necessary_items) enable_morphling = self.world.options.enable_morphling == EnableMorphling.option_true item_list = get_full_item_list() self.logical_inventory = [ - item.name for item in inventory + locked_items + self.existing_items + item.name for item in inventory + locked_items + self.existing_items + necessary_items if item_list[item.name].is_important_for_filtering() # Track all Progression items and those with complex rules for filtering ] requirements = mission_requirements @@ -90,7 +91,7 @@ def attempt_removal(item: Item) -> bool: maxNbUpgrade = get_option_value(self.world, "max_number_of_upgrades") if maxNbUpgrade != -1: unit_avail_upgrades = {} - # Needed to take into account locked/existing items + # Needed to take into account locked/existing/necessary items unit_nb_upgrades = {} for item in inventory: cItem = item_list[item.name] @@ -104,8 +105,8 @@ def attempt_removal(item: Item) -> bool: else: unit_avail_upgrades[cItem.parent_item].append(item) unit_nb_upgrades[cItem.parent_item] += 1 - # For those two categories, we count them but dont include them in removal - for item in locked_items + self.existing_items: + # For those categories, we count them but dont include them in removal + for item in locked_items + self.existing_items + necessary_items: cItem = item_list[item.name] if item.name in UPGRADABLE_ITEMS and item.name not in unit_avail_upgrades: unit_avail_upgrades[item.name] = [] @@ -131,7 +132,7 @@ def attempt_removal(item: Item) -> bool: # Locking minimum upgrades for items that have already been locked/placed when minimum required if minimum_upgrades > 0: - known_items = self.existing_items + locked_items + known_items = self.existing_items + locked_items + necessary_items known_parents = [item for item in known_items if item in parent_items] for parent in known_parents: child_items = self.item_children[parent] @@ -161,23 +162,28 @@ def attempt_removal(item: Item) -> bool: for item in generic_items[:reserved_generic_amount]: locked_items.append(copy_item(item)) inventory.remove(item) - if item.name not in self.logical_inventory and item.name not in self.locked_items: + if item.name not in self.logical_inventory: removable_generic_items.append(item) # Main cull process unused_items: List[str] = [] # Reusable items for the second pass - while len(inventory) + len(locked_items) > inventory_size: + while len(inventory) + len(locked_items) + len(necessary_items) > inventory_size: if len(inventory) == 0: # There are more items than locations and all of them are already locked due to YAML or logic. # First, drop non-logic generic items to free up space - while len(removable_generic_items) > 0 and len(locked_items) > inventory_size: + while len(removable_generic_items) > 0 and len(locked_items) + len(necessary_items) > inventory_size: removed_item = removable_generic_items.pop() locked_items.remove(removed_item) # If there still isn't enough space, push locked items into start inventory self.world.random.shuffle(locked_items) - while len(locked_items) > inventory_size: + while len(locked_items) > 0 and len(locked_items) + len(necessary_items) > inventory_size: item: Item = locked_items.pop() self.multiworld.push_precollected(item) + # If locked items weren't enough either, push necessary items into start inventory too + self.world.random.shuffle(necessary_items) + while len(necessary_items) > inventory_size: + item: Item = necessary_items.pop() + self.multiworld.push_precollected(item) break # Select random item from removable items item = self.world.random.choice(inventory) @@ -185,7 +191,7 @@ def attempt_removal(item: Item) -> bool: if minimum_upgrades > 0: parent_item = parent_lookup.get(item, None) if parent_item: - count = sum(1 if item in self.item_children[parent_item] else 0 for item in inventory + locked_items) + count = sum(1 if item in self.item_children[parent_item] else 0 for item in inventory + locked_items + necessary_items) if count <= minimum_upgrades: if parent_item in inventory: # Attempt to remove parent instead, if possible @@ -213,7 +219,7 @@ def attempt_removal(item: Item) -> bool: if attempt_removal(item): unused_items.append(item.name) - pool_items: List[str] = [item.name for item in (inventory + locked_items + self.existing_items)] + pool_items: List[str] = [item.name for item in (inventory + locked_items + self.existing_items + necessary_items)] unused_items = [ unused_item for unused_item in unused_items if item_list[unused_item].parent_item is None @@ -383,6 +389,7 @@ def attempt_removal(item: Item) -> bool: # Cull finished, adding locked items back into inventory inventory += locked_items + inventory += necessary_items # Replacing empty space with generically useful items replacement_items = [item for item in self.item_pool @@ -399,13 +406,14 @@ def attempt_removal(item: Item) -> bool: return inventory def __init__(self, world: 'SC2World', - item_pool: List[Item], existing_items: List[Item], locked_items: List[Item], + item_pool: List[Item], existing_items: List[Item], locked_items: List[Item], necessary_items: List[Item] ): self.multiworld = world.multiworld self.player = world.player self.world: 'SC2World' = world self.logical_inventory = list() self.locked_items = locked_items[:] + self.necessary_items = necessary_items[:] self.existing_items = existing_items # Initial filter of item pool self.item_pool = [] @@ -434,7 +442,7 @@ def __init__(self, world: 'SC2World', def filter_items(world: 'SC2World', location_cache: List[Location], - item_pool: List[Item], existing_items: List[Item], locked_items: List[Item]) -> List[Item]: + item_pool: List[Item], existing_items: List[Item], locked_items: List[Item], necessary_items: List[Item]) -> List[Item]: """ Returns a semi-randomly pruned set of items based on number of available locations. The returned inventory must be capable of logically accessing every location in the world. @@ -445,7 +453,7 @@ def filter_items(world: 'SC2World', location_cache: List[Location], mission_requirements = [] else: mission_requirements = [(location.name, location.access_rule) for location in location_cache] - valid_inventory = ValidInventory(world, item_pool, existing_items, locked_items) + valid_inventory = ValidInventory(world, item_pool, existing_items, locked_items, necessary_items) valid_items = valid_inventory.generate_reduced_inventory(inventory_size, mission_requirements) return valid_items diff --git a/worlds/sc2/rules.py b/worlds/sc2/rules.py index 0dec559ce019..771a4d5f3f7b 100644 --- a/worlds/sc2/rules.py +++ b/worlds/sc2/rules.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Set from BaseClasses import CollectionState from .options import ( @@ -7,9 +7,9 @@ get_enabled_campaigns, MissionOrder, EnableMorphling, get_enabled_races ) from .items import ( - get_basic_units, tvx_defense_ratings, tvz_defense_ratings, kerrigan_actives, tvx_air_defense_ratings, + tvx_defense_ratings, tvz_defense_ratings, kerrigan_actives, tvx_air_defense_ratings, kerrigan_levels, get_full_item_list, zvx_air_defense_ratings, zvx_defense_ratings, pvx_defense_ratings, - pvz_defense_ratings + pvz_defense_ratings, no_logic_basic_units, advanced_basic_units, basic_units ) from .mission_tables import SC2Race, SC2Campaign from . import item_names @@ -1391,3 +1391,12 @@ def __init__(self, world: 'SC2World'): self.spear_of_adun_autonomously_cast_presence = get_option_value(world, "spear_of_adun_autonomously_cast_ability_presence") self.enabled_campaigns = get_enabled_campaigns(world) self.mission_order = get_option_value(world, "mission_order") + +def get_basic_units(world: 'SC2World', race: SC2Race) -> Set[str]: + logic_level = get_option_value(world, 'required_tactics') + if logic_level == RequiredTactics.option_no_logic: + return no_logic_basic_units[race] + elif logic_level == RequiredTactics.option_advanced: + return advanced_basic_units[race] + else: + return basic_units[race] diff --git a/worlds/sc2/test/test_custom_mission_orders.py b/worlds/sc2/test/test_custom_mission_orders.py index d90d751ae857..afc690e92c28 100644 --- a/worlds/sc2/test/test_custom_mission_orders.py +++ b/worlds/sc2/test/test_custom_mission_orders.py @@ -4,9 +4,12 @@ from .test_base import Sc2SetupTestBase from .. import MissionFlag +from .. import item_names +from .. import items +from BaseClasses import ItemClassification class TestCustomMissionOrders(Sc2SetupTestBase): - + def test_mini_wol_generates(self): world_options = { 'mission_order': 'custom', @@ -101,3 +104,86 @@ def test_mini_wol_generates(self): self.assertEqual(flags.get(MissionFlag.Zerg, 0), 0) sc2_regions = set(self.multiworld.regions.region_cache[self.player]) - {"Menu"} self.assertEqual(len(self.world.custom_mission_order.get_used_missions()), len(sc2_regions)) + + def test_locked_and_necessary_item_appears_once(self): + # This is a filler upgrade with a parent + test_item = item_names.ZERGLING_METABOLIC_BOOST + world_options = { + 'mission_order': 'custom', + 'locked_items': { test_item: 1 }, + 'custom_mission_order': { + 'test': { + 'type': 'column', + 'size': 5, # Give the generator some space to place the key + 'max_difficulty': 'easy', + 'missions': [{ + 'index': 4, + 'entry_rules': [{ + 'items': { test_item: 1 } + }] + }] + } + } + } + + self.assertNotEqual(items.item_table[test_item].classification, ItemClassification.progression, f"Test item {test_item} won't change classification") + + self.generate_world(world_options) + test_items_in_pool = [item for item in self.multiworld.itempool if item.name == test_item] + self.assertEqual(len(test_items_in_pool), 1) + self.assertEqual(test_items_in_pool[0].classification, ItemClassification.progression) + + def test_start_inventory_and_necessary_item_appears_once(self): + # This is a filler upgrade with a parent + test_item = item_names.ZERGLING_METABOLIC_BOOST + world_options = { + 'mission_order': 'custom', + 'start_inventory': { test_item: 1 }, + 'custom_mission_order': { + 'test': { + 'type': 'column', + 'size': 5, # Give the generator some space to place the key + 'max_difficulty': 'easy', + 'missions': [{ + 'index': 4, + 'entry_rules': [{ + 'items': { test_item: 1 } + }] + }] + } + } + } + + self.generate_world(world_options) + test_items_in_pool = [item for item in self.multiworld.itempool if item.name == test_item] + self.assertEqual(len(test_items_in_pool), 0) + test_items_in_start_inventory = [item for item in self.multiworld.precollected_items[self.player] if item.name == test_item] + self.assertEqual(len(test_items_in_start_inventory), 1) + + def test_start_inventory_and_locked_and_necessary_item_appears_once(self): + # This is a filler upgrade with a parent + test_item = item_names.ZERGLING_METABOLIC_BOOST + world_options = { + 'mission_order': 'custom', + 'start_inventory': { test_item: 1 }, + 'locked_items': { test_item: 1 }, + 'custom_mission_order': { + 'test': { + 'type': 'column', + 'size': 5, # Give the generator some space to place the key + 'max_difficulty': 'easy', + 'missions': [{ + 'index': 4, + 'entry_rules': [{ + 'items': { test_item: 1 } + }] + }] + } + } + } + + self.generate_world(world_options) + test_items_in_pool = [item for item in self.multiworld.itempool if item.name == test_item] + self.assertEqual(len(test_items_in_pool), 0) + test_items_in_start_inventory = [item for item in self.multiworld.precollected_items[self.player] if item.name == test_item] + self.assertEqual(len(test_items_in_start_inventory), 1)