import logging import os from typing import Callable, Optional, ClassVar, Counter, Any import webbrowser import Utils from worlds.generic.Rules import forbid_items_for_player from worlds.LauncherComponents import Component, SuffixIdentifier, components, Type, launch, icon_paths from .Data import item_table, location_table, event_table, category_table from .Game import game_name, filler_item_name, starting_items, unused_goals_are_locations, glitches_item_name from .Meta import world_description, world_webworld from .Locations import location_id_to_name, location_name_to_id, location_name_to_location, location_name_groups, victory_names, event_name_to_event, event_name_groups from .Items import item_id_to_name, item_name_to_id, item_name_to_item, item_name_groups from .DataValidation import runGenerationDataValidation, runPreFillDataValidation from .Regions import create_regions, create_events from .Items import ManualItem from .Rules import set_rules from .Options import manual_options_data from .Helpers import is_item_enabled, get_option_value, remove_specific_item, resolve_yaml_option, format_state_prog_items_key, convert_string_to_itemclassification, ProgItemsCat from .container import APManualFile from BaseClasses import CollectionState, ItemClassification, Item, Location from Options import PerGameCommonOptions from worlds.AutoWorld import World from .hooks.World import \ hook_get_filler_item_name, before_create_regions, after_create_regions, \ before_create_items_all, before_create_items_place_items, before_create_items_starting, before_create_items_filler, after_create_items, \ before_create_item, after_create_item, \ before_set_rules, after_set_rules, \ before_generate_basic, \ before_fill_slot_data, after_fill_slot_data, before_write_spoiler, \ before_extend_hint_information, after_extend_hint_information, \ after_collect_item, after_remove_item, before_generate_early, hook_interpret_slot_data class ManualWorld(World): __doc__ = world_description game: ClassVar[str] = game_name web = world_webworld options_dataclass = manual_options_data data_version = 2 required_client_version = (0, 3, 4) # These properties are set from the imports of the same name above. item_table = item_table location_table = location_table # this is likely imported from Data instead of Locations because the Game Complete location should not be in here, but is used for lookups event_table = event_table category_table = category_table item_id_to_name = item_id_to_name item_name_to_id = item_name_to_id item_name_to_item = item_name_to_item item_name_groups = item_name_groups filler_item_name = filler_item_name item_counts: dict[int, Counter[str]] = {} item_counts_progression: dict[int, Counter[str]] = {} start_inventory = {} location_id_to_name = location_id_to_name location_name_to_id = location_name_to_id location_name_to_location = location_name_to_location location_name_groups = location_name_groups victory_names = victory_names event_name_to_event = event_name_to_event event_name_groups = event_name_groups item_and_event_name_groups: dict[str, set[str]] = dict(item_name_groups) for name, category in event_name_groups.items(): if name in item_and_event_name_groups.keys(): item_and_event_name_groups[name] = item_name_groups[name].union(category) else: item_and_event_name_groups[name] = category # UT (the universal-est of trackers) can now generate without a YAML ut_can_gen_without_yaml = True glitches_item_name: str | None = glitches_item_name origin_region_name = "Manual" def get_filler_item_name(self) -> str: hook_result = hook_get_filler_item_name(self, self.multiworld, self.player) if hook_result and isinstance(hook_result, str): return hook_result elif hook_result and isinstance(hook_result, list): return self.random.choice(hook_result) elif isinstance(filler_item_name, str): return self.filler_item_name else: random_filler_name = self.random.choice(filler_item_name) return random_filler_name def interpret_slot_data(self, slot_data: dict[str, Any]) -> dict[str, Any]: #this is called by tools like UT if not slot_data: return {} regen = hook_interpret_slot_data(self, self.player, slot_data) or slot_data return regen @classmethod def stage_assert_generate(cls, multiworld) -> None: runGenerationDataValidation(cls) def generate_early(self) -> None: before_generate_early(self, self.multiworld, self.player) for item in item_table: if item.get("local"): if item.get("name") not in self.options.local_items.value: self.options.local_items.value.add(item["name"]) if hasattr(self.multiworld, "re_gen_passthrough"): slot_data = self.multiworld.re_gen_passthrough.get(self.game, {}) if slot_data: for key, value in slot_data.items(): if hasattr(self.options, key): getattr(self.options, key).value = value def create_regions(self): before_create_regions(self, self.multiworld, self.player) create_regions(self, self.multiworld, self.player) create_events(self, self.multiworld, self.player) location_game_complete = self.multiworld.get_location(victory_names[get_option_value(self.multiworld, self.player, 'goal')], self.player) location_game_complete.address = None if not unused_goals_are_locations: for unused_goal in [self.multiworld.get_location(name, self.player) for name in victory_names if name != location_game_complete.name]: unused_goal.parent_region.locations.remove(unused_goal) location_game_complete.place_locked_item( ManualItem("__Victory__", ItemClassification.progression, None, player=self.player)) after_create_regions(self, self.multiworld, self.player) def create_items(self): # Generate item pool pool: list[Item] = [] traps = [] configured_item_names = self.item_id_to_name.copy() is_UT = bool(getattr(self.multiworld, "generation_is_fake", False)) items_config: dict[str, int|dict[ItemClassification | str | int, int]] = {} for name in configured_item_names.values(): if name == "__Victory__": continue # intentionally using the Game.py filler_item_name here because it can be a non-Items item if isinstance(filler_item_name, str): if name == filler_item_name: continue elif isinstance(filler_item_name, list): if name in filler_item_name: continue item = self.item_name_to_item[name] item_count = int(item.get("count", 1)) if item.get("trap"): traps.append(name) if not is_item_enabled(self.multiworld, self.player, item): items_config[name] = 0 else: if item.get("classification_count"): items_config[name] = item["classification_count"] else: items_config[name] = item_count items_config = before_create_items_all(items_config, self, self.multiworld, self.player) for name, configs in items_config.items(): total_created = 0 if type(configs) is int: total_created = configs for _ in range(configs): new_item = self.create_item(name) pool.append(new_item) elif type(configs) is dict: for override, count in configs.items(): total_created += count if isinstance(override, ItemClassification): true_class = override else: try: if isinstance(override, int): true_class = ItemClassification(override) else: true_class = convert_string_to_itemclassification(override) except Exception as ex: raise Exception(f"Item override '{override}' for {name} improperly defined\n\n{type(ex).__name__}:{ex}") for _ in range(count): new_item = self.create_item(name, true_class) pool.append(new_item) else: raise Exception(f"Item override for {name} improperly defined") if total_created == 0 or is_UT: continue item = self.item_name_to_item[name] if item.get("early"): # Some or all early if isinstance(item["early"],int) or (isinstance(item["early"],str) and item["early"].isnumeric()): self.multiworld.early_items[self.player][name] = int(item["early"]) elif isinstance(item["early"],bool): #No need to deal with true vs false since false wont get here self.multiworld.early_items[self.player][name] = total_created else: raise Exception(f"Item {name}'s 'early' has an invalid value of '{item['early']}'. \nA boolean or an integer was expected.") if item.get("local_early"): # Some or all local and early if isinstance(item["local_early"],int) or (isinstance(item["local_early"],str) and item["local_early"].isnumeric()): self.multiworld.local_early_items[self.player][name] = int(item["local_early"]) elif isinstance(item["local_early"],bool): self.multiworld.local_early_items[self.player][name] = total_created else: raise Exception(f"Item {name}'s 'local_early' has an invalid value of '{item['local_early']}'. \nA boolean or an integer was expected.") # Handle item forbidding/placement pool = before_create_items_place_items(pool, self, self.multiworld, self.player) locations_with_forbid: list[Location] = [] locations_with_placements: list[Location] = [] if not is_UT: for location in self.multiworld.get_unfilled_locations(player=self.player): manual_location = self.location_name_to_location.get(location.name, {}) if manual_location.get("place_item") or manual_location.get("place_item_category"): locations_with_placements.append(location) elif manual_location.get("dont_place_item") or manual_location.get("dont_place_item_category"): locations_with_forbid.append(location) # Handle specific item forbidding using forbid_items_for_player for location in locations_with_forbid: manual_location = self.location_name_to_location.get(location.name, {}) forbidden_item_names: set[str] = set() if manual_location.get("dont_place_item"): forbidden_item_names |= set(manual_location["dont_place_item"]) if manual_location.get("dont_place_item_category"): for cat in manual_location["dont_place_item_category"]: forbidden_item_names |= self.item_name_groups.get(cat, set()) if forbidden_item_names: forbid_items_for_player(location, set(forbidden_item_names), self.player) # Handle specific item placements using place_locked_item for location in locations_with_placements: manual_location = self.location_name_to_location.get(location.name, {}) eligible_items = [] eligible_item_names: set[str] = set() forbidden_item_names = set() place_messages = [] forbid_messages = [] #First we get possible items names if manual_location.get("place_item"): eligible_item_names |= set(manual_location["place_item"]) place_messages.append('", "'.join(manual_location["place_item"])) if manual_location.get("place_item_category"): for cat in manual_location["place_item_category"]: eligible_item_names |= self.item_name_groups.get(cat, set()) place_messages.append('", "'.join(manual_location["place_item_category"]) + " category(ies)") # Second we check for forbidden items names if manual_location.get("dont_place_item"): forbidden_item_names |= set(manual_location["dont_place_item"]) forbid_messages.append('", "'.join(manual_location["dont_place_item"]) + ' items') if manual_location.get("dont_place_item_category"): for cat in manual_location["dont_place_item_category"]: forbidden_item_names |= self.item_name_groups.get(cat, set()) forbid_messages.append('", "'.join(manual_location["dont_place_item_category"]) + ' category(ies)') # If we forbid some names, check for those in the possible names and remove them if forbidden_item_names: eligible_item_names = {name for name in eligible_item_names if name not in forbidden_item_names} if eligible_item_names: eligible_items = [item for item in pool if item.name in eligible_item_names] if len(eligible_items) == 0: nl = "\n" if forbidden_item_names: raise Exception(f'Could not find a suitable item to place at "{manual_location["name"]}".\n No items that match "{f"{nl} or ".join(place_messages)}"\n Maybe because of forbidden "{f"{nl} or ".join(forbid_messages)}"') raise Exception(f'Could not find a suitable item to place at "{manual_location["name"]}". \n No items that match "{f"{nl} or ".join(place_messages)}"') item_to_place = self.random.choice(eligible_items) location.place_locked_item(item_to_place) # remove the item we're about to place from the pool so it isn't placed twice remove_specific_item(pool, item_to_place) # Handle game.json's starting_items pool = before_create_items_starting(pool, self, self.multiworld, self.player) items_started: list[Item] = [] if starting_items: for starting_item_block in starting_items: if not resolve_yaml_option(self.multiworld, self.player, starting_item_block): continue # if there's a condition on having a previous item, check for any of them # if not found in items started, this starting item rule shouldn't execute, and check the next one if "if_previous_item" in starting_item_block: matching_items = [item for item in items_started if item.name in starting_item_block["if_previous_item"]] if len(matching_items) == 0: continue # start with the full pool of items items = pool # if the setting lists specific item names, limit the items to just those if "items" in starting_item_block: items = [item for item in pool if item.name in starting_item_block["items"]] # if the setting lists specific item categories, limit the items to ones that have any of those categories if "item_categories" in starting_item_block: items_in_categories = [item["name"] for item in self.item_name_to_item.values() if "category" in item and len(set(starting_item_block["item_categories"]).intersection(item["category"])) > 0] items = [item for item in pool if item.name in items_in_categories] self.random.shuffle(items) # if the setting lists a specific number of random items that should be pulled, only use a subset equal to that number if "random" in starting_item_block: items = items[0:starting_item_block["random"]] for starting_item in items: items_started.append(starting_item) self.multiworld.push_precollected(starting_item) remove_specific_item(pool, starting_item) self.start_inventory = {i.name: items_started.count(i) for i in items_started} pool = before_create_items_filler(pool, self, self.multiworld, self.player) pool = self.adjust_filler_items(pool, traps) pool = after_create_items(pool, self, self.multiworld, self.player) # need to put all of the items in the pool so we can have a full state for placement self.multiworld.itempool += pool # Preparing to count the items: precollected_items = list(self.multiworld.precollected_items[self.player]) # UT doesn't precollect the exceptions so this can be skipped if not is_UT: # Filter Precollected items for those not in logic aka created by start_inventory(_from_pool) precollected_exceptions = self.options.start_inventory.value + self.options.start_inventory_from_pool.value # type: ignore for item, count in precollected_exceptions.items(): items_iter = iter([i for i in precollected_items if i.name == item]) for _ in range(count): precollected_items.remove(next(items_iter)) # Placed items: placed_pool: list[Item] = [] for location in self.multiworld.get_filled_locations(self.player): if location.item is not None: # For type checkers placed_pool.append(location.item) real_pool = pool + precollected_items + placed_pool self.item_counts[self.player] = self.get_item_counts(pool=real_pool) self.item_counts_progression[self.player] = self.get_item_counts(pool=real_pool, only_progression=True) def create_item(self, name: str, class_override: Optional['ItemClassification']=None) -> Item: name = before_create_item(name, self, self.multiworld, self.player) if name == self.glitches_item_name: return ManualItem(name, class_override or ItemClassification.progression, None, self.player) item = self.item_name_to_item[name] classification: ItemClassification = ItemClassification.filler if class_override is not None: classification = class_override elif item.get("classification_count"): # This should only be run if create_item is called outside of create_items not_prog_classes: list[ItemClassification] = [] progression_classes: list[ItemClassification] = [] for cat, count in item["classification_count"].items(): if count: true_class = convert_string_to_itemclassification(cat) if ItemClassification.progression in true_class: progression_classes.append(true_class) else: not_prog_classes.append(true_class) if progression_classes: classification |= self.random.choice(progression_classes) elif not_prog_classes: classification |= not_prog_classes[0] else: if item.get("trap"): classification |= ItemClassification.trap if item.get("useful"): classification |= ItemClassification.useful if item.get("progression_skip_balancing"): classification |= ItemClassification.progression_skip_balancing elif item.get("progression"): classification |= ItemClassification.progression item_object = ManualItem(name, classification, self.item_name_to_id[name], player=self.player) item_object = after_create_item(item_object, self, self.multiworld, self.player) return item_object # Item Value need a tweaked collect and remove: def collect(self, state: CollectionState, item: Item) -> bool: change = super().collect(state, item) manual_item = self.item_name_to_item.get(item.name, {}) if change and manual_item.get("value"): for key, value in manual_item["value"].items(): state.prog_items[item.player][format_state_prog_items_key(ProgItemsCat.VALUE, key)] += int(value) after_collect_item(self, state, change, item) return change def remove(self, state: CollectionState, item: Item) -> bool: change = super().remove(state, item) manual_item = self.item_name_to_item.get(item.name, {}) if change and manual_item.get("value"): for key, value in manual_item["value"].items(): state.prog_items[item.player][format_state_prog_items_key(ProgItemsCat.VALUE, key)] -= int(value) after_remove_item(self, state, change, item) return change def set_rules(self): before_set_rules(self, self.multiworld, self.player) set_rules(self, self.multiworld, self.player) after_set_rules(self, self.multiworld, self.player) def generate_basic(self): before_generate_basic(self, self.multiworld, self.player) # Enable this in Meta.json to generate a diagram of your manual. Only works on 0.4.4+ if get_option_value(self.multiworld, self.player, "generate_region_diagram"): from Utils import visualize_regions visualize_regions(self.multiworld.get_region("Manual", self.player), f"{self.game}_{self.player}.puml") def pre_fill(self): # DataValidation after all the hooks are done but before fill runPreFillDataValidation(self, self.multiworld) def fill_slot_data(self): slot_data = before_fill_slot_data({}, self, self.multiworld, self.player) # slot_data["DeathLink"] = bool(self.multiworld.death_link[self.player].value) common_options = set(PerGameCommonOptions.type_hints.keys()) common_options |= set(["generate_region_diagram", "start_inventory_from_pool"]) for option_key, _ in self.options_dataclass.type_hints.items(): if option_key in common_options: continue slot_data[option_key] = get_option_value(self.multiworld, self.player, option_key) slot_data["visible_events"] = {} for _, event in self.event_name_to_event.items(): event_name = event["name"] if event["visible"] and event_name not in slot_data["visible_events"]: slot_data["visible_events"][event_name] = event.get("category", []) elif event_name in slot_data["visible_events"]: temp_list = event.get("category", []) + slot_data["visible_events"][event_name] slot_data["visible_events"][event_name] = list(set(temp_list)) slot_data = after_fill_slot_data(slot_data, self, self.multiworld, self.player) return slot_data def generate_output(self, output_directory: str): filename = f"{self.multiworld.get_out_file_name_base(self.player)}.apmanual" zf_path = os.path.join(output_directory, filename) apmanual = APManualFile(zf_path, player=self.player, player_name=self.player_name) apmanual.write() if get_option_value(self.multiworld, self.player, "generate_region_diagram"): from Utils import visualize_regions visualize_regions(self.multiworld.get_region("Manual", self.player), f"{self.game}_{self.player}_spoiler.puml") def write_spoiler(self, spoiler_handle): before_write_spoiler(self, self.multiworld, spoiler_handle) def extend_hint_information(self, hint_data: dict[int, dict[int, str]]) -> None: before_extend_hint_information(hint_data, self, self.multiworld, self.player) for location in self.multiworld.get_locations(self.player): if not location.address: continue if "hint_entrance" in self.location_name_to_location[location.name]: if self.player not in hint_data: hint_data.update({self.player: {}}) hint_data[self.player][location.address] = self.location_name_to_location[location.name]["hint_entrance"] after_extend_hint_information(hint_data, self, self.multiworld, self.player) ### # Non-standard AP world methods ### rules_functions_maximum_recursion: int = 5 """Default: 5\n The maximum time a location/region's requirement can loop to check for functions\n One thing to remember is the more you loop the longer generation will take. So probably leave it as is unless you really needs it.""" def add_filler_items(self, item_pool, traps): Utils.deprecate("You're calling the deprecated add_filler_items() function. Use the adjust_filler_items() function instead.") return self.adjust_filler_items(item_pool, traps) def adjust_filler_items(self, item_pool, traps): extras = len(self.multiworld.get_unfilled_locations(player=self.player)) - len(item_pool) if extras > 0: trap_percent = get_option_value(self.multiworld, self.player, "filler_traps") if not traps: trap_percent = 0 trap_count = extras * trap_percent // 100 filler_count = extras - trap_count for _ in range(0, trap_count): extra_item = self.create_item(self.random.choice(traps)) item_pool.append(extra_item) for _ in range(0, filler_count): extra_item = self.create_item(self.get_filler_item_name()) item_pool.append(extra_item) elif extras < 0: logging.warning(f"{self.game} has more items than locations. {abs(extras)} non-progression items will be removed at random.") # Filler is only assigned if the item doesn't have any other tags, so it only has to be covered by itself. # Skip Balancing is also not covered due to how it's only supported when paired with Progression. # As a result, these cover every possible combination can be removed. fillers = [item for item in item_pool if item.classification == ItemClassification.filler] traps = [item for item in item_pool if item.classification == ItemClassification.trap] useful = [item for item in item_pool if item.classification == ItemClassification.useful] # Useful + Trap is classified separately so that it can have a unique priority ranking. useful_traps = [item for item in item_pool if ItemClassification.progression not in item.classification and ItemClassification.useful in item.classification and ItemClassification.trap in item.classification] self.random.shuffle(fillers) self.random.shuffle(traps) self.random.shuffle(useful) self.random.shuffle(useful_traps) for _ in range(0, abs(extras)): popped = None if fillers: popped = fillers.pop() elif traps: popped = traps.pop() elif useful: popped = useful.pop() elif useful_traps: popped = useful_traps.pop() else: logging.warning("Could not remove enough non-progression items from the pool.") break remove_specific_item(item_pool, popped) return item_pool def get_item_counts(self, player: Optional[int] = None, pool: list[Item] | None | bool = None, only_progression: bool = False) -> Counter[str]: """Returns the player real item counts.\n If you provide an item pool using the pool argument, then it's item counts will be returned. Otherwise, this function will only work after create_items, before then an empty Counter is returned.\n The only_progression argument let you filter the items to only get the count of progression items.""" if player is None: player = self.player if isinstance(pool, bool): Utils.deprecate("the 'reset' argument of get_item_counts has been deprecated to increase the stability of item counts.\ \nIt should be removed. If you require a new up to date count you can get it using the 'pool' argument.\ \nThat result wont be saved to world unless you override the values of world.item_counts_progression or world.item_counts depending on if you counted only the items with progresion or not.") pool = None if pool is not None: return Counter([i.name for i in pool if not only_progression or i.advancement]) if only_progression: return self.item_counts_progression.get(player, Counter()) else: return self.item_counts.get(player, Counter()) ### # Non-world client methods ### def launch_client(*args): import CommonClient from .ManualClient import launch as Main if CommonClient.gui_enabled: launch(Main, name="Manual client", args=args) else: Main(*args) class VersionedComponent(Component): def __init__(self, display_name: str, script_name: Optional[str] = None, func: Optional[Callable] = None, version: int = 0, file_identifier: Optional[Callable[[str], bool]] = None, icon: Optional[str] = None): super().__init__(display_name=display_name, script_name=script_name, func=func, component_type=Type.CLIENT, file_identifier=file_identifier, icon=icon) self.version = version def add_client_to_launcher() -> None: version = 2026_09_18 # YYYYMMDD found = False if "manual" not in icon_paths: icon_paths["manual"] = Utils.user_path('data', 'manual.png') discord_component = None for c in components: if c.display_name == "Manual Client": found = True if getattr(c, "version", 0) < version: # We have a newer version of the Manual Client than the one the last apworld added c.version = version c.func = launch_client c.icon = "manual" elif c.display_name == "Manual Discord Server": discord_component = c if not found: components.append(VersionedComponent("Manual Client", "ManualClient", func=launch_client, version=version, file_identifier=SuffixIdentifier('.apmanual'), icon="manual")) if not discord_component: components.append(Component("Manual Discord Server", "ManualDiscord", func=lambda: webbrowser.open("https://discord.gg/hm4rQnTzQ5"), icon="discord", component_type=Type.ADJUSTER)) add_client_to_launcher()