|
12 | 12 | from dataclasses import dataclass
|
13 | 13 |
|
14 | 14 | from schema import And, Optional, Or, Schema
|
| 15 | +from typing_extensions import Self |
15 | 16 |
|
16 | 17 | from Utils import get_fuzzy_results, is_iterable_except_str
|
17 | 18 |
|
@@ -896,6 +897,228 @@ class ItemSet(OptionSet):
|
896 | 897 | convert_name_groups = True
|
897 | 898 |
|
898 | 899 |
|
| 900 | +class PlandoText(typing.NamedTuple): |
| 901 | + at: str |
| 902 | + text: typing.List[str] |
| 903 | + percentage: int = 100 |
| 904 | + |
| 905 | + |
| 906 | +PlandoTextsFromAnyType = typing.Union[ |
| 907 | + typing.Iterable[typing.Union[typing.Mapping[str, typing.Any], PlandoText, typing.Any]], typing.Any |
| 908 | +] |
| 909 | + |
| 910 | + |
| 911 | +class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys): |
| 912 | + default = () |
| 913 | + supports_weighting = False |
| 914 | + display_name = "Plando Texts" |
| 915 | + |
| 916 | + def __init__(self, value: typing.Iterable[PlandoText]) -> None: |
| 917 | + self.value = list(deepcopy(value)) |
| 918 | + super().__init__() |
| 919 | + |
| 920 | + def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None: |
| 921 | + from BaseClasses import PlandoOptions |
| 922 | + if self.value and not (PlandoOptions.texts & plando_options): |
| 923 | + # plando is disabled but plando options were given so overwrite the options |
| 924 | + self.value = [] |
| 925 | + logging.warning(f"The plando texts module is turned off, " |
| 926 | + f"so text for {player_name} will be ignored.") |
| 927 | + |
| 928 | + @classmethod |
| 929 | + def from_any(cls, data: PlandoTextsFromAnyType) -> Self: |
| 930 | + texts: typing.List[PlandoText] = [] |
| 931 | + if isinstance(data, typing.Iterable): |
| 932 | + for text in data: |
| 933 | + if isinstance(text, typing.Mapping): |
| 934 | + if random.random() < float(text.get("percentage", 100)/100): |
| 935 | + at = text.get("at", None) |
| 936 | + if at is not None: |
| 937 | + given_text = text.get("text", []) |
| 938 | + if isinstance(given_text, str): |
| 939 | + given_text = [given_text] |
| 940 | + texts.append(PlandoText( |
| 941 | + at, |
| 942 | + given_text, |
| 943 | + text.get("percentage", 100) |
| 944 | + )) |
| 945 | + elif isinstance(text, PlandoText): |
| 946 | + if random.random() < float(text.percentage/100): |
| 947 | + texts.append(text) |
| 948 | + else: |
| 949 | + raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}") |
| 950 | + cls.verify_keys([text.at for text in texts]) |
| 951 | + return cls(texts) |
| 952 | + else: |
| 953 | + raise NotImplementedError(f"Cannot Convert from non-list, got {type(data)}") |
| 954 | + |
| 955 | + @classmethod |
| 956 | + def get_option_name(cls, value: typing.List[PlandoText]) -> str: |
| 957 | + return str({text.at: " ".join(text.text) for text in value}) |
| 958 | + |
| 959 | + def __iter__(self) -> typing.Iterator[PlandoText]: |
| 960 | + yield from self.value |
| 961 | + |
| 962 | + def __getitem__(self, index: typing.SupportsIndex) -> PlandoText: |
| 963 | + return self.value.__getitem__(index) |
| 964 | + |
| 965 | + def __len__(self) -> int: |
| 966 | + return self.value.__len__() |
| 967 | + |
| 968 | + |
| 969 | +class ConnectionsMeta(AssembleOptions): |
| 970 | + def __new__(mcs, name: str, bases: tuple[type, ...], attrs: dict[str, typing.Any]): |
| 971 | + if name != "PlandoConnections": |
| 972 | + assert "entrances" in attrs, f"Please define valid entrances for {name}" |
| 973 | + attrs["entrances"] = frozenset((connection.lower() for connection in attrs["entrances"])) |
| 974 | + assert "exits" in attrs, f"Please define valid exits for {name}" |
| 975 | + attrs["exits"] = frozenset((connection.lower() for connection in attrs["exits"])) |
| 976 | + if "__doc__" not in attrs: |
| 977 | + attrs["__doc__"] = PlandoConnections.__doc__ |
| 978 | + cls = super().__new__(mcs, name, bases, attrs) |
| 979 | + return cls |
| 980 | + |
| 981 | + |
| 982 | +class PlandoConnection(typing.NamedTuple): |
| 983 | + class Direction: |
| 984 | + entrance = "entrance" |
| 985 | + exit = "exit" |
| 986 | + both = "both" |
| 987 | + |
| 988 | + entrance: str |
| 989 | + exit: str |
| 990 | + direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.8 is dropped |
| 991 | + percentage: int = 100 |
| 992 | + |
| 993 | + |
| 994 | +PlandoConFromAnyType = typing.Union[ |
| 995 | + typing.Iterable[typing.Union[typing.Mapping[str, typing.Any], PlandoConnection, typing.Any]], typing.Any |
| 996 | +] |
| 997 | + |
| 998 | + |
| 999 | +class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=ConnectionsMeta): |
| 1000 | + """Generic connections plando. Format is: |
| 1001 | + - entrance: "Entrance Name" |
| 1002 | + exit: "Exit Name" |
| 1003 | + direction: "Direction" |
| 1004 | + percentage: 100 |
| 1005 | + Direction must be one of 'entrance', 'exit', or 'both', and defaults to 'both' if omitted. |
| 1006 | + Percentage is an integer from 1 to 100, and defaults to 100 when omitted.""" |
| 1007 | + |
| 1008 | + display_name = "Plando Connections" |
| 1009 | + |
| 1010 | + default = () |
| 1011 | + supports_weighting = False |
| 1012 | + |
| 1013 | + entrances: typing.ClassVar[typing.AbstractSet[str]] |
| 1014 | + exits: typing.ClassVar[typing.AbstractSet[str]] |
| 1015 | + |
| 1016 | + duplicate_exits: bool = False |
| 1017 | + """Whether or not exits should be allowed to be duplicate.""" |
| 1018 | + |
| 1019 | + def __init__(self, value: typing.Iterable[PlandoConnection]): |
| 1020 | + self.value = list(deepcopy(value)) |
| 1021 | + super(PlandoConnections, self).__init__() |
| 1022 | + |
| 1023 | + @classmethod |
| 1024 | + def validate_entrance_name(cls, entrance: str) -> bool: |
| 1025 | + return entrance.lower() in cls.entrances |
| 1026 | + |
| 1027 | + @classmethod |
| 1028 | + def validate_exit_name(cls, exit: str) -> bool: |
| 1029 | + return exit.lower() in cls.exits |
| 1030 | + |
| 1031 | + @classmethod |
| 1032 | + def can_connect(cls, entrance: str, exit: str) -> bool: |
| 1033 | + """Checks that a given entrance can connect to a given exit. |
| 1034 | + By default, this will always return true unless overridden.""" |
| 1035 | + return True |
| 1036 | + |
| 1037 | + @classmethod |
| 1038 | + def validate_plando_connections(cls, connections: typing.Iterable[PlandoConnection]) -> None: |
| 1039 | + used_entrances: typing.List[str] = [] |
| 1040 | + used_exits: typing.List[str] = [] |
| 1041 | + for connection in connections: |
| 1042 | + entrance = connection.entrance |
| 1043 | + exit = connection.exit |
| 1044 | + direction = connection.direction |
| 1045 | + if direction not in (PlandoConnection.Direction.entrance, |
| 1046 | + PlandoConnection.Direction.exit, |
| 1047 | + PlandoConnection.Direction.both): |
| 1048 | + raise ValueError(f"Unknown direction: {direction}") |
| 1049 | + if entrance in used_entrances: |
| 1050 | + raise ValueError(f"Duplicate Entrance {entrance} not allowed.") |
| 1051 | + if not cls.duplicate_exits and exit in used_exits: |
| 1052 | + raise ValueError(f"Duplicate Exit {exit} not allowed.") |
| 1053 | + used_entrances.append(entrance) |
| 1054 | + used_exits.append(exit) |
| 1055 | + if not cls.validate_entrance_name(entrance): |
| 1056 | + raise ValueError(f"{entrance.title()} is not a valid entrance.") |
| 1057 | + if not cls.validate_exit_name(exit): |
| 1058 | + raise ValueError(f"{exit.title()} is not a valid exit.") |
| 1059 | + if not cls.can_connect(entrance, exit): |
| 1060 | + raise ValueError(f"Connection between {entrance.title()} and {exit.title()} is invalid.") |
| 1061 | + |
| 1062 | + @classmethod |
| 1063 | + def from_any(cls, data: PlandoConFromAnyType) -> Self: |
| 1064 | + if not isinstance(data, typing.Iterable): |
| 1065 | + raise Exception(f"Cannot create plando connections from non-List value, got {type(data)}.") |
| 1066 | + |
| 1067 | + value: typing.List[PlandoConnection] = [] |
| 1068 | + for connection in data: |
| 1069 | + if isinstance(connection, typing.Mapping): |
| 1070 | + percentage = connection.get("percentage", 100) |
| 1071 | + if random.random() < float(percentage / 100): |
| 1072 | + entrance = connection.get("entrance", None) |
| 1073 | + if is_iterable_except_str(entrance): |
| 1074 | + entrance = random.choice(sorted(entrance)) |
| 1075 | + exit = connection.get("exit", None) |
| 1076 | + if is_iterable_except_str(exit): |
| 1077 | + exit = random.choice(sorted(exit)) |
| 1078 | + direction = connection.get("direction", "both") |
| 1079 | + |
| 1080 | + if not entrance or not exit: |
| 1081 | + raise Exception("Plando connection must have an entrance and an exit.") |
| 1082 | + value.append(PlandoConnection( |
| 1083 | + entrance, |
| 1084 | + exit, |
| 1085 | + direction, |
| 1086 | + percentage |
| 1087 | + )) |
| 1088 | + elif isinstance(connection, PlandoConnection): |
| 1089 | + if random.random() < float(connection.percentage / 100): |
| 1090 | + value.append(connection) |
| 1091 | + else: |
| 1092 | + raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.") |
| 1093 | + cls.validate_plando_connections(value) |
| 1094 | + return cls(value) |
| 1095 | + |
| 1096 | + def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None: |
| 1097 | + from BaseClasses import PlandoOptions |
| 1098 | + if self.value and not (PlandoOptions.connections & plando_options): |
| 1099 | + # plando is disabled but plando options were given so overwrite the options |
| 1100 | + self.value = [] |
| 1101 | + logging.warning(f"The plando connections module is turned off, " |
| 1102 | + f"so connections for {player_name} will be ignored.") |
| 1103 | + |
| 1104 | + @classmethod |
| 1105 | + def get_option_name(cls, value: typing.List[PlandoConnection]) -> str: |
| 1106 | + return ", ".join(["%s %s %s" % (connection.entrance, |
| 1107 | + "<=>" if connection.direction == PlandoConnection.Direction.both else |
| 1108 | + "<=" if connection.direction == PlandoConnection.Direction.exit else |
| 1109 | + "=>", |
| 1110 | + connection.exit) for connection in value]) |
| 1111 | + |
| 1112 | + def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection: |
| 1113 | + return self.value.__getitem__(index) |
| 1114 | + |
| 1115 | + def __iter__(self) -> typing.Iterator[PlandoConnection]: |
| 1116 | + yield from self.value |
| 1117 | + |
| 1118 | + def __len__(self) -> int: |
| 1119 | + return len(self.value) |
| 1120 | + |
| 1121 | + |
899 | 1122 | class Accessibility(Choice):
|
900 | 1123 | """Set rules for reachability of your items/locations.
|
901 | 1124 | Locations: ensure everything can be reached and acquired.
|
@@ -1049,7 +1272,8 @@ class ItemLinks(OptionList):
|
1049 | 1272 | ])
|
1050 | 1273 |
|
1051 | 1274 | @staticmethod
|
1052 |
| - def verify_items(items: typing.List[str], item_link: str, pool_name: str, world, allow_item_groups: bool = True) -> typing.Set: |
| 1275 | + def verify_items(items: typing.List[str], item_link: str, pool_name: str, world, |
| 1276 | + allow_item_groups: bool = True) -> typing.Set: |
1053 | 1277 | pool = set()
|
1054 | 1278 | for item_name in items:
|
1055 | 1279 | if item_name not in world.item_names and (not allow_item_groups or item_name not in world.item_name_groups):
|
|
0 commit comments