Skip to content

Commit f99ee77

Browse files
NewSoupViExempt-Medicblack-sliver
authored
The Witness: Add some unit tests (#3328)
* Add hidden early symbol item option, make some unit tests * Add early symbol item false to the arrows test * I guess it's not an issue * more tests * assertEqual * cleanup * add minimum symbols test for all 3 modes * Formatting * Add more minimal beatability tests * one more for the road * I HATE THIS AAAAAAAAAAAHHHHHHHHHHH WHY DID WE GO WITH OPTIONS * loiaqeäsdhgalikSDGHjasDÖKHGASKLDÖGHJASKLJGHJSAÖkfaöslifjasöfASGJÖASDLFGJ'sklgösLGIKsdhJLGÖsdfjälghklDASFJghjladshfgjasdfälkjghasdöLfghasd-kjgjASDLÖGHAESKDLJGJÖsdaLGJHsadöKGjFDSLAkgjölSÄDghbASDFKGjasdLJGhjLÖSDGHLJASKDkgjldafjghjÖLADSFghäasdökgjäsadjlgkjsadkLHGsaDÖLGSADGÖLwSdlgkJLwDSFÄLHBJsaöfdkHweaFGIoeWjvlkdösmVJÄlsafdJKhvjdsJHFGLsdaövhWDsköLV-ksdFJHGVöSEKD * fix imports (within apworld needs to be relative) * Update worlds/witness/options.py Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * Sure * good suggestion * subtest * Add some EP shuffle unit tests, also an explicit event-checking unit test * add more tests yay * oops * mypy * Update worlds/witness/options.py Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com> * Collapse into one test :( * More efficiency * line length * More collapsing * Cleanup and docstrings --------- Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
1 parent bfac100 commit f99ee77

10 files changed

+685
-16
lines changed

worlds/witness/__init__.py

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -185,21 +185,22 @@ def create_regions(self) -> None:
185185

186186
self.items_placed_early.append("Puzzle Skip")
187187

188-
# Pick an early item to place on the tutorial gate.
189-
early_items = [
190-
item for item in self.player_items.get_early_items() if item in self.player_items.get_mandatory_items()
191-
]
192-
if early_items:
193-
random_early_item = self.random.choice(early_items)
194-
if self.options.puzzle_randomization == "sigma_expert":
195-
# In Expert, only tag the item as early, rather than forcing it onto the gate.
196-
self.multiworld.local_early_items[self.player][random_early_item] = 1
197-
else:
198-
# Force the item onto the tutorial gate check and remove it from our random pool.
199-
gate_item = self.create_item(random_early_item)
200-
self.get_location("Tutorial Gate Open").place_locked_item(gate_item)
201-
self.own_itempool.append(gate_item)
202-
self.items_placed_early.append(random_early_item)
188+
if self.options.early_symbol_item:
189+
# Pick an early item to place on the tutorial gate.
190+
early_items = [
191+
item for item in self.player_items.get_early_items() if item in self.player_items.get_mandatory_items()
192+
]
193+
if early_items:
194+
random_early_item = self.random.choice(early_items)
195+
if self.options.puzzle_randomization == "sigma_expert":
196+
# In Expert, only tag the item as early, rather than forcing it onto the gate.
197+
self.multiworld.local_early_items[self.player][random_early_item] = 1
198+
else:
199+
# Force the item onto the tutorial gate check and remove it from our random pool.
200+
gate_item = self.create_item(random_early_item)
201+
self.get_location("Tutorial Gate Open").place_locked_item(gate_item)
202+
self.own_itempool.append(gate_item)
203+
self.items_placed_early.append(random_early_item)
203204

204205
# There are some really restrictive settings in The Witness.
205206
# They are rarely played, but when they are, we add some extra sphere 1 locations.

worlds/witness/options.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from schema import And, Schema
44

5-
from Options import Choice, DefaultOnToggle, OptionDict, OptionGroup, PerGameCommonOptions, Range, Toggle
5+
from Options import Choice, DefaultOnToggle, OptionDict, OptionGroup, PerGameCommonOptions, Range, Toggle, Visibility
66

77
from .data import static_logic as static_witness_logic
88
from .data.item_definition_classes import ItemCategory, WeightedItemDefinition
@@ -35,6 +35,14 @@ class EarlyCaves(Choice):
3535
alias_on = 2
3636

3737

38+
class EarlySymbolItem(DefaultOnToggle):
39+
"""
40+
Put a random helpful symbol item on an early check, specifically Tutorial Gate Open if it is available early.
41+
"""
42+
43+
visibility = Visibility.none
44+
45+
3846
class ShuffleSymbols(DefaultOnToggle):
3947
"""
4048
If on, you will need to unlock puzzle symbols as items to be able to solve the panels that contain those symbols.
@@ -325,6 +333,7 @@ class TheWitnessOptions(PerGameCommonOptions):
325333
mountain_lasers: MountainLasers
326334
challenge_lasers: ChallengeLasers
327335
early_caves: EarlyCaves
336+
early_symbol_item: EarlySymbolItem
328337
elevators_come_to_you: ElevatorsComeToYou
329338
trap_percentage: TrapPercentage
330339
trap_weights: TrapWeights

worlds/witness/test/__init__.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
from test.bases import WorldTestBase
2+
from test.general import gen_steps, setup_multiworld
3+
from test.multiworld.test_multiworlds import MultiworldTestBase
4+
from typing import Any, ClassVar, Dict, Iterable, List, Mapping, Union, cast
5+
6+
from BaseClasses import CollectionState, Entrance, Item, Location, Region
7+
8+
from .. import WitnessWorld
9+
10+
11+
class WitnessTestBase(WorldTestBase):
12+
game = "The Witness"
13+
player: ClassVar[int] = 1
14+
15+
world: WitnessWorld
16+
17+
def can_beat_game_with_items(self, items: Iterable[Item]) -> bool:
18+
"""
19+
Check that the items listed are enough to beat the game.
20+
"""
21+
22+
state = CollectionState(self.multiworld)
23+
for item in items:
24+
state.collect(item)
25+
return state.multiworld.can_beat_game(state)
26+
27+
def assert_dependency_on_event_item(self, spot: Union[Location, Region, Entrance], item_name: str) -> None:
28+
"""
29+
WorldTestBase.assertAccessDependency, but modified & simplified to work with event items
30+
"""
31+
event_items = [item for item in self.multiworld.get_items() if item.name == item_name]
32+
self.assertTrue(event_items, f"Event item {item_name} does not exist.")
33+
34+
event_locations = [cast(Location, event_item.location) for event_item in event_items]
35+
36+
# Checking for an access dependency on an event item requires a bit of extra work,
37+
# as state.remove forces a sweep, which will pick up the event item again right after we tried to remove it.
38+
# So, we temporarily set the access rules of the event locations to be impossible.
39+
original_rules = {event_location.name: event_location.access_rule for event_location in event_locations}
40+
for event_location in event_locations:
41+
event_location.access_rule = lambda _: False
42+
43+
# We can't use self.assertAccessDependency here, it doesn't work for event items. (As of 2024-06-30)
44+
test_state = self.multiworld.get_all_state(False)
45+
46+
self.assertFalse(spot.can_reach(test_state), f"{spot.name} is reachable without {item_name}")
47+
48+
test_state.collect(event_items[0])
49+
50+
self.assertTrue(spot.can_reach(test_state), f"{spot.name} is not reachable despite having {item_name}")
51+
52+
# Restore original access rules.
53+
for event_location in event_locations:
54+
event_location.access_rule = original_rules[event_location.name]
55+
56+
def assert_location_exists(self, location_name: str, strict_check: bool = True) -> None:
57+
"""
58+
Assert that a location exists in this world.
59+
If strict_check, also make sure that this (non-event) location COULD exist.
60+
"""
61+
62+
if strict_check:
63+
self.assertIn(location_name, self.world.location_name_to_id, f"Location {location_name} can never exist")
64+
65+
try:
66+
self.world.get_location(location_name)
67+
except KeyError:
68+
self.fail(f"Location {location_name} does not exist.")
69+
70+
def assert_location_does_not_exist(self, location_name: str, strict_check: bool = True) -> None:
71+
"""
72+
Assert that a location exists in this world.
73+
If strict_check, be explicit about whether the location could exist in the first place.
74+
"""
75+
76+
if strict_check:
77+
self.assertIn(location_name, self.world.location_name_to_id, f"Location {location_name} can never exist")
78+
79+
self.assertRaises(
80+
KeyError,
81+
lambda _: self.world.get_location(location_name),
82+
f"Location {location_name} exists, but is not supposed to.",
83+
)
84+
85+
def assert_can_beat_with_minimally(self, required_item_counts: Mapping[str, int]) -> None:
86+
"""
87+
Assert that the specified mapping of items is enough to beat the game,
88+
and that having one less of any item would result in the game being unbeatable.
89+
"""
90+
# Find the actual items
91+
found_items = [item for item in self.multiworld.get_items() if item.name in required_item_counts]
92+
actual_items: Dict[str, List[Item]] = {item_name: [] for item_name in required_item_counts}
93+
for item in found_items:
94+
if len(actual_items[item.name]) < required_item_counts[item.name]:
95+
actual_items[item.name].append(item)
96+
97+
# Assert that enough items exist in the item pool to satisfy the specified required counts
98+
for item_name, item_objects in actual_items.items():
99+
self.assertEqual(
100+
len(item_objects),
101+
required_item_counts[item_name],
102+
f"Couldn't find {required_item_counts[item_name]} copies of item {item_name} available in the pool, "
103+
f"only found {len(item_objects)}",
104+
)
105+
106+
# assert that multiworld is beatable with the items specified
107+
self.assertTrue(
108+
self.can_beat_game_with_items(item for items in actual_items.values() for item in items),
109+
f"Could not beat game with items: {required_item_counts}",
110+
)
111+
112+
# assert that one less copy of any item would result in the multiworld being unbeatable
113+
for item_name, item_objects in actual_items.items():
114+
with self.subTest(f"Verify cannot beat game with one less copy of {item_name}"):
115+
removed_item = item_objects.pop()
116+
self.assertFalse(
117+
self.can_beat_game_with_items(item for items in actual_items.values() for item in items),
118+
f"Game was beatable despite having {len(item_objects)} copies of {item_name} "
119+
f"instead of the specified {required_item_counts[item_name]}",
120+
)
121+
item_objects.append(removed_item)
122+
123+
124+
class WitnessMultiworldTestBase(MultiworldTestBase):
125+
options_per_world: List[Dict[str, Any]]
126+
common_options: Dict[str, Any] = {}
127+
128+
def setUp(self) -> None:
129+
"""
130+
Set up a multiworld with multiple players, each using different options.
131+
"""
132+
133+
self.multiworld = setup_multiworld([WitnessWorld] * len(self.options_per_world), ())
134+
135+
for world, options in zip(self.multiworld.worlds.values(), self.options_per_world):
136+
for option_name, option_value in {**self.common_options, **options}.items():
137+
option = getattr(world.options, option_name)
138+
self.assertIsNotNone(option)
139+
140+
option.value = option.from_any(option_value).value
141+
142+
self.assertSteps(gen_steps)
143+
144+
def collect_by_name(self, item_names: Union[str, Iterable[str]], player: int) -> List[Item]:
145+
"""
146+
Collect all copies of a specified item name (or list of item names) for a player in the multiworld item pool.
147+
"""
148+
149+
items = self.get_items_by_name(item_names, player)
150+
for item in items:
151+
self.multiworld.state.collect(item)
152+
return items
153+
154+
def get_items_by_name(self, item_names: Union[str, Iterable[str]], player: int) -> List[Item]:
155+
"""
156+
Return all copies of a specified item name (or list of item names) for a player in the multiworld item pool.
157+
"""
158+
159+
if isinstance(item_names, str):
160+
item_names = (item_names,)
161+
return [item for item in self.multiworld.itempool if item.name in item_names and item.player == player]
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from ..test import WitnessMultiworldTestBase, WitnessTestBase
2+
3+
4+
class TestElevatorsComeToYou(WitnessTestBase):
5+
options = {
6+
"elevators_come_to_you": True,
7+
"shuffle_doors": "mixed",
8+
"shuffle_symbols": False,
9+
}
10+
11+
def test_bunker_laser(self) -> None:
12+
"""
13+
In elevators_come_to_you, Bunker can be entered from the back.
14+
This means that you can access the laser with just Bunker Elevator Control (Panel).
15+
It also means that you can, for example, access UV Room with the Control and the Elevator Room Entry Door.
16+
"""
17+
18+
self.assertFalse(self.multiworld.state.can_reach("Bunker Laser Panel", "Location", self.player))
19+
20+
self.collect_by_name("Bunker Elevator Control (Panel)")
21+
22+
self.assertTrue(self.multiworld.state.can_reach("Bunker Laser Panel", "Location", self.player))
23+
self.assertFalse(self.multiworld.state.can_reach("Bunker UV Room 2", "Location", self.player))
24+
25+
self.collect_by_name("Bunker Elevator Room Entry (Door)")
26+
self.collect_by_name("Bunker Drop-Down Door Controls (Panel)")
27+
28+
self.assertTrue(self.multiworld.state.can_reach("Bunker UV Room 2", "Location", self.player))
29+
30+
31+
class TestElevatorsComeToYouBleed(WitnessMultiworldTestBase):
32+
options_per_world = [
33+
{
34+
"elevators_come_to_you": False,
35+
},
36+
{
37+
"elevators_come_to_you": True,
38+
},
39+
{
40+
"elevators_come_to_you": False,
41+
},
42+
]
43+
44+
common_options = {
45+
"shuffle_symbols": False,
46+
"shuffle_doors": "panels",
47+
}
48+
49+
def test_correct_access_per_player(self) -> None:
50+
"""
51+
Test that in a multiworld with players that alternate the elevators_come_to_you option,
52+
the actual behavior alternates as well and doesn't bleed over from slot to slot.
53+
(This is essentially a "does connection info bleed over" test).
54+
"""
55+
56+
self.assertFalse(self.multiworld.state.can_reach("Bunker Laser Panel", "Location", 1))
57+
self.assertFalse(self.multiworld.state.can_reach("Bunker Laser Panel", "Location", 2))
58+
self.assertFalse(self.multiworld.state.can_reach("Bunker Laser Panel", "Location", 3))
59+
60+
self.collect_by_name(["Bunker Elevator Control (Panel)"], 1)
61+
self.collect_by_name(["Bunker Elevator Control (Panel)"], 2)
62+
self.collect_by_name(["Bunker Elevator Control (Panel)"], 3)
63+
64+
self.assertFalse(self.multiworld.state.can_reach("Bunker Laser Panel", "Location", 1))
65+
self.assertTrue(self.multiworld.state.can_reach("Bunker Laser Panel", "Location", 2))
66+
self.assertFalse(self.multiworld.state.can_reach("Bunker Laser Panel", "Location", 3))
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from ..rules import _has_lasers
2+
from ..test import WitnessTestBase
3+
4+
5+
class TestDisableNonRandomized(WitnessTestBase):
6+
options = {
7+
"disable_non_randomized_puzzles": True,
8+
"shuffle_doors": "panels",
9+
"early_symbol_item": False,
10+
}
11+
12+
def test_locations_got_disabled_and_alternate_activation_triggers_work(self) -> None:
13+
"""
14+
Test the different behaviors of the disable_non_randomized mode:
15+
16+
1. Unrandomized locations like Orchard Apple Tree 5 are disabled.
17+
2. Certain doors or lasers that would usually be activated by unrandomized panels depend on event items instead.
18+
3. These alternate activations are tied to solving Discarded Panels.
19+
"""
20+
21+
with self.subTest("Test that unrandomized locations are disabled."):
22+
self.assert_location_does_not_exist("Orchard Apple Tree 5")
23+
24+
with self.subTest("Test that alternate activation trigger events exist."):
25+
self.assert_dependency_on_event_item(
26+
self.world.get_entrance("Town Tower After Third Door to Town Tower Top"),
27+
"Town Tower 4th Door Opens",
28+
)
29+
30+
with self.subTest("Test that alternate activation triggers award lasers."):
31+
self.assertFalse(_has_lasers(1, self.world, False)(self.multiworld.state))
32+
33+
self.collect_by_name("Triangles")
34+
35+
# Alternate triggers yield Bunker Laser (Mountainside Discard) and Monastery Laser (Desert Discard)
36+
self.assertTrue(_has_lasers(2, self.world, False)(self.multiworld.state))
37+
self.assertFalse(_has_lasers(3, self.world, False)(self.multiworld.state))
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from ..test import WitnessTestBase
2+
3+
4+
class TestIndividualDoors(WitnessTestBase):
5+
options = {
6+
"shuffle_doors": "doors",
7+
"door_groupings": "off",
8+
}
9+
10+
def test_swamp_laser_shortcut(self) -> None:
11+
"""
12+
Test that Door Shuffle grants early access to Swamp Laser from the back shortcut.
13+
"""
14+
15+
self.assertTrue(self.get_items_by_name("Swamp Laser Shortcut (Door)"))
16+
17+
self.assertAccessDependency(
18+
["Swamp Laser Panel"],
19+
[
20+
["Swamp Laser Shortcut (Door)"],
21+
["Swamp Red Underwater Exit (Door)"],
22+
],
23+
only_check_listed=True,
24+
)

0 commit comments

Comments
 (0)