diff --git a/BaseClasses.py b/BaseClasses.py index e1df8cf3..6cd0db6b 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -23,6 +23,8 @@ class Group(TypedDict, total=False): players: Set[int] item_pool: Set[str] replacement_items: Dict[int, Optional[str]] + local_items: Set[str] + non_local_items: Set[str] class MultiWorld(): @@ -219,6 +221,8 @@ class MultiWorld(): item_links[item_link["name"]]["players"][player] = item_link["replacement_item"] item_links[item_link["name"]]["item_pool"] &= set(item_link["item_pool"]) item_links[item_link["name"]]["exclude"] |= set(item_link.get("exclude", [])) + item_links[item_link["name"]]["local_items"] &= set(item_link.get("local_items", [])) + item_links[item_link["name"]]["non_local_items"] &= set(item_link.get("non_local_items", [])) else: if item_link["name"] in self.player_name.values(): raise Exception(f"Cannot name a ItemLink group the same as a player ({item_link['name']}) ({self.get_player_name(player)}).") @@ -226,23 +230,37 @@ class MultiWorld(): "players": {player: item_link["replacement_item"]}, "item_pool": set(item_link["item_pool"]), "exclude": set(item_link.get("exclude", [])), - "game": self.game[player] + "game": self.game[player], + "local_items": set(item_link.get("local_items", [])), + "non_local_items": set(item_link.get("non_local_items", [])) } for name, item_link in item_links.items(): current_item_name_groups = AutoWorld.AutoWorldRegister.world_types[item_link["game"]].item_name_groups pool = set() + local_items = set() + non_local_items = set() for item in item_link["item_pool"]: pool |= current_item_name_groups.get(item, {item}) for item in item_link["exclude"]: pool -= current_item_name_groups.get(item, {item}) + for item in item_link["local_items"]: + local_items |= current_item_name_groups.get(item, {item}) + for item in item_link["non_local_items"]: + non_local_items |= current_item_name_groups.get(item, {item}) + local_items &= pool + non_local_items &= pool item_link["item_pool"] = pool + item_link["local_items"] = local_items + item_link["non_local_items"] = non_local_items for group_name, item_link in item_links.items(): game = item_link["game"] group_id, group = self.add_group(group_name, game, set(item_link["players"])) group["item_pool"] = item_link["item_pool"] group["replacement_items"] = item_link["players"] + group["local_items"] = item_link["local_items"] + group["non_local_items"] = item_link["non_local_items"] # intended for unittests def set_default_common_options(self): diff --git a/Main.py b/Main.py index d6bdcea2..04ac2124 100644 --- a/Main.py +++ b/Main.py @@ -17,7 +17,7 @@ from worlds.alttp.Regions import lookup_vanilla_location_to_entrance from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots from Utils import output_path, get_options, __version__, version_tuple -from worlds.generic.Rules import locality_rules, exclusion_rules +from worlds.generic.Rules import locality_rules, exclusion_rules, group_locality_rules from worlds import AutoWorld ordered_areas = ( @@ -127,6 +127,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No if world.players > 1: for player in world.player_ids: locality_rules(world, player) + group_locality_rules(world) else: world.non_local_items[1].value = set() world.local_items[1].value = set() diff --git a/Options.py b/Options.py index 632b90d3..4cab6769 100644 --- a/Options.py +++ b/Options.py @@ -651,10 +651,30 @@ class ItemLinks(OptionList): "name": And(str, len), "item_pool": [And(str, len)], Optional("exclude"): [And(str, len)], - "replacement_item": Or(And(str, len), None) + "replacement_item": Or(And(str, len), None), + Optional("local_items"): [And(str, len)], + Optional("non_local_items"): [And(str, len)] } ]) + @staticmethod + def verify_items(items: typing.List[str], item_link: str, pool_name: str, world, allow_item_groups: bool = True) -> typing.Set: + pool = set() + for item_name in items: + if item_name not in world.item_names and (not allow_item_groups or item_name not in world.item_name_groups): + picks = get_fuzzy_results(item_name, world.item_names, limit=1) + picks_group = get_fuzzy_results(item_name, world.item_name_groups.keys(), limit=1) + picks_group = f" or '{picks_group[0][0]}' ({picks_group[0][1]}% sure)" if allow_item_groups else "" + + raise Exception(f"Item {item_name} from item link {item_link} " + f"is not a valid item from {world.game} for {pool_name}. " + f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure){picks_group}") + if allow_item_groups: + pool |= world.item_name_groups.get(item_name, {item_name}) + else: + pool |= {item_name} + return pool + def verify(self, world): super(ItemLinks, self).verify(world) existing_links = set() @@ -662,18 +682,27 @@ class ItemLinks(OptionList): if link["name"] in existing_links: raise Exception(f"You cannot have more than one link named {link['name']}.") existing_links.add(link["name"]) - for item_name in link["item_pool"]: - if item_name not in world.item_names and item_name not in world.item_name_groups: - raise Exception(f"Item {item_name} from item link {link} " - f"is not a valid item name from {world.game}") + + pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world) + local_items = set() + non_local_items = set() + if "exclude" in link: - for item_name in link["exclude"]: - if item_name not in world.item_names and item_name not in world.item_name_groups: - raise Exception(f"Item {item_name} from item link {link} " - f"is not a valid item name from {world.game}") - if link["replacement_item"] and link["replacement_item"] not in world.item_names: - raise Exception(f"Item {link['replacement_item']} from item link {link} " - f"is not a valid item name from {world.game}") + pool -= self.verify_items(link["exclude"], link["name"], "exclude", world) + if link["replacement_item"]: + self.verify_items([link["replacement_item"]], link["name"], "replacement_item", world, False) + if "local_items" in link: + local_items = self.verify_items(link["local_items"], link["name"], "local_items", world) + local_items &= pool + if "non_local_items" in link: + non_local_items = self.verify_items(link["non_local_items"], link["name"], "non_local_items", world) + non_local_items &= pool + + intersection = local_items.intersection(non_local_items) + if intersection: + raise Exception(f"item_link {link['name']} has {intersection} items in both its local_items and non_local_items pool.") + + per_game_common_options = { diff --git a/worlds/generic/Rules.py b/worlds/generic/Rules.py index 2a607d98..e8224209 100644 --- a/worlds/generic/Rules.py +++ b/worlds/generic/Rules.py @@ -12,6 +12,20 @@ else: ItemRule = typing.Callable[[object], bool] +def group_locality_rules(world): + for group_id, group in world.groups.items(): + if set(world.player_ids) == set(group["players"]): + continue + if group["local_items"]: + for location in world.get_locations(): + if location.player not in group["players"]: + forbid_items_for_player(location, group["local_items"], group_id) + if group["non_local_items"]: + for location in world.get_locations(): + if location.player in group["players"]: + forbid_items_for_player(location, group["non_local_items"], group_id) + + def locality_rules(world, player: int): if world.local_items[player].value: for location in world.get_locations():