The Witness: More control over progressive symbols (#3961)

* 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
This commit is contained in:
NewSoupVi
2026-04-18 17:07:23 +01:00
committed by GitHub
parent 0b38065123
commit dc911399fb
15 changed files with 710 additions and 109 deletions

View File

@@ -82,7 +82,7 @@ class WitnessWorld(World):
item_name_groups = static_witness_items.ITEM_GROUPS
location_name_groups = static_witness_locations.AREA_LOCATION_GROUPS
required_client_version = (0, 6, 4)
required_client_version = (0, 6, 8)
player_logic: WitnessPlayerLogic
player_locations: WitnessPlayerLocations
@@ -110,7 +110,7 @@ class WitnessWorld(World):
"hunt_entities": [int(h, 16) for h in self.player_logic.HUNT_ENTITIES],
"log_ids_to_hints": self.log_ids_to_hints,
"laser_ids_to_hints": self.laser_ids_to_hints,
"progressive_item_lists": self.player_items.get_progressive_item_ids_in_pool(),
"progressive_item_lists": self.player_items.get_progressive_item_ids(),
"obelisk_side_id_to_EPs": static_witness_logic.OBELISK_SIDE_ID_TO_EP_HEXES,
"precompleted_puzzles": [int(h, 16) for h in self.player_logic.EXCLUDED_ENTITIES],
"panel_hunt_required_absolute": self.panel_hunt_required_count
@@ -429,19 +429,47 @@ class WitnessWorld(World):
else:
item_data = static_witness_items.ITEM_DATA[item_name]
return WitnessItem(item_name, item_data.classification, item_data.ap_code, player=self.player)
item = WitnessItem(item_name, item_data.classification, item_data.ap_code, player=self.player)
def collect(self, state: "CollectionState", item: WitnessItem) -> bool:
changed = super().collect(state, item)
if changed and item.eggs:
item.is_alias_for = static_witness_items.ALL_ITEM_ALIASES.get(item_name, None)
if hasattr(self, "player_items") and self.player_items:
item.progressive_chain = self.player_items.all_progressive_item_lists.get(item_name, None)
return item
def collect(self, state: CollectionState, item: WitnessItem) -> bool:
if not super().collect(state, item):
return False
if item.eggs:
state.prog_items[self.player]["Egg"] += item.eggs
return changed
def remove(self, state: "CollectionState", item: WitnessItem) -> bool:
changed = super().remove(state, item)
if changed and item.eggs:
elif item.is_alias_for:
state.prog_items[self.player][item.is_alias_for] += 1
elif item.progressive_chain:
index = state.prog_items[self.player][item.name] - 1
if index < len(item.progressive_chain):
state.prog_items[self.player][item.progressive_chain[index]] += 1
return True
def remove(self, state: CollectionState, item: WitnessItem) -> bool:
if not super().remove(state, item):
return False
if item.eggs:
state.prog_items[self.player]["Egg"] -= item.eggs
return changed
elif item.is_alias_for:
state.prog_items[self.player][item.is_alias_for] -= 1
elif item.progressive_chain:
index = state.prog_items[self.player][item.name]
if index < len(item.progressive_chain):
state.prog_items[self.player][item.progressive_chain[index]] -= 1
return True
def get_filler_item_name(self) -> str:
return "Speed Boost"

View File

@@ -2,8 +2,8 @@ Symbols:
0 - Dots
1 - Colored Dots
2 - Full Dots
3 - Invisible Dots
5 - Sound Dots
7 - Sparse Dots
10 - Symmetry
20 - Triangles
30 - Eraser
@@ -12,12 +12,16 @@ Symbols:
50 - Negative Shapers
60 - Stars
61 - Stars + Same Colored Symbol
67 - Simple Stars
71 - Black/White Squares
72 - Colored Squares
80 - Arrows
200 - Progressive Dots - Dots,Full Dots
210 - Progressive Symmetry - Symmetry,Colored Dots
260 - Progressive Stars - Stars,Stars + Same Colored Symbol
200 - Progressive Dots
240 - Progressive Shapers
210 - Progressive Symmetry
260 - Progressive Stars
270 - Progressive Squares
280 - Progressive Discard Symbols
Useful:
510 - Puzzle Skip

View File

@@ -35,7 +35,7 @@ class ItemDefinition:
@dataclass(frozen=True)
class ProgressiveItemDefinition(ItemDefinition):
child_item_names: List[str]
pass
@dataclass(frozen=True)

View File

@@ -1,13 +1,18 @@
Items:
Arrows
Progressive Dots
Dots
Sparse Dots
Full Dots
Sound Dots
Progressive Symmetry
Symmetry
Colored Dots
Triangles
Eraser
Shapers
Rotated Shapers
Negative Shapers
Progressive Stars
Stars
Simple Stars
Stars + Same Colored Symbol
Black/White Squares
Colored Squares

View File

@@ -0,0 +1,8 @@
PROGRESSIVE_SYMBOLS = {
"Progressive Dots": ["Dots", "Full Dots"],
"Progressive Symmetry": ["Symmetry", "Colored Dots"],
"Progressive Stars": ["Stars", "Stars + Same Colored Symbol"],
"Progressive Shapers": ["Shapers", "Rotated Shapers", "Negative Shapers"],
"Progressive Squares": ["Black/White Squares", "Colored Squares"],
"Progressive Discard Symbols": ["Triangles", "Arrows"],
}

View File

@@ -13,7 +13,15 @@ ITEM_GROUPS: Dict[str, Set[str]] = {}
# item list during get_progression_items.
_special_usefuls: List[str] = ["Puzzle Skip"]
ALWAYS_GOOD_SYMBOL_ITEMS: Set[str] = {"Dots", "Black/White Squares", "Symmetry", "Shapers", "Stars"}
ALL_ITEM_ALIASES: Dict[str, str] = { # Keeping this as str->str for now for efficiency
"Sparse Dots": "Dots",
"Simple Stars": "Stars",
}
ALWAYS_GOOD_SYMBOL_ITEMS: Set[str] = {
"Dots", "Sparse Dots", "Black/White Squares", "Symmetry", "Shapers", "Stars", "Simple Stars"
}
MODE_SPECIFIC_GOOD_ITEMS: Dict[str, Set[str]] = {
"none": set(),

View File

@@ -294,7 +294,6 @@ class StaticWitnessLogicObj:
# Item data parsed from WitnessItems.txt
ALL_ITEMS: Dict[str, ItemDefinition] = {}
_progressive_lookup: Dict[str, str] = {}
def parse_items() -> None:
@@ -328,22 +327,13 @@ def parse_items() -> None:
# Read filler weights.
weight = int(arguments[0]) if len(arguments) >= 1 else 1
ALL_ITEMS[item_name] = WeightedItemDefinition(item_code, current_category, weight)
elif arguments:
elif item_name.startswith("Progressive"):
# Progressive items.
ALL_ITEMS[item_name] = ProgressiveItemDefinition(item_code, current_category, arguments)
for child_item in arguments:
_progressive_lookup[child_item] = item_name
ALL_ITEMS[item_name] = ProgressiveItemDefinition(item_code, current_category)
else:
ALL_ITEMS[item_name] = ItemDefinition(item_code, current_category)
def get_parent_progressive_item(item_name: str) -> str:
"""
Returns the name of the item's progressive parent, if there is one, or the item's name if not.
"""
return _progressive_lookup.get(item_name, item_name)
@cache_argsless
def get_vanilla() -> StaticWitnessLogicObj:
return StaticWitnessLogicObj(get_vanilla_logic())

View File

@@ -46,6 +46,8 @@ def get_always_hint_items(world: "WitnessWorld") -> List[str]:
"Boat",
"Caves Shortcuts",
"Progressive Dots",
"Dots",
"Sparse Dots",
]
difficulty = world.options.puzzle_randomization
@@ -100,7 +102,11 @@ def get_priority_hint_items(world: "WitnessWorld") -> List[str]:
if world.options.shuffle_symbols:
symbols = [
"Progressive Dots",
"Dots",
"Sparse Dots",
"Progressive Stars",
"Stars",
"Simple Stars",
"Shapers",
"Rotated Shapers",
"Negative Shapers",
@@ -110,9 +116,15 @@ def get_priority_hint_items(world: "WitnessWorld") -> List[str]:
"Black/White Squares",
"Colored Squares",
"Sound Dots",
"Progressive Symmetry"
"Progressive Symmetry",
"Progressive Shapers",
"Progressive Squares",
"Progressive Discard Symbols",
]
# Only consider symbols that are actually in the pool
symbols = [symbol for symbol in symbols if symbol in world.player_items.item_data]
priority.update(world.random.sample(symbols, 5))
if world.options.shuffle_lasers:

View File

@@ -1,8 +1,6 @@
from dataclasses import dataclass
from typing import Tuple
from schema import And, Schema
from Options import (
Choice,
DefaultOnToggle,
@@ -76,6 +74,72 @@ class ShuffleSymbols(DefaultOnToggle):
display_name = "Shuffle Symbols"
class ProgressiveSymbols(OptionSet):
"""
Make some symbols progressive, if they exist.
By default, includes the chains where the second item can't be used without the first.
Progressive Dots: Dots -> Full Dots
Progressive Symmetry: Symmetry -> Colored Dots
Progressive Stars: Stars -> Stars + Same Colored Symbol
Progressive Squares: Black/White Squares -> Colored Squares
Progressive Shapers: Shapers -> Rotated Shapers -> Negative Shapers
Progressive Discard Symbols: Triangles -> Arrows
"""
display_name = "Progressive Symbols"
valid_keys = {
"Progressive Dots",
"Progressive Symmetry",
"Progressive Stars",
"Progressive Squares",
"Progressive Shapers",
"Progressive Discard Symbols"
}
default = frozenset({"Progressive Dots", "Progressive Symmetry", "Progressive Stars"})
class SecondStageSymbolsActIndependently(OptionSet):
"""
Makes certain second stage symbols act independently of first stage symbols if they are not progressive.
- "Full Dots": "Full Dots" unlocks Full Dots panels even if you don't have "Dots". "Dots" is renamed to "Sparse Dots".
- "Stars + Same Colored Symbol": "Stars + Same Colored Symbol" unlocks Stars + Same Colored Symbol panels even if you don't have "Stars". "Stars" is renamed to "Simlpe Stars".
- "Colored Dots": Removes the Symmetry requirement from the Symmetry Laser panel sets so that Colored Dots can unlock something on their own. This is on by default.
Rotated Shapers always act independently from Shapers. The ability to make them dependent on Shapers by omitting them in this option may be added in the future.
"""
valid_keys = {
"Full Dots",
"Stars + Same Colored Symbol",
"Colored Dots",
}
default = frozenset({"Colored Dots"})
visibility = Visibility.template | Visibility.complex_ui
class ColoredDotsAreProgressiveDots(Toggle):
"""
Put Colored Dots into the "Progressive Dots" group, after Dots.
This removes Progressive Symmetry.
"""
visibility = Visibility.template | Visibility.complex_ui
class SoundDotsAreProgressiveDots(Toggle):
"""
Put Sound Dots into the "Progressive Dots" group, before Full Dots.
"""
visibility = Visibility.template | Visibility.complex_ui
class ShuffleLasers(Choice):
"""
If on, the 11 lasers are turned into items and will activate on their own upon receiving them.
@@ -537,6 +601,10 @@ class PuzzleRandomizationSeed(Range):
class TheWitnessOptions(PerGameCommonOptions):
puzzle_randomization: PuzzleRandomization
shuffle_symbols: ShuffleSymbols
progressive_symbols: ProgressiveSymbols
colored_dots_are_progressive_dots: ColoredDotsAreProgressiveDots
sound_dots_are_progressive_dots: SoundDotsAreProgressiveDots
second_stage_symbols_act_independently: SecondStageSymbolsActIndependently
shuffle_doors: ShuffleDoors
door_groupings: DoorGroupings
shuffle_boat: ShuffleBoat
@@ -600,6 +668,10 @@ witness_option_groups = [
]),
OptionGroup("Progression Items", [
ShuffleSymbols,
ProgressiveSymbols,
SecondStageSymbolsActIndependently,
ColoredDotsAreProgressiveDots,
SoundDotsAreProgressiveDots,
ShuffleDoors,
DoorGroupings,
ShuffleLasers,

View File

@@ -7,7 +7,6 @@ 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 import static_logic as static_witness_logic
from .data.item_definition_classes import (
DoorItemDefinition,
ItemCategory,
@@ -32,6 +31,8 @@ class WitnessItem(Item):
"""
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):
@@ -55,12 +56,31 @@ class WitnessPlayerItems:
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 = {
name: data for (name, data) in self.item_data.items()
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
}
@@ -86,7 +106,7 @@ class WitnessPlayerItems:
}
for item_name, item_data in progression_dict.items():
if isinstance(item_data.definition, ProgressiveItemDefinition):
num_progression = len(self._logic.PROGRESSIVE_LISTS[item_name])
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
@@ -141,9 +161,15 @@ class WitnessPlayerItems:
if self._world.options.puzzle_randomization == "umbra_variety":
self._proguseful_items.add("Triangles")
# This needs to be improved when the improved independent&progressive symbols PR is merged
for item in list(self._proguseful_items):
self._proguseful_items.add(static_witness_logic.get_parent_progressive_item(item))
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:
@@ -215,7 +241,7 @@ class WitnessPlayerItems:
# Replace progressive items with their parents.
good_symbols = [
static_witness_logic.get_parent_progressive_item(item) for item in 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]
@@ -331,13 +357,14 @@ class WitnessPlayerItems:
if name not in self.item_data.keys() and data.definition.category is ItemCategory.SYMBOL
]
def get_progressive_item_ids_in_pool(self) -> Dict[int, List[int]]:
output: Dict[int, List[int]] = {}
for item_name, quantity in dict(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 options.
output[cast_not_none(item.ap_code)] = [cast_not_none(static_witness_items.ITEM_DATA[child_item].ap_code)
for child_item in item.definition.child_item_names]
return output
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()
}

View File

@@ -22,6 +22,7 @@ from typing import TYPE_CHECKING, Dict, List, Set, Tuple, cast
from .data import static_logic as static_witness_logic
from .data.definition_classes import ConnectionDefinition, WitnessRule
from .data.item_definition_classes import DoorItemDefinition, ItemCategory, ProgressiveItemDefinition
from .data.settings.progressive_items import PROGRESSIVE_SYMBOLS
from .data.static_logic import StaticWitnessLogicObj
from .data.utils import (
get_boat,
@@ -74,12 +75,12 @@ class WitnessPlayerLogic:
self.UNREACHABLE_REGIONS: Set[str] = set()
self.THEORETICAL_BASE_ITEMS: Set[str] = set()
self.THEORETICAL_ITEMS: Set[str] = set()
self.BASE_PROGESSION_ITEMS_ACTUALLY_IN_THE_GAME: Set[str] = set()
self.THEORETICAL_PROGRESSIVE_LISTS: Dict[str, List[str]] = {}
self.ENABLED_PROGRESSIVE_LISTS: Dict[str, List[str]] = {}
self.FINALIZED_PROGRESSIVE_LISTS: Dict[str, List[str]] = {}
self.PARENT_ITEM_COUNT_PER_BASE_ITEM: Dict[str, int] = {}
self.BASE_PROGESSION_ITEMS_ACTUALLY_IN_THE_GAME: Set[str] = set() # No "progressive" conversion yet
self.PROGRESSION_ITEMS_ACTUALLY_IN_THE_GAME: Set[str] = set()
self.PARENT_ITEM_COUNT_PER_BASE_ITEM: Dict[str, int] = defaultdict(lambda: 1)
self.PROGRESSIVE_LISTS: Dict[str, List[str]] = {}
self.DOOR_ITEMS_BY_ID: Dict[str, List[str]] = {}
self.FORBIDDEN_DOORS: Set[str] = set()
@@ -303,15 +304,13 @@ class WitnessPlayerLogic:
line_split = line.split(" - ")
item_name = line_split[0]
# Do not add progressive items, delete the individual items
assert not isinstance(static_witness_logic.ALL_ITEMS[item_name], ProgressiveItemDefinition)
if item_name not in static_witness_items.ITEM_DATA:
raise RuntimeError(f'Item "{item_name}" does not exist.')
self.THEORETICAL_ITEMS.add(item_name)
if isinstance(static_witness_logic.ALL_ITEMS[item_name], ProgressiveItemDefinition):
self.THEORETICAL_BASE_ITEMS.update(cast(ProgressiveItemDefinition,
static_witness_logic.ALL_ITEMS[item_name]).child_item_names)
else:
self.THEORETICAL_BASE_ITEMS.add(item_name)
self.THEORETICAL_BASE_ITEMS.add(item_name)
if static_witness_logic.ALL_ITEMS[item_name].category in [ItemCategory.DOOR, ItemCategory.LASER]:
entity_hexes = cast(DoorItemDefinition, static_witness_logic.ALL_ITEMS[item_name]).panel_id_hexes
@@ -323,13 +322,10 @@ class WitnessPlayerLogic:
if adj_type == "Remove Items":
item_name = line
self.THEORETICAL_ITEMS.discard(item_name)
if isinstance(static_witness_logic.ALL_ITEMS[item_name], ProgressiveItemDefinition):
self.THEORETICAL_BASE_ITEMS.difference_update(
cast(ProgressiveItemDefinition, static_witness_logic.ALL_ITEMS[item_name]).child_item_names
)
else:
self.THEORETICAL_BASE_ITEMS.discard(item_name)
self.THEORETICAL_BASE_ITEMS.discard(item_name)
# Do not delete progressive items, delete the individual items
assert not isinstance(static_witness_logic.ALL_ITEMS[item_name], ProgressiveItemDefinition)
if static_witness_logic.ALL_ITEMS[item_name].category in [ItemCategory.DOOR, ItemCategory.LASER]:
entity_hexes = cast(DoorItemDefinition, static_witness_logic.ALL_ITEMS[item_name]).panel_id_hexes
@@ -537,6 +533,52 @@ class WitnessPlayerLogic:
return postgame_adjustments
def add_implicit_dependencies_to_requirements(self, dependencies: Dict[str, str]) -> None:
if not dependencies:
return
for entity, requirement in self.DEPENDENT_REQUIREMENTS_BY_HEX.items():
if "items" not in requirement:
continue
new_requirement_options = set()
for requirement_option in requirement["items"]:
changed_requirement_option = set(requirement_option)
for item1, item2 in dependencies.items():
if item1 in requirement_option:
changed_requirement_option.add(item2)
new_requirement_options.add(frozenset(changed_requirement_option))
self.DEPENDENT_REQUIREMENTS_BY_HEX[entity]["items"] = frozenset(new_requirement_options)
def adjust_requirements_for_second_stage_symbols(self, world: "WitnessWorld") -> None:
"""
When playing with non-progressive symbols,
there are some second-stage symbols that can't be used without the first-stage symbol.
However, there is a player option that makes these second stage symbols independent.
If they are independent, we rename "Dots" to "Sparse Dots" and "Stars" to "Simple Stars"
to drive home the separation of the respective items.
If they are not independent, we need to add the "Dots" requirement to every "Full Dots" panel,
as well as the "Stars" requirement to every "Stars + Same Colored Symbol" panel.
Also, if Progressive Symmetry is off and independent symbols are off, a Symmetry requirement is added to the
Symmetry Laser sets.
"""
implicit_dependencies = {}
if "Full Dots" not in world.options.second_stage_symbols_act_independently:
implicit_dependencies["Full Dots"] = "Dots"
if "Stars + Same Colored Symbol" not in world.options.second_stage_symbols_act_independently:
implicit_dependencies["Stars + Same Colored Symbol"] = "Stars"
if "Colored Dots" not in world.options.second_stage_symbols_act_independently:
implicit_dependencies["Colored Dots"] = "Symmetry"
self.add_implicit_dependencies_to_requirements(implicit_dependencies)
def set_easter_egg_requirements(self, world: "WitnessWorld") -> None:
eggs_per_check, logically_required_eggs_per_check = world.options.easter_egg_hunt.get_step_and_logical_step()
@@ -649,6 +691,28 @@ class WitnessPlayerLogic:
if world.options.shuffle_symbols:
adjustment_linesets_in_order.append(get_symbol_shuffle_list())
self.THEORETICAL_PROGRESSIVE_LISTS = copy.deepcopy(PROGRESSIVE_SYMBOLS)
self.adjust_requirements_for_second_stage_symbols(world)
if world.options.colored_dots_are_progressive_dots:
# Insert after Dots
dots_index = self.THEORETICAL_PROGRESSIVE_LISTS["Progressive Dots"].index("Dots")
self.THEORETICAL_PROGRESSIVE_LISTS["Progressive Dots"].insert(dots_index + 1, "Colored Dots")
# Remove from Progressive Symmetry
self.THEORETICAL_PROGRESSIVE_LISTS["Progressive Symmetry"].remove("Colored Dots")
if world.options.sound_dots_are_progressive_dots:
# Insert before Full Dots
full_dots_index = self.THEORETICAL_PROGRESSIVE_LISTS["Progressive Dots"].index("Full Dots")
self.THEORETICAL_PROGRESSIVE_LISTS["Progressive Dots"].insert(full_dots_index, "Sound Dots")
self.ENABLED_PROGRESSIVE_LISTS = {
progressive_item: item_list for progressive_item, item_list in self.THEORETICAL_PROGRESSIVE_LISTS.items()
if progressive_item in world.options.progressive_symbols
}
if world.options.EP_difficulty == "normal":
adjustment_linesets_in_order.append(get_ep_easy())
elif world.options.EP_difficulty == "tedious":
@@ -962,18 +1026,32 @@ class WitnessPlayerLogic:
"""
Finalise which items are used in the world, and handle their progressive versions.
"""
for item in self.BASE_PROGESSION_ITEMS_ACTUALLY_IN_THE_GAME:
if item not in self.THEORETICAL_ITEMS:
progressive_item_name = static_witness_logic.get_parent_progressive_item(item)
self.PROGRESSION_ITEMS_ACTUALLY_IN_THE_GAME.add(progressive_item_name)
child_items = cast(ProgressiveItemDefinition,
static_witness_logic.ALL_ITEMS[progressive_item_name]).child_item_names
progressive_list = [child_item for child_item in child_items
if child_item in self.BASE_PROGESSION_ITEMS_ACTUALLY_IN_THE_GAME]
self.PARENT_ITEM_COUNT_PER_BASE_ITEM[item] = progressive_list.index(item) + 1
self.PROGRESSIVE_LISTS[progressive_item_name] = progressive_list
else:
self.PROGRESSION_ITEMS_ACTUALLY_IN_THE_GAME.add(item)
self.FINALIZED_PROGRESSIVE_LISTS = self.ENABLED_PROGRESSIVE_LISTS.copy()
# Filter non existent base items
self.FINALIZED_PROGRESSIVE_LISTS = {
progressive_item: [
item for item in base_items if item in self.BASE_PROGESSION_ITEMS_ACTUALLY_IN_THE_GAME
]
for progressive_item, base_items in self.FINALIZED_PROGRESSIVE_LISTS.items()
}
# Filter empty chains / chains with only one item (no point in having those)
self.FINALIZED_PROGRESSIVE_LISTS = {
progressive_item: base_items
for progressive_item, base_items in self.FINALIZED_PROGRESSIVE_LISTS.items()
if len(base_items) >= 2 # No point in a single-item progressive chain
}
# Build PROGRESSION_ITEMS_ACTUALLY_IN_THE_GAME with the finalized progressive item replacements in mind
self.PROGRESSION_ITEMS_ACTUALLY_IN_THE_GAME = self.BASE_PROGESSION_ITEMS_ACTUALLY_IN_THE_GAME.copy()
for progressive_item, base_items in self.FINALIZED_PROGRESSIVE_LISTS.items():
self.PROGRESSION_ITEMS_ACTUALLY_IN_THE_GAME.add(progressive_item)
self.PROGRESSION_ITEMS_ACTUALLY_IN_THE_GAME -= set(base_items)
for i, base_item in enumerate(base_items):
self.PARENT_ITEM_COUNT_PER_BASE_ITEM[base_item] = i + 1
def solvability_guaranteed(self, entity_hex: str) -> bool:
return not (

View File

@@ -123,7 +123,7 @@ witness_option_presets: Dict[str, Dict[str, Any]] = {
"trap_percentage": TrapPercentage.default,
"puzzle_skip_amount": 15,
"trap_weights": TrapWeights.default,
"hint_amount": HintAmount.default,
"area_hint_percentage": AreaHintPercentage.default,
"laser_hints": LaserHints.default,

View File

@@ -203,10 +203,7 @@ def _has_item(item: str, world: "WitnessWorld",
if item == "Theater to Tunnels":
return lambda state: _can_do_theater_to_tunnels(state, world)
actual_item = static_witness_logic.get_parent_progressive_item(item)
needed_amount = player_logic.PARENT_ITEM_COUNT_PER_BASE_ITEM[item]
simple_rule: SimpleItemRepresentation = SimpleItemRepresentation(actual_item, needed_amount)
simple_rule: SimpleItemRepresentation = SimpleItemRepresentation(item, 1)
return simple_rule
@@ -214,6 +211,7 @@ def optimize_requirement_option(requirement_option: List[Union[CollectionRule, S
-> List[Union[CollectionRule, SimpleItemRepresentation]]:
"""
This optimises out a requirement like [("Progressive Dots": 1), ("Progressive Dots": 2)] to only the "2" version.
It is unclear how much this does after the recent rework the progressive items, but there is no reason to remove it.
"""
direct_items = [rule for rule in requirement_option if isinstance(rule, SimpleItemRepresentation)]
@@ -231,12 +229,15 @@ def optimize_requirement_option(requirement_option: List[Union[CollectionRule, S
def convert_requirement_option(requirement: List[Union[CollectionRule, SimpleItemRepresentation]],
player: int) -> List[CollectionRule]:
world: "WitnessWorld") -> List[CollectionRule]:
"""
Converts a list of CollectionRules and SimpleItemRepresentations to just a list of CollectionRules.
If the list is ONLY SimpleItemRepresentations, we can just return a CollectionRule based on state.has_all_counts()
"""
player_logic = world.player_logic
player = world.player
collection_rules = [rule for rule in requirement if not isinstance(rule, SimpleItemRepresentation)]
item_rules = [rule for rule in requirement if isinstance(rule, SimpleItemRepresentation)]
@@ -251,7 +252,7 @@ def convert_requirement_option(requirement: List[Union[CollectionRule, SimpleIte
# Sort the list by which item you are least likely to have (E.g. last stage of progressive item chains)
sorted_item_list = sorted(
item_counts.keys(),
key=lambda item_name: item_counts[item_name] if ("Progressive" in item_name) else 1.5,
key=lambda item_name: player_logic.PARENT_ITEM_COUNT_PER_BASE_ITEM.get(item_name, 1.5),
reverse=True
# 1.5 because you are less likely to have a single stage item than one copy of a 2-stage chain
# I did some testing and every part of this genuinely gives a tiiiiny performance boost over not having it!
@@ -272,8 +273,6 @@ def _meets_item_requirements(requirements: WitnessRule, world: "WitnessWorld") -
"""
Converts a WitnessRule into a CollectionRule.
"""
player = world.player
if requirements == frozenset({frozenset()}):
return None
@@ -284,7 +283,7 @@ def _meets_item_requirements(requirements: WitnessRule, world: "WitnessWorld") -
optimized_rule_conversion = [optimize_requirement_option(sublist) for sublist in rule_conversion]
fully_converted_rules = [convert_requirement_option(sublist, player) for sublist in optimized_rule_conversion]
fully_converted_rules = [convert_requirement_option(sublist, world) for sublist in optimized_rule_conversion]
if len(fully_converted_rules) == 1:
if len(fully_converted_rules[0]) == 1:

View File

@@ -122,6 +122,20 @@ class WitnessTestBase(WorldTestBase):
)
item_objects.append(removed_item)
def assert_quantities_in_itempool(self, expected_quantities: Mapping[str, int]) -> None:
for item, expected_quantity in expected_quantities.items():
with self.subTest(f"Verify that there are {expected_quantity} copies of {item} in the itempool."):
found_items = self.get_items_by_name(item)
self.assertEqual(len(found_items), expected_quantity)
def assert_item_exists_and_is_proguseful(self, item_name: str, proguseful=True):
items = self.get_items_by_name(item_name)
self.assertTrue(items)
if proguseful:
self.assertTrue(all(item.advancement and item.useful for item in items))
else:
self.assertTrue(all(item.advancement and not item.useful for item in items))
class WitnessMultiworldTestBase(MultiworldTestBase):
options_per_world: List[Dict[str, Any]]

View File

@@ -1,38 +1,394 @@
from ..test.bases import WitnessMultiworldTestBase, WitnessTestBase
class TestSymbols(WitnessTestBase):
class TestProgressiveSymbols(WitnessTestBase):
options = {
"early_good_items": {},
"puzzle_randomization": "umbra_variety",
"progressive_symbols": {
"Progressive Dots",
"Progressive Symmetry",
"Progressive Stars",
"Progressive Squares",
"Progressive Shapers",
"Progressive Discard Symbols"
}
}
def test_progressive_symbols(self) -> None:
"""
Test that Dots & Full Dots are correctly replaced by 2x Progressive Dots,
Test that Full Dots are correctly replaced by 2x Progressive Dots,
and test that Dots puzzles and Full Dots puzzles require 1 and 2 copies of this item respectively.
"""
expected_quantities = {
# Individual items that are replaced by progressive items
"Dots": 0,
"Sparse Dots": 0,
"Full Dots": 0,
"Symmetry": 0,
"Colored Dots": 0,
"Stars": 0,
"Simple Stars": 0,
"Stars + Same Colored Symbol": 0,
"Black/White Squares": 0,
"Colored Squares": 0,
"Shapers": 0,
"Rotated Shapers": 0,
"Negative Shapers": 0,
"Triangles": 0,
"Arrows": 0,
# Progressive items
"Progressive Dots": 2,
"Progressive Symmetry": 2,
"Progressive Stars": 2,
"Progressive Squares": 2,
"Progressive Shapers": 3,
"Progressive Discard Symbols": 2,
# Individual items that still exist because they aren't a part of any progressive chain
"Sound Dots": 1,
}
self.assert_quantities_in_itempool(expected_quantities)
with self.subTest("Verify that Dots panels need 1 copy of Progressive Dots and Full Dots panel need 2 copies"):
self.collect_all_but("Progressive Dots")
progressive_dots = self.get_items_by_name("Progressive Dots")
self.assertEqual(len(progressive_dots), 2)
self.assertFalse(self.multiworld.state.can_reach("Tutorial Patio Floor", "Location", self.player))
self.assertFalse(self.multiworld.state.can_reach("Outside Tutorial Shed Row 5", "Location", self.player))
self.collect(progressive_dots.pop())
self.assertTrue(self.multiworld.state.can_reach("Tutorial Patio Floor", "Location", self.player))
self.assertFalse(self.multiworld.state.can_reach("Outside Tutorial Shed Row 5", "Location", self.player))
self.collect(progressive_dots.pop())
self.assertTrue(self.multiworld.state.can_reach("Tutorial Patio Floor", "Location", self.player))
self.assertTrue(self.multiworld.state.can_reach("Outside Tutorial Shed Row 5", "Location", self.player))
with self.subTest("Verify proguseful status of progressive & alias items"):
self.assert_item_exists_and_is_proguseful("Progressive Symmetry", proguseful=False)
self.assert_item_exists_and_is_proguseful("Sound Dots", proguseful=False)
self.assert_item_exists_and_is_proguseful("Progressive Dots")
self.assert_item_exists_and_is_proguseful("Progressive Stars")
self.assert_item_exists_and_is_proguseful("Progressive Squares")
self.assert_item_exists_and_is_proguseful("Progressive Shapers")
self.assert_item_exists_and_is_proguseful("Progressive Discard Symbols")
class TestIndependentSecondStageSymbols(WitnessTestBase):
options = {
"early_good_items": {},
"puzzle_randomization": "umbra_variety",
"progressive_symbols": {},
"second_stage_symbols_act_independently": {
"Full Dots",
"Stars + Same Colored Symbol",
"Colored Dots",
},
"shuffle_doors": "doors",
}
def test_independent_second_stage_symbols(self) -> None:
expected_quantities = {
# Progressive items shouldn't exist
"Progressive Dots": 0,
"Progressive Symmetry": 0,
"Progressive Stars": 0,
"Progressive Squares": 0,
"Progressive Shapers": 0,
"Progressive Discard Symbols": 0,
# Dots and Stars are replaced by Sparse Dots and Simple Stars
"Dots": 0,
"Stars": 0,
"Sparse Dots": 1,
"Simple Stars": 1,
# None of the symbols are progressive, so they should all exist
"Full Dots": 1,
"Symmetry": 1,
"Colored Dots": 1,
"Stars + Same Colored Symbol": 1,
"Black/White Squares": 1,
"Colored Squares": 1,
"Shapers": 1,
"Rotated Shapers": 1,
"Negative Shapers": 1,
"Triangles": 1,
"Arrows": 1,
"Sound Dots": 1,
}
self.assert_quantities_in_itempool(expected_quantities)
with self.subTest("Verify that Full Dots panels only need Full Dots"):
self.collect_by_name("Black/White Squares")
self.collect_by_name("Triangles")
self.collect_by_name("Outside Tutorial Outpost Exit (Door)")
self.assertFalse(
self.multiworld.state.can_reach("Outside Tutorial Outpost Exit Panel", "Location", self.player)
)
self.collect_by_name("Full Dots")
self.assertTrue(
self.multiworld.state.can_reach("Outside Tutorial Outpost Exit Panel", "Location", self.player)
)
with self.subTest("Verify that Stars + Same Colored Symbol panels only need Stars + Same Colored Symbol"):
self.collect_by_name("Eraser")
self.collect_by_name("Quarry Entry 1 (Door)")
self.collect_by_name("Quarry Entry 2 (Door)")
self.assertFalse(
self.multiworld.state.can_reach("Quarry Stoneworks Entry Left Panel", "Location", self.player)
)
self.collect_by_name("Stars + Same Colored Symbol")
self.assertTrue(
self.multiworld.state.can_reach("Quarry Stoneworks Entry Left Panel", "Location", self.player)
)
with self.subTest("Verify that non-symmetry Colored Dots panels only need Colored Dots"):
self.collect_by_name("Symmetry Island Lower (Door)")
self.collect_by_name("Symmetry Island Upper (Door)")
self.assertFalse(
self.multiworld.state.can_reach("Symmetry Island Laser Blue 3", "Location", self.player)
)
self.collect_by_name("Colored Dots")
self.assertTrue(
self.multiworld.state.can_reach("Symmetry Island Laser Blue 3", "Location", self.player)
)
with self.subTest("Verify proguseful status of progressive & alias items"):
self.assert_item_exists_and_is_proguseful("Full Dots", proguseful=False)
self.assert_item_exists_and_is_proguseful("Stars + Same Colored Symbol", proguseful=False)
self.assert_item_exists_and_is_proguseful("Arrows", proguseful=False) # Variety
self.assert_item_exists_and_is_proguseful("Sparse Dots")
self.assert_item_exists_and_is_proguseful("Simple Stars")
self.assert_item_exists_and_is_proguseful("Triangles") # Variety
class TestDependentSecondStageSymbols(WitnessTestBase):
options = {
"early_good_items": {},
"puzzle_randomization": "umbra_variety",
"progressive_symbols": {},
"second_stage_symbols_act_independently": {},
"shuffle_doors": "doors",
}
def test_dependent_second_stage_symbols(self) -> None:
expected_quantities = {
# Progressive items shouldn't exist
"Progressive Dots": 0,
"Progressive Symmetry": 0,
"Progressive Stars": 0,
"Progressive Squares": 0,
"Progressive Shapers": 0,
"Progressive Discard Symbols": 0,
# Dots and Stars are NOT replaced by Sparse Dots and Simple Stars
"Dots": 1,
"Stars": 1,
"Sparse Dots": 0,
"Simple Stars": 0,
# None of the symbols are progressive, so they should all exist
"Full Dots": 1,
"Symmetry": 1,
"Colored Dots": 1,
"Stars + Same Colored Symbol": 1,
"Black/White Squares": 1,
"Colored Squares": 1,
"Shapers": 1,
"Rotated Shapers": 1,
"Negative Shapers": 1,
"Triangles": 1,
"Arrows": 1,
"Sound Dots": 1,
}
self.assert_quantities_in_itempool(expected_quantities)
with self.subTest("Verify that Full Dots panels need Dots as well"):
self.collect_by_name("Black/White Squares")
self.collect_by_name("Triangles")
self.collect_by_name("Outside Tutorial Outpost Exit (Door)")
self.assertFalse(
self.multiworld.state.can_reach("Outside Tutorial Outpost Exit Panel", "Location", self.player))
self.collect_by_name("Full Dots")
self.assertFalse(
self.multiworld.state.can_reach("Outside Tutorial Outpost Exit Panel", "Location", self.player))
self.collect_by_name("Dots")
self.assertTrue(
self.multiworld.state.can_reach("Outside Tutorial Outpost Exit Panel", "Location", self.player)
)
with self.subTest("Verify that Stars + Same Colored Symbol panels need Stars as well"):
self.collect_by_name("Eraser")
self.collect_by_name("Quarry Entry 1 (Door)")
self.collect_by_name("Quarry Entry 2 (Door)")
self.assertFalse(
self.multiworld.state.can_reach("Quarry Stoneworks Entry Left Panel", "Location", self.player)
)
self.collect_by_name("Stars + Same Colored Symbol")
self.assertFalse(
self.multiworld.state.can_reach("Quarry Stoneworks Entry Left Panel", "Location", self.player)
)
self.collect_by_name("Stars")
self.assertTrue(
self.multiworld.state.can_reach("Quarry Stoneworks Entry Left Panel", "Location", self.player)
)
with self.subTest("Verify that non-symmetry Colored Dots panels need Symmetry as well"):
self.collect_by_name("Symmetry Island Lower (Door)")
self.collect_by_name("Symmetry Island Upper (Door)")
self.assertFalse(
self.multiworld.state.can_reach("Symmetry Island Laser Blue 3", "Location", self.player)
)
self.collect_by_name("Colored Dots")
self.assertFalse(
self.multiworld.state.can_reach("Symmetry Island Laser Blue 3", "Location", self.player)
)
self.collect_by_name("Symmetry")
self.assertTrue(
self.multiworld.state.can_reach("Symmetry Island Laser Blue 3", "Location", self.player)
)
with self.subTest("Verify proguseful status of progressive & alias items"):
self.assert_item_exists_and_is_proguseful("Full Dots", proguseful=False)
self.assert_item_exists_and_is_proguseful("Stars + Same Colored Symbol", proguseful=False)
self.assert_item_exists_and_is_proguseful("Arrows", proguseful=False) # Variety
self.assert_item_exists_and_is_proguseful("Dots")
self.assert_item_exists_and_is_proguseful("Stars")
self.assert_item_exists_and_is_proguseful("Triangles") # Variety
class TestAlternateProgressiveDots(WitnessTestBase):
options = {
"early_good_items": {},
"puzzle_randomization": "umbra_variety",
"progressive_symbols": {
"Progressive Dots",
"Progressive Symmetry"
},
"second_stage_symbols_act_independently": {
"Full Dots",
"Stars + Same Colored Symbol",
"Colored Dots",
},
"colored_dots_are_progressive_dots": True,
"sound_dots_are_progressive_dots": True,
"shuffle_doors": "doors",
}
def test_alternate_progressive_dots(self) -> None:
expected_quantities = {
# Progressive Dots chain now has 4 members
"Progressive Dots": 4,
# Dots items don't exist because Progressive Dots is on.
# For this test, this includes Colored Dots and Sound Dots as well
"Dots": 0,
"Sparse Dots": 0,
"Colored Dots": 0,
"Sound Dots": 0,
"Full Dots": 0,
# Progressive Symmetry no longer exists, because Colored Dots is part of the Progressive Dots chain instead
"Progressive Symmetry": 0,
# Other Progressive Symbols don't exist
"Progressive Stars": 0,
"Progressive Squares": 0,
"Progressive Shapers": 0,
"Progressive Discard Symbols": 0,
# Other standalone items exist, because they are not progressive
"Symmetry": 1,
"Stars + Same Colored Symbol": 1,
"Black/White Squares": 1,
"Colored Squares": 1,
"Shapers": 1,
"Rotated Shapers": 1,
"Negative Shapers": 1,
"Triangles": 1,
"Arrows": 1,
# This test is set to have independent symbols, so Simple Stars exist instead of Stars
"Stars": 0,
"Simple Stars": 1,
}
self.assert_quantities_in_itempool(expected_quantities)
self.collect_all_but(["Progressive Dots", "Symmetry"]) # Skip Symmetry so we can also test a little quirk
progressive_dots = self.get_items_by_name("Progressive Dots")
self.assertEqual(len(progressive_dots), 2)
self.assertEqual(len(progressive_dots), 4)
self.assertFalse(self.multiworld.state.can_reach("Outside Tutorial Shed Row 5", "Location", self.player))
self.assertFalse(
self.multiworld.state.can_reach("Outside Tutorial Outpost Entry Panel", "Location", self.player)
)
with self.subTest("Test that one copy of Progressive Dots unlocks Dots panels"):
self.assertFalse(self.multiworld.state.can_reach("Tutorial Patio Floor", "Location", self.player))
self.assertFalse(
self.multiworld.state.can_reach("Symmetry Island Laser Blue 3", "Location", self.player)
)
self.assertFalse(self.multiworld.state.can_reach("Jungle Popup Wall 6", "Location", self.player))
self.assertFalse(self.multiworld.state.can_reach("Outside Tutorial Shed Row 5", "Location", self.player))
self.collect(progressive_dots.pop())
self.collect(progressive_dots.pop())
self.assertTrue(self.multiworld.state.can_reach("Outside Tutorial Shed Row 5", "Location", self.player))
self.assertFalse(
self.multiworld.state.can_reach("Outside Tutorial Outpost Entry Panel", "Location", self.player)
)
self.assertTrue(self.multiworld.state.can_reach("Tutorial Patio Floor", "Location", self.player))
self.assertFalse(
self.multiworld.state.can_reach("Symmetry Island Laser Blue 3", "Location", self.player)
)
self.assertFalse(self.multiworld.state.can_reach("Jungle Popup Wall 6", "Location", self.player))
self.assertFalse(self.multiworld.state.can_reach("Outside Tutorial Shed Row 5", "Location", self.player))
self.collect(progressive_dots.pop())
with self.subTest("Test that two copies of Progressive Dots unlocks Colored Dots panels"):
self.collect(progressive_dots.pop())
self.assertTrue(self.multiworld.state.can_reach("Outside Tutorial Shed Row 5", "Location", self.player))
self.assertTrue(
self.multiworld.state.can_reach("Outside Tutorial Outpost Entry Panel", "Location", self.player)
)
self.assertTrue(self.multiworld.state.can_reach("Tutorial Patio Floor", "Location", self.player))
# Also test here that these "Colored Dots" act independently from Symmetry like they are supposed to
self.assertTrue(
self.multiworld.state.can_reach("Symmetry Island Laser Blue 3", "Location", self.player)
)
self.assertFalse(self.multiworld.state.can_reach("Jungle Popup Wall 6", "Location", self.player))
self.assertFalse(self.multiworld.state.can_reach("Outside Tutorial Shed Row 5", "Location", self.player))
with self.subTest("Test that three copies of Progressive Dots unlocks Sound Dots panels"):
self.collect(progressive_dots.pop())
self.assertTrue(self.multiworld.state.can_reach("Tutorial Patio Floor", "Location", self.player))
self.assertTrue(
self.multiworld.state.can_reach("Symmetry Island Laser Blue 3", "Location", self.player)
)
self.assertTrue(self.multiworld.state.can_reach("Jungle Popup Wall 6", "Location", self.player))
self.assertFalse(self.multiworld.state.can_reach("Outside Tutorial Shed Row 5", "Location", self.player))
with self.subTest("Test that four copies of Progressive Dots unlocks Full Dots panels"):
self.collect(progressive_dots.pop())
self.assertTrue(self.multiworld.state.can_reach("Tutorial Patio Floor", "Location", self.player))
self.assertTrue(
self.multiworld.state.can_reach("Symmetry Island Laser Blue 3", "Location", self.player)
)
self.assertTrue(self.multiworld.state.can_reach("Jungle Popup Wall 6", "Location", self.player))
self.assertTrue(self.multiworld.state.can_reach("Outside Tutorial Shed Row 5", "Location", self.player))
with self.subTest("Verify proguseful status of progressive & alias items"):
self.assert_item_exists_and_is_proguseful("Progressive Dots")
self.assert_item_exists_and_is_proguseful("Simple Stars")
class TestSymbolRequirementsMultiworld(WitnessMultiworldTestBase):