from enum import IntEnum, auto
from .generated import data_pb2 as data_pb2
from .items import SYMBOL_ITEMS
from typing import TYPE_CHECKING, NamedTuple
from .options import VictoryCondition, ShuffleLetters, CyanDoorBehavior
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(real_l, 0) + 1, 2)
return histogram
class AccessRequirements:
items: set[str]
progressives: dict[str, int]
rooms: set[str]
letters: dict[str, int]
cyans: bool
# This is an AND of ORs.
or_logic: list[list["AccessRequirements"]]
# When complete_at is set, at least that many of the requirements in possibilities must be accessible. This should
# only be used for doors with complete_at > 1, as or_logic is more efficient for complete_at == 1.
complete_at: int | None
possibilities: list["AccessRequirements"]
def __init__(self):
self.items = set()
self.progressives = dict()
self.rooms = set()
self.letters = dict()
self.cyans = False
self.or_logic = list()
self.complete_at = None
self.possibilities = list()
def copy(self) -> "AccessRequirements":
reqs = AccessRequirements()
reqs.items = self.items.copy()
reqs.progressives = self.progressives.copy()
reqs.rooms = self.rooms.copy()
reqs.letters = self.letters.copy()
reqs.cyans = self.cyans
reqs.or_logic = [[other_req.copy() for other_req in disjunction] for disjunction in self.or_logic]
reqs.complete_at = self.complete_at
reqs.possibilities = self.possibilities.copy()
return reqs
def merge(self, other: "AccessRequirements"):
for item in other.items:
self.items.add(item)
for item, amount in other.progressives.items():
self.progressives[item] = max(amount, self.progressives.get(item, 0))
for room in other.rooms:
self.rooms.add(room)
for letter, level in other.letters.items():
self.letters[letter] = max(self.letters.get(letter, 0), level)
self.cyans = self.cyans or other.cyans
for disjunction in other.or_logic:
self.or_logic.append(disjunction)
if other.complete_at is not None:
# Merging multiple requirements that use complete_at sucks, and is part of why we want to minimize use of
# it. If both requirements use complete_at, we will cheat by using the or_logic field, which supports
# conjunctions of requirements.
if self.complete_at is not None:
print("Merging requirements with complete_at > 1. This is messy and should be avoided!")
left_req = AccessRequirements()
left_req.complete_at = self.complete_at
left_req.possibilities = self.possibilities
self.or_logic.append([left_req])
self.complete_at = None
self.possibilities = list()
right_req = AccessRequirements()
right_req.complete_at = other.complete_at
right_req.possibilities = other.possibilities
self.or_logic.append([right_req])
else:
self.complete_at = other.complete_at
self.possibilities = other.possibilities
def is_empty(self) -> bool:
return (len(self.items) == 0 and len(self.progressives) == 0 and len(self.rooms) == 0 and len(self.letters) == 0
and not self.cyans and len(self.or_logic) == 0 and self.complete_at is None)
def __eq__(self, other: "AccessRequirements"):
return (self.items == other.items and self.progressives == other.progressives and self.rooms == other.rooms and
self.letters == other.letters and self.cyans == other.cyans and self.or_logic == other.or_logic and
self.complete_at == other.complete_at and self.possibilities == other.possibilities)
def simplify(self):
resimplify = False
if len(self.or_logic) > 0:
old_or_logic = self.or_logic
def remove_redundant(sub_reqs: "AccessRequirements"):
sub_reqs.letters = {l: v for l, v in sub_reqs.letters.items() if self.letters.get(l, 0) < v}
self.or_logic = []
for disjunction in old_or_logic:
new_disjunction = []
for ssr in disjunction:
remove_redundant(ssr)
if not ssr.is_empty():
new_disjunction.append(ssr)
else:
new_disjunction.clear()
break
if len(new_disjunction) == 1:
self.merge(new_disjunction[0])
resimplify = True
elif len(new_disjunction) > 1:
if all(cjr == new_disjunction[0] for cjr in new_disjunction):
self.merge(new_disjunction[0])
resimplify = True
else:
self.or_logic.append(new_disjunction)
if resimplify:
self.simplify()
def __repr__(self):
parts = []
if len(self.items) > 0:
parts.append(f"items={self.items}")
if len(self.progressives) > 0:
parts.append(f"progressives={self.progressives}")
if len(self.rooms) > 0:
parts.append(f"rooms={self.rooms}")
if len(self.letters) > 0:
parts.append(f"letters={self.letters}")
if self.cyans:
parts.append(f"cyans=True")
if len(self.or_logic) > 0:
parts.append(f"or_logic={self.or_logic}")
if self.complete_at is not None:
parts.append(f"complete_at={self.complete_at}")
if len(self.possibilities) > 0:
parts.append(f"possibilities={self.possibilities}")
return "AccessRequirements(" + ", ".join(parts) + ")"
class PlayerLocation(NamedTuple):
code: int | None
reqs: AccessRequirements
class LetterBehavior(IntEnum):
VANILLA = auto()
ITEM = auto()
UNLOCKED = auto()
class Lingo2PlayerLogic:
world: "Lingo2World"
locations_by_room: dict[int, list[PlayerLocation]]
event_loc_item_by_room: dict[int, dict[str, str]]
item_by_door: dict[int, tuple[str, int]]
panel_reqs: dict[int, AccessRequirements]
proxy_reqs: dict[int, dict[str, AccessRequirements]]
door_reqs: dict[int, AccessRequirements]
real_items: list[str]
double_letter_amount: dict[str, int]
def __init__(self, world: "Lingo2World"):
self.world = world
self.locations_by_room = {}
self.event_loc_item_by_room = {}
self.item_by_door = {}
self.panel_reqs = dict()
self.proxy_reqs = dict()
self.door_reqs = dict()
self.real_items = list()
self.double_letter_amount = dict()
if self.world.options.shuffle_doors:
for progressive in world.static_logic.objects.progressives:
for i in range(0, len(progressive.doors)):
self.item_by_door[progressive.doors[i]] = (progressive.name, i + 1)
self.real_items.append(progressive.name)
for door_group in world.static_logic.objects.door_groups:
if door_group.type == data_pb2.DoorGroupType.CONNECTOR:
if not self.world.options.shuffle_doors:
continue
elif door_group.type == data_pb2.DoorGroupType.COLOR_CONNECTOR:
if not self.world.options.shuffle_control_center_colors:
continue
elif door_group.type == data_pb2.DoorGroupType.SHUFFLE_GROUP:
if not self.world.options.shuffle_doors:
continue
else:
continue
for door in door_group.doors:
self.item_by_door[door] = (door_group.name, 1)
self.real_items.append(door_group.name)
# We iterate through the doors in two parts because it is essential that we determine which doors are shuffled
# before we calculate any access requirements.
for door in world.static_logic.objects.doors:
if door.type in [data_pb2.DoorType.EVENT, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
continue
if door.id in self.item_by_door:
continue
if (door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.ITEM_ONLY] and
not self.world.options.shuffle_doors):
continue
if (door.type == data_pb2.DoorType.CONTROL_CENTER_COLOR and
not self.world.options.shuffle_control_center_colors):
continue
if door.type == data_pb2.DoorType.GALLERY_PAINTING and not self.world.options.shuffle_gallery_paintings:
continue
door_item_name = self.world.static_logic.get_door_item_name(door)
self.item_by_door[door.id] = (door_item_name, 1)
self.real_items.append(door_item_name)
# We handle cyan_door_behavior = Item after door shuffle, because cyan doors that are impacted by door shuffle
# should be exempt from cyan_door_behavior.
if world.options.cyan_door_behavior == CyanDoorBehavior.option_item:
for door_group in world.static_logic.objects.door_groups:
if door_group.type != data_pb2.DoorGroupType.CYAN_DOORS:
continue
for door in door_group.doors:
if not door in self.item_by_door:
self.item_by_door[door] = (door_group.name, 1)
self.real_items.append(door_group.name)
for door in world.static_logic.objects.doors:
if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
self.locations_by_room.setdefault(door.room_id, []).append(PlayerLocation(door.ap_id,
self.get_door_reqs(door.id)))
for letter in world.static_logic.objects.letters:
self.locations_by_room.setdefault(letter.room_id, []).append(PlayerLocation(letter.ap_id,
AccessRequirements()))
behavior = self.get_letter_behavior(letter.key, letter.level2)
if behavior == LetterBehavior.VANILLA:
letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}"
event_name = f"{letter_name} (Collected)"
self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper()
if letter.level2:
event_name = f"{letter_name} (Double Collected)"
self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper()
elif behavior == LetterBehavior.ITEM:
self.real_items.append(letter.key.upper())
if behavior != LetterBehavior.UNLOCKED:
self.double_letter_amount[letter.key.upper()] = self.double_letter_amount.get(letter.key.upper(), 0) + 1
for mastery in world.static_logic.objects.masteries:
self.locations_by_room.setdefault(mastery.room_id, []).append(PlayerLocation(mastery.ap_id,
AccessRequirements()))
for ending in world.static_logic.objects.endings:
# Don't ever create a location for White Ending. Don't even make an event for it if it's not the victory
# condition, since it is necessarily going to be in the postgame.
if ending.name == "WHITE":
if self.world.options.victory_condition != VictoryCondition.option_white_ending:
continue
else:
self.locations_by_room.setdefault(ending.room_id, []).append(PlayerLocation(ending.ap_id,
AccessRequirements()))
event_name = f"{ending.name.capitalize()} Ending (Achieved)"
item_name = event_name
if world.options.victory_condition.current_key.removesuffix("_ending").upper() == ending.name:
item_name = "Victory"
self.event_loc_item_by_room.setdefault(ending.room_id, {})[event_name] = item_name
if self.world.options.keyholder_sanity:
for keyholder in world.static_logic.objects.keyholders:
if keyholder.HasField("key"):
reqs = AccessRequirements()
if self.get_letter_behavior(keyholder.key, False) != LetterBehavior.UNLOCKED:
reqs.letters[keyholder.key.upper()] = 1
self.locations_by_room.setdefault(keyholder.room_id, []).append(PlayerLocation(keyholder.ap_id,
reqs))
if self.world.options.shuffle_symbols:
for symbol_name in SYMBOL_ITEMS.values():
self.real_items.append(symbol_name)
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:
self.add_solution_reqs(reqs, answer)
elif len(panel.proxies) > 0:
possibilities = []
already_filled = False
for proxy in panel.proxies:
proxy_reqs = AccessRequirements()
self.add_solution_reqs(proxy_reqs, proxy.answer)
if not proxy_reqs.is_empty():
possibilities.append(proxy_reqs)
else:
already_filled = True
break
if not already_filled and not any(proxy.answer == panel.answer for proxy in panel.proxies):
proxy_reqs = AccessRequirements()
self.add_solution_reqs(proxy_reqs, panel.answer)
if not proxy_reqs.is_empty():
possibilities.append(proxy_reqs)
else:
already_filled = True
if not already_filled:
reqs.or_logic.append(possibilities)
else:
self.add_solution_reqs(reqs, panel.answer)
if self.world.options.shuffle_symbols:
for symbol in panel.symbols:
reqs.items.add(SYMBOL_ITEMS.get(symbol))
if panel.HasField("required_door"):
door_reqs = self.get_door_open_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
# This gets/calculates the requirements described by the door object. This is most notably used as the requirements
# for clearing a location, or opening a door when the door is not shuffled.
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()
if not door.HasField("complete_at") or door.complete_at == 0:
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)
elif door.complete_at == 1:
disjunction = []
for proxy in door.panels:
proxy_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None)
if proxy_reqs.is_empty():
disjunction.clear()
break
else:
disjunction.append(proxy_reqs)
if len(disjunction) > 0:
reqs.or_logic.append(disjunction)
else:
reqs.complete_at = door.complete_at
for proxy in door.panels:
panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None)
reqs.possibilities.append(panel_reqs)
if door.HasField("control_center_color"):
# TODO: Logic for ensuring two CC states aren't needed at once.
reqs.rooms.add("Control Center - Main Area")
self.add_solution_reqs(reqs, door.control_center_color)
if door.double_letters:
if self.world.options.cyan_door_behavior == CyanDoorBehavior.option_collect_h2:
reqs.rooms.add("The Repetitive - Main Room")
elif self.world.options.cyan_door_behavior == CyanDoorBehavior.option_any_double_letter:
if self.world.options.shuffle_letters != ShuffleLetters.option_unlocked:
reqs.cyans = True
elif self.world.options.cyan_door_behavior == CyanDoorBehavior.option_item:
# There shouldn't be any locations that are cyan doors.
pass
for keyholder_uses in door.keyholders:
key_name = keyholder_uses.key.upper()
if (self.get_letter_behavior(keyholder_uses.key, False) != LetterBehavior.UNLOCKED
and key_name not in reqs.letters):
reqs.letters[key_name] = 1
keyholder = self.world.static_logic.objects.keyholders[keyholder_uses.keyholder]
reqs.rooms.add(self.world.static_logic.get_room_region_name(keyholder.room_id))
for room in door.rooms:
reqs.rooms.add(self.world.static_logic.get_room_region_name(room))
for ending_id in door.endings:
ending = self.world.static_logic.objects.endings[ending_id]
if self.world.options.victory_condition.current_key.removesuffix("_ending").upper() == ending.name:
reqs.items.add("Victory")
else:
reqs.items.add(f"{ending.name.capitalize()} Ending (Achieved)")
for sub_door_id in door.doors:
sub_reqs = self.get_door_open_reqs(sub_door_id)
reqs.merge(sub_reqs)
reqs.simplify()
return reqs
# This gets the requirements to open a door within the world. When a door is shuffled, this means having the item
# that acts as the door's key.
def get_door_open_reqs(self, door_id: int) -> AccessRequirements:
if door_id in self.item_by_door:
reqs = AccessRequirements()
item_name, amount = self.item_by_door.get(door_id)
if amount == 1:
reqs.items.add(item_name)
else:
reqs.progressives[item_name] = amount
return reqs
else:
return self.get_door_reqs(door_id)
def get_letter_behavior(self, letter: str, level2: bool) -> LetterBehavior:
if self.world.options.shuffle_letters == ShuffleLetters.option_unlocked:
return LetterBehavior.UNLOCKED
if self.world.options.shuffle_letters in [ShuffleLetters.option_vanilla_cyan, ShuffleLetters.option_item_cyan]:
if level2:
if self.world.options.shuffle_letters == ShuffleLetters.option_vanilla_cyan:
return LetterBehavior.VANILLA
else:
return LetterBehavior.ITEM
else:
return LetterBehavior.UNLOCKED
if not level2 and letter in ["h", "i", "n", "t"]:
return LetterBehavior.UNLOCKED
if self.world.options.shuffle_letters == ShuffleLetters.option_progressive:
return LetterBehavior.ITEM
return LetterBehavior.VANILLA
def add_solution_reqs(self, reqs: AccessRequirements, solution: str):
histogram = calculate_letter_histogram(solution)
for l, a in histogram.items():
needed = min(a, 2)
level2 = (needed == 2)
if level2 and self.get_letter_behavior(l.lower(), True) == LetterBehavior.UNLOCKED:
needed = 1
if self.get_letter_behavior(l.lower(), False) == LetterBehavior.UNLOCKED:
needed = needed - 1
if needed > 0:
reqs.letters[l] = max(reqs.letters.get(l, 0), needed)
lass="p">}
enum DoorGroupType {
DOOR_GROUP_TYPE_UNKNOWN = 0;
// These doors border a worldport. They should be grouped when connections are
// not shuffled.
CONNECTOR = 1;
// Similar to CONNECTOR, but these doors are also ordinarily opened by solving
// the COLOR panel in the Control Center. These should be grouped when
// connections are not shuffled, but are not items at all when control center
// colors are not shuffled.
COLOR_CONNECTOR = 2;
// Groups with this type become an item if cyan door behavior is set to item.
CYAN_DOORS = 3;
// Groups with this type always become an item if door shuffle is on.
SHUFFLE_GROUP = 4;
}
enum AxisDirection {
AXIS_DIRECTION_UNKNOWN = 0;
X_PLUS = 1;
X_MINUS = 2;
Y_PLUS = 3;
Y_MINUS = 4;
Z_PLUS = 5;
Z_MINUS = 6;
}
enum PuzzleSymbol {
PUZZLE_SYMBOL_UNKNOWN = 0;
SUN = 1;
SPARKLES = 2;
ZERO = 3;
EXAMPLE = 4;
BOXES = 5;
PLANET = 6;
PYRAMID = 7;
CROSS = 8;
SWEET = 9;
GENDER = 10;
AGE = 11;
SOUND = 12;
ANAGRAM = 13;
JOB = 14;
STARS = 15;
NULL = 16;
EVAL = 17;
LINGO = 18;
QUESTION = 19;
}
message ProxyIdentifier {
optional uint64 panel = 1;
optional string answer = 2;
}
message KeyholderAnswer {
optional uint64 keyholder = 1;
optional string key = 2;
}
message Connection {
optional uint64 from_room = 1;
optional uint64 to_room = 2;
optional uint64 required_door = 3;
oneof trigger {
uint64 port = 4;
uint64 painting = 5;
ProxyIdentifier panel = 6;
}
optional bool roof_access = 7;
}
message Door {
optional uint64 id = 1;
optional uint64 ap_id = 11;
optional uint64 map_id = 9;
optional uint64 room_id = 10;
optional string name = 2;
repeated string receivers = 3;
repeated uint64 move_paintings = 4;
repeated ProxyIdentifier panels = 5;
optional uint64 complete_at = 12;
optional string control_center_color = 6;
repeated KeyholderAnswer keyholders = 13;
repeated uint64 rooms = 14;
repeated uint64 doors = 15;
repeated uint64 endings = 16;
optional bool double_letters = 18;
optional DoorType type = 8;
optional string location_name = 17;
}
message PanelData {
optional uint64 id = 1;
optional uint64 ap_id = 10;
optional uint64 room_id = 2;
optional string name = 3;
optional string path = 4;
optional string clue = 5;
optional string answer = 6;
repeated PuzzleSymbol symbols = 7;
repeated Proxy proxies = 8;
optional uint64 required_door = 9;
optional uint64 required_room = 11;
optional string display_name = 12;
}
message PaintingData {
optional uint64 id = 1;
optional uint64 room_id = 2;
optional string name = 9;
optional string path = 10;
optional string display_name = 4;
optional string orientation = 3;
optional bool move = 6;
optional bool enter_only = 7;
optional AxisDirection gravity = 8;
optional bool exit_only = 11;
optional uint64 required_door = 5;
}
message Port {
optional uint64 id = 1;
optional uint64 room_id = 2;
optional string name = 3;
optional string path = 4;
optional string orientation = 5;
optional AxisDirection gravity = 7;
optional uint64 required_door = 6;
}
message KeyholderData {
optional uint64 id = 1;
optional uint64 ap_id = 6;
optional uint64 room_id = 2;
optional string name = 3;
optional string path = 4;
optional string key = 5;
}
message Letter {
optional uint64 id = 3;
optional uint64 ap_id = 5;
optional uint64 room_id = 4;
optional string key = 1;
optional bool level2 = 2;
optional string path = 6;
}
message Mastery {
optional uint64 id = 1;
optional uint64 ap_id = 2;
optional uint64 room_id = 3;
optional string name = 4;
optional string path = 5;
}
message Ending {
optional uint64 id = 1;
optional uint64 ap_id = 2;
optional uint64 room_id = 3;
optional string name = 4;
optional string path = 5;
}
message Room {
optional uint64 id = 1;
optional uint64 map_id = 8;
optional string name = 2;
optional string display_name = 3;
optional string panel_display_name = 13;
repeated uint64 panels = 4;
repeated uint64 paintings = 5;
repeated uint64 letters = 6;
repeated uint64 ports = 7;
repeated uint64 doors = 9;
repeated uint64 masteries = 10;
repeated uint64 keyholders = 11;
repeated uint64 endings = 12;
}
message Map {
optional uint64 id = 1;
optional string name = 2;
optional string display_name = 3;
}
message Progressive {
optional uint64 id = 1;
optional string name = 2;
optional uint64 ap_id = 3;
repeated uint64 doors = 4;
}
message DoorGroup {
optional uint64 id = 1;
optional string name = 2;
optional uint64 ap_id = 3;
optional DoorGroupType type = 4;
repeated uint64 doors = 5;
}
message AllObjects {
optional uint64 version = 15;
repeated Map maps = 7;
repeated Room rooms = 1;
repeated Door doors = 2;
repeated PanelData panels = 3;
repeated PaintingData paintings = 4;
repeated Port ports = 5;
repeated KeyholderData keyholders = 11;
repeated Letter letters = 9;
repeated Mastery masteries = 10;
repeated Ending endings = 12;
repeated Connection connections = 6;
repeated Progressive progressives = 13;
repeated DoorGroup door_groups = 14;
map<string, uint64> special_ids = 8;
}