From 1f6db1279738c89a0cc5577b9143165f2a167bec Mon Sep 17 00:00:00 2001 From: blastron Date: Tue, 18 Jul 2023 20:02:57 -0700 Subject: [PATCH] The Witness: Item loading refactor. (#1953) Co-authored-by: el-u <109771707+el-u@users.noreply.github.com> Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com> --- worlds/witness/Options.py | 2 +- worlds/witness/WitnessItems.txt | 47 ++-- worlds/witness/__init__.py | 276 +++++++++-------------- worlds/witness/items.py | 376 +++++++++++++++++--------------- worlds/witness/locations.py | 6 +- worlds/witness/player_logic.py | 60 +++-- worlds/witness/rules.py | 8 +- worlds/witness/static_logic.py | 130 +++++++---- worlds/witness/utils.py | 95 +++----- 9 files changed, 482 insertions(+), 518 deletions(-) diff --git a/worlds/witness/Options.py b/worlds/witness/Options.py index 2047eb9c..b7364b5e 100644 --- a/worlds/witness/Options.py +++ b/worlds/witness/Options.py @@ -1,6 +1,6 @@ from typing import Dict, Union from BaseClasses import MultiWorld -from Options import Toggle, DefaultOnToggle, Option, Range, Choice +from Options import Toggle, DefaultOnToggle, Range, Choice # class HardMode(Toggle): diff --git a/worlds/witness/WitnessItems.txt b/worlds/witness/WitnessItems.txt index 8e5d9383..71ffe276 100644 --- a/worlds/witness/WitnessItems.txt +++ b/worlds/witness/WitnessItems.txt @@ -1,4 +1,4 @@ -Progression: +Symbols: 0 - Dots 1 - Colored Dots 2 - Full Dots @@ -18,16 +18,22 @@ Progression: 200 - Progressive Dots - Dots,Full Dots 260 - Progressive Stars - Stars,Stars + Same Colored Symbol -Usefuls: -101 - Functioning Brain - False -510 - Puzzle Skip - True +Useful: +510 - Puzzle Skip +#511 - Energy Capacity -Boosts: -500 - Speed Boost +Filler: +500 - Speed Boost - 1 +#501 - Energy Fill (Small) - 6 +#502 - Energy Fill - 3 +#503 - Energy Fill (Max) - 1 Traps: -600 - Slowness -610 - Power Surge +600 - Slowness - 8 +610 - Power Surge - 2 + +Jokes: +650 - Functioning Brain Doors: 1100 - Glass Factory Entry (Panel) - 0x01A54 @@ -63,18 +69,6 @@ Doors: 1400 - Caves Mountain Shortcut (Door) - 0x2D73F -1500 - Symmetry Laser - 0x00509 -1501 - Desert Laser - 0x012FB,0x01317 -1502 - Quarry Laser - 0x01539 -1503 - Shadows Laser - 0x181B3 -1504 - Keep Laser - 0x014BB -1505 - Monastery Laser - 0x17C65 -1506 - Town Laser - 0x032F9 -1507 - Jungle Laser - 0x00274 -1508 - Bunker Laser - 0x0C2B2 -1509 - Swamp Laser - 0x00BF6 -1510 - Treehouse Laser - 0x028A4 - 1600 - Outside Tutorial Outpost Path (Door) - 0x03BA2 1603 - Outside Tutorial Outpost Entry (Door) - 0x0A170 1606 - Outside Tutorial Outpost Exit (Door) - 0x04CA3 @@ -198,3 +192,16 @@ Doors: 1981 - Caves Doors to Challenge - 0x019A5,0x0A19A 1984 - Caves Exits to Main Island - 0x2D859,0x2D73F 1987 - Tunnels Doors - 0x27739,0x27263,0x09E87 + +Lasers: +1500 - Symmetry Laser - 0x00509 +1501 - Desert Laser - 0x012FB,0x01317 +1502 - Quarry Laser - 0x01539 +1503 - Shadows Laser - 0x181B3 +1504 - Keep Laser - 0x014BB +1505 - Monastery Laser - 0x17C65 +1506 - Town Laser - 0x032F9 +1507 - Jungle Laser - 0x00274 +1508 - Bunker Laser - 0x0C2B2 +1509 - Swamp Laser - 0x00BF6 +1510 - Treehouse Laser - 0x028A4 \ No newline at end of file diff --git a/worlds/witness/__init__.py b/worlds/witness/__init__.py index 7c37436c..457a9e52 100644 --- a/worlds/witness/__init__.py +++ b/worlds/witness/__init__.py @@ -3,19 +3,19 @@ Archipelago init file for The Witness """ import typing -from BaseClasses import Region, Location, MultiWorld, Item, Entrance, Tutorial, ItemClassification +from BaseClasses import Region, Location, MultiWorld, Item, Entrance, Tutorial from .hints import get_always_hint_locations, get_always_hint_items, get_priority_hint_locations, \ get_priority_hint_items, make_hints, generate_joke_hints from worlds.AutoWorld import World, WebWorld from .player_logic import WitnessPlayerLogic from .static_logic import StaticWitnessLogic from .locations import WitnessPlayerLocations, StaticWitnessLocations -from .items import WitnessItem, StaticWitnessItems, WitnessPlayerItems +from .items import WitnessItem, StaticWitnessItems, WitnessPlayerItems, ItemData from .rules import set_rules from .regions import WitnessRegions from .Options import is_option_enabled, the_witness_options, get_option_value -from .utils import best_junk_to_add_based_on_weights, get_audio_logs, make_warning_string -from logging import warning +from .utils import get_audio_logs +from logging import warning, error class WitnessWebWorld(WebWorld): @@ -40,39 +40,59 @@ class WitnessWorld(World): topology_present = False data_version = 13 - static_logic = StaticWitnessLogic() - static_locat = StaticWitnessLocations() - static_items = StaticWitnessItems() + StaticWitnessLogic() + StaticWitnessLocations() + StaticWitnessItems() web = WitnessWebWorld() option_definitions = the_witness_options item_name_to_id = { - name: data.code for name, data in static_items.ALL_ITEM_TABLE.items() + name: data.ap_code for name, data in StaticWitnessItems.item_data.items() } location_name_to_id = StaticWitnessLocations.ALL_LOCATIONS_TO_ID - item_name_groups = StaticWitnessItems.ITEM_NAME_GROUPS + item_name_groups = StaticWitnessItems.item_groups required_client_version = (0, 3, 9) + def __init__(self, multiworld: "MultiWorld", player: int): + super().__init__(multiworld, player) + + self.player_logic = None + self.locat = None + self.items = None + self.regio = None + + self.log_ids_to_hints = None + def _get_slot_data(self): return { 'seed': self.multiworld.per_slot_randoms[self.player].randint(0, 1000000), 'victory_location': int(self.player_logic.VICTORY_LOCATION, 16), 'panelhex_to_id': self.locat.CHECK_PANELHEX_TO_ID, - 'item_id_to_door_hexes': self.static_items.ITEM_ID_TO_DOOR_HEX_ALL, - 'door_hexes_in_the_pool': self.items.DOORS, - 'symbols_not_in_the_game': self.items.SYMBOLS_NOT_IN_THE_GAME, + 'item_id_to_door_hexes': StaticWitnessItems.get_item_to_door_mappings(), + 'door_hexes_in_the_pool': self.items.get_door_ids_in_pool(), + 'symbols_not_in_the_game': self.items.get_symbol_ids_not_in_pool(), 'disabled_panels': list(self.player_logic.COMPLETELY_DISABLED_CHECKS), 'log_ids_to_hints': self.log_ids_to_hints, - 'progressive_item_lists': self.items.MULTI_LISTS_BY_CODE, - 'obelisk_side_id_to_EPs': self.static_logic.OBELISK_SIDE_ID_TO_EP_HEXES, + 'progressive_item_lists': self.items.get_progressive_item_ids_in_pool(), + 'obelisk_side_id_to_EPs': StaticWitnessLogic.OBELISK_SIDE_ID_TO_EP_HEXES, 'precompleted_puzzles': [int(h, 16) for h in self.player_logic.EXCLUDED_LOCATIONS | self.player_logic.PRECOMPLETED_LOCATIONS], 'entity_to_name': self.static_logic.ENTITY_ID_TO_NAME, } def generate_early(self): - self.items_by_name = dict() + disabled_locations = self.multiworld.exclude_locations[self.player].value + + self.player_logic = WitnessPlayerLogic( + self.multiworld, self.player, disabled_locations, self.multiworld.start_inventory[self.player].value + ) + + self.locat: WitnessPlayerLocations = WitnessPlayerLocations(self.multiworld, self.player, self.player_logic) + self.items: WitnessPlayerItems = WitnessPlayerItems(self.multiworld, self.player, self.player_logic, self.locat) + self.regio: WitnessRegions = WitnessRegions(self.locat) + + self.log_ids_to_hints = dict() if not (is_option_enabled(self.multiworld, self.player, "shuffle_symbols") or get_option_value(self.multiworld, self.player, "shuffle_doors") @@ -84,49 +104,52 @@ class WitnessWorld(World): raise Exception("This Witness world doesn't have any progression items. Please turn on Symbol Shuffle," " Door Shuffle or Laser Shuffle.") - disabled_locations = self.multiworld.exclude_locations[self.player].value - - self.player_logic = WitnessPlayerLogic( - self.multiworld, self.player, disabled_locations, self.multiworld.start_inventory[self.player].value - ) - - self.locat = WitnessPlayerLocations(self.multiworld, self.player, self.player_logic) - self.items = WitnessPlayerItems(self.locat, self.multiworld, self.player, self.player_logic) - self.regio = WitnessRegions(self.locat) - - self.log_ids_to_hints = dict() - self.junk_items_created = {key: 0 for key in self.items.JUNK_WEIGHTS.keys()} - def create_regions(self): self.regio.create_regions(self.multiworld, self.player, self.player_logic) def create_items(self): - # Generate item pool - pool = [] - for item in self.items.ITEM_TABLE: - for i in range(0, self.items.PROG_ITEM_AMOUNTS[item]): - if item in self.items.PROGRESSION_TABLE: - witness_item = self.create_item(item) - pool.append(witness_item) - self.items_by_name[item] = witness_item - for precol_item in self.multiworld.precollected_items[self.player]: - if precol_item.name in self.items_by_name: # if item is in the pool, remove 1 instance. - item_obj = self.items_by_name[precol_item.name] + # Determine pool size. Note that the dog location is included in the location list, so this needs to be -1. + pool_size: int = len(self.locat.CHECK_LOCATION_TABLE) - len(self.locat.EVENT_LOCATION_TABLE) - 1 - if item_obj in pool: - pool.remove(item_obj) # remove one instance of this pre-collected item if it exists + # Fill mandatory items and remove precollected and/or starting items from the pool. + item_pool: dict[str, int] = self.items.get_mandatory_items() - for item in self.player_logic.STARTING_INVENTORY: - self.multiworld.push_precollected(self.items_by_name[item]) - pool.remove(self.items_by_name[item]) + for precollected_item_name in [item.name for item in self.multiworld.precollected_items[self.player]]: + if precollected_item_name in item_pool: + if item_pool[precollected_item_name] == 1: + item_pool.pop(precollected_item_name) + else: + item_pool[precollected_item_name] -= 1 - for item in self.items.EXTRA_AMOUNTS: - for i in range(0, self.items.EXTRA_AMOUNTS[item]): - witness_item = self.create_item(item) - pool.append(witness_item) + for inventory_item_name in self.player_logic.STARTING_INVENTORY: + if inventory_item_name in item_pool: + if item_pool[inventory_item_name] == 1: + item_pool.pop(inventory_item_name) + else: + item_pool[inventory_item_name] -= 1 - # Tie Event Items to Event Locations (e.g. Laser Activations) + if len(item_pool) > pool_size: + error_string = "The Witness world has too few locations ({num_loc}) to place its necessary items " \ + "({num_item})." + error(error_string.format(num_loc=pool_size, num_item=len(item_pool))) + return + + remaining_item_slots = pool_size - sum(item_pool.values()) + + # Add puzzle skips. + num_puzzle_skips = get_option_value(self.multiworld, self.player, "puzzle_skip_amount") + if num_puzzle_skips > remaining_item_slots: + warning(f"The Witness world has insufficient locations to place all requested puzzle skips.") + num_puzzle_skips = remaining_item_slots + item_pool["Puzzle Skip"] = num_puzzle_skips + remaining_item_slots -= num_puzzle_skips + + # Add junk items. + if remaining_item_slots > 0: + item_pool.update(self.items.get_filler_items(remaining_item_slots)) + + # Add event items and tie them to event locations (e.g. laser activations). for event_location in self.locat.EVENT_LOCATION_TABLE: item_obj = self.create_item( self.player_logic.EVENT_ITEM_PAIRS[event_location] @@ -134,113 +157,34 @@ class WitnessWorld(World): location_obj = self.multiworld.get_location(event_location, self.player) location_obj.place_locked_item(item_obj) - # Find out how much empty space there is for junk items. -1 for the "Town Pet the Dog" check - itempool_difference = len(self.locat.CHECK_LOCATION_TABLE) - len(self.locat.EVENT_LOCATION_TABLE) - 1 - itempool_difference -= len(pool) - - # Place two locked items: Good symbol on Tutorial Gate Open, and a Puzzle Skip on "Town Pet the Dog" - good_items_in_the_game = [] - plandoed_items = set() - - for v in self.multiworld.plando_items[self.player]: - if v.get("from_pool", True): - for item_key in {"item", "items"}: - if item_key in v: - if type(v[item_key]) is str: - plandoed_items.add(v[item_key]) - elif type(v[item_key]) is dict: - plandoed_items.update(item for item, weight in v[item_key].items() if weight) - else: - # Other type of iterable - plandoed_items.update(v[item_key]) - - for symbol in self.items.GOOD_ITEMS: - item = self.items_by_name[symbol] - if item in pool and symbol not in plandoed_items: - # for now, any item that is mentioned in any plando option, even if it's a list of items, is ineligible. - # Hopefully, in the future, plando gets resolved before create_items. - # I could also partially resolve lists myself, but this could introduce errors if not done carefully. - good_items_in_the_game.append(symbol) - - if good_items_in_the_game: - random_good_item = self.multiworld.random.choice(good_items_in_the_game) - - item = self.items_by_name[random_good_item] + # BAD DOG GET BACK HERE WITH THAT PUZZLE SKIP YOU'RE POLLUTING THE ITEM POOL + self.multiworld.get_location("Town Pet the Dog", self.player)\ + .place_locked_item(self.create_item("Puzzle Skip")) + # Pick an early item to place on the tutorial gate. + early_items = [item for item in self.items.get_early_items() if item in item_pool] + if early_items: + random_early_item = self.multiworld.random.choice(early_items) if get_option_value(self.multiworld, self.player, "puzzle_randomization") == 1: - self.multiworld.local_early_items[self.player][random_good_item] = 1 + # In Expert, only tag the item as early, rather than forcing it onto the gate. + self.multiworld.local_early_items[self.player][random_early_item] = 1 else: - first_check = self.multiworld.get_location( - "Tutorial Gate Open", self.player - ) + # Force the item onto the tutorial gate check and remove it from our random pool. + self.multiworld.get_location("Tutorial Gate Open", self.player)\ + .place_locked_item(self.create_item(random_early_item)) + if item_pool[random_early_item] == 1: + item_pool.pop(random_early_item) + else: + item_pool[random_early_item] -= 1 - first_check.place_locked_item(item) - pool.remove(item) + # Generate the actual items. + for item_name, quantity in item_pool.items(): + self.multiworld.itempool += [self.create_item(item_name) for _ in range(0, quantity)] + if self.items.item_data[item_name].local_only: + self.multiworld.local_items[self.player].value.add(item_name) - dog_check = self.multiworld.get_location( - "Town Pet the Dog", self.player - ) - - dog_check.place_locked_item(self.create_item("Puzzle Skip")) - - # Fill rest of item pool with junk if there is room - if itempool_difference > 0: - for i in range(0, itempool_difference): - self.multiworld.itempool.append(self.create_item(self.get_filler_item_name())) - - # Remove junk, Functioning Brain, useful items (non-door), useful door items in that order until there is room - if itempool_difference < 0: - junk = [ - item for item in pool - if item.classification in {ItemClassification.filler, ItemClassification.trap} - and item.name != "Functioning Brain" - ] - - f_brain = [item for item in pool if item.name == "Functioning Brain"] - - usefuls = [ - item for item in pool - if item.classification == ItemClassification.useful - and item.name not in StaticWitnessLogic.ALL_DOOR_ITEMS_AS_DICT - ] - - removable_doors = [ - item for item in pool - if item.classification == ItemClassification.useful - and item.name in StaticWitnessLogic.ALL_DOOR_ITEMS_AS_DICT - ] - - self.multiworld.per_slot_randoms[self.player].shuffle(junk) - self.multiworld.per_slot_randoms[self.player].shuffle(usefuls) - self.multiworld.per_slot_randoms[self.player].shuffle(removable_doors) - - removed_junk = False - removed_usefuls = False - removed_doors = False - - for i in range(itempool_difference, 0): - if junk: - pool.remove(junk.pop()) - removed_junk = True - elif f_brain: - pool.remove(f_brain.pop()) - elif usefuls: - pool.remove(usefuls.pop()) - removed_usefuls = True - elif removable_doors: - pool.remove(removable_doors.pop()) - removed_doors = True - - warn = make_warning_string( - removed_junk, removed_usefuls, removed_doors, not junk, not usefuls, not removable_doors - ) - - if warn: - warning(f"This Witness world has too few locations to place all its items." - f" In order to make space, {warn} had to be removed.") - - # Finally, add the generated pool to the overall itempool - self.multiworld.itempool += pool + # Sort the output for consistency across versions if the implementation changes but the logic does not. + self.multiworld.itempool = sorted(self.multiworld.itempool, key=lambda item: item.name) def set_rules(self): set_rules(self.multiworld, self.player, self.player_logic, self.locat) @@ -291,33 +235,15 @@ class WitnessWorld(World): return slot_data - def create_item(self, name: str) -> Item: + def create_item(self, item_name: str) -> Item: # this conditional is purely for unit tests, which need to be able to create an item before generate_early - if hasattr(self, 'items') and name in self.items.ITEM_TABLE: - item = self.items.ITEM_TABLE[name] + item_data: ItemData + if hasattr(self, 'items') and self.items and item_name in self.items.item_data: + item_data = self.items.item_data[item_name] else: - item = StaticWitnessItems.ALL_ITEM_TABLE[name] + item_data = StaticWitnessItems.item_data[item_name] - if item.trap: - classification = ItemClassification.trap - elif item.progression: - classification = ItemClassification.progression - elif item.never_exclude: - classification = ItemClassification.useful - else: - classification = ItemClassification.filler - - new_item = WitnessItem( - name, classification, item.code, player=self.player - ) - return new_item - - def get_filler_item_name(self) -> str: # Used by itemlinks - item = best_junk_to_add_based_on_weights(self.items.JUNK_WEIGHTS, self.junk_items_created) - - self.junk_items_created[item] += 1 - - return item + return WitnessItem(item_name, item_data.classification, item_data.ap_code, player=self.player) class WitnessLocation(Location): diff --git a/worlds/witness/items.py b/worlds/witness/items.py index da4a3964..8079acf4 100644 --- a/worlds/witness/items.py +++ b/worlds/witness/items.py @@ -2,24 +2,30 @@ Defines progression, junk and event items for The Witness """ import copy -from collections import defaultdict -from typing import Dict, NamedTuple, Optional, Set +from dataclasses import dataclass +from typing import Optional -from BaseClasses import Item, MultiWorld -from . import StaticWitnessLogic, WitnessPlayerLocations, WitnessPlayerLogic +from BaseClasses import Item, MultiWorld, ItemClassification from .Options import get_option_value, is_option_enabled, the_witness_options -from fractions import Fraction + +from .locations import ID_START, WitnessPlayerLocations +from .player_logic import WitnessPlayerLogic +from .static_logic import ItemDefinition, DoorItemDefinition, ProgressiveItemDefinition, ItemCategory, \ + StaticWitnessLogic, WeightedItemDefinition +from .utils import build_weighted_int_list + +NUM_ENERGY_UPGRADES = 4 -class ItemData(NamedTuple): +@dataclass() +class ItemData: """ ItemData for an item in The Witness """ - code: Optional[int] - progression: bool - event: bool = False - trap: bool = False - never_exclude: bool = False + ap_code: Optional[int] + definition: ItemDefinition + classification: ItemClassification + local_only: bool = False class WitnessItem(Item): @@ -33,75 +39,50 @@ class StaticWitnessItems: """ Class that handles Witness items independent of world settings """ + item_data: dict[str, ItemData] = {} + item_groups: dict[str, list[str]] = {} - ALL_ITEM_TABLE: Dict[str, ItemData] = {} - - ITEM_NAME_GROUPS: Dict[str, Set[str]] = dict() - - # These should always add up to 1!!! - BONUS_WEIGHTS = { - "Speed Boost": Fraction(1, 1), - } - - # These should always add up to 1!!! - TRAP_WEIGHTS = { - "Slowness": Fraction(8, 10), - "Power Surge": Fraction(2, 10), - } - - ALL_JUNK_ITEMS = set(BONUS_WEIGHTS.keys()) | set(TRAP_WEIGHTS.keys()) - - ITEM_ID_TO_DOOR_HEX_ALL = dict() + # Useful items that are treated specially at generation time and should not be automatically added to the player's + # item list during get_progression_items. + special_usefuls: list[str] = ["Puzzle Skip"] def __init__(self): - item_tab = dict() + for item_name, definition in StaticWitnessLogic.all_items.items(): + ap_item_code = definition.local_code + ID_START + classification: ItemClassification = ItemClassification.filler + local_only: bool = False - for item in StaticWitnessLogic.ALL_SYMBOL_ITEMS: - if item[0] == "11 Lasers" or item == "7 Lasers": - continue + if definition.category is ItemCategory.SYMBOL: + classification = ItemClassification.progression + StaticWitnessItems.item_groups.setdefault("Symbols", []).append(item_name) + elif definition.category is ItemCategory.DOOR: + classification = ItemClassification.progression + StaticWitnessItems.item_groups.setdefault("Doors", []).append(item_name) + elif definition.category is ItemCategory.LASER: + classification = ItemClassification.progression + StaticWitnessItems.item_groups.setdefault("Lasers", []).append(item_name) + elif definition.category is ItemCategory.USEFUL: + classification = ItemClassification.useful + elif definition.category is ItemCategory.FILLER: + if item_name in ["Energy Fill (Small)"]: + local_only = True + classification = ItemClassification.filler + elif definition.category is ItemCategory.TRAP: + classification = ItemClassification.trap + elif definition.category is ItemCategory.JOKE: + classification = ItemClassification.filler - item_tab[item[0]] = ItemData(158000 + item[1], True, False) + StaticWitnessItems.item_data[item_name] = ItemData(ap_item_code, definition, + classification, local_only) - self.ITEM_NAME_GROUPS.setdefault("Symbols", set()).add(item[0]) - - for progressive, item_list in StaticWitnessLogic.PROGRESSIVE_TO_ITEMS.items(): - if not item_list: - continue - - if item_list[0] in self.ITEM_NAME_GROUPS.setdefault("Symbols", set()): - self.ITEM_NAME_GROUPS.setdefault("Symbols", set()).add(progressive) - - for item in StaticWitnessLogic.ALL_DOOR_ITEMS: - item_tab[item[0]] = ItemData(158000 + item[1], True, False) - - # 1500 - 1510 are the laser items, which are handled like doors but should be their own separate group. - if item[1] in range(1500, 1511): - self.ITEM_NAME_GROUPS.setdefault("Lasers", set()).add(item[0]) - else: - self.ITEM_NAME_GROUPS.setdefault("Doors", set()).add(item[0]) - - for item in StaticWitnessLogic.ALL_TRAPS: - item_tab[item[0]] = ItemData( - 158000 + item[1], False, False, True - ) - - for item in StaticWitnessLogic.ALL_BOOSTS: - item_tab[item[0]] = ItemData(158000 + item[1], False, False) - - for item in StaticWitnessLogic.ALL_USEFULS: - item_tab[item[0]] = ItemData(158000 + item[1], False, False, False, item[2]) - - item_tab = dict(sorted( - item_tab.items(), - key=lambda single_item: single_item[1].code - if isinstance(single_item[1].code, int) else 0) - ) - - for key, item in item_tab.items(): - self.ALL_ITEM_TABLE[key] = item - - for door in StaticWitnessLogic.ALL_DOOR_ITEMS: - self.ITEM_ID_TO_DOOR_HEX_ALL[door[1] + 158000] = {int(door_hex, 16) for door_hex in door[2]} + @staticmethod + def get_item_to_door_mappings() -> dict[int, list[int]]: + output: dict[int, list[int]] = {} + for item_name, item_data in {name: data for name, data in StaticWitnessItems.item_data.items() + if isinstance(data.definition, DoorItemDefinition)}.items(): + item = StaticWitnessItems.item_data[item_name] + output[item.ap_code] = [int(hex_string, 16) for hex_string in item_data.definition.panel_id_hexes] + return output class WitnessPlayerItems: @@ -109,138 +90,171 @@ class WitnessPlayerItems: Class that defines Items for a single world """ - @staticmethod - def code(item_name: str): - return StaticWitnessItems.ALL_ITEM_TABLE[item_name].code - - @staticmethod - def is_progression(item_name: str, multiworld: MultiWorld, player: int): - useless_doors = { - "River Monastery Shortcut (Door)", - "Jungle & River Shortcuts", - "Monastery Shortcut (Door)", - "Orchard Second Gate (Door)", - } - - if item_name in useless_doors: - return False - - ep_doors = { - "Monastery Garden Entry (Door)", - "Monastery Shortcuts", - } - - if item_name in ep_doors: - return get_option_value(multiworld, player, "shuffle_EPs") != 0 - - return True - - def __init__(self, locat: WitnessPlayerLocations, multiworld: MultiWorld, player: int, logic: WitnessPlayerLogic): + def __init__(self, multiworld: MultiWorld, player: int, logic: WitnessPlayerLogic, locat: WitnessPlayerLocations): """Adds event items after logic changes due to options""" - self.EVENT_ITEM_TABLE = dict() - self.ITEM_TABLE = copy.copy(StaticWitnessItems.ALL_ITEM_TABLE) - self.PROGRESSION_TABLE = dict() + self._world: MultiWorld = multiworld + self._player_id: int = player + self._logic: WitnessPlayerLogic = logic + self._locations: WitnessPlayerLocations = locat - self.ITEM_ID_TO_DOOR_HEX = dict() - self.DOORS = list() + # Duplicate the static item data, then make any player-specific adjustments to classification. + self.item_data: dict[str, ItemData] = copy.copy(StaticWitnessItems.item_data) - self.PROG_ITEM_AMOUNTS = defaultdict(lambda: 1) + # Remove all progression items that aren't actually in the game. + self.item_data = {name: data for (name, data) in self.item_data.items() + if data.classification is not ItemClassification.progression or + name in logic.PROG_ITEMS_ACTUALLY_IN_THE_GAME} - self.SYMBOLS_NOT_IN_THE_GAME = list() + # Adjust item classifications based on game settings. + eps_shuffled = get_option_value(self._world, self._player_id, "shuffle_EPs") != 0 + for item_name, item_data in self.item_data.items(): + if not eps_shuffled and item_name in ["Monastery Garden Entry (Door)", "Monastery Shortcuts"]: + # Downgrade doors that only gate progress in EP shuffle. + item_data.classification = ItemClassification.useful + elif item_name in ["River Monastery Shortcut (Door)", "Jungle & River Shortcuts", + "Monastery Shortcut (Door)", + "Orchard Second Gate (Door)"]: + # Downgrade doors that don't gate progress. + item_data.classification = ItemClassification.useful - self.EXTRA_AMOUNTS = { - "Functioning Brain": 1, - "Puzzle Skip": get_option_value(multiworld, player, "puzzle_skip_amount") - } + # Build the mandatory item list. + self._mandatory_items: dict[str, int] = {} - for k, v in self.ITEM_TABLE.items(): - if v.progression and not self.is_progression(k, multiworld, player): - self.ITEM_TABLE[k] = ItemData(v.code, False, False, never_exclude=True) - - for item in StaticWitnessLogic.ALL_SYMBOL_ITEMS.union(StaticWitnessLogic.ALL_DOOR_ITEMS): - if item[0] not in logic.PROG_ITEMS_ACTUALLY_IN_THE_GAME: - del self.ITEM_TABLE[item[0]] - if item in StaticWitnessLogic.ALL_SYMBOL_ITEMS: - self.SYMBOLS_NOT_IN_THE_GAME.append(StaticWitnessItems.ALL_ITEM_TABLE[item[0]].code) + # Add progression items to the mandatory item list. + for item_name, item_data in {name: data for (name, data) in self.item_data.items() + if data.classification == ItemClassification.progression}.items(): + if isinstance(item_data.definition, ProgressiveItemDefinition): + num_progression = len(self._logic.MULTI_LISTS[item_name]) + self._mandatory_items[item_name] = num_progression else: - if item[0] in StaticWitnessLogic.PROGRESSIVE_TO_ITEMS: - self.PROG_ITEM_AMOUNTS[item[0]] = len(logic.MULTI_LISTS[item[0]]) + self._mandatory_items[item_name] = 1 - self.PROGRESSION_TABLE[item[0]] = self.ITEM_TABLE[item[0]] + # Add setting-specific useful items to the mandatory item list. + for item_name, item_data in {name: data for (name, data) in self.item_data.items() + if data.classification == ItemClassification.useful}.items(): + if item_name in StaticWitnessItems.special_usefuls: + continue + elif item_name == "Energy Capacity": + self._mandatory_items[item_name] = NUM_ENERGY_UPGRADES + elif isinstance(item_data.classification, ProgressiveItemDefinition): + self._mandatory_items[item_name] = len(item_data.mappings) + else: + self._mandatory_items[item_name] = 1 - self.MULTI_LISTS_BY_CODE = dict() + # Add event items to the item definition list for later lookup. + for event_location in self._locations.EVENT_LOCATION_TABLE: + location_name = logic.EVENT_ITEM_PAIRS[event_location] + self.item_data[location_name] = ItemData(None, ItemDefinition(0, ItemCategory.EVENT), + ItemClassification.progression, False) - for item in self.PROG_ITEM_AMOUNTS: - multi_list = logic.MULTI_LISTS[item] - self.MULTI_LISTS_BY_CODE[self.code(item)] = [self.code(single_item) for single_item in multi_list] + def get_mandatory_items(self) -> dict[str, int]: + """ + Returns the list of items that must be in the pool for the game to successfully generate. + """ + return self._mandatory_items - for entity_hex, items in logic.DOOR_ITEMS_BY_ID.items(): - entity_hex_int = int(entity_hex, 16) + def get_filler_items(self, quantity: int) -> dict[str, int]: + """ + Generates a list of filler items of the given length. + """ + if quantity <= 0: + return {} - self.DOORS.append(entity_hex_int) + output: dict[str, int] = {} + remaining_quantity = quantity - for item in items: - item_id = StaticWitnessItems.ALL_ITEM_TABLE[item].code - self.ITEM_ID_TO_DOOR_HEX.setdefault(item_id, set()).add(entity_hex_int) + # Add joke items. + output.update({name: 1 for (name, data) in self.item_data.items() + if data.definition.category is ItemCategory.JOKE}) + remaining_quantity -= len(output) - symbols = is_option_enabled(multiworld, player, "shuffle_symbols") + # Read trap configuration data. + trap_weight = get_option_value(self._world, self._player_id, "trap_percentage") / 100 + filler_weight = 1 - trap_weight - if "shuffle_symbols" not in the_witness_options.keys(): - symbols = True + # Add filler items to the list. + filler_items: dict[str, float] + filler_items = {name: data.definition.weight if isinstance(data.definition, WeightedItemDefinition) else 1 + for (name, data) in self.item_data.items() if data.definition.category is ItemCategory.FILLER} + filler_items = {name: base_weight * filler_weight / sum(filler_items.values()) + for name, base_weight in filler_items.items() if base_weight > 0} - doors = get_option_value(multiworld, player, "shuffle_doors") + # Add trap items. + if trap_weight > 0: + trap_items = {name: data.definition.weight if isinstance(data.definition, WeightedItemDefinition) else 1 + for (name, data) in self.item_data.items() if data.definition.category is ItemCategory.TRAP} + filler_items.update({name: base_weight * trap_weight / sum(trap_items.values()) + for name, base_weight in trap_items.items() if base_weight > 0}) - self.GOOD_ITEMS = [] + # Get the actual number of each item by scaling the float weight values to match the target quantity. + int_weights: list[int] = build_weighted_int_list(filler_items.values(), remaining_quantity) + output.update(zip(filler_items.keys(), int_weights)) - if symbols: - self.GOOD_ITEMS = [ - "Dots", "Black/White Squares", "Stars", - "Shapers", "Symmetry" - ] + return output - if doors: - self.GOOD_ITEMS = [ - "Dots", "Black/White Squares", "Symmetry" - ] + def get_early_items(self) -> list[str]: + """ + Returns items that are ideal for placing on extremely early checks, like the tutorial gate. + """ + output: list[str] = [] + if "shuffle_symbols" not in the_witness_options.keys() \ + or is_option_enabled(self._world, self._player_id, "shuffle_symbols"): + if get_option_value(self._world, self._player_id, "shuffle_doors") > 0: + output = ["Dots", "Black/White Squares", "Symmetry"] + else: + output = ["Dots", "Black/White Squares", "Symmetry", "Shapers", "Stars"] - if is_option_enabled(multiworld, player, "shuffle_discarded_panels"): - if get_option_value(multiworld, player, "puzzle_randomization") == 1: - self.GOOD_ITEMS.append("Arrows") + if is_option_enabled(self._world, self._player_id, "shuffle_discarded_panels"): + if get_option_value(self._world, self._player_id, "puzzle_randomization") == 1: + output.append("Arrows") else: - self.GOOD_ITEMS.append("Triangles") + output.append("Triangles") - self.GOOD_ITEMS = [ - StaticWitnessLogic.ITEMS_TO_PROGRESSIVE.get(item, item) for item in self.GOOD_ITEMS - ] + # Replace progressive items with their parents. + output = [StaticWitnessLogic.get_parent_progressive_item(item) for item in output] - for event_location in locat.EVENT_LOCATION_TABLE: - location = logic.EVENT_ITEM_PAIRS[event_location] - self.EVENT_ITEM_TABLE[location] = ItemData(None, True, True) - self.ITEM_TABLE[location] = ItemData(None, True, True) + # Remove items that are mentioned in any plando options. (Hopefully, in the future, plando will get resolved + # before create_items so that we'll be able to check placed items instead of just removing all items mentioned + # regardless of whether or not they actually wind up being manually placed. + for plando_setting in self._world.plando_items[self._player_id]: + if plando_setting.get("from_pool", True): + if "item" in plando_setting and type(plando_setting["item"]) is str: + output.remove(plando_setting["item"]) + elif "items" in plando_setting: + if type(plando_setting["items"]) is dict: + output -= [item for item, weight in plando_setting["items"].items() if weight] + else: + # Assume this is some other kind of iterable. + output -= plando_setting["items"] - trap_percentage = get_option_value(multiworld, player, "trap_percentage") + # Sort the output for consistency across versions if the implementation changes but the logic does not. + return sorted(output) - self.JUNK_WEIGHTS = dict() + def get_door_ids_in_pool(self) -> list[int]: + """ + Returns the total set of all door IDs that are controlled by items in the pool. + """ + output: list[int] = [] + for item_name, item_data in {name: data for name, data in self.item_data.items() + if isinstance(data.definition, DoorItemDefinition)}.items(): + output += [int(hex_string, 16) for hex_string in item_data.definition.panel_id_hexes] + return output - if trap_percentage != 0: - # I'm sure there must be some super "pythonic" way of doing this :D + def get_symbol_ids_not_in_pool(self) -> list[int]: + """ + Returns the item IDs of symbol items that were defined in the configuration file but are not in the pool. + """ + return [data.ap_code for name, data in StaticWitnessItems.item_data.items() + if name not in self.item_data.keys() and data.definition.category is ItemCategory.SYMBOL] - for trap_name, trap_weight in StaticWitnessItems.TRAP_WEIGHTS.items(): - self.JUNK_WEIGHTS[trap_name] = (trap_weight * trap_percentage) / 100 - - if trap_percentage != 100: - for bonus_name, bonus_weight in StaticWitnessItems.BONUS_WEIGHTS.items(): - self.JUNK_WEIGHTS[bonus_name] = (bonus_weight * (100 - trap_percentage)) / 100 - - self.JUNK_WEIGHTS = { - key: value for (key, value) - in self.JUNK_WEIGHTS.items() - if key in self.ITEM_TABLE.keys() - } - - # JUNK_WEIGHTS will add up to 1 if the boosts weights and the trap weights each add up to 1 respectively. - - for junk_item in StaticWitnessItems.ALL_JUNK_ITEMS: - if junk_item not in self.JUNK_WEIGHTS.keys(): - del self.ITEM_TABLE[junk_item] + def get_progressive_item_ids_in_pool(self) -> dict[int, list[int]]: + output: dict[int, list[int]] = {} + for item_name, quantity in {name: quantity for name, quantity in self._mandatory_items.items()}.items(): + item = self.item_data[item_name] + if isinstance(item.definition, ProgressiveItemDefinition): + # Note: we need to reference the static table here rather than the player-specific one because the child + # items were removed from the pool when we pruned out all progression items not in the settings. + output[item.ap_code] = [StaticWitnessItems.item_data[child_item].ap_code + for child_item in item.definition.child_item_names] + return output diff --git a/worlds/witness/locations.py b/worlds/witness/locations.py index 1aa186e7..b33e276e 100644 --- a/worlds/witness/locations.py +++ b/worlds/witness/locations.py @@ -7,11 +7,13 @@ from .player_logic import WitnessPlayerLogic from .static_logic import StaticWitnessLogic +ID_START = 158000 + + class StaticWitnessLocations: """ Witness Location Constants that stay consistent across worlds """ - ID_START = 158000 GENERAL_LOCATIONS = { "Tutorial Front Left", @@ -468,7 +470,7 @@ class WitnessPlayerLocations: victory = get_option_value(world, player, "victory_condition") mount_lasers = get_option_value(world, player, "mountain_lasers") chal_lasers = get_option_value(world, player, "challenge_lasers") - laser_shuffle = get_option_value(world, player, "shuffle_lasers") + # laser_shuffle = get_option_value(world, player, "shuffle_lasers") postgame = set() postgame = postgame | StaticWitnessLocations.CAVES_LOCATIONS diff --git a/worlds/witness/player_logic.py b/worlds/witness/player_logic.py index 3a38c5fa..919247e8 100644 --- a/worlds/witness/player_logic.py +++ b/worlds/witness/player_logic.py @@ -16,11 +16,11 @@ When the world has parsed its options, a second function is called to finalize t """ import copy -from typing import Set, Dict +from typing import Set, Dict, cast from logging import warning from BaseClasses import MultiWorld -from .static_logic import StaticWitnessLogic +from .static_logic import StaticWitnessLogic, DoorItemDefinition, ItemCategory, ProgressiveItemDefinition from .utils import define_new_region, get_disable_unrandomized_list, parse_lambda, get_early_utm_list, \ get_symbol_shuffle_list, get_door_panel_shuffle_list, get_doors_complex_list, get_doors_max_list, \ get_doors_simple_list, get_laser_shuffle, get_ep_all_individual, get_ep_obelisks, get_ep_easy, get_ep_no_eclipse, \ @@ -124,31 +124,41 @@ class WitnessPlayerLogic: if adj_type == "Items": line_split = line.split(" - ") - item = line_split[0] + item_name = line_split[0] - if item not in StaticWitnessItems.ALL_ITEM_TABLE: - raise RuntimeError("Item \"" + item + "\" does not exit.") + if item_name not in StaticWitnessItems.item_data: + raise RuntimeError("Item \"" + item_name + "\" does not exist.") - self.THEORETICAL_ITEMS.add(item) - self.THEORETICAL_ITEMS_NO_MULTI.update(StaticWitnessLogic.PROGRESSIVE_TO_ITEMS.get(item, [item])) + self.THEORETICAL_ITEMS.add(item_name) + if isinstance(StaticWitnessLogic.all_items[item_name], ProgressiveItemDefinition): + self.THEORETICAL_ITEMS_NO_MULTI.update(cast(ProgressiveItemDefinition, + StaticWitnessLogic.all_items[item_name]).child_item_names) + else: + self.THEORETICAL_ITEMS_NO_MULTI.add(item_name) - if item in StaticWitnessLogic.ALL_DOOR_ITEMS_AS_DICT: - panel_hexes = StaticWitnessLogic.ALL_DOOR_ITEMS_AS_DICT[item][2] + if StaticWitnessLogic.all_items[item_name].category in [ItemCategory.DOOR, ItemCategory.LASER]: + panel_hexes = cast(DoorItemDefinition, StaticWitnessLogic.all_items[item_name]).panel_id_hexes for panel_hex in panel_hexes: - self.DOOR_ITEMS_BY_ID.setdefault(panel_hex, set()).add(item) + self.DOOR_ITEMS_BY_ID.setdefault(panel_hex, []).append(item_name) return if adj_type == "Remove Items": - self.THEORETICAL_ITEMS.discard(line) - for i in StaticWitnessLogic.PROGRESSIVE_TO_ITEMS.get(line, [line]): - self.THEORETICAL_ITEMS_NO_MULTI.discard(i) + item_name = line - if line in StaticWitnessLogic.ALL_DOOR_ITEMS_AS_DICT: - panel_hexes = StaticWitnessLogic.ALL_DOOR_ITEMS_AS_DICT[line][2] + self.THEORETICAL_ITEMS.discard(item_name) + if isinstance(StaticWitnessLogic.all_items[item_name], ProgressiveItemDefinition): + self.THEORETICAL_ITEMS_NO_MULTI\ + .difference_update(cast(ProgressiveItemDefinition, + StaticWitnessLogic.all_items[item_name]).child_item_names) + else: + self.THEORETICAL_ITEMS_NO_MULTI.discard(item_name) + + if StaticWitnessLogic.all_items[item_name].category in [ItemCategory.DOOR, ItemCategory.LASER]: + panel_hexes = cast(DoorItemDefinition, StaticWitnessLogic.all_items[item_name]).panel_id_hexes for panel_hex in panel_hexes: if panel_hex in self.DOOR_ITEMS_BY_ID: - self.DOOR_ITEMS_BY_ID[panel_hex].discard(line) + self.DOOR_ITEMS_BY_ID[panel_hex].remove(item_name) if adj_type == "Starting Inventory": self.STARTING_INVENTORY.add(line) @@ -186,7 +196,9 @@ class WitnessPlayerLogic: if len(line_split) > 2: required_items = parse_lambda(line_split[2]) - items_actually_in_the_game = {item[0] for item in StaticWitnessLogic.ALL_SYMBOL_ITEMS} + items_actually_in_the_game = [item_name for item_name, item_definition + in StaticWitnessLogic.all_items.items() + if item_definition.category is ItemCategory.SYMBOL] required_items = frozenset( subset.intersection(items_actually_in_the_game) for subset in required_items @@ -337,12 +349,14 @@ class WitnessPlayerLogic: for item in self.PROG_ITEMS_ACTUALLY_IN_THE_GAME_NO_MULTI: if item not in self.THEORETICAL_ITEMS: - corresponding_multi = StaticWitnessLogic.ITEMS_TO_PROGRESSIVE[item] - self.PROG_ITEMS_ACTUALLY_IN_THE_GAME.add(corresponding_multi) - multi_list = StaticWitnessLogic.PROGRESSIVE_TO_ITEMS[StaticWitnessLogic.ITEMS_TO_PROGRESSIVE[item]] - multi_list = [item for item in multi_list if item in self.PROG_ITEMS_ACTUALLY_IN_THE_GAME_NO_MULTI] + progressive_item_name = StaticWitnessLogic.get_parent_progressive_item(item) + self.PROG_ITEMS_ACTUALLY_IN_THE_GAME.add(progressive_item_name) + child_items = cast(ProgressiveItemDefinition, + StaticWitnessLogic.all_items[progressive_item_name]).child_item_names + multi_list = [child_item for child_item in child_items + if child_item in self.PROG_ITEMS_ACTUALLY_IN_THE_GAME_NO_MULTI] self.MULTI_AMOUNTS[item] = multi_list.index(item) + 1 - self.MULTI_LISTS[corresponding_multi] = multi_list + self.MULTI_LISTS[progressive_item_name] = multi_list else: self.PROG_ITEMS_ACTUALLY_IN_THE_GAME.add(item) @@ -407,7 +421,7 @@ class WitnessPlayerLogic: self.MULTI_LISTS = dict() self.PROG_ITEMS_ACTUALLY_IN_THE_GAME_NO_MULTI = set() self.PROG_ITEMS_ACTUALLY_IN_THE_GAME = set() - self.DOOR_ITEMS_BY_ID = dict() + self.DOOR_ITEMS_BY_ID: dict[str, list[int]] = {} self.STARTING_INVENTORY = set() self.DIFFICULTY = get_option_value(world, player, "puzzle_randomization") diff --git a/worlds/witness/rules.py b/worlds/witness/rules.py index b8b8d2e8..4cf3054a 100644 --- a/worlds/witness/rules.py +++ b/worlds/witness/rules.py @@ -156,15 +156,15 @@ class WitnessLogic(LogicMixin): if not (direct_access or theater_from_town and tunnels_from_town): valid_option = False break - - elif item in player_logic.EVENT_PANELS: if not self._witness_can_solve_panel(item, world, player, player_logic, locat): valid_option = False break elif not self.has(item, player): - prog_dict = StaticWitnessLogic.ITEMS_TO_PROGRESSIVE - if not (item in prog_dict and self.has(prog_dict[item], player, player_logic.MULTI_AMOUNTS[item])): + # The player doesn't have the item. Check to see if it's part of a progressive item and, if so, the + # player has enough of that. + prog_item = StaticWitnessLogic.get_parent_progressive_item(item) + if prog_item is item or not self.has(prog_item, player, player_logic.MULTI_AMOUNTS[item]): valid_option = False break diff --git a/worlds/witness/static_logic.py b/worlds/witness/static_logic.py index 1c1c0214..52d25fa5 100644 --- a/worlds/witness/static_logic.py +++ b/worlds/witness/static_logic.py @@ -1,7 +1,53 @@ +from dataclasses import dataclass +from enum import Enum + from .utils import define_new_region, parse_lambda, lazy, get_items, get_sigma_normal_logic, get_sigma_expert_logic,\ get_vanilla_logic +class ItemCategory(Enum): + SYMBOL = 0 + DOOR = 1 + LASER = 2 + USEFUL = 3 + FILLER = 4 + TRAP = 5 + JOKE = 6 + EVENT = 7 + + +CATEGORY_NAME_MAPPINGS: dict[str, ItemCategory] = { + "Symbols:": ItemCategory.SYMBOL, + "Doors:": ItemCategory.DOOR, + "Lasers:": ItemCategory.LASER, + "Useful:": ItemCategory.USEFUL, + "Filler:": ItemCategory.FILLER, + "Traps:": ItemCategory.TRAP, + "Jokes:": ItemCategory.JOKE +} + + +@dataclass(frozen=True) +class ItemDefinition: + local_code: int + category: ItemCategory + + +@dataclass(frozen=True) +class ProgressiveItemDefinition(ItemDefinition): + child_item_names: list[str] + + +@dataclass(frozen=True) +class DoorItemDefinition(ItemDefinition): + panel_id_hexes: list[str] + + +@dataclass(frozen=True) +class WeightedItemDefinition(ItemDefinition): + weight: int + + class StaticWitnessLogicObj: def read_logic_file(self, lines): """ @@ -11,7 +57,7 @@ class StaticWitnessLogicObj: current_region = dict() for line in lines: - if line == "": + if line == "" or line[0] == "#": continue if line[-1] == ":": @@ -131,15 +177,9 @@ class StaticWitnessLogicObj: class StaticWitnessLogic: - ALL_SYMBOL_ITEMS = set() - ITEMS_TO_PROGRESSIVE = dict() - PROGRESSIVE_TO_ITEMS = dict() - ALL_DOOR_ITEMS = set() - ALL_DOOR_ITEMS_AS_DICT = dict() - ALL_USEFULS = set() - ALL_TRAPS = set() - ALL_BOOSTS = set() - CONNECTIONS_TO_SEVER_BY_DOOR_HEX = dict() + # Item data parsed from WitnessItems.txt + all_items: dict[str, ItemDefinition] = {} + _progressive_lookup: dict[str, str] = {} ALL_REGIONS_BY_NAME = dict() STATIC_CONNECTIONS_BY_REGION_NAME = dict() @@ -154,50 +194,54 @@ class StaticWitnessLogic: ENTITY_ID_TO_NAME = dict() - def parse_items(self): + @staticmethod + def parse_items(): """ Parses currently defined items from WitnessItems.txt """ - lines = get_items() - current_set = self.ALL_SYMBOL_ITEMS + lines: list[str] = get_items() + current_category: ItemCategory = ItemCategory.SYMBOL for line in lines: - if line == "Progression:": - current_set = self.ALL_SYMBOL_ITEMS + # Skip empty lines and comments. + if line == "" or line[0] == "#": continue - if line == "Boosts:": - current_set = self.ALL_BOOSTS - continue - if line == "Traps:": - current_set = self.ALL_TRAPS - continue - if line == "Usefuls:": - current_set = self.ALL_USEFULS - continue - if line == "Doors:": - current_set = self.ALL_DOOR_ITEMS - continue - if line == "": + + # If this line is a category header, update our cached category. + if line in CATEGORY_NAME_MAPPINGS.keys(): + current_category = CATEGORY_NAME_MAPPINGS[line] continue line_split = line.split(" - ") - if current_set is self.ALL_USEFULS: - current_set.add((line_split[1], int(line_split[0]), line_split[2] == "True")) - elif current_set is self.ALL_DOOR_ITEMS: - new_door = (line_split[1], int(line_split[0]), frozenset(line_split[2].split(","))) - current_set.add(new_door) - self.ALL_DOOR_ITEMS_AS_DICT[line_split[1]] = new_door + item_code = int(line_split[0]) + item_name = line_split[1] + arguments: list[str] = line_split[2].split(",") if len(line_split) >= 3 else [] + + if current_category in [ItemCategory.DOOR, ItemCategory.LASER]: + # Map doors to IDs. + StaticWitnessLogic.all_items[item_name] = DoorItemDefinition(item_code, current_category, + arguments) + elif current_category == ItemCategory.TRAP or current_category == ItemCategory.FILLER: + # Read filler weights. + weight = int(arguments[0]) if len(arguments) >= 1 else 1 + StaticWitnessLogic.all_items[item_name] = WeightedItemDefinition(item_code, current_category, weight) + elif arguments: + # Progressive items. + StaticWitnessLogic.all_items[item_name] = ProgressiveItemDefinition(item_code, current_category, + arguments) + for child_item in arguments: + StaticWitnessLogic._progressive_lookup[child_item] = item_name else: - if len(line_split) > 2: - progressive_items = line_split[2].split(",") - for i, value in enumerate(progressive_items): - self.ITEMS_TO_PROGRESSIVE[value] = line_split[1] - self.PROGRESSIVE_TO_ITEMS[line_split[1]] = progressive_items - current_set.add((line_split[1], int(line_split[0]))) - continue - current_set.add((line_split[1], int(line_split[0]))) + StaticWitnessLogic.all_items[item_name] = ItemDefinition(item_code, current_category) + + @staticmethod + def get_parent_progressive_item(item_name: str): + """ + Returns the name of the item's progressive parent, if there is one, or the item's name if not. + """ + return StaticWitnessLogic._progressive_lookup.get(item_name, item_name) @lazy def sigma_expert(self) -> StaticWitnessLogicObj: @@ -225,4 +269,4 @@ class StaticWitnessLogic: self.EP_TO_OBELISK_SIDE.update(self.sigma_normal.EP_TO_OBELISK_SIDE) - self.ENTITY_ID_TO_NAME.update(self.sigma_normal.ENTITY_ID_TO_NAME) \ No newline at end of file + self.ENTITY_ID_TO_NAME.update(self.sigma_normal.ENTITY_ID_TO_NAME) diff --git a/worlds/witness/utils.py b/worlds/witness/utils.py index 109479df..7528e49f 100644 --- a/worlds/witness/utils.py +++ b/worlds/witness/utils.py @@ -1,80 +1,37 @@ from functools import lru_cache -from itertools import accumulate +from math import floor from typing import * from fractions import Fraction from pkgutil import get_data -def make_warning_string(any_j: bool, any_u: bool, any_d: bool, all_j: bool, all_u: bool, all_d: bool) -> str: - warning_string = "" - - if any_j: - if all_j: - warning_string += "all " - else: - warning_string += "some " - - warning_string += "junk" - - if any_u or any_d: - if warning_string: - warning_string += " and " - - if all_u: - warning_string += "all " - else: - warning_string += "some " - - warning_string += "usefuls" - - if any_d: - warning_string += ", including " - - if all_d: - warning_string += "all " - else: - warning_string += "some " - - warning_string += "non-essential door items" - - return warning_string - - -def best_junk_to_add_based_on_weights(weights: Dict[Any, Fraction], created_junk: Dict[Any, int]): - min_error = ("", 2) - - for junk_name, instances in created_junk.items(): - new_dist = created_junk.copy() - new_dist[junk_name] += 1 - new_dist_length = sum(new_dist.values()) - new_dist = {key: Fraction(value/1)/new_dist_length for key, value in new_dist.items()} - - errors = {key: abs(new_dist[key] - weights[key]) for key in created_junk.keys()} - - new_min_error = max(errors.values()) - - if min_error[1] > new_min_error: - min_error = (junk_name, new_min_error) - - return min_error[0] - - -def weighted_list(weights: Dict[Any, Fraction], length): +def build_weighted_int_list(inputs: Collection[float], total: int) -> list[int]: """ - Example: - weights = {A: 0.3, B: 0.3, C: 0.4} - length = 10 - - returns: [A, A, A, B, B, B, C, C, C, C] - - Makes sure to match length *exactly*, might approximate as a result + Converts a list of floats to a list of ints of a given length, using the Largest Remainder Method. """ - vals = accumulate(map(lambda x: x * length, weights.values()), lambda x, y: x + y) - output_list = [] - for k, v in zip(weights.keys(), vals): - while len(output_list) < v: - output_list.append(k) - return output_list + + # Scale the inputs to sum to the desired total. + scale_factor: float = total / sum(inputs) + scaled_input = [x * scale_factor for x in inputs] + + # Generate whole number counts, always rounding down. + rounded_output: list[int] = [floor(x) for x in scaled_input] + rounded_sum = sum(rounded_output) + + # If the output's total is insufficient, increment the value that has the largest remainder until we meet our goal. + remainders: list[float] = [real - rounded for real, rounded in zip(scaled_input, rounded_output)] + while rounded_sum < total: + max_remainder = max(remainders) + if max_remainder == 0: + break + + # Consume the remainder and increment the total for the given target. + max_remainder_index = remainders.index(max_remainder) + remainders[max_remainder_index] = 0 + rounded_output[max_remainder_index] += 1 + rounded_sum += 1 + + return rounded_output def define_new_region(region_string):