mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-08-06 03:46:11 -07:00
* More control over progressive symbols! * Cleanup crew * lol * Make it configurable how second stage items act on their own * only let independent symbols work if they're not progressive * revert defaults * Better description * comment for reviewers * A complicated docstring for a complicated function * More accurate * This probably works more generically * Actually, this would cause other issues anyway, so no need to make this generic yet * :/ * oops * Change the system to use collect/remove override so that plando and start inventory work correctly * Vi stop doing that thing challenge * Make SecondStateSymbolsActIndependently an OptionSet * jank * oop * this is why we make unit tests I guess * More unit tests * More unit tests * Add note about the absence of Rotated Shapers from the independent symbols optionset * More verbose description * More verbose description * slight reword * Ruff ruff :3 I am a good puppy <3 * Remove invis dots * oops * Remove some unused symbols * display name * linecounting -> discard * Make all progressive chains always work * Unfortunately, this optimisation is now unsafe with plando :( * oops * This is now a possible optimisation * optimise optimise optimise * optimise optimise optimise * fix * ruff * oh * fixed frfr * mypy * Clean up the tests a bit * oop * I actually like this better now, so I'm doing it as default * Put stuff on the actual item class for faster collect/remove * giga oops * Make proguseful work * None item left beef * unnecessary change * formatting * add proguseful test to progressive symbols tests * add proguseful test to progressive symbols tests * clean up collect/remove a bit more * giga lmfao * Put stuff in option groups * Add the new symbol items to hint system * bump req client version * fix soft conflict in unit tests * fix more merge errors
371 lines
17 KiB
Python
371 lines
17 KiB
Python
"""
|
|
Defines progression, junk and event items for The Witness
|
|
"""
|
|
import copy
|
|
from typing import TYPE_CHECKING, Dict, List, Set
|
|
|
|
from BaseClasses import Item, ItemClassification, MultiWorld
|
|
|
|
from .data import static_items as static_witness_items
|
|
from .data.item_definition_classes import (
|
|
DoorItemDefinition,
|
|
ItemCategory,
|
|
ItemData,
|
|
ItemDefinition,
|
|
ProgressiveItemDefinition,
|
|
WeightedItemDefinition,
|
|
)
|
|
from .data.utils import build_weighted_int_list, cast_not_none
|
|
from .locations import WitnessPlayerLocations
|
|
from .player_logic import WitnessPlayerLogic
|
|
|
|
if TYPE_CHECKING:
|
|
from . import WitnessWorld
|
|
|
|
NUM_ENERGY_UPGRADES = 4
|
|
|
|
|
|
class WitnessItem(Item):
|
|
"""
|
|
Item from the game The Witness
|
|
"""
|
|
game: str = "The Witness"
|
|
eggs: int = 0
|
|
is_alias_for: str | None = None
|
|
progressive_chain: list[str] | None = None
|
|
|
|
@classmethod
|
|
def make_egg_event(cls, item_name: str, player: int):
|
|
ret = cls(item_name, ItemClassification.progression, None, player)
|
|
ret.eggs = int(item_name[1:].split(" ", 1)[0])
|
|
return ret
|
|
|
|
|
|
class WitnessPlayerItems:
|
|
"""
|
|
Class that defines Items for a single world
|
|
"""
|
|
|
|
def __init__(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic,
|
|
player_locations: WitnessPlayerLocations) -> None:
|
|
"""Adds event items after logic changes due to options"""
|
|
|
|
self._world: WitnessWorld = world
|
|
self._multiworld: MultiWorld = world.multiworld
|
|
self._player_id: int = world.player
|
|
self._logic: WitnessPlayerLogic = player_logic
|
|
self._locations: WitnessPlayerLocations = player_locations
|
|
|
|
self.replacement_items = {}
|
|
# Make item aliases for "Sparse Dots" and "Simple Stars" if necessary
|
|
if "Full Dots" in world.options.second_stage_symbols_act_independently:
|
|
self.replacement_items["Dots"] = "Sparse Dots"
|
|
if "Stars + Same Colored Symbol" in world.options.second_stage_symbols_act_independently:
|
|
self.replacement_items["Stars"] = "Simple Stars"
|
|
|
|
assert all(
|
|
static_witness_items.ALL_ITEM_ALIASES.get(value, None) == key
|
|
for key, value in self.replacement_items.items()
|
|
), "A replacement item was used without setting up the alias in static_witness_items.ALL_ITEM_ALIASES"
|
|
|
|
self.all_progressive_item_lists = copy.deepcopy(self._logic.THEORETICAL_PROGRESSIVE_LISTS)
|
|
self.progressive_item_lists_in_use = copy.deepcopy(self._logic.FINALIZED_PROGRESSIVE_LISTS)
|
|
|
|
self.progressive_item_lookup: Dict[str, str] = {}
|
|
for progressive_item, chain_items in self.progressive_item_lists_in_use.items():
|
|
self.progressive_item_lookup.update({chain_item: progressive_item for chain_item in chain_items})
|
|
|
|
# Duplicate the static item data, then make any player-specific adjustments to classification.
|
|
self.item_data: Dict[str, ItemData] = copy.deepcopy(static_witness_items.ITEM_DATA)
|
|
|
|
# Remove all progression items that aren't actually in the game.
|
|
self.item_data = {
|
|
self.replacement_items.get(name, name): data for (name, data) in self.item_data.items()
|
|
if ItemClassification.progression not in data.classification
|
|
or name in player_logic.PROGRESSION_ITEMS_ACTUALLY_IN_THE_GAME
|
|
}
|
|
|
|
# Downgrade door items and make lasers local if local lasers is on
|
|
for item_name, item_data in self.item_data.items():
|
|
if not isinstance(item_data.definition, DoorItemDefinition):
|
|
continue
|
|
|
|
if all(not self._logic.solvability_guaranteed(e_hex) for e_hex in item_data.definition.panel_id_hexes):
|
|
item_data.classification = ItemClassification.useful
|
|
|
|
if item_data.definition.category == ItemCategory.LASER and self._world.options.shuffle_lasers == "local":
|
|
item_data.local_only = True
|
|
|
|
# Build the mandatory item list.
|
|
self._mandatory_items: Dict[str, int] = {}
|
|
|
|
# Add progression items to the mandatory item list.
|
|
progression_dict = {
|
|
name: data for (name, data) in self.item_data.items()
|
|
if ItemClassification.progression in data.classification
|
|
}
|
|
for item_name, item_data in progression_dict.items():
|
|
if isinstance(item_data.definition, ProgressiveItemDefinition):
|
|
num_progression = len(self.progressive_item_lists_in_use[item_name])
|
|
self._mandatory_items[item_name] = num_progression
|
|
else:
|
|
self._mandatory_items[item_name] = 1
|
|
|
|
# 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 static_witness_items._special_usefuls:
|
|
continue
|
|
|
|
if 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
|
|
|
|
# Add event items to the item definition list for later lookup.
|
|
for event_location in self._locations.EVENT_LOCATION_TABLE:
|
|
location_name = player_logic.EVENT_ITEM_PAIRS[event_location][0]
|
|
self.item_data[location_name] = ItemData(None, ItemDefinition(0, ItemCategory.EVENT),
|
|
ItemClassification.progression, False)
|
|
|
|
# Determine which items should be progression + useful, if they exist in some capacity.
|
|
# Note: Some of these may need to be updated for the "independent symbols" PR.
|
|
self._proguseful_items = {
|
|
"Dots", "Stars", "Shapers", "Black/White Squares",
|
|
"Caves Shortcuts", "Caves Mountain Shortcut (Door)", "Caves Swamp Shortcut (Door)",
|
|
"Boat",
|
|
}
|
|
|
|
if self._world.options.shuffle_EPs == "individual":
|
|
self._proguseful_items |= {
|
|
"Town Obelisk Key", # Most checks
|
|
"Monastery Obelisk Key", # Most sphere 1 checks, and also super dense ("Jackpot" vibes)}
|
|
}
|
|
|
|
if self._world.options.shuffle_discarded_panels:
|
|
# Discards only give a moderate amount of checks, but are very spread out and a lot of them are in sphere 1.
|
|
# Thus, you really want to have the discard-unlocking item as quickly as possible.
|
|
|
|
if self._world.options.puzzle_randomization in ("none", "sigma_normal"):
|
|
self._proguseful_items.add("Triangles")
|
|
elif self._world.options.puzzle_randomization == "sigma_expert":
|
|
self._proguseful_items.add("Arrows")
|
|
# Discards require two symbols in Variety, so the "sphere 1 unlocking power" of Arrows is not there.
|
|
if self._world.options.puzzle_randomization == "sigma_expert":
|
|
self._proguseful_items.add("Triangles")
|
|
self._proguseful_items.add("Full Dots")
|
|
self._proguseful_items.add("Stars + Same Colored Symbol")
|
|
self._proguseful_items.discard("Stars") # Stars are not that useful on their own.
|
|
if self._world.options.puzzle_randomization == "umbra_variety":
|
|
self._proguseful_items.add("Triangles")
|
|
|
|
for progressive_item, progressive_item_chain in player_logic.FINALIZED_PROGRESSIVE_LISTS.items():
|
|
for chain_item in progressive_item_chain:
|
|
if chain_item in self._proguseful_items:
|
|
self._proguseful_items.add(progressive_item)
|
|
break
|
|
|
|
for alias_item, real_item in static_witness_items.ALL_ITEM_ALIASES.items():
|
|
if real_item in self._proguseful_items:
|
|
self._proguseful_items.add(alias_item)
|
|
|
|
for item_name, item_data in self.item_data.items():
|
|
if item_name in self._proguseful_items:
|
|
item_data.classification |= ItemClassification.useful
|
|
|
|
|
|
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.copy()
|
|
|
|
def get_filler_items(self, quantity: int) -> Dict[str, int]:
|
|
"""
|
|
Generates a list of filler items of the given length.
|
|
"""
|
|
if quantity <= 0:
|
|
return {}
|
|
|
|
output: Dict[str, int] = {}
|
|
remaining_quantity = quantity
|
|
|
|
# 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)
|
|
|
|
# Read trap configuration data.
|
|
trap_weight = self._world.options.trap_percentage / 100
|
|
trap_items = self._world.options.trap_weights.value
|
|
|
|
if not sum(trap_items.values()):
|
|
trap_weight = 0
|
|
|
|
# Add filler items to the list.
|
|
filler_weight = 1 - trap_weight
|
|
|
|
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}
|
|
|
|
# Add trap items.
|
|
if trap_weight > 0:
|
|
filler_items.update({name: base_weight * trap_weight / sum(trap_items.values())
|
|
for name, base_weight in trap_items.items() if base_weight > 0})
|
|
|
|
# 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))
|
|
|
|
return output
|
|
|
|
def get_early_items(self, existing_items: Set[str]) -> Dict[str, List[str]]:
|
|
"""
|
|
Returns items that are ideal for placing on extremely early checks, like the tutorial gate.
|
|
"""
|
|
output: Dict[str, List[str]] = {}
|
|
|
|
if "Symbol" in self._world.options.early_good_items.value:
|
|
good_symbols = ["Dots", "Black/White Squares", "Symmetry", "Shapers", "Stars"]
|
|
|
|
if self._world.options.shuffle_discarded_panels:
|
|
if self._world.options.puzzle_randomization == "sigma_expert":
|
|
good_symbols.append("Arrows")
|
|
else:
|
|
good_symbols.append("Triangles")
|
|
|
|
# Replace progressive items with their parents.
|
|
good_symbols = [
|
|
self.progressive_item_lookup.get(item, item) for item in good_symbols
|
|
]
|
|
|
|
output["Symbol"] = [symbol for symbol in good_symbols if symbol in existing_items]
|
|
|
|
if "Door / Door Panel" in self._world.options.early_good_items.value:
|
|
good_doors = [
|
|
"Desert Doors & Elevator", "Keep Hedge Maze Doors", "Keep Pressure Plates Doors",
|
|
"Shadows Lower Doors", "Tunnels Doors", "Quarry Stoneworks Doors",
|
|
|
|
"Keep Tower Shortcut (Door)", "Shadows Timed Door", "Tunnels Town Shortcut (Door)",
|
|
"Quarry Stoneworks Roof Exit (Door)",
|
|
|
|
"Desert Panels", "Keep Hedge Maze Panels",
|
|
|
|
"Shadows Door Timer (Panel)",
|
|
]
|
|
|
|
# While Caves Shortcuts don't unlock anything in symbol shuffle, you'd still rather have them early.
|
|
# But, we need to do some special handling for them.
|
|
|
|
if self._world.options.shuffle_doors in ("doors", "mixed"):
|
|
# Caves Shortcuts might exist in vanilla/panel doors because of "early caves: add to pool".
|
|
# But if the player wanted them early, they would have chosen "early caves: starting inventory".
|
|
# This is why we make sure these are "natural" Caves Shortcuts.
|
|
good_doors.append("Caves Shortcuts")
|
|
|
|
# These two doors are logically equivalent, so we choose a random one as to not give them twice the chance.
|
|
good_doors.append(
|
|
self._world.random.choice(["Caves Mountain Shortcut (Door)", "Caves Swamp Shortcut (Door)"])
|
|
)
|
|
|
|
if self._world.options.shuffle_vault_boxes and not self._world.options.disable_non_randomized_puzzles:
|
|
good_doors.append("Windmill & Theater Doors")
|
|
if not self._world.options.shuffle_symbols:
|
|
good_doors += [
|
|
"Windmill & Theater Panels",
|
|
|
|
"Windmill & Theater Control Panels",
|
|
]
|
|
|
|
if self._world.options.shuffle_doors == "doors": # It's not as good in mixed doors because of Light Control
|
|
good_doors.append("Desert Light Room Entry (Door)")
|
|
|
|
if not self._world.options.shuffle_symbols:
|
|
good_doors += [
|
|
"Bunker Doors", "Swamp Doors", "Glass Factory Doors", "Town Doors",
|
|
|
|
"Bunker Entry (Door)", "Glass Factory Entry (Door)", "Symmetry Island Lower (Door)",
|
|
|
|
"Bunker Panels", "Swamp Panels", "Quarry Outside Panels", "Glass Factory Panels"
|
|
|
|
"Glass Factory Entry (Panel)",
|
|
]
|
|
|
|
existing_doors = [door for door in good_doors if door in existing_items]
|
|
|
|
# On some options combinations with doors, there just aren't a lot of doors that unlock much early.
|
|
# In this case, we add some doors that aren't great, but are at least guaranteed to unlock 1 location.
|
|
|
|
fallback_doors = [
|
|
"Keep Shadows Shortcut (Door)", # Always Keep Shadows Shortcut Panel
|
|
"Keep Shortcuts", # -"-
|
|
"Keep Pressure Plates Doors", # -"-
|
|
"Keep Hedge Maze 1 (Panel)", # Always Hedge 1
|
|
"Town Maze Stairs (Panel)", # Always Maze Panel
|
|
"Shadows Laser Room Doors", # Always Shadows Laser Panel
|
|
"Swamp Laser Shortcut (Door)", # Always Swamp Laser
|
|
"Town Maze Panels", # Always Town Maze Panel
|
|
"Town Doors", # Always Town Church Lattice
|
|
"Town Church Entry (Door)", # -"-
|
|
"Town Tower Doors", # Always Town Laser
|
|
]
|
|
self._world.random.shuffle(fallback_doors)
|
|
|
|
while len(existing_doors) < 4 and fallback_doors:
|
|
fallback_door = fallback_doors.pop()
|
|
if fallback_door in existing_items and fallback_door not in existing_doors:
|
|
existing_doors.append(fallback_door)
|
|
|
|
output["Door"] = existing_doors
|
|
|
|
if "Obelisk Key" in self._world.options.early_good_items.value:
|
|
obelisk_keys = [
|
|
"Desert Obelisk Key", "Town Obelisk Key", "Quarry Obelisk Key",
|
|
"Treehouse Obelisk Key", "Monastery Obelisk Key", "Mountainside Obelisk Key"
|
|
]
|
|
output["Obelisk Key"] = [key for key in obelisk_keys if key in existing_items]
|
|
|
|
assert all(item in self._world.item_names for sublist in output.values() for item in sublist), (
|
|
[item for sublist in output.values() for item in sublist if item not in self._world.item_names]
|
|
)
|
|
|
|
# Cull empty lists
|
|
return {item_type: item_list for item_type, item_list in output.items() if item_list}
|
|
|
|
def get_door_item_ids_in_pool(self) -> List[int]:
|
|
"""
|
|
Returns the ids of all door items that exist in the pool.
|
|
"""
|
|
|
|
return [
|
|
cast_not_none(item_data.ap_code) for item_data in self.item_data.values()
|
|
if isinstance(item_data.definition, DoorItemDefinition)
|
|
]
|
|
|
|
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 is guaranteed for a symbol definition
|
|
cast_not_none(data.ap_code) for name, data in static_witness_items.ITEM_DATA.items()
|
|
if name not in self.item_data.keys() and data.definition.category is ItemCategory.SYMBOL
|
|
]
|
|
|
|
def get_progressive_item_ids(self) -> Dict[int, List[int]]:
|
|
"""
|
|
Returns a dict from progressive item IDs to the list of IDs of the base item that they unlock, in order.
|
|
"""
|
|
return {
|
|
cast_not_none(static_witness_items.ITEM_DATA[progressive_item].ap_code): [
|
|
cast_not_none(static_witness_items.ITEM_DATA[base_item].ap_code)
|
|
for base_item in corresponding_base_items
|
|
]
|
|
for progressive_item, corresponding_base_items in self.all_progressive_item_lists.items()
|
|
}
|