diff --git a/.gitignore b/.gitignore
index 5686f43de380..791f7b1bb7fe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -150,7 +150,7 @@ venv/
ENV/
env.bak/
venv.bak/
-.code-workspace
+*.code-workspace
shell.nix
# Spyder project settings
diff --git a/BaseClasses.py b/BaseClasses.py
index 88857f803212..81601506d084 100644
--- a/BaseClasses.py
+++ b/BaseClasses.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import collections
import copy
import itertools
import functools
@@ -63,7 +64,6 @@ class MultiWorld():
state: CollectionState
plando_options: PlandoOptions
- accessibility: Dict[int, Options.Accessibility]
early_items: Dict[int, Dict[str, int]]
local_early_items: Dict[int, Dict[str, int]]
local_items: Dict[int, Options.LocalItems]
@@ -288,6 +288,86 @@ def set_item_links(self):
group["non_local_items"] = item_link["non_local_items"]
group["link_replacement"] = replacement_prio[item_link["link_replacement"]]
+ def link_items(self) -> None:
+ """Called to link together items in the itempool related to the registered item link groups."""
+ from worlds import AutoWorld
+
+ for group_id, group in self.groups.items():
+ def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
+ Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
+ ]:
+ classifications: Dict[str, int] = collections.defaultdict(int)
+ counters = {player: {name: 0 for name in shared_pool} for player in players}
+ for item in self.itempool:
+ if item.player in counters and item.name in shared_pool:
+ counters[item.player][item.name] += 1
+ classifications[item.name] |= item.classification
+
+ for player in players.copy():
+ if all([counters[player][item] == 0 for item in shared_pool]):
+ players.remove(player)
+ del (counters[player])
+
+ if not players:
+ return None, None
+
+ for item in shared_pool:
+ count = min(counters[player][item] for player in players)
+ if count:
+ for player in players:
+ counters[player][item] = count
+ else:
+ for player in players:
+ del (counters[player][item])
+ return counters, classifications
+
+ common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
+ if not common_item_count:
+ continue
+
+ new_itempool: List[Item] = []
+ for item_name, item_count in next(iter(common_item_count.values())).items():
+ for _ in range(item_count):
+ new_item = group["world"].create_item(item_name)
+ # mangle together all original classification bits
+ new_item.classification |= classifications[item_name]
+ new_itempool.append(new_item)
+
+ region = Region("Menu", group_id, self, "ItemLink")
+ self.regions.append(region)
+ locations = region.locations
+ for item in self.itempool:
+ count = common_item_count.get(item.player, {}).get(item.name, 0)
+ if count:
+ loc = Location(group_id, f"Item Link: {item.name} -> {self.player_name[item.player]} {count}",
+ None, region)
+ loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
+ state.has(item_name, group_id_, count_)
+
+ locations.append(loc)
+ loc.place_locked_item(item)
+ common_item_count[item.player][item.name] -= 1
+ else:
+ new_itempool.append(item)
+
+ itemcount = len(self.itempool)
+ self.itempool = new_itempool
+
+ while itemcount > len(self.itempool):
+ items_to_add = []
+ for player in group["players"]:
+ if group["link_replacement"]:
+ item_player = group_id
+ else:
+ item_player = player
+ if group["replacement_items"][player]:
+ items_to_add.append(AutoWorld.call_single(self, "create_item", item_player,
+ group["replacement_items"][player]))
+ else:
+ items_to_add.append(AutoWorld.call_single(self, "create_filler", item_player))
+ self.random.shuffle(items_to_add)
+ self.itempool.extend(items_to_add[:itemcount - len(self.itempool)])
+
def secure(self):
self.random = ThreadBarrierProxy(secrets.SystemRandom())
self.is_race = True
@@ -523,26 +603,22 @@ def fulfills_accessibility(self, state: Optional[CollectionState] = None):
players: Dict[str, Set[int]] = {
"minimal": set(),
"items": set(),
- "locations": set()
+ "full": set()
}
- for player, access in self.accessibility.items():
- players[access.current_key].add(player)
+ for player, world in self.worlds.items():
+ players[world.options.accessibility.current_key].add(player)
beatable_fulfilled = False
- def location_condition(location: Location):
+ def location_condition(location: Location) -> bool:
"""Determine if this location has to be accessible, location is already filtered by location_relevant"""
- if location.player in players["locations"] or (location.item and location.item.player not in
- players["minimal"]):
- return True
- return False
+ return location.player in players["full"] or \
+ (location.item and location.item.player not in players["minimal"])
- def location_relevant(location: Location):
+ def location_relevant(location: Location) -> bool:
"""Determine if this location is relevant to sweep."""
- if location.progress_type != LocationProgressType.EXCLUDED \
- and (location.player in players["locations"] or location.advancement):
- return True
- return False
+ return location.progress_type != LocationProgressType.EXCLUDED \
+ and (location.player in players["full"] or location.advancement)
def all_done() -> bool:
"""Check if all access rules are fulfilled"""
@@ -680,13 +756,13 @@ def can_reach_entrance(self, spot: str, player: int) -> bool:
def can_reach_region(self, spot: str, player: int) -> bool:
return self.multiworld.get_region(spot, player).can_reach(self)
- def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
+ def sweep_for_events(self, locations: Optional[Iterable[Location]] = None) -> None:
if locations is None:
locations = self.multiworld.get_filled_locations()
reachable_events = True
# since the loop has a good chance to run more than once, only filter the events once
- locations = {location for location in locations if location.advancement and location not in self.events and
- not key_only or getattr(location.item, "locked_dungeon_item", False)}
+ locations = {location for location in locations if location.advancement and location not in self.events}
+
while reachable_events:
reachable_events = {location for location in locations if location.can_reach(self)}
locations -= reachable_events
@@ -1291,8 +1367,6 @@ def create_playthrough(self, create_paths: bool = True) -> None:
state = CollectionState(multiworld)
collection_spheres = []
while required_locations:
- state.sweep_for_events(key_only=True)
-
sphere = set(filter(state.can_reach, required_locations))
for location in sphere:
diff --git a/CommonClient.py b/CommonClient.py
index f8d1fcb7a221..09937e4b9ab8 100644
--- a/CommonClient.py
+++ b/CommonClient.py
@@ -61,6 +61,7 @@ def _cmd_connect(self, address: str = "") -> bool:
if address:
self.ctx.server_address = None
self.ctx.username = None
+ self.ctx.password = None
elif not self.ctx.server_address:
self.output("Please specify an address.")
return False
@@ -514,6 +515,7 @@ def update_permissions(self, permissions: typing.Dict[str, int]):
async def shutdown(self):
self.server_address = ""
self.username = None
+ self.password = None
self.cancel_autoreconnect()
if self.server and not self.server.socket.closed:
await self.server.socket.close()
diff --git a/Fill.py b/Fill.py
index 4967ff073601..5185bbb60ee4 100644
--- a/Fill.py
+++ b/Fill.py
@@ -646,7 +646,6 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
def get_sphere_locations(sphere_state: CollectionState,
locations: typing.Set[Location]) -> typing.Set[Location]:
- sphere_state.sweep_for_events(key_only=True, locations=locations)
return {loc for loc in locations if sphere_state.can_reach(loc)}
def item_percentage(player: int, num: int) -> float:
diff --git a/Main.py b/Main.py
index de6b467f93d9..ce054dcd393f 100644
--- a/Main.py
+++ b/Main.py
@@ -124,14 +124,19 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player in multiworld.player_ids:
exclusion_rules(multiworld, player, multiworld.worlds[player].options.exclude_locations.value)
multiworld.worlds[player].options.priority_locations.value -= multiworld.worlds[player].options.exclude_locations.value
+ world_excluded_locations = set()
for location_name in multiworld.worlds[player].options.priority_locations.value:
try:
location = multiworld.get_location(location_name, player)
- except KeyError as e: # failed to find the given location. Check if it's a legitimate location
- if location_name not in multiworld.worlds[player].location_name_to_id:
- raise Exception(f"Unable to prioritize location {location_name} in player {player}'s world.") from e
- else:
+ except KeyError:
+ continue
+
+ if location.progress_type != LocationProgressType.EXCLUDED:
location.progress_type = LocationProgressType.PRIORITY
+ else:
+ logger.warning(f"Unable to prioritize location \"{location_name}\" in player {player}'s world because the world excluded it.")
+ world_excluded_locations.add(location_name)
+ multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
# Set local and non-local item rules.
if multiworld.players > 1:
@@ -179,82 +184,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
assert len(multiworld.itempool) == len(new_items), "Item Pool amounts should not change."
multiworld.itempool[:] = new_items
- # temporary home for item links, should be moved out of Main
- for group_id, group in multiworld.groups.items():
- def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
- Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
- ]:
- classifications: Dict[str, int] = collections.defaultdict(int)
- counters = {player: {name: 0 for name in shared_pool} for player in players}
- for item in multiworld.itempool:
- if item.player in counters and item.name in shared_pool:
- counters[item.player][item.name] += 1
- classifications[item.name] |= item.classification
-
- for player in players.copy():
- if all([counters[player][item] == 0 for item in shared_pool]):
- players.remove(player)
- del (counters[player])
-
- if not players:
- return None, None
-
- for item in shared_pool:
- count = min(counters[player][item] for player in players)
- if count:
- for player in players:
- counters[player][item] = count
- else:
- for player in players:
- del (counters[player][item])
- return counters, classifications
-
- common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
- if not common_item_count:
- continue
-
- new_itempool: List[Item] = []
- for item_name, item_count in next(iter(common_item_count.values())).items():
- for _ in range(item_count):
- new_item = group["world"].create_item(item_name)
- # mangle together all original classification bits
- new_item.classification |= classifications[item_name]
- new_itempool.append(new_item)
-
- region = Region("Menu", group_id, multiworld, "ItemLink")
- multiworld.regions.append(region)
- locations = region.locations
- for item in multiworld.itempool:
- count = common_item_count.get(item.player, {}).get(item.name, 0)
- if count:
- loc = Location(group_id, f"Item Link: {item.name} -> {multiworld.player_name[item.player]} {count}",
- None, region)
- loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
- state.has(item_name, group_id_, count_)
-
- locations.append(loc)
- loc.place_locked_item(item)
- common_item_count[item.player][item.name] -= 1
- else:
- new_itempool.append(item)
-
- itemcount = len(multiworld.itempool)
- multiworld.itempool = new_itempool
-
- while itemcount > len(multiworld.itempool):
- items_to_add = []
- for player in group["players"]:
- if group["link_replacement"]:
- item_player = group_id
- else:
- item_player = player
- if group["replacement_items"][player]:
- items_to_add.append(AutoWorld.call_single(multiworld, "create_item", item_player,
- group["replacement_items"][player]))
- else:
- items_to_add.append(AutoWorld.call_single(multiworld, "create_filler", item_player))
- multiworld.random.shuffle(items_to_add)
- multiworld.itempool.extend(items_to_add[:itemcount - len(multiworld.itempool)])
+ multiworld.link_items()
if any(multiworld.item_links.values()):
multiworld._all_state = None
diff --git a/Options.py b/Options.py
index b5fb25ea34a0..d040828509d1 100644
--- a/Options.py
+++ b/Options.py
@@ -786,17 +786,22 @@ class VerifyKeys(metaclass=FreezeValidKeys):
verify_location_name: bool = False
value: typing.Any
- @classmethod
- def verify_keys(cls, data: typing.Iterable[str]) -> None:
- if cls.valid_keys:
- data = set(data)
- dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
- extra = dataset - cls._valid_keys
+ def verify_keys(self) -> None:
+ if self.valid_keys:
+ data = set(self.value)
+ dataset = set(word.casefold() for word in data) if self.valid_keys_casefold else set(data)
+ extra = dataset - self._valid_keys
if extra:
- raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
- f"Allowed keys: {cls._valid_keys}.")
+ raise OptionError(
+ f"Found unexpected key {', '.join(extra)} in {getattr(self, 'display_name', self)}. "
+ f"Allowed keys: {self._valid_keys}."
+ )
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
+ try:
+ self.verify_keys()
+ except OptionError as validation_error:
+ raise OptionError(f"Player {player_name} has invalid option keys:\n{validation_error}")
if self.convert_name_groups and self.verify_item_name:
new_value = type(self.value)() # empty container of whatever value is
for item_name in self.value:
@@ -833,7 +838,6 @@ def __init__(self, value: typing.Dict[str, typing.Any]):
@classmethod
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
if type(data) == dict:
- cls.verify_keys(data)
return cls(data)
else:
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
@@ -879,7 +883,6 @@ def from_text(cls, text: str):
@classmethod
def from_any(cls, data: typing.Any):
if is_iterable_except_str(data):
- cls.verify_keys(data)
return cls(data)
return cls.from_text(str(data))
@@ -905,7 +908,6 @@ def from_text(cls, text: str):
@classmethod
def from_any(cls, data: typing.Any):
if is_iterable_except_str(data):
- cls.verify_keys(data)
return cls(data)
return cls.from_text(str(data))
@@ -948,6 +950,19 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P
self.value = []
logging.warning(f"The plando texts module is turned off, "
f"so text for {player_name} will be ignored.")
+ else:
+ super().verify(world, player_name, plando_options)
+
+ def verify_keys(self) -> None:
+ if self.valid_keys:
+ data = set(text.at for text in self)
+ dataset = set(word.casefold() for word in data) if self.valid_keys_casefold else set(data)
+ extra = dataset - self._valid_keys
+ if extra:
+ raise OptionError(
+ f"Invalid \"at\" placement {', '.join(extra)} in {getattr(self, 'display_name', self)}. "
+ f"Allowed placements: {self._valid_keys}."
+ )
@classmethod
def from_any(cls, data: PlandoTextsFromAnyType) -> Self:
@@ -971,7 +986,6 @@ def from_any(cls, data: PlandoTextsFromAnyType) -> Self:
texts.append(text)
else:
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
- cls.verify_keys([text.at for text in texts])
return cls(texts)
else:
raise NotImplementedError(f"Cannot Convert from non-list, got {type(data)}")
@@ -1144,18 +1158,35 @@ def __len__(self) -> int:
class Accessibility(Choice):
- """Set rules for reachability of your items/locations.
+ """
+ Set rules for reachability of your items/locations.
+
+ **Full:** ensure everything can be reached and acquired.
- - **Locations:** ensure everything can be reached and acquired.
- - **Items:** ensure all logically relevant items can be acquired.
- - **Minimal:** ensure what is needed to reach your goal can be acquired.
+ **Minimal:** ensure what is needed to reach your goal can be acquired.
"""
display_name = "Accessibility"
rich_text_doc = True
- option_locations = 0
- option_items = 1
+ option_full = 0
option_minimal = 2
alias_none = 2
+ alias_locations = 0
+ alias_items = 0
+ default = 0
+
+
+class ItemsAccessibility(Accessibility):
+ """
+ Set rules for reachability of your items/locations.
+
+ **Full:** ensure everything can be reached and acquired.
+
+ **Minimal:** ensure what is needed to reach your goal can be acquired.
+
+ **Items:** ensure all logically relevant items can be acquired. Some items, such as keys, may be self-locking, and
+ some locations may be inaccessible.
+ """
+ option_items = 1
default = 1
diff --git a/README.md b/README.md
index cebd4f7e7529..5b66e3db8782 100644
--- a/README.md
+++ b/README.md
@@ -72,6 +72,7 @@ Currently, the following games are supported:
* Aquaria
* Yu-Gi-Oh! Ultimate Masters: World Championship Tournament 2006
* A Hat in Time
+* Old School Runescape
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
diff --git a/WebHostLib/options.py b/WebHostLib/options.py
index 33339daa1983..15b7bd61ceee 100644
--- a/WebHostLib/options.py
+++ b/WebHostLib/options.py
@@ -231,6 +231,13 @@ def generate_yaml(game: str):
del options[key]
+ # Detect keys which end with -range, indicating a NamedRange with a possible custom value
+ elif key_parts[-1].endswith("-range"):
+ if options[key_parts[-1][:-6]] == "custom":
+ options[key_parts[-1][:-6]] = val
+
+ del options[key]
+
# Detect random-* keys and set their options accordingly
for key, val in options.copy().items():
if key.startswith("random-"):
diff --git a/WebHostLib/templates/playerOptions/macros.html b/WebHostLib/templates/playerOptions/macros.html
index 415739b861a1..30a4fc78dff3 100644
--- a/WebHostLib/templates/playerOptions/macros.html
+++ b/WebHostLib/templates/playerOptions/macros.html
@@ -54,7 +54,7 @@
{% macro NamedRange(option_name, option) %}
{{ OptionTitle(option_name, option) }}
-