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>
This commit is contained in:
blastron
2023-07-18 20:02:57 -07:00
committed by GitHub
parent 18c9779815
commit 1f6db12797
9 changed files with 482 additions and 518 deletions

View File

@@ -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