The Messenger: Transition Shuffle (#4402)

* The Messenger: transition rando

* remove unused import

* always link both directions for plando when using coupled transitions

* er_type was renamed to randomization_type

* use frozenset for things that shouldn't change

* review suggestions

* do portal and transition shuffle in `connect_entrances`

* remove some unnecessary connections that were causing entrance caching collisions

* add test for strictest possible ER settings

* use unittest.skip on the skipped test, so we don't waste time doing setUp and tearDown

* use the world helpers

* make the plando connection description more verbose

* always add searing crags portal if portal shuffle is disabled

* guarantee an arbitrary number of locations with first connection

* make the constraints more lenient for a bit more variety
This commit is contained in:
Aaron Wagener
2025-03-10 10:16:09 -05:00
committed by GitHub
parent 4882366ffc
commit 7c30c4a169
7 changed files with 258 additions and 75 deletions

View File

@@ -1,7 +1,7 @@
import logging
from typing import Any, ClassVar, TextIO
from BaseClasses import CollectionState, Entrance, Item, ItemClassification, MultiWorld, Tutorial
from BaseClasses import CollectionState, Entrance, EntranceType, Item, ItemClassification, MultiWorld, Tutorial
from Options import Accessibility
from Utils import output_path
from settings import FilePath, Group
@@ -17,6 +17,7 @@ from .regions import LEVELS, MEGA_SHARDS, LOCATIONS, REGION_CONNECTIONS
from .rules import MessengerHardRules, MessengerOOBRules, MessengerRules
from .shop import FIGURINES, PROG_SHOP_ITEMS, SHOP_ITEMS, USEFUL_SHOP_ITEMS, shuffle_shop_prices
from .subclasses import MessengerEntrance, MessengerItem, MessengerRegion, MessengerShopLocation
from .transitions import shuffle_transitions
components.append(
Component("The Messenger", component_type=Type.CLIENT, func=launch_game, game_name="The Messenger", supports_uri=True)
@@ -128,7 +129,7 @@ class MessengerWorld(World):
spoiler_portal_mapping: dict[str, str]
portal_mapping: list[int]
transitions: list[Entrance]
reachable_locs: int = 0
reachable_locs: bool = False
filler: dict[str, int]
def generate_early(self) -> None:
@@ -145,13 +146,13 @@ class MessengerWorld(World):
self.shop_prices, self.figurine_prices = shuffle_shop_prices(self)
starting_portals = ["Autumn Hills", "Howling Grotto", "Glacial Peak", "Riviere Turquoise", "Sunken Shrine", "Searing Crags"]
starting_portals = ["Autumn Hills", "Howling Grotto", "Glacial Peak", "Riviere Turquoise", "Sunken Shrine",
"Searing Crags"]
self.starting_portals = [f"{portal} Portal"
for portal in starting_portals[:3] +
self.random.sample(starting_portals[3:], k=self.options.available_portals - 3)]
# super complicated method for adding searing crags to starting portals if it wasn't chosen
# TODO add a check for transition shuffle when that gets added back in
if not self.options.shuffle_portals and "Searing Crags Portal" not in self.starting_portals:
self.starting_portals.append("Searing Crags Portal")
portals_to_strip = [portal for portal in ["Riviere Turquoise Portal", "Sunken Shrine Portal"]
@@ -181,7 +182,7 @@ class MessengerWorld(World):
region_name = region.name.removeprefix(f"{region.parent} - ")
connection_data = CONNECTIONS[region.parent][region_name]
for exit_region in connection_data:
region.connect(self.multiworld.get_region(exit_region, self.player))
region.connect(self.get_region(exit_region))
# all regions need to be created before i can do these connections so we create and connect the complex first
for region in [level for level in simple_regions if level.name in REGION_CONNECTIONS]:
@@ -256,6 +257,7 @@ class MessengerWorld(World):
f" {logic} for {self.multiworld.get_player_name(self.player)}")
# MessengerOOBRules(self).set_messenger_rules()
def connect_entrances(self) -> None:
add_closed_portal_reqs(self)
# i need portal shuffle to happen after rules exist so i can validate it
attempts = 5
@@ -271,6 +273,9 @@ class MessengerWorld(World):
else:
raise RuntimeError("Unable to generate valid portal output.")
if self.options.shuffle_transitions:
shuffle_transitions(self)
def write_spoiler_header(self, spoiler_handle: TextIO) -> None:
if self.options.available_portals < 6:
spoiler_handle.write(f"\nStarting Portals:\n\n")
@@ -286,9 +291,54 @@ class MessengerWorld(World):
key=lambda portal:
["Autumn Hills", "Riviere Turquoise",
"Howling Grotto", "Sunken Shrine",
"Searing Crags", "Glacial Peak"].index(portal[0]))
"Searing Crags", "Glacial Peak"].index(portal[0])
)
for portal, output in portal_info:
spoiler.set_entrance(f"{portal} Portal", output, "I can write anything I want here lmao", self.player)
spoiler.set_entrance(f"{portal} Portal", output, "", self.player)
if self.options.shuffle_transitions:
for transition in self.transitions:
if (transition.randomization_type == EntranceType.TWO_WAY
and (transition.connected_region.name, "both", self.player) in spoiler.entrances):
continue
spoiler.set_entrance(
transition.name if "->" not in transition.name else transition.parent_region.name,
transition.connected_region.name,
"both" if transition.randomization_type == EntranceType.TWO_WAY
and self.options.shuffle_transitions == ShuffleTransitions.option_coupled else "",
self.player
)
def extend_hint_information(self, hint_data: dict[int, dict[int, str]]) -> None:
if not self.options.shuffle_transitions:
return
hint_data.update({self.player: {}})
all_state = self.multiworld.get_all_state(True)
# sometimes some of my regions aren't in path for some reason?
all_state.update_reachable_regions(self.player)
paths = all_state.path
start = self.get_region("Tower HQ")
start_connections = [entrance.name for entrance in start.exits if entrance not in {"Home", "Shrink Down"}]
transition_names = [transition.name for transition in self.transitions] + start_connections
for loc in self.get_locations():
if (loc.parent_region.name in {"Tower HQ", "The Shop", "Music Box", "The Craftsman's Corner"}
or loc.address is None):
continue
path_to_loc: list[str] = []
name, connection = paths.get(loc.parent_region, (None, None))
while connection != ("Menu", None) and name is not None:
name, connection = connection
if name in transition_names:
if name in start_connections:
name = f"{name} -> {self.get_entrance(name).connected_region.name}"
path_to_loc.append(name)
text = " => ".join(reversed(path_to_loc))
if not text:
continue
hint_data[self.player][loc.address] = text
def fill_slot_data(self) -> dict[str, Any]:
slot_data = {
@@ -308,11 +358,13 @@ class MessengerWorld(World):
def get_filler_item_name(self) -> str:
if not getattr(self, "_filler_items", None):
self._filler_items = [name for name in self.random.choices(
list(self.filler),
weights=list(self.filler.values()),
k=20
)]
self._filler_items = [
name for name in self.random.choices(
list(self.filler),
weights=list(self.filler.values()),
k=20
)
]
return self._filler_items.pop(0)
def create_item(self, name: str) -> MessengerItem:
@@ -331,7 +383,7 @@ class MessengerWorld(World):
self.total_shards += count
return ItemClassification.progression_skip_balancing if count else ItemClassification.filler
if name == "Windmill Shuriken" and getattr(self, "multiworld", None) is not None:
if name == "Windmill Shuriken":
return ItemClassification.progression if self.options.logic_level else ItemClassification.filler
if name == "Power Seal":
@@ -344,7 +396,7 @@ class MessengerWorld(World):
if name in {*USEFUL_ITEMS, *USEFUL_SHOP_ITEMS}:
return ItemClassification.useful
if name in TRAPS:
return ItemClassification.trap
@@ -354,7 +406,7 @@ class MessengerWorld(World):
def create_group(cls, multiworld: "MultiWorld", new_player_id: int, players: set[int]) -> World:
group = super().create_group(multiworld, new_player_id, players)
assert isinstance(group, MessengerWorld)
group.filler = FILLER.copy()
group.options.traps.value = all(multiworld.worlds[player].options.traps for player in players)
if group.options.traps:

View File

@@ -244,14 +244,12 @@ CONNECTIONS: dict[str, dict[str, list[str]]] = {
"Bottom Left": [
"Howling Grotto - Top",
"Quillshroom Marsh - Sand Trap Shop",
"Quillshroom Marsh - Bottom Right",
],
"Top Right": [
"Quillshroom Marsh - Queen of Quills Shop",
"Searing Crags - Left",
],
"Bottom Right": [
"Quillshroom Marsh - Bottom Left",
"Quillshroom Marsh - Sand Trap Shop",
"Searing Crags - Bottom",
],
@@ -639,43 +637,43 @@ CONNECTIONS: dict[str, dict[str, list[str]]] = {
}
RANDOMIZED_CONNECTIONS: dict[str, str] = {
"Ninja Village - Right": "Autumn Hills - Left",
"Autumn Hills - Left": "Ninja Village - Right",
"Autumn Hills - Right": "Forlorn Temple - Left",
"Autumn Hills - Bottom": "Catacombs - Bottom Left",
"Forlorn Temple - Left": "Autumn Hills - Right",
"Forlorn Temple - Right": "Bamboo Creek - Top Left",
"Forlorn Temple - Bottom": "Catacombs - Top Left",
"Catacombs - Top Left": "Forlorn Temple - Bottom",
"Catacombs - Bottom Left": "Autumn Hills - Bottom",
"Catacombs - Bottom": "Dark Cave - Right",
"Catacombs - Right": "Bamboo Creek - Bottom Left",
"Bamboo Creek - Bottom Left": "Catacombs - Right",
"Bamboo Creek - Right": "Howling Grotto - Left",
"Bamboo Creek - Top Left": "Forlorn Temple - Right",
"Howling Grotto - Left": "Bamboo Creek - Right",
"Howling Grotto - Top": "Quillshroom Marsh - Bottom Left",
"Howling Grotto - Right": "Quillshroom Marsh - Top Left",
"Howling Grotto - Bottom": "Sunken Shrine - Left",
"Quillshroom Marsh - Top Left": "Howling Grotto - Right",
"Quillshroom Marsh - Bottom Left": "Howling Grotto - Top",
"Quillshroom Marsh - Top Right": "Searing Crags - Left",
"Ninja Village - Right": "Autumn Hills - Left",
"Autumn Hills - Left": "Ninja Village - Right",
"Autumn Hills - Right": "Forlorn Temple - Left",
"Autumn Hills - Bottom": "Catacombs - Bottom Left",
"Forlorn Temple - Left": "Autumn Hills - Right",
"Forlorn Temple - Right": "Bamboo Creek - Top Left",
"Forlorn Temple - Bottom": "Catacombs - Top Left",
"Catacombs - Top Left": "Forlorn Temple - Bottom",
"Catacombs - Bottom Left": "Autumn Hills - Bottom",
"Catacombs - Bottom": "Dark Cave - Right",
"Catacombs - Right": "Bamboo Creek - Bottom Left",
"Bamboo Creek - Bottom Left": "Catacombs - Right",
"Bamboo Creek - Right": "Howling Grotto - Left",
"Bamboo Creek - Top Left": "Forlorn Temple - Right",
"Howling Grotto - Left": "Bamboo Creek - Right",
"Howling Grotto - Top": "Quillshroom Marsh - Bottom Left",
"Howling Grotto - Right": "Quillshroom Marsh - Top Left",
"Howling Grotto - Bottom": "Sunken Shrine - Left",
"Quillshroom Marsh - Top Left": "Howling Grotto - Right",
"Quillshroom Marsh - Bottom Left": "Howling Grotto - Top",
"Quillshroom Marsh - Top Right": "Searing Crags - Left",
"Quillshroom Marsh - Bottom Right": "Searing Crags - Bottom",
"Searing Crags - Left": "Quillshroom Marsh - Top Right",
"Searing Crags - Top": "Glacial Peak - Bottom",
"Searing Crags - Bottom": "Quillshroom Marsh - Bottom Right",
"Searing Crags - Right": "Underworld - Left",
"Glacial Peak - Bottom": "Searing Crags - Top",
"Glacial Peak - Top": "Cloud Ruins - Left",
"Glacial Peak - Left": "Elemental Skylands - Air Shmup",
"Cloud Ruins - Left": "Glacial Peak - Top",
"Elemental Skylands - Right": "Glacial Peak - Left",
"Tower HQ": "Tower of Time - Left",
"Artificer": "Corrupted Future",
"Underworld - Left": "Searing Crags - Right",
"Dark Cave - Right": "Catacombs - Bottom",
"Dark Cave - Left": "Riviere Turquoise - Right",
"Sunken Shrine - Left": "Howling Grotto - Bottom",
"Searing Crags - Left": "Quillshroom Marsh - Top Right",
"Searing Crags - Top": "Glacial Peak - Bottom",
"Searing Crags - Bottom": "Quillshroom Marsh - Bottom Right",
"Searing Crags - Right": "Underworld - Left",
"Glacial Peak - Bottom": "Searing Crags - Top",
"Glacial Peak - Top": "Cloud Ruins - Left",
"Glacial Peak - Left": "Elemental Skylands - Air Shmup",
"Cloud Ruins - Left": "Glacial Peak - Top",
"Elemental Skylands - Right": "Glacial Peak - Left",
"Tower HQ": "Tower of Time - Left",
"Artificer": "Corrupted Future",
"Underworld - Left": "Searing Crags - Right",
"Dark Cave - Right": "Catacombs - Bottom",
"Dark Cave - Left": "Riviere Turquoise - Right",
"Sunken Shrine - Left": "Howling Grotto - Bottom",
}
TRANSITIONS: list[str] = [

View File

@@ -3,7 +3,8 @@ from dataclasses import dataclass
from schema import And, Optional, Or, Schema
from Options import Choice, DeathLinkMixin, DefaultOnToggle, ItemsAccessibility, OptionDict, PerGameCommonOptions, \
PlandoConnections, Range, StartInventoryPool, Toggle, Visibility
PlandoConnections, Range, StartInventoryPool, Toggle
from . import RANDOMIZED_CONNECTIONS
from .portals import CHECKPOINTS, PORTALS, SHOP_POINTS
@@ -30,17 +31,25 @@ class PortalPlando(PlandoConnections):
portals = [f"{portal} Portal" for portal in PORTALS]
shop_points = [point for points in SHOP_POINTS.values() for point in points]
checkpoints = [point for points in CHECKPOINTS.values() for point in points]
portal_entrances = PORTALS
portal_exits = portals + shop_points + checkpoints
entrances = portal_entrances
exits = portal_exits
entrances = frozenset(PORTALS)
exits = frozenset(portals + shop_points + checkpoints)
# for back compatibility. To later be replaced with transition plando
class HiddenPortalPlando(PortalPlando):
visibility = Visibility.none
entrances = PortalPlando.entrances
exits = PortalPlando.exits
class TransitionPlando(PlandoConnections):
"""
Plando connections to be used with transition shuffle.
List of valid connections can be found at https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/messenger/connections.py#L641.
Dictionary keys (left) are entrances and values (right) are exits. If transition shuffle is on coupled all plando
connections will be coupled. If on decoupled, "entrance" and "exit" will be treated the same, simply making the
plando connection one-way from entrance to exit.
Example:
- entrance: Searing Crags - Top
exit: Dark Cave - Right
direction: both
"""
entrances = frozenset(RANDOMIZED_CONNECTIONS.keys())
exits = frozenset(RANDOMIZED_CONNECTIONS.values())
class Logic(Choice):
@@ -226,7 +235,7 @@ class MessengerOptions(DeathLinkMixin, PerGameCommonOptions):
early_meditation: EarlyMed
available_portals: AvailablePortals
shuffle_portals: ShufflePortals
# shuffle_transitions: ShuffleTransitions
shuffle_transitions: ShuffleTransitions
goal: Goal
music_box: MusicBox
notes_needed: NotesNeeded
@@ -236,4 +245,4 @@ class MessengerOptions(DeathLinkMixin, PerGameCommonOptions):
shop_price: ShopPrices
shop_price_plan: PlannedShopPrices
portal_plando: PortalPlando
plando_connections: HiddenPortalPlando
plando_connections: TransitionPlando

View File

@@ -1,7 +1,7 @@
from copy import deepcopy
from typing import TYPE_CHECKING
from BaseClasses import CollectionState, PlandoOptions
from BaseClasses import CollectionState
from Options import PlandoConnection
if TYPE_CHECKING:
@@ -252,9 +252,7 @@ def shuffle_portals(world: "MessengerWorld") -> None:
world.random.shuffle(available_portals)
plando = world.options.portal_plando.value
if not plando:
plando = world.options.plando_connections.value
if plando and world.multiworld.plando_options & PlandoOptions.connections and not world.plando_portals:
if plando and not world.plando_portals:
try:
handle_planned_portals(plando)
# any failure i expect will trigger on available_portals.remove
@@ -294,8 +292,8 @@ def disconnect_portals(world: "MessengerWorld") -> None:
def validate_portals(world: "MessengerWorld") -> bool:
# if world.options.shuffle_transitions:
# return True
if world.options.shuffle_transitions:
return True
new_state = CollectionState(world.multiworld)
new_state.update_reachable_regions(world.player)
reachable_locs = 0

View File

@@ -1,7 +1,8 @@
from functools import cached_property
from typing import TYPE_CHECKING
from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Location, Region
from BaseClasses import CollectionState, Entrance, EntranceType, Item, ItemClassification, Location, Region
from entrance_rando import ERPlacementState
from .regions import LOCATIONS, MEGA_SHARDS
from .shop import FIGURINES, SHOP_ITEMS
@@ -12,9 +13,21 @@ if TYPE_CHECKING:
class MessengerEntrance(Entrance):
world: "MessengerWorld | None" = None
def can_connect_to(self, other: Entrance, dead_end: bool, state: "ERPlacementState") -> bool:
can_connect = super().can_connect_to(other, dead_end, state)
world: MessengerWorld = getattr(self, "world", None)
if not world or world.reachable_locs or not can_connect:
return can_connect
empty_state = CollectionState(world.multiworld, True)
self.connected_region = other.connected_region
empty_state.update_reachable_regions(world.player)
world.reachable_locs = any(loc.can_reach(empty_state) and not loc.is_event for loc in world.get_locations())
self.connected_region = None
return world.reachable_locs and (not state.coupled or self.name != other.name)
class MessengerRegion(Region):
parent: str
parent: str | None
entrance_type = MessengerEntrance
def __init__(self, name: str, world: "MessengerWorld", parent: str | None = None) -> None:
@@ -32,8 +45,9 @@ class MessengerRegion(Region):
for shop_loc in SHOP_ITEMS}
self.add_locations(shop_locations, MessengerShopLocation)
elif name == "The Craftsman's Corner":
self.add_locations({figurine: world.location_name_to_id[figurine] for figurine in FIGURINES},
MessengerLocation)
self.add_locations(
{figurine: world.location_name_to_id[figurine] for figurine in FIGURINES},
MessengerLocation)
elif name == "Tower HQ":
locations.append("Money Wrench")
@@ -57,6 +71,7 @@ class MessengerLocation(Location):
class MessengerShopLocation(MessengerLocation):
@cached_property
def cost(self) -> int:
name = self.name.removeprefix("The Shop - ")

View File

@@ -0,0 +1,19 @@
import unittest
from . import MessengerTestBase
class StrictEntranceRandoTest(MessengerTestBase):
"""Bare-bones world that tests the strictest possible settings to ensure it doesn't crash"""
auto_construct = True
options = {
"limited_movement": 1,
"available_portals": 3,
"shuffle_portals": 1,
"shuffle_transitions": 1,
}
@unittest.skip
def test_all_state_can_reach_everything(self) -> None:
"""It's not possible to reach everything with these options so skip this test."""
pass

View File

@@ -0,0 +1,92 @@
from typing import TYPE_CHECKING
from BaseClasses import Region
from entrance_rando import EntranceType, randomize_entrances
from .connections import RANDOMIZED_CONNECTIONS, TRANSITIONS
from .options import ShuffleTransitions, TransitionPlando
if TYPE_CHECKING:
from . import MessengerWorld
def connect_plando(world: "MessengerWorld", plando_connections: TransitionPlando) -> None:
def remove_dangling_exit(region: Region) -> None:
# find the disconnected exit and remove references to it
for _exit in region.exits:
if not _exit.connected_region:
break
else:
raise ValueError(f"Unable to find randomized transition for {plando_connection}")
region.exits.remove(_exit)
def remove_dangling_entrance(region: Region) -> None:
# find the disconnected entrance and remove references to it
for _entrance in region.entrances:
if not _entrance.parent_region:
break
else:
raise ValueError(f"Invalid target region for {plando_connection}")
region.entrances.remove(_entrance)
for plando_connection in plando_connections:
# get the connecting regions
reg1 = world.get_region(plando_connection.entrance)
reg2 = world.get_region(plando_connection.exit)
remove_dangling_exit(reg1)
remove_dangling_entrance(reg2)
# connect the regions
reg1.connect(reg2)
# pretend the user set the plando direction as "both" regardless of what they actually put on coupled
if ((world.options.shuffle_transitions == ShuffleTransitions.option_coupled
or plando_connection.direction == "both")
and plando_connection.exit in RANDOMIZED_CONNECTIONS):
remove_dangling_exit(reg2)
remove_dangling_entrance(reg1)
reg2.connect(reg1)
def shuffle_transitions(world: "MessengerWorld") -> None:
coupled = world.options.shuffle_transitions == ShuffleTransitions.option_coupled
def disconnect_entrance() -> None:
child_region.entrances.remove(entrance)
entrance.connected_region = None
er_type = EntranceType.ONE_WAY if child == "Glacial Peak - Left" else \
EntranceType.TWO_WAY if child in RANDOMIZED_CONNECTIONS else EntranceType.ONE_WAY
if er_type == EntranceType.TWO_WAY:
mock_entrance = parent_region.create_er_target(entrance.name)
else:
mock_entrance = child_region.create_er_target(child)
entrance.randomization_type = er_type
mock_entrance.randomization_type = er_type
for parent, child in RANDOMIZED_CONNECTIONS.items():
if child == "Corrupted Future":
entrance = world.get_entrance("Artificer's Portal")
elif child == "Tower of Time - Left":
entrance = world.get_entrance("Artificer's Challenge")
else:
entrance = world.get_entrance(f"{parent} -> {child}")
parent_region = entrance.parent_region
child_region = entrance.connected_region
entrance.world = world
disconnect_entrance()
plando = world.options.plando_connections
if plando:
connect_plando(world, plando)
result = randomize_entrances(world, coupled, {0: [0]})
world.transitions = sorted(result.placements, key=lambda entrance: TRANSITIONS.index(entrance.parent_region.name))
for transition in world.transitions:
if "->" not in transition.name:
continue
transition.parent_region.exits.remove(transition)
transition.name = f"{transition.parent_region.name} -> {transition.connected_region.name}"
transition.parent_region.exits.append(transition)