From 447a222b57e498f7904033c59e68d21d6a246abd Mon Sep 17 00:00:00 2001 From: Star Rauchenberger Date: Tue, 12 Aug 2025 12:33:24 -0400 Subject: Items and connections in the apworld --- apworld/__init__.py | 14 +++++ apworld/items.py | 5 ++ apworld/locations.py | 2 +- apworld/options.py | 9 ++- apworld/player_logic.py | 143 +++++++++++++++++++++++++++++++++++++++++++++++- apworld/regions.py | 53 +++++++++++++++++- apworld/rules.py | 27 +++++++++ apworld/static_logic.py | 10 +++- 8 files changed, 254 insertions(+), 9 deletions(-) create mode 100644 apworld/items.py create mode 100644 apworld/rules.py diff --git a/apworld/__init__.py b/apworld/__init__.py index 1544c7b..20c1454 100644 --- a/apworld/__init__.py +++ b/apworld/__init__.py @@ -1,7 +1,9 @@ """ Archipelago init file for Lingo 2 """ +from BaseClasses import ItemClassification, Item from worlds.AutoWorld import WebWorld, World +from .items import Lingo2Item from .options import Lingo2Options from .player_logic import Lingo2PlayerLogic from .regions import create_regions @@ -36,3 +38,15 @@ class Lingo2World(World): def create_regions(self): create_regions(self) + + from Utils import visualize_regions + + visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml") + + def create_items(self): + pool = [self.create_item(name) for name in self.player_logic.real_items] + + self.multiworld.itempool += pool + + def create_item(self, name: str) -> Item: + return Lingo2Item(name, ItemClassification.progression, self.item_name_to_id.get(name), self.player) diff --git a/apworld/items.py b/apworld/items.py new file mode 100644 index 0000000..971a709 --- /dev/null +++ b/apworld/items.py @@ -0,0 +1,5 @@ +from BaseClasses import Item + + +class Lingo2Item(Item): + game: str = "Lingo 2" diff --git a/apworld/locations.py b/apworld/locations.py index 818be39..108decb 100644 --- a/apworld/locations.py +++ b/apworld/locations.py @@ -2,4 +2,4 @@ from BaseClasses import Location class Lingo2Location(Location): - game: str = "Lingo 2" \ No newline at end of file + game: str = "Lingo 2" diff --git a/apworld/options.py b/apworld/options.py index f33f5af..77f0ae3 100644 --- a/apworld/options.py +++ b/apworld/options.py @@ -1,8 +1,13 @@ from dataclasses import dataclass -from Options import PerGameCommonOptions +from Options import PerGameCommonOptions, Toggle + + +class ShuffleDoors(Toggle): + """If enabled, most doors will open from receiving an item rather than fulfilling the in-game requirements.""" + display_name = "Shuffle Doors" @dataclass class Lingo2Options(PerGameCommonOptions): - pass + shuffle_doors: ShuffleDoors diff --git a/apworld/player_logic.py b/apworld/player_logic.py index a3b86bf..958abc5 100644 --- a/apworld/player_logic.py +++ b/apworld/player_logic.py @@ -5,22 +5,159 @@ if TYPE_CHECKING: from . import Lingo2World +def calculate_letter_histogram(solution: str) -> dict[str, int]: + histogram = dict() + for l in solution: + if l.isalpha(): + real_l = l.upper() + histogram[real_l] = min(histogram.get(l, 0) + 1, 2) + + return histogram + + +class AccessRequirements: + items: set[str] + rooms: set[str] + symbols: set[str] + letters: dict[str, int] + + # This is an AND of ORs. + or_logic: list[list["AccessRequirements"]] + + def __init__(self): + self.items = set() + self.rooms = set() + self.symbols = set() + self.letters = dict() + self.or_logic = list() + + def add_solution(self, solution: str): + histogram = calculate_letter_histogram(solution) + + for l, a in histogram.items(): + self.letters[l] = max(self.letters.get(l, 0), histogram.get(l)) + + def merge(self, other: "AccessRequirements"): + for item in other.items: + self.items.add(item) + + for room in other.rooms: + self.rooms.add(room) + + for symbol in other.symbols: + self.symbols.add(symbol) + + for letter, level in other.letters.items(): + self.letters[letter] = max(self.letters.get(letter, 0), level) + + for disjunction in other.or_logic: + self.or_logic.append(disjunction) + + class PlayerLocation(NamedTuple): code: int | None + reqs: AccessRequirements class Lingo2PlayerLogic: + world: "Lingo2World" + locations_by_room: dict[int, list[PlayerLocation]] + panel_reqs: dict[int, AccessRequirements] + proxy_reqs: dict[int, dict[str, AccessRequirements]] + door_reqs: dict[int, AccessRequirements] + + real_items: list[str] + def __init__(self, world: "Lingo2World"): + self.world = world self.locations_by_room = {} + self.panel_reqs = dict() + self.proxy_reqs = dict() + self.door_reqs = dict() + self.real_items = list() for door in world.static_logic.objects.doors: if door.type in [common_pb2.DoorType.STANDARD, common_pb2.DoorType.LOCATION_ONLY]: - self.locations_by_room.setdefault(door.room_id, []).append(PlayerLocation(door.ap_id)) + self.locations_by_room.setdefault(door.room_id, []).append(PlayerLocation(door.ap_id, + self.get_door_reqs(door.id))) + + if door.type in [common_pb2.DoorType.STANDARD, common_pb2.DoorType.ITEM_ONLY] and self.world.options.shuffle_doors: + self.real_items.append(self.world.static_logic.get_door_item_name(door.id)) for letter in world.static_logic.objects.letters: - self.locations_by_room.setdefault(letter.room_id, []).append(PlayerLocation(letter.ap_id)) + self.locations_by_room.setdefault(letter.room_id, []).append(PlayerLocation(letter.ap_id, + AccessRequirements())) for mastery in world.static_logic.objects.masteries: - self.locations_by_room.setdefault(mastery.room_id, []).append(PlayerLocation(mastery.ap_id)) + self.locations_by_room.setdefault(mastery.room_id, []).append(PlayerLocation(mastery.ap_id, + AccessRequirements())) + + def get_panel_reqs(self, panel_id: int, answer: str | None) -> AccessRequirements: + if answer is None: + if panel_id not in self.panel_reqs: + self.panel_reqs[panel_id] = self.calculate_panel_reqs(panel_id, answer) + + return self.panel_reqs.get(panel_id) + else: + if panel_id not in self.proxy_reqs or answer not in self.proxy_reqs.get(panel_id): + self.proxy_reqs.setdefault(panel_id, {})[answer] = self.calculate_panel_reqs(panel_id, answer) + + return self.proxy_reqs.get(panel_id).get(answer) + + def calculate_panel_reqs(self, panel_id: int, answer: str | None) -> AccessRequirements: + panel = self.world.static_logic.objects.panels[panel_id] + reqs = AccessRequirements() + + reqs.rooms.add(self.world.static_logic.get_room_region_name(panel.room_id)) + + if answer is not None: + reqs.add_solution(answer) + elif len(panel.proxies) > 0: + for proxy in panel.proxies: + proxy_reqs = AccessRequirements() + proxy_reqs.add_solution(proxy.answer) + + reqs.or_logic.append([proxy_reqs]) + else: + reqs.add_solution(panel.answer) + + for symbol in panel.symbols: + reqs.symbols.add(symbol) + + if panel.HasField("required_door"): + door_reqs = self.get_door_reqs(panel.required_door) + reqs.merge(door_reqs) + + if panel.HasField("required_room"): + reqs.rooms.add(self.world.static_logic.get_room_region_name(panel.required_room)) + + return reqs + + def get_door_reqs(self, door_id: int) -> AccessRequirements: + if door_id not in self.door_reqs: + self.door_reqs[door_id] = self.calculate_door_reqs(door_id) + + return self.door_reqs.get(door_id) + + def calculate_door_reqs(self, door_id: int) -> AccessRequirements: + door = self.world.static_logic.objects.doors[door_id] + reqs = AccessRequirements() + + use_item = False + if door.type in [common_pb2.DoorType.STANDARD, common_pb2.DoorType.ITEM_ONLY] and self.world.options.shuffle_doors: + use_item = True + + if use_item: + reqs.items.add(self.world.static_logic.get_door_item_name(door.id)) + else: + # TODO: complete_at, control_center_color, switches, keyholders + for proxy in door.panels: + panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None) + reqs.merge(panel_reqs) + + for room in door.rooms: + reqs.rooms.add(self.world.static_logic.get_room_region_name(room)) + + return reqs diff --git a/apworld/regions.py b/apworld/regions.py index d388678..2a850ef 100644 --- a/apworld/regions.py +++ b/apworld/regions.py @@ -2,17 +2,20 @@ from typing import TYPE_CHECKING from BaseClasses import Region from .locations import Lingo2Location +from .player_logic import AccessRequirements +from .rules import make_location_lambda if TYPE_CHECKING: from . import Lingo2World def create_region(room, world: "Lingo2World") -> Region: - new_region = Region(room.name, world.player, world.multiworld) + new_region = Region(world.static_logic.get_room_region_name(room.id), world.player, world.multiworld) for location in world.player_logic.locations_by_room.get(room.id, {}): new_location = Lingo2Location(world.player, world.static_logic.location_id_to_name[location.code], location.code, new_region) + new_location.access_rule = make_location_lambda(location.reqs, world) new_region.locations.append(new_location) return new_region @@ -24,6 +27,52 @@ def create_regions(world: "Lingo2World"): } for room in world.static_logic.objects.rooms: - regions[room.name] = create_region(room, world) + region = create_region(room, world) + regions[region.name] = region + + regions["Menu"].connect(regions["the_entry - Starting Room"], "Start Game") + + # TODO: The requirements of the opposite trigger also matter. + for connection in world.static_logic.objects.connections: + from_region = world.static_logic.get_room_region_name(connection.from_room) + to_region = world.static_logic.get_room_region_name(connection.to_room) + connection_name = f"{from_region} -> {to_region}" + + reqs = AccessRequirements() + + if connection.HasField("required_door"): + reqs.merge(world.player_logic.get_door_reqs(connection.required_door)) + + door = world.static_logic.objects.doors[connection.required_door] + wmap = world.static_logic.objects.maps[door.map_id] + connection_name = f"{connection_name} (using {wmap.name} - {door.name})" + + if connection.HasField("port"): + port = world.static_logic.objects.ports[connection.port] + connection_name = f"{connection_name} (via port {port.name})" + + if port.HasField("required_door"): + reqs.merge(world.player_logic.get_door_reqs(port.required_door)) + + if connection.HasField("painting"): + painting = world.static_logic.objects.paintings[connection.painting] + connection_name = f"{connection_name} (via painting {painting.name})" + + if painting.HasField("required_door"): + reqs.merge(world.player_logic.get_door_reqs(painting.required_door)) + + if connection.HasField("panel"): + proxy = connection.panel + reqs.merge(world.player_logic.get_panel_reqs(proxy.panel, + proxy.answer if proxy.HasField("answer") else None)) + + panel = world.static_logic.objects.panels[proxy.panel] + if proxy.HasField("answer"): + connection_name = f"{connection_name} (via panel {panel.name}/{proxy.answer})" + else: + connection_name = f"{connection_name} (via panel {panel.name})" + + if from_region in regions and to_region in regions: + regions[from_region].connect(regions[to_region], connection_name, make_location_lambda(reqs, world)) world.multiworld.regions += regions.values() diff --git a/apworld/rules.py b/apworld/rules.py new file mode 100644 index 0000000..05689e9 --- /dev/null +++ b/apworld/rules.py @@ -0,0 +1,27 @@ +from collections.abc import Callable +from typing import TYPE_CHECKING + +from BaseClasses import CollectionState +from .player_logic import AccessRequirements + +if TYPE_CHECKING: + from . import Lingo2World + + +def lingo2_can_satisfy_requirements(state: CollectionState, reqs: AccessRequirements, world: "Lingo2World") -> bool: + if not all(state.has(item, world.player) for item in reqs.items): + return False + + if not all(state.can_reach_region(region_name, world.player) for region_name in reqs.rooms): + return False + + # TODO: symbols, letters + + for disjunction in reqs.or_logic: + if not any(lingo2_can_satisfy_requirements(state, sub_reqs, world) for sub_reqs in disjunction): + return False + + return True + +def make_location_lambda(reqs: AccessRequirements, world: "Lingo2World") -> Callable[[CollectionState], bool]: + return lambda state: lingo2_can_satisfy_requirements(state, reqs, world) diff --git a/apworld/static_logic.py b/apworld/static_logic.py index 4fc38f8..ff58e96 100644 --- a/apworld/static_logic.py +++ b/apworld/static_logic.py @@ -23,7 +23,7 @@ class Lingo2StaticLogic: self.location_id_to_name[door.ap_id] = location_name if door.type not in [common_pb2.DoorType.EVENT, common_pb2.DoorType.LOCATION_ONLY]: - item_name = f"{self.objects.maps[door.map_id].name} - {door.name}" + item_name = self.get_door_item_name(door.id) self.item_id_to_name[door.ap_id] = item_name for letter in self.objects.letters: @@ -40,3 +40,11 @@ class Lingo2StaticLogic: self.item_name_to_id = {name: ap_id for ap_id, name in self.item_id_to_name.items()} self.location_name_to_id = {name: ap_id for ap_id, name in self.location_id_to_name.items()} + + def get_door_item_name(self, door_id: int) -> str: + door = self.objects.doors[door_id] + return f"{self.objects.maps[door.map_id].name} - {door.name}" + + def get_room_region_name(self, room_id: int) -> str: + room = self.objects.rooms[room_id] + return f"{self.objects.maps[room.map_id].name} - {room.name}" -- cgit 1.4.1