Skip to content

Commit ceb865c

Browse files
authored
Merge branch 'ScipioWright:main' into Frogmonster
2 parents 7871b3a + a3e8f69 commit ceb865c

40 files changed

+2188
-1889
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ jobs:
5151
run: |
5252
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
5353
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
54-
choco install innosetup --version=6.2.2 --allow-downgrade
54+
choco install innosetup --version=6.7.0 --allow-downgrade
5555
- name: Build
5656
run: |
5757
python -m pip install --upgrade pip

BaseClasses.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
if TYPE_CHECKING:
2424
from entrance_rando import ERPlacementState
25+
from rule_builder.rules import Rule
2526
from worlds import AutoWorld
2627

2728

@@ -787,9 +788,11 @@ def _update_reachable_regions_explicit_indirect_conditions(self, player: int, qu
787788
self.multiworld.worlds[player].reached_region(self, new_region)
788789

789790
# Retry connections if the new region can unblock them
790-
for new_entrance in self.multiworld.indirect_connections.get(new_region, set()):
791-
if new_entrance in blocked_connections and new_entrance not in queue:
792-
queue.append(new_entrance)
791+
entrances = self.multiworld.indirect_connections.get(new_region)
792+
if entrances is not None:
793+
relevant_entrances = entrances.intersection(blocked_connections)
794+
relevant_entrances.difference_update(queue)
795+
queue.extend(relevant_entrances)
793796

794797
def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: deque[Entrance]):
795798
reachable_regions = self.reachable_regions[player]
@@ -1368,7 +1371,7 @@ def add_event(
13681371
self,
13691372
location_name: str,
13701373
item_name: str | None = None,
1371-
rule: CollectionRule | None = None,
1374+
rule: CollectionRule | Rule[Any] | None = None,
13721375
location_type: type[Location] | None = None,
13731376
item_type: type[Item] | None = None,
13741377
show_in_spoiler: bool = True,
@@ -1396,7 +1399,7 @@ def add_event(
13961399
event_location = location_type(self.player, location_name, None, self)
13971400
event_location.show_in_spoiler = show_in_spoiler
13981401
if rule is not None:
1399-
event_location.access_rule = rule
1402+
self.multiworld.worlds[self.player].set_rule(event_location, rule)
14001403

14011404
event_item = item_type(item_name, ItemClassification.progression, None, self.player)
14021405

@@ -1407,16 +1410,16 @@ def add_event(
14071410
return event_item
14081411

14091412
def connect(self, connecting_region: Region, name: Optional[str] = None,
1410-
rule: Optional[CollectionRule] = None) -> Entrance:
1413+
rule: Optional[CollectionRule | Rule[Any]] = None) -> Entrance:
14111414
"""
14121415
Connects this Region to another Region, placing the provided rule on the connection.
14131416
14141417
:param connecting_region: Region object to connect to path is `self -> exiting_region`
14151418
:param name: name of the connection being created
14161419
:param rule: callable to determine access of this connection to go from self to the exiting_region"""
14171420
exit_ = self.create_exit(name if name else f"{self.name} -> {connecting_region.name}")
1418-
if rule:
1419-
exit_.access_rule = rule
1421+
if rule is not None:
1422+
self.multiworld.worlds[self.player].set_rule(exit_, rule)
14201423
exit_.connect(connecting_region)
14211424
return exit_
14221425

@@ -1441,7 +1444,7 @@ def create_er_target(self, name: str) -> Entrance:
14411444
return entrance
14421445

14431446
def add_exits(self, exits: Iterable[str] | Mapping[str, str | None],
1444-
rules: Mapping[str, CollectionRule] | None = None) -> List[Entrance]:
1447+
rules: Mapping[str, CollectionRule | Rule[Any]] | None = None) -> List[Entrance]:
14451448
"""
14461449
Connects current region to regions in exit dictionary. Passed region names must exist first.
14471450

Main.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,9 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
207207
else:
208208
logger.info("Progression balancing skipped.")
209209

210+
AutoWorld.call_all(multiworld, "finalize_multiworld")
211+
AutoWorld.call_all(multiworld, "pre_output")
212+
210213
# we're about to output using multithreading, so we're removing the global random state to prevent accidental use
211214
multiworld.random.passthrough = False
212215

MultiServer.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,8 @@ def _load(self, decoded_obj: MultiData, game_data_packages: typing.Dict[str, typ
496496

497497
self.read_data = {}
498498
# there might be a better place to put this.
499-
self.read_data["race_mode"] = lambda: decoded_obj.get("race_mode", 0)
499+
race_mode = decoded_obj.get("race_mode", 0)
500+
self.read_data["race_mode"] = lambda: race_mode
500501
mdata_ver = decoded_obj["minimum_versions"]["server"]
501502
if mdata_ver > version_tuple:
502503
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver}, "
@@ -1301,6 +1302,13 @@ def __new__(cls, name, bases, attrs):
13011302
commands.update(base.commands)
13021303
commands.update({command_name[5:]: method for command_name, method in attrs.items() if
13031304
command_name.startswith("_cmd_")})
1305+
for command_name, method in commands.items():
1306+
# wrap async def functions so they run on default asyncio loop
1307+
if inspect.iscoroutinefunction(method):
1308+
def _wrapper(self, *args, _method=method, **kwargs):
1309+
return async_start(_method(self, *args, **kwargs))
1310+
functools.update_wrapper(_wrapper, method)
1311+
commands[command_name] = _wrapper
13041312
return super(CommandMeta, cls).__new__(cls, name, bases, attrs)
13051313

13061314

Options.py

Lines changed: 75 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,39 @@
2424
import pathlib
2525

2626

27+
_RANDOM_OPTS = [
28+
"random", "random-low", "random-middle", "random-high",
29+
"random-range-low-<min>-<max>", "random-range-middle-<min>-<max>",
30+
"random-range-high-<min>-<max>", "random-range-<min>-<max>",
31+
]
32+
33+
34+
def triangular(lower: int, end: int, tri: float = 0.5) -> int:
35+
"""
36+
Integer triangular distribution for `lower` inclusive to `end` inclusive.
37+
38+
Expects `lower <= end` and `0.0 <= tri <= 1.0`. The result of other inputs is undefined.
39+
"""
40+
# Use the continuous range [lower, end + 1) to produce an integer result in [lower, end].
41+
# random.triangular is actually [a, b] and not [a, b), so there is a very small chance of getting exactly b even
42+
# when a != b, so ensure the result is never more than `end`.
43+
return min(end, math.floor(random.triangular(0.0, 1.0, tri) * (end - lower + 1) + lower))
44+
45+
46+
def random_weighted_range(text: str, range_start: int, range_end: int):
47+
if text == "random-low":
48+
return triangular(range_start, range_end, 0.0)
49+
elif text == "random-high":
50+
return triangular(range_start, range_end, 1.0)
51+
elif text == "random-middle":
52+
return triangular(range_start, range_end)
53+
elif text == "random":
54+
return random.randint(range_start, range_end)
55+
else:
56+
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
57+
f"Acceptable values are: {', '.join(_RANDOM_OPTS)}.")
58+
59+
2760
def roll_percentage(percentage: int | float) -> bool:
2861
"""Roll a percentage chance.
2962
percentage is expected to be in range [0, 100]"""
@@ -690,12 +723,6 @@ class Range(NumericOption):
690723
range_start = 0
691724
range_end = 1
692725

693-
_RANDOM_OPTS = [
694-
"random", "random-low", "random-middle", "random-high",
695-
"random-range-low-<min>-<max>", "random-range-middle-<min>-<max>",
696-
"random-range-high-<min>-<max>", "random-range-<min>-<max>",
697-
]
698-
699726
def __init__(self, value: int):
700727
if value < self.range_start:
701728
raise Exception(f"{value} is lower than minimum {self.range_start} for option {self.__class__.__name__}")
@@ -744,40 +771,26 @@ def from_text(cls, text: str) -> Range:
744771

745772
@classmethod
746773
def weighted_range(cls, text) -> Range:
747-
if text == "random-low":
748-
return cls(cls.triangular(cls.range_start, cls.range_end, 0.0))
749-
elif text == "random-high":
750-
return cls(cls.triangular(cls.range_start, cls.range_end, 1.0))
751-
elif text == "random-middle":
752-
return cls(cls.triangular(cls.range_start, cls.range_end))
753-
elif text.startswith("random-range-"):
774+
if text.startswith("random-range-"):
754775
return cls.custom_range(text)
755-
elif text == "random":
756-
return cls(random.randint(cls.range_start, cls.range_end))
757776
else:
758-
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
759-
f"Acceptable values are: {', '.join(cls._RANDOM_OPTS)}.")
777+
return cls(random_weighted_range(text, cls.range_start, cls.range_end))
760778

761779
@classmethod
762780
def custom_range(cls, text) -> Range:
763781
textsplit = text.split("-")
764782
try:
765-
random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])]
783+
random_range = [int(textsplit[-2]), int(textsplit[-1])]
766784
except ValueError:
767785
raise ValueError(f"Invalid random range {text} for option {cls.__name__}")
768786
random_range.sort()
769787
if random_range[0] < cls.range_start or random_range[1] > cls.range_end:
770788
raise Exception(
771789
f"{random_range[0]}-{random_range[1]} is outside allowed range "
772790
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
773-
if text.startswith("random-range-low"):
774-
return cls(cls.triangular(random_range[0], random_range[1], 0.0))
775-
elif text.startswith("random-range-middle"):
776-
return cls(cls.triangular(random_range[0], random_range[1]))
777-
elif text.startswith("random-range-high"):
778-
return cls(cls.triangular(random_range[0], random_range[1], 1.0))
779-
else:
780-
return cls(random.randint(random_range[0], random_range[1]))
791+
if textsplit[2] in ("low", "middle", "high"):
792+
return cls(random_weighted_range(f"{textsplit[0]}-{textsplit[2]}", *random_range))
793+
return cls(random_weighted_range("random", *random_range))
781794

782795
@classmethod
783796
def from_any(cls, data: typing.Any) -> Range:
@@ -792,18 +805,6 @@ def get_option_name(cls, value: int) -> str:
792805
def __str__(self) -> str:
793806
return str(self.value)
794807

795-
@staticmethod
796-
def triangular(lower: int, end: int, tri: float = 0.5) -> int:
797-
"""
798-
Integer triangular distribution for `lower` inclusive to `end` inclusive.
799-
800-
Expects `lower <= end` and `0.0 <= tri <= 1.0`. The result of other inputs is undefined.
801-
"""
802-
# Use the continuous range [lower, end + 1) to produce an integer result in [lower, end].
803-
# random.triangular is actually [a, b] and not [a, b), so there is a very small chance of getting exactly b even
804-
# when a != b, so ensure the result is never more than `end`.
805-
return min(end, math.floor(random.triangular(0.0, 1.0, tri) * (end - lower + 1) + lower))
806-
807808

808809
class NamedRange(Range):
809810
special_range_names: typing.Dict[str, int] = {}
@@ -1000,13 +1001,19 @@ def __contains__(self, item):
10001001
class OptionSet(Option[typing.Set[str]], VerifyKeys):
10011002
default = frozenset()
10021003
supports_weighting = False
1004+
random_str: str | None
10031005

1004-
def __init__(self, value: typing.Iterable[str]):
1006+
def __init__(self, value: typing.Iterable[str], random_str: str | None = None):
10051007
self.value = set(deepcopy(value))
1008+
self.random_str = random_str
10061009
super(OptionSet, self).__init__()
10071010

10081011
@classmethod
10091012
def from_text(cls, text: str):
1013+
check_text = text.lower().split(",")
1014+
if ((cls.valid_keys or cls.verify_item_name or cls.verify_location_name)
1015+
and len(check_text) == 1 and check_text[0].startswith("random")):
1016+
return cls((), check_text[0])
10101017
return cls([option.strip() for option in text.split(",")])
10111018

10121019
@classmethod
@@ -1015,6 +1022,35 @@ def from_any(cls, data: typing.Any):
10151022
return cls(data)
10161023
return cls.from_text(str(data))
10171024

1025+
def verify(self, world: typing.Type[World], player_name: str, plando_options: PlandoOptions) -> None:
1026+
if self.random_str and not self.value:
1027+
choice_list = sorted(self.valid_keys)
1028+
if self.verify_item_name:
1029+
choice_list.extend(sorted(world.item_names))
1030+
if self.verify_location_name:
1031+
choice_list.extend(sorted(world.location_names))
1032+
if self.random_str.startswith("random-range-"):
1033+
textsplit = self.random_str.split("-")
1034+
try:
1035+
random_range = [int(textsplit[-2]), int(textsplit[-1])]
1036+
except ValueError:
1037+
raise ValueError(f"Invalid random range {self.random_str} for option {self.__class__.__name__} "
1038+
f"for player {player_name}")
1039+
random_range.sort()
1040+
if random_range[0] < 0 or random_range[1] > len(choice_list):
1041+
raise Exception(
1042+
f"{random_range[0]}-{random_range[1]} is outside allowed range "
1043+
f"0-{len(choice_list)} for option {self.__class__.__name__} for player {player_name}")
1044+
if textsplit[2] in ("low", "middle", "high"):
1045+
choice_count = random_weighted_range(f"{textsplit[0]}-{textsplit[2]}",
1046+
random_range[0], random_range[1])
1047+
else:
1048+
choice_count = random_weighted_range("random", random_range[0], random_range[1])
1049+
else:
1050+
choice_count = random_weighted_range(self.random_str, 0, len(choice_list))
1051+
self.value = set(random.sample(choice_list, k=choice_count))
1052+
super(Option, self).verify(world, player_name, plando_options)
1053+
10181054
@classmethod
10191055
def get_option_name(cls, value):
10201056
return ", ".join(sorted(value))

0 commit comments

Comments
 (0)