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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user