diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 000000000000..17a60ad125f7
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,5 @@
+[report]
+exclude_lines =
+ pragma: no cover
+ if TYPE_CHECKING:
+ if typing.TYPE_CHECKING:
diff --git a/.github/labeler.yml b/.github/labeler.yml
new file mode 100644
index 000000000000..2743104f410e
--- /dev/null
+++ b/.github/labeler.yml
@@ -0,0 +1,31 @@
+'is: documentation':
+- changed-files:
+ - all-globs-to-all-files: '{**/docs/**,**/README.md}'
+
+'affects: webhost':
+- changed-files:
+ - all-globs-to-any-file: 'WebHost.py'
+ - all-globs-to-any-file: 'WebHostLib/**/*'
+
+'affects: core':
+- changed-files:
+ - all-globs-to-any-file:
+ - '!*Client.py'
+ - '!README.md'
+ - '!LICENSE'
+ - '!*.yml'
+ - '!.gitignore'
+ - '!**/docs/**'
+ - '!typings/kivy/**'
+ - '!test/**'
+ - '!data/**'
+ - '!.run/**'
+ - '!.github/**'
+ - '!worlds_disabled/**'
+ - '!worlds/**'
+ - '!WebHost.py'
+ - '!WebHostLib/**'
+ - any-glob-to-any-file: # exceptions to the above rules of "stuff that isn't core"
+ - 'worlds/generic/**/*.py'
+ - 'worlds/*.py'
+ - 'CommonClient.py'
diff --git a/.github/workflows/analyze-modified-files.yml b/.github/workflows/analyze-modified-files.yml
index ba2660809aaa..d01365745c96 100644
--- a/.github/workflows/analyze-modified-files.yml
+++ b/.github/workflows/analyze-modified-files.yml
@@ -71,7 +71,7 @@ jobs:
continue-on-error: true
if: env.diff != '' && matrix.task == 'flake8'
run: |
- flake8 --count --max-complexity=10 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }}
+ flake8 --count --max-complexity=14 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }}
- name: "mypy: Type check modified files"
continue-on-error: true
diff --git a/.github/workflows/label-pull-requests.yml b/.github/workflows/label-pull-requests.yml
new file mode 100644
index 000000000000..e26f6f34a4d2
--- /dev/null
+++ b/.github/workflows/label-pull-requests.yml
@@ -0,0 +1,46 @@
+name: Label Pull Request
+on:
+ pull_request_target:
+ types: ['opened', 'reopened', 'synchronize', 'ready_for_review', 'converted_to_draft', 'closed']
+ branches: ['main']
+permissions:
+ contents: read
+ pull-requests: write
+
+jobs:
+ labeler:
+ name: 'Apply content-based labels'
+ if: github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'synchronize'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/labeler@v5
+ with:
+ sync-labels: true
+ peer_review:
+ name: 'Apply peer review label'
+ needs: labeler
+ if: >-
+ (github.event.action == 'opened' || github.event.action == 'reopened' ||
+ github.event.action == 'ready_for_review') && !github.event.pull_request.draft
+ runs-on: ubuntu-latest
+ steps:
+ - name: 'Add label'
+ run: "gh pr edit \"$PR_URL\" --add-label 'waiting-on: peer-review'"
+ env:
+ PR_URL: ${{ github.event.pull_request.html_url }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ unblock_draft_prs:
+ name: 'Remove waiting-on labels'
+ needs: labeler
+ if: github.event.action == 'converted_to_draft' || github.event.action == 'closed'
+ runs-on: ubuntu-latest
+ steps:
+ - name: 'Remove labels'
+ run: |-
+ gh pr edit "$PR_URL" --remove-label 'waiting-on: peer-review' \
+ --remove-label 'waiting-on: core-review' \
+ --remove-label 'waiting-on: world-maintainer' \
+ --remove-label 'waiting-on: author'
+ env:
+ PR_URL: ${{ github.event.pull_request.html_url }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/scan-build.yml b/.github/workflows/scan-build.yml
new file mode 100644
index 000000000000..5234d862b4d3
--- /dev/null
+++ b/.github/workflows/scan-build.yml
@@ -0,0 +1,65 @@
+name: Native Code Static Analysis
+
+on:
+ push:
+ paths:
+ - '**.c'
+ - '**.cc'
+ - '**.cpp'
+ - '**.cxx'
+ - '**.h'
+ - '**.hh'
+ - '**.hpp'
+ - '**.pyx'
+ - 'setup.py'
+ - 'requirements.txt'
+ - '.github/workflows/scan-build.yml'
+ pull_request:
+ paths:
+ - '**.c'
+ - '**.cc'
+ - '**.cpp'
+ - '**.cxx'
+ - '**.h'
+ - '**.hh'
+ - '**.hpp'
+ - '**.pyx'
+ - 'setup.py'
+ - 'requirements.txt'
+ - '.github/workflows/scan-build.yml'
+
+jobs:
+ scan-build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: recursive
+ - name: Install newer Clang
+ run: |
+ wget https://apt.llvm.org/llvm.sh
+ chmod +x ./llvm.sh
+ sudo ./llvm.sh 17
+ - name: Install scan-build command
+ run: |
+ sudo apt install clang-tools-17
+ - name: Get a recent python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+ - name: Install dependencies
+ run: |
+ python -m venv venv
+ source venv/bin/activate
+ python -m pip install --upgrade pip -r requirements.txt
+ - name: scan-build
+ run: |
+ source venv/bin/activate
+ scan-build-17 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
+ - name: Store report
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: scan-build-reports
+ path: scan-build-reports
diff --git a/.run/Archipelago Unittests.run.xml b/.run/Archipelago Unittests.run.xml
new file mode 100644
index 000000000000..24fea0f73fec
--- /dev/null
+++ b/.run/Archipelago Unittests.run.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/AM2RClient.py b/AM2RClient.py
new file mode 100644
index 000000000000..0906405f36b8
--- /dev/null
+++ b/AM2RClient.py
@@ -0,0 +1,11 @@
+from __future__ import annotations
+
+import ModuleUpdate
+ModuleUpdate.update()
+
+from worlds.am2r.Client import launch
+import Utils
+
+if __name__ == "__main__":
+ Utils.init_logging("AM2RClient", exception_logger="Client")
+ launch()
diff --git a/AdventureClient.py b/AdventureClient.py
index d2f4e734ac2c..06e4d60dad43 100644
--- a/AdventureClient.py
+++ b/AdventureClient.py
@@ -115,11 +115,12 @@ def on_package(self, cmd: str, args: dict):
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "Retrieved":
- self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"]
- if self.freeincarnates_used is None:
- self.freeincarnates_used = 0
- self.freeincarnates_used += self.freeincarnate_pending
- self.send_pending_freeincarnates()
+ if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]:
+ self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"]
+ if self.freeincarnates_used is None:
+ self.freeincarnates_used = 0
+ self.freeincarnates_used += self.freeincarnate_pending
+ self.send_pending_freeincarnates()
elif cmd == "SetReply":
if args["key"] == f"adventure_{self.auth}_freeincarnates_used":
self.freeincarnates_used = args["value"]
diff --git a/BaseClasses.py b/BaseClasses.py
index 7965eb8b0d0d..2be9a9820d07 100644
--- a/BaseClasses.py
+++ b/BaseClasses.py
@@ -18,11 +18,14 @@
import Options
import Utils
+if typing.TYPE_CHECKING:
+ from worlds import AutoWorld
+
class Group(TypedDict, total=False):
name: str
game: str
- world: auto_world
+ world: "AutoWorld.World"
players: Set[int]
item_pool: Set[str]
replacement_items: Dict[int, Optional[str]]
@@ -55,7 +58,7 @@ class MultiWorld():
plando_texts: List[Dict[str, str]]
plando_items: List[List[Dict[str, Any]]]
plando_connections: List
- worlds: Dict[int, auto_world]
+ worlds: Dict[int, "AutoWorld.World"]
groups: Dict[int, Group]
regions: RegionManager
itempool: List[Item]
@@ -107,10 +110,14 @@ def __iadd__(self, other: Iterable[Region]):
return self
def append(self, region: Region):
+ assert region.name not in self.region_cache[region.player], \
+ f"{region.name} already exists in region cache."
self.region_cache[region.player][region.name] = region
def extend(self, regions: Iterable[Region]):
for region in regions:
+ assert region.name not in self.region_cache[region.player], \
+ f"{region.name} already exists in region cache."
self.region_cache[region.player][region.name] = region
def add_group(self, new_id: int):
@@ -156,11 +163,11 @@ def __init__(self, players: int):
self.fix_trock_doors = self.AttributeProxy(
lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
self.fix_skullwoods_exit = self.AttributeProxy(
- lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
+ lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeons_simple'])
self.fix_palaceofdarkness_exit = self.AttributeProxy(
- lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
+ lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeons_simple'])
self.fix_trock_exit = self.AttributeProxy(
- lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
+ lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeons_simple'])
for player in range(1, players + 1):
def set_player_attr(attr, val):
@@ -219,6 +226,8 @@ def get_all_ids(self) -> Tuple[int, ...]:
def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tuple[int, Group]:
"""Create a group with name and return the assigned player ID and group.
If a group of this name already exists, the set of players is extended instead of creating a new one."""
+ from worlds import AutoWorld
+
for group_id, group in self.groups.items():
if group["name"] == name:
group["players"] |= players
@@ -252,19 +261,28 @@ def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optio
range(1, self.players + 1)}
def set_options(self, args: Namespace) -> None:
+ # TODO - remove this section once all worlds use options dataclasses
+ from worlds import AutoWorld
+
+ all_keys: Set[str] = {key for player in self.player_ids for key in
+ AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints}
+ for option_key in all_keys:
+ option = Utils.DeprecateDict(f"Getting options from multiworld is now deprecated. "
+ f"Please use `self.options.{option_key}` instead.")
+ option.update(getattr(args, option_key, {}))
+ setattr(self, option_key, option)
+
for player in self.player_ids:
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
self.worlds[player] = world_type(self, player)
self.worlds[player].random = self.per_slot_randoms[player]
- for option_key in world_type.options_dataclass.type_hints:
- option_values = getattr(args, option_key, {})
- setattr(self, option_key, option_values)
- # TODO - remove this loop once all worlds use options dataclasses
- options_dataclass: typing.Type[Options.PerGameCommonOptions] = self.worlds[player].options_dataclass
+ options_dataclass: typing.Type[Options.PerGameCommonOptions] = world_type.options_dataclass
self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player]
for option_key in options_dataclass.type_hints})
def set_item_links(self):
+ from worlds import AutoWorld
+
item_links = {}
replacement_prio = [False, True, None]
for player in self.player_ids:
@@ -491,7 +509,7 @@ def has_beaten_game(self, state: CollectionState, player: Optional[int] = None)
else:
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1)))
- def can_beat_game(self, starting_state: Optional[CollectionState] = None):
+ def can_beat_game(self, starting_state: Optional[CollectionState] = None) -> bool:
if starting_state:
if self.has_beaten_game(starting_state):
return True
@@ -504,7 +522,7 @@ def can_beat_game(self, starting_state: Optional[CollectionState] = None):
and location.item.advancement and location not in state.locations_checked}
while prog_locations:
- sphere = set()
+ sphere: Set[Location] = set()
# build up spheres of collection radius.
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
for location in prog_locations:
@@ -524,12 +542,19 @@ def can_beat_game(self, starting_state: Optional[CollectionState] = None):
return False
- def get_spheres(self):
+ def get_spheres(self) -> Iterator[Set[Location]]:
+ """
+ yields a set of locations for each logical sphere
+
+ If there are unreachable locations, the last sphere of reachable
+ locations is followed by an empty set, and then a set of all of the
+ unreachable locations.
+ """
state = CollectionState(self)
locations = set(self.get_filled_locations())
while locations:
- sphere = set()
+ sphere: Set[Location] = set()
for location in locations:
if location.can_reach(state):
@@ -560,9 +585,10 @@ def fulfills_accessibility(self, state: Optional[CollectionState] = None):
def location_condition(location: Location):
"""Determine if this location has to be accessible, location is already filtered by location_relevant"""
- if location.player in players["minimal"]:
- return False
- return True
+ if location.player in players["locations"] or (location.item and location.item.player not in
+ players["minimal"]):
+ return True
+ return False
def location_relevant(location: Location):
"""Determine if this location is relevant to sweep."""
@@ -639,34 +665,34 @@ def __init__(self, parent: MultiWorld):
def update_reachable_regions(self, player: int):
self.stale[player] = False
- rrp = self.reachable_regions[player]
- bc = self.blocked_connections[player]
+ reachable_regions = self.reachable_regions[player]
+ blocked_connections = self.blocked_connections[player]
queue = deque(self.blocked_connections[player])
- start = self.multiworld.get_region('Menu', player)
+ start = self.multiworld.get_region("Menu", player)
# init on first call - this can't be done on construction since the regions don't exist yet
- if start not in rrp:
- rrp.add(start)
- bc.update(start.exits)
+ if start not in reachable_regions:
+ reachable_regions.add(start)
+ blocked_connections.update(start.exits)
queue.extend(start.exits)
# run BFS on all connections, and keep track of those blocked by missing items
while queue:
connection = queue.popleft()
new_region = connection.connected_region
- if new_region in rrp:
- bc.remove(connection)
+ if new_region in reachable_regions:
+ blocked_connections.remove(connection)
elif connection.can_reach(self):
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
- rrp.add(new_region)
- bc.remove(connection)
- bc.update(new_region.exits)
+ reachable_regions.add(new_region)
+ blocked_connections.remove(connection)
+ blocked_connections.update(new_region.exits)
queue.extend(new_region.exits)
self.path[new_region] = (new_region.name, self.path.get(connection, None))
# Retry connections if the new region can unblock them
for new_entrance in self.multiworld.indirect_connections.get(new_region, set()):
- if new_entrance in bc and new_entrance not in queue:
+ if new_entrance in blocked_connections and new_entrance not in queue:
queue.append(new_entrance)
def copy(self) -> CollectionState:
@@ -691,14 +717,23 @@ def can_reach(self,
assert isinstance(player, int), "can_reach: player is required if spot is str"
# try to resolve a name
if resolution_hint == 'Location':
- spot = self.multiworld.get_location(spot, player)
+ return self.can_reach_location(spot, player)
elif resolution_hint == 'Entrance':
- spot = self.multiworld.get_entrance(spot, player)
+ return self.can_reach_entrance(spot, player)
else:
# default to Region
- spot = self.multiworld.get_region(spot, player)
+ return self.can_reach_region(spot, player)
return spot.can_reach(self)
+ def can_reach_location(self, spot: str, player: int) -> bool:
+ return self.multiworld.get_location(spot, player).can_reach(self)
+
+ def can_reach_entrance(self, spot: str, player: int) -> bool:
+ return self.multiworld.get_entrance(spot, player).can_reach(self)
+
+ 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:
if locations is None:
locations = self.multiworld.get_filled_locations()
@@ -811,8 +846,8 @@ def __repr__(self):
return self.__str__()
def __str__(self):
- world = self.parent_region.multiworld if self.parent_region else None
- return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
+ multiworld = self.parent_region.multiworld if self.parent_region else None
+ return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
class Region:
@@ -855,6 +890,8 @@ def __delitem__(self, index: int) -> None:
del(self.region_manager.location_cache[location.player][location.name])
def insert(self, index: int, value: Location) -> None:
+ assert value.name not in self.region_manager.location_cache[value.player], \
+ f"{value.name} already exists in the location cache."
self._list.insert(index, value)
self.region_manager.location_cache[value.player][value.name] = value
@@ -865,6 +902,8 @@ def __delitem__(self, index: int) -> None:
del(self.region_manager.entrance_cache[entrance.player][entrance.name])
def insert(self, index: int, value: Entrance) -> None:
+ assert value.name not in self.region_manager.entrance_cache[value.player], \
+ f"{value.name} already exists in the entrance cache."
self._list.insert(index, value)
self.region_manager.entrance_cache[value.player][value.name] = value
@@ -1028,8 +1067,8 @@ def __repr__(self):
return self.__str__()
def __str__(self):
- world = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
- return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
+ multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
+ return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
def __hash__(self):
return hash((self.name, self.player))
@@ -1044,9 +1083,6 @@ def native_item(self) -> bool:
@property
def hint_text(self) -> str:
- hint_text = getattr(self, "_hint_text", None)
- if hint_text:
- return hint_text
return "at " + self.name.replace("_", " ").replace("-", " ")
@@ -1166,7 +1202,7 @@ def set_entrance(self, entrance: str, exit_: str, direction: str, player: int) -
{"player": player, "entrance": entrance, "exit": exit_, "direction": direction}
def create_playthrough(self, create_paths: bool = True) -> None:
- """Destructive to the world while it is run, damage gets repaired afterwards."""
+ """Destructive to the multiworld while it is run, damage gets repaired afterwards."""
from itertools import chain
# get locations containing progress items
multiworld = self.multiworld
@@ -1253,12 +1289,12 @@ def create_playthrough(self, create_paths: bool = True) -> None:
for location in sphere:
state.collect(location.item, True, location)
- required_locations -= sphere
-
collection_spheres.append(sphere)
logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres),
len(sphere), len(required_locations))
+
+ required_locations -= sphere
if not sphere:
raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}')
@@ -1317,6 +1353,8 @@ def get_path(state: CollectionState, region: Region) -> List[Union[Tuple[str, st
get_path(state, multiworld.get_region('Inverted Big Bomb Shop', player))
def to_file(self, filename: str) -> None:
+ from worlds import AutoWorld
+
def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None:
res = getattr(self.multiworld.worlds[player].options, option_key)
display_name = getattr(option_obj, "display_name", option_key)
@@ -1440,8 +1478,3 @@ def get_seed(seed: Optional[int] = None) -> int:
random.seed(None)
return random.randint(0, pow(10, seeddigits) - 1)
return seed
-
-
-from worlds import AutoWorld
-
-auto_world = AutoWorld.World
diff --git a/CommonClient.py b/CommonClient.py
index c4d80f341611..c75ca3fd806e 100644
--- a/CommonClient.py
+++ b/CommonClient.py
@@ -460,7 +460,7 @@ async def prepare_data_package(self, relevant_games: typing.Set[str],
else:
self.update_game(cached_game)
if needed_updates:
- await self.send_msgs([{"cmd": "GetDataPackage", "games": list(needed_updates)}])
+ await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates])
def update_game(self, game_package: dict):
for item_name, item_id in game_package["item_name_to_id"].items():
@@ -477,6 +477,7 @@ def consume_network_data_package(self, data_package: dict):
current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {})
current_cache.update(data_package["games"])
Utils.persistent_store("datapackage", "games", current_cache)
+ logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}")
for game, game_data in data_package["games"].items():
Utils.store_data_package_for_checksum(game, game_data)
@@ -727,7 +728,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
await ctx.server_auth(args['password'])
elif cmd == 'DataPackage':
- logger.info("Got new ID/Name DataPackage")
ctx.consume_network_data_package(args['data'])
elif cmd == 'ConnectionRefused':
@@ -941,4 +941,5 @@ async def main(args):
if __name__ == '__main__':
+ logging.getLogger().setLevel(logging.INFO) # force log-level to work around log level resetting to WARNING
run_as_textclient()
diff --git a/Fill.py b/Fill.py
index 342c155079dd..2d6257eae30a 100644
--- a/Fill.py
+++ b/Fill.py
@@ -27,12 +27,12 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
return new_state
-def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
+def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
allow_partial: bool = False, allow_excluded: bool = False, name: str = "Unknown") -> None:
"""
- :param world: Multiworld to be filled.
+ :param multiworld: Multiworld to be filled.
:param base_state: State assumed before fill.
:param locations: Locations to be filled with item_pool
:param item_pool: Items to fill into the locations
@@ -68,7 +68,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
maximum_exploration_state = sweep_from_pool(
base_state, item_pool + unplaced_items)
- has_beaten_game = world.has_beaten_game(maximum_exploration_state)
+ has_beaten_game = multiworld.has_beaten_game(maximum_exploration_state)
while items_to_place:
# if we have run out of locations to fill,break out of this loop
@@ -80,8 +80,8 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
spot_to_fill: typing.Optional[Location] = None
# if minimal accessibility, only check whether location is reachable if game not beatable
- if world.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
- perform_access_check = not world.has_beaten_game(maximum_exploration_state,
+ if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
+ perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state,
item_to_place.player) \
if single_player_placement else not has_beaten_game
else:
@@ -122,11 +122,11 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
# Verify placing this item won't reduce available locations, which would be a useless swap.
prev_state = swap_state.copy()
prev_loc_count = len(
- world.get_reachable_locations(prev_state))
+ multiworld.get_reachable_locations(prev_state))
swap_state.collect(item_to_place, True)
new_loc_count = len(
- world.get_reachable_locations(swap_state))
+ multiworld.get_reachable_locations(swap_state))
if new_loc_count >= prev_loc_count:
# Add this item to the existing placement, and
@@ -156,7 +156,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
else:
unplaced_items.append(item_to_place)
continue
- world.push_item(spot_to_fill, item_to_place, False)
+ multiworld.push_item(spot_to_fill, item_to_place, False)
spot_to_fill.locked = lock
placements.append(spot_to_fill)
spot_to_fill.event = item_to_place.advancement
@@ -173,7 +173,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
# validate all placements and remove invalid ones
state = sweep_from_pool(base_state, [])
for placement in placements:
- if world.accessibility[placement.item.player] != "minimal" and not placement.can_reach(state):
+ if multiworld.worlds[placement.item.player].options.accessibility != "minimal" and not placement.can_reach(state):
placement.item.location = None
unplaced_items.append(placement.item)
placement.item = None
@@ -188,7 +188,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
if excluded_locations:
for location in excluded_locations:
location.progress_type = location.progress_type.DEFAULT
- fill_restrictive(world, base_state, excluded_locations, unplaced_items, single_player_placement, lock,
+ fill_restrictive(multiworld, base_state, excluded_locations, unplaced_items, single_player_placement, lock,
swap, on_place, allow_partial, False)
for location in excluded_locations:
if not location.item:
@@ -196,7 +196,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
if not allow_partial and len(unplaced_items) > 0 and len(locations) > 0:
# There are leftover unplaceable items and locations that won't accept them
- if world.can_beat_game():
+ if multiworld.can_beat_game():
logging.warning(
f'Not all items placed. Game beatable anyway. (Could not place {unplaced_items})')
else:
@@ -206,9 +206,10 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
item_pool.extend(unplaced_items)
-def remaining_fill(world: MultiWorld,
+def remaining_fill(multiworld: MultiWorld,
locations: typing.List[Location],
- itempool: typing.List[Item]) -> None:
+ itempool: typing.List[Item],
+ name: str = "Remaining") -> None:
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
@@ -261,14 +262,14 @@ def remaining_fill(world: MultiWorld,
unplaced_items.append(item_to_place)
continue
- world.push_item(spot_to_fill, item_to_place, False)
+ multiworld.push_item(spot_to_fill, item_to_place, False)
placements.append(spot_to_fill)
placed += 1
if not placed % 1000:
- _log_fill_progress("Remaining", placed, total)
+ _log_fill_progress(name, placed, total)
if total > 1000:
- _log_fill_progress("Remaining", placed, total)
+ _log_fill_progress(name, placed, total)
if unplaced_items and locations:
# There are leftover unplaceable items and locations that won't accept them
@@ -278,19 +279,19 @@ def remaining_fill(world: MultiWorld,
itempool.extend(unplaced_items)
-def fast_fill(world: MultiWorld,
+def fast_fill(multiworld: MultiWorld,
item_pool: typing.List[Item],
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
placing = min(len(item_pool), len(fill_locations))
for item, location in zip(item_pool, fill_locations):
- world.push_item(location, item, False)
+ multiworld.push_item(location, item, False)
return item_pool[placing:], fill_locations[placing:]
-def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]):
+def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]):
maximum_exploration_state = sweep_from_pool(state, pool)
- minimal_players = {player for player in world.player_ids if world.worlds[player].options.accessibility == "minimal"}
- unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and
+ minimal_players = {player for player in multiworld.player_ids if multiworld.worlds[player].options.accessibility == "minimal"}
+ unreachable_locations = [location for location in multiworld.get_locations() if location.player in minimal_players and
not location.can_reach(maximum_exploration_state)]
for location in unreachable_locations:
if (location.item is not None and location.item.advancement and location.address is not None and not
@@ -304,36 +305,36 @@ def accessibility_corrections(world: MultiWorld, state: CollectionState, locatio
locations.append(location)
if pool and locations:
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
- fill_restrictive(world, state, locations, pool, name="Accessibility Corrections")
+ fill_restrictive(multiworld, state, locations, pool, name="Accessibility Corrections")
-def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations):
+def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState, locations):
maximum_exploration_state = sweep_from_pool(state)
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
if unreachable_locations:
def forbid_important_item_rule(item: Item):
- return not ((item.classification & 0b0011) and world.worlds[item.player].options.accessibility != 'minimal')
+ return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != 'minimal')
for location in unreachable_locations:
add_item_rule(location, forbid_important_item_rule)
-def distribute_early_items(world: MultiWorld,
+def distribute_early_items(multiworld: MultiWorld,
fill_locations: typing.List[Location],
itempool: typing.List[Item]) -> typing.Tuple[typing.List[Location], typing.List[Item]]:
""" returns new fill_locations and itempool """
early_items_count: typing.Dict[typing.Tuple[str, int], typing.List[int]] = {}
- for player in world.player_ids:
- items = itertools.chain(world.early_items[player], world.local_early_items[player])
+ for player in multiworld.player_ids:
+ items = itertools.chain(multiworld.early_items[player], multiworld.local_early_items[player])
for item in items:
- early_items_count[item, player] = [world.early_items[player].get(item, 0),
- world.local_early_items[player].get(item, 0)]
+ early_items_count[item, player] = [multiworld.early_items[player].get(item, 0),
+ multiworld.local_early_items[player].get(item, 0)]
if early_items_count:
early_locations: typing.List[Location] = []
early_priority_locations: typing.List[Location] = []
loc_indexes_to_remove: typing.Set[int] = set()
- base_state = world.state.copy()
- base_state.sweep_for_events(locations=(loc for loc in world.get_filled_locations() if loc.address is None))
+ base_state = multiworld.state.copy()
+ base_state.sweep_for_events(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None))
for i, loc in enumerate(fill_locations):
if loc.can_reach(base_state):
if loc.progress_type == LocationProgressType.PRIORITY:
@@ -345,8 +346,8 @@ def distribute_early_items(world: MultiWorld,
early_prog_items: typing.List[Item] = []
early_rest_items: typing.List[Item] = []
- early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
- early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
+ early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in multiworld.player_ids}
+ early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in multiworld.player_ids}
item_indexes_to_remove: typing.Set[int] = set()
for i, item in enumerate(itempool):
if (item.name, item.player) in early_items_count:
@@ -370,28 +371,28 @@ def distribute_early_items(world: MultiWorld,
if len(early_items_count) == 0:
break
itempool = [item for i, item in enumerate(itempool) if i not in item_indexes_to_remove]
- for player in world.player_ids:
+ for player in multiworld.player_ids:
player_local = early_local_rest_items[player]
- fill_restrictive(world, base_state,
+ fill_restrictive(multiworld, base_state,
[loc for loc in early_locations if loc.player == player],
player_local, lock=True, allow_partial=True, name=f"Local Early Items P{player}")
if player_local:
logging.warning(f"Could not fulfill rules of early items: {player_local}")
early_rest_items.extend(early_local_rest_items[player])
early_locations = [loc for loc in early_locations if not loc.item]
- fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True, allow_partial=True,
+ fill_restrictive(multiworld, base_state, early_locations, early_rest_items, lock=True, allow_partial=True,
name="Early Items")
early_locations += early_priority_locations
- for player in world.player_ids:
+ for player in multiworld.player_ids:
player_local = early_local_prog_items[player]
- fill_restrictive(world, base_state,
+ fill_restrictive(multiworld, base_state,
[loc for loc in early_locations if loc.player == player],
player_local, lock=True, allow_partial=True, name=f"Local Early Progression P{player}")
if player_local:
logging.warning(f"Could not fulfill rules of early items: {player_local}")
early_prog_items.extend(player_local)
early_locations = [loc for loc in early_locations if not loc.item]
- fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True, allow_partial=True,
+ fill_restrictive(multiworld, base_state, early_locations, early_prog_items, lock=True, allow_partial=True,
name="Early Progression")
unplaced_early_items = early_rest_items + early_prog_items
if unplaced_early_items:
@@ -400,18 +401,18 @@ def distribute_early_items(world: MultiWorld,
itempool += unplaced_early_items
fill_locations.extend(early_locations)
- world.random.shuffle(fill_locations)
+ multiworld.random.shuffle(fill_locations)
return fill_locations, itempool
-def distribute_items_restrictive(world: MultiWorld) -> None:
- fill_locations = sorted(world.get_unfilled_locations())
- world.random.shuffle(fill_locations)
+def distribute_items_restrictive(multiworld: MultiWorld) -> None:
+ fill_locations = sorted(multiworld.get_unfilled_locations())
+ multiworld.random.shuffle(fill_locations)
# get items to distribute
- itempool = sorted(world.itempool)
- world.random.shuffle(itempool)
+ itempool = sorted(multiworld.itempool)
+ multiworld.random.shuffle(itempool)
- fill_locations, itempool = distribute_early_items(world, fill_locations, itempool)
+ fill_locations, itempool = distribute_early_items(multiworld, fill_locations, itempool)
progitempool: typing.List[Item] = []
usefulitempool: typing.List[Item] = []
@@ -425,7 +426,7 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
else:
filleritempool.append(item)
- call_all(world, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations)
+ call_all(multiworld, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations)
locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
loc_type: [] for loc_type in LocationProgressType}
@@ -446,34 +447,34 @@ def mark_for_locking(location: Location):
if prioritylocations:
# "priority fill"
- fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking,
+ fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking,
name="Priority")
- accessibility_corrections(world, world.state, prioritylocations, progitempool)
+ accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations
if progitempool:
# "advancement/progression fill"
- fill_restrictive(world, world.state, defaultlocations, progitempool, name="Progression")
+ fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, name="Progression")
if progitempool:
raise FillError(
f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
- accessibility_corrections(world, world.state, defaultlocations)
+ accessibility_corrections(multiworld, multiworld.state, defaultlocations)
for location in lock_later:
if location.item:
location.locked = True
del mark_for_locking, lock_later
- inaccessible_location_rules(world, world.state, defaultlocations)
+ inaccessible_location_rules(multiworld, multiworld.state, defaultlocations)
- remaining_fill(world, excludedlocations, filleritempool)
+ remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded")
if excludedlocations:
raise FillError(
f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items")
restitempool = filleritempool + usefulitempool
- remaining_fill(world, defaultlocations, restitempool)
+ remaining_fill(multiworld, defaultlocations, restitempool)
unplaced = restitempool
unfilled = defaultlocations
@@ -481,40 +482,40 @@ def mark_for_locking(location: Location):
if unplaced or unfilled:
logging.warning(
f'Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}')
- items_counter = Counter(location.item.player for location in world.get_locations() if location.item)
- locations_counter = Counter(location.player for location in world.get_locations())
+ items_counter = Counter(location.item.player for location in multiworld.get_locations() if location.item)
+ locations_counter = Counter(location.player for location in multiworld.get_locations())
items_counter.update(item.player for item in unplaced)
locations_counter.update(location.player for location in unfilled)
print_data = {"items": items_counter, "locations": locations_counter}
logging.info(f'Per-Player counts: {print_data})')
-def flood_items(world: MultiWorld) -> None:
+def flood_items(multiworld: MultiWorld) -> None:
# get items to distribute
- world.random.shuffle(world.itempool)
- itempool = world.itempool
+ multiworld.random.shuffle(multiworld.itempool)
+ itempool = multiworld.itempool
progress_done = False
# sweep once to pick up preplaced items
- world.state.sweep_for_events()
+ multiworld.state.sweep_for_events()
- # fill world from top of itempool while we can
+ # fill multiworld from top of itempool while we can
while not progress_done:
- location_list = world.get_unfilled_locations()
- world.random.shuffle(location_list)
+ location_list = multiworld.get_unfilled_locations()
+ multiworld.random.shuffle(location_list)
spot_to_fill = None
for location in location_list:
- if location.can_fill(world.state, itempool[0]):
+ if location.can_fill(multiworld.state, itempool[0]):
spot_to_fill = location
break
if spot_to_fill:
item = itempool.pop(0)
- world.push_item(spot_to_fill, item, True)
+ multiworld.push_item(spot_to_fill, item, True)
continue
# ran out of spots, check if we need to step in and correct things
- if len(world.get_reachable_locations()) == len(world.get_locations()):
+ if len(multiworld.get_reachable_locations()) == len(multiworld.get_locations()):
progress_done = True
continue
@@ -524,7 +525,7 @@ def flood_items(world: MultiWorld) -> None:
for item in itempool:
if item.advancement:
candidate_item_to_place = item
- if world.unlocks_new_location(item):
+ if multiworld.unlocks_new_location(item):
item_to_place = item
break
@@ -537,20 +538,20 @@ def flood_items(world: MultiWorld) -> None:
raise FillError('No more progress items left to place.')
# find item to replace with progress item
- location_list = world.get_reachable_locations()
- world.random.shuffle(location_list)
+ location_list = multiworld.get_reachable_locations()
+ multiworld.random.shuffle(location_list)
for location in location_list:
if location.item is not None and not location.item.advancement:
# safe to replace
replace_item = location.item
replace_item.location = None
itempool.append(replace_item)
- world.push_item(location, item_to_place, True)
+ multiworld.push_item(location, item_to_place, True)
itempool.remove(item_to_place)
break
-def balance_multiworld_progression(world: MultiWorld) -> None:
+def balance_multiworld_progression(multiworld: MultiWorld) -> None:
# A system to reduce situations where players have no checks remaining, popularly known as "BK mode."
# Overall progression balancing algorithm:
# Gather up all locations in a sphere.
@@ -558,28 +559,28 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
# If other players are below the threshold value, swap progression in this sphere into earlier spheres,
# which gives more locations available by this sphere.
balanceable_players: typing.Dict[int, float] = {
- player: world.worlds[player].options.progression_balancing / 100
- for player in world.player_ids
- if world.worlds[player].options.progression_balancing > 0
+ player: multiworld.worlds[player].options.progression_balancing / 100
+ for player in multiworld.player_ids
+ if multiworld.worlds[player].options.progression_balancing > 0
}
if not balanceable_players:
logging.info('Skipping multiworld progression balancing.')
else:
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
logging.debug(balanceable_players)
- state: CollectionState = CollectionState(world)
+ state: CollectionState = CollectionState(multiworld)
checked_locations: typing.Set[Location] = set()
- unchecked_locations: typing.Set[Location] = set(world.get_locations())
+ unchecked_locations: typing.Set[Location] = set(multiworld.get_locations())
total_locations_count: typing.Counter[int] = Counter(
location.player
- for location in world.get_locations()
+ for location in multiworld.get_locations()
if not location.locked
)
reachable_locations_count: typing.Dict[int, int] = {
player: 0
- for player in world.player_ids
- if total_locations_count[player] and len(world.get_filled_locations(player)) != 0
+ for player in multiworld.player_ids
+ if total_locations_count[player] and len(multiworld.get_filled_locations(player)) != 0
}
balanceable_players = {
player: balanceable_players[player]
@@ -658,7 +659,7 @@ def item_percentage(player: int, num: int) -> float:
balancing_unchecked_locations.remove(location)
if not location.locked:
balancing_reachables[location.player] += 1
- if world.has_beaten_game(balancing_state) or all(
+ if multiworld.has_beaten_game(balancing_state) or all(
item_percentage(player, reachables) >= threshold_percentages[player]
for player, reachables in balancing_reachables.items()
if player in threshold_percentages):
@@ -675,7 +676,7 @@ def item_percentage(player: int, num: int) -> float:
locations_to_test = unlocked_locations[player]
items_to_test = list(candidate_items[player])
items_to_test.sort()
- world.random.shuffle(items_to_test)
+ multiworld.random.shuffle(items_to_test)
while items_to_test:
testing = items_to_test.pop()
reducing_state = state.copy()
@@ -687,8 +688,8 @@ def item_percentage(player: int, num: int) -> float:
reducing_state.sweep_for_events(locations=locations_to_test)
- if world.has_beaten_game(balancing_state):
- if not world.has_beaten_game(reducing_state):
+ if multiworld.has_beaten_game(balancing_state):
+ if not multiworld.has_beaten_game(reducing_state):
items_to_replace.append(testing)
else:
reduced_sphere = get_sphere_locations(reducing_state, locations_to_test)
@@ -696,33 +697,32 @@ def item_percentage(player: int, num: int) -> float:
if p < threshold_percentages[player]:
items_to_replace.append(testing)
- replaced_items = False
+ old_moved_item_count = moved_item_count
# sort then shuffle to maintain deterministic behaviour,
# while allowing use of set for better algorithm growth behaviour elsewhere
replacement_locations = sorted(l for l in checked_locations if not l.event and not l.locked)
- world.random.shuffle(replacement_locations)
+ multiworld.random.shuffle(replacement_locations)
items_to_replace.sort()
- world.random.shuffle(items_to_replace)
+ multiworld.random.shuffle(items_to_replace)
# Start swapping items. Since we swap into earlier spheres, no need for accessibility checks.
while replacement_locations and items_to_replace:
old_location = items_to_replace.pop()
- for new_location in replacement_locations:
+ for i, new_location in enumerate(replacement_locations):
if new_location.can_fill(state, old_location.item, False) and \
old_location.can_fill(state, new_location.item, False):
- replacement_locations.remove(new_location)
+ replacement_locations.pop(i)
swap_location_item(old_location, new_location)
logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, "
f"displacing {old_location.item} into {old_location}")
moved_item_count += 1
state.collect(new_location.item, True, new_location)
- replaced_items = True
break
else:
logging.warning(f"Could not Progression Balance {old_location.item}")
- if replaced_items:
+ if old_moved_item_count < moved_item_count:
logging.debug(f"Moved {moved_item_count} items so far\n")
unlocked = {fresh for player in balancing_players for fresh in unlocked_locations[player]}
for location in get_sphere_locations(state, unlocked):
@@ -736,7 +736,7 @@ def item_percentage(player: int, num: int) -> float:
state.collect(location.item, True, location)
checked_locations |= sphere_locations
- if world.has_beaten_game(state):
+ if multiworld.has_beaten_game(state):
break
elif not sphere_locations:
logging.warning("Progression Balancing ran out of paths.")
@@ -756,7 +756,7 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked:
location_1.event, location_2.event = location_2.event, location_1.event
-def distribute_planned(world: MultiWorld) -> None:
+def distribute_planned(multiworld: MultiWorld) -> None:
def warn(warning: str, force: typing.Union[bool, str]) -> None:
if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']:
logging.warning(f'{warning}')
@@ -769,24 +769,24 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None:
else:
warn(warning, force)
- swept_state = world.state.copy()
+ swept_state = multiworld.state.copy()
swept_state.sweep_for_events()
- reachable = frozenset(world.get_reachable_locations(swept_state))
+ reachable = frozenset(multiworld.get_reachable_locations(swept_state))
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
- for loc in world.get_unfilled_locations():
+ for loc in multiworld.get_unfilled_locations():
if loc in reachable:
early_locations[loc.player].append(loc.name)
else: # not reachable with swept state
non_early_locations[loc.player].append(loc.name)
- world_name_lookup = world.world_name_lookup
+ world_name_lookup = multiworld.world_name_lookup
block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
plando_blocks: typing.List[typing.Dict[str, typing.Any]] = []
- player_ids = set(world.player_ids)
+ player_ids = set(multiworld.player_ids)
for player in player_ids:
- for block in world.plando_items[player]:
+ for block in multiworld.plando_items[player]:
block['player'] = player
if 'force' not in block:
block['force'] = 'silent'
@@ -800,12 +800,12 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None:
else:
target_world = block['world']
- if target_world is False or world.players == 1: # target own world
+ if target_world is False or multiworld.players == 1: # target own world
worlds: typing.Set[int] = {player}
elif target_world is True: # target any worlds besides own
- worlds = set(world.player_ids) - {player}
+ worlds = set(multiworld.player_ids) - {player}
elif target_world is None: # target all worlds
- worlds = set(world.player_ids)
+ worlds = set(multiworld.player_ids)
elif type(target_world) == list: # list of target worlds
worlds = set()
for listed_world in target_world:
@@ -815,9 +815,9 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None:
continue
worlds.add(world_name_lookup[listed_world])
elif type(target_world) == int: # target world by slot number
- if target_world not in range(1, world.players + 1):
+ if target_world not in range(1, multiworld.players + 1):
failed(
- f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
+ f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
block['force'])
continue
worlds = {target_world}
@@ -845,7 +845,7 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None:
item_list: typing.List[str] = []
for key, value in items.items():
if value is True:
- value = world.itempool.count(world.worlds[player].create_item(key))
+ value = multiworld.itempool.count(multiworld.worlds[player].create_item(key))
item_list += [key] * value
items = item_list
if isinstance(items, str):
@@ -895,17 +895,17 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None:
count = block['count']
failed(f"Plando count {count} greater than locations specified", block['force'])
block['count'] = len(block['locations'])
- block['count']['target'] = world.random.randint(block['count']['min'], block['count']['max'])
+ block['count']['target'] = multiworld.random.randint(block['count']['min'], block['count']['max'])
if block['count']['target'] > 0:
plando_blocks.append(block)
# shuffle, but then sort blocks by number of locations minus number of items,
# so less-flexible blocks get priority
- world.random.shuffle(plando_blocks)
+ multiworld.random.shuffle(plando_blocks)
plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target']
if len(block['locations']) > 0
- else len(world.get_unfilled_locations(player)) - block['count']['target']))
+ else len(multiworld.get_unfilled_locations(player)) - block['count']['target']))
for placement in plando_blocks:
player = placement['player']
@@ -916,19 +916,19 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None:
maxcount = placement['count']['target']
from_pool = placement['from_pool']
- candidates = list(world.get_unfilled_locations_for_players(locations, sorted(worlds)))
- world.random.shuffle(candidates)
- world.random.shuffle(items)
+ candidates = list(multiworld.get_unfilled_locations_for_players(locations, sorted(worlds)))
+ multiworld.random.shuffle(candidates)
+ multiworld.random.shuffle(items)
count = 0
err: typing.List[str] = []
successful_pairs: typing.List[typing.Tuple[Item, Location]] = []
for item_name in items:
- item = world.worlds[player].create_item(item_name)
+ item = multiworld.worlds[player].create_item(item_name)
for location in reversed(candidates):
if (location.address is None) == (item.code is None): # either both None or both not None
if not location.item:
if location.item_rule(item):
- if location.can_fill(world.state, item, False):
+ if location.can_fill(multiworld.state, item, False):
successful_pairs.append((item, location))
candidates.remove(location)
count = count + 1
@@ -946,21 +946,21 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None:
if count < placement['count']['min']:
m = placement['count']['min']
failed(
- f"Plando block failed to place {m - count} of {m} item(s) for {world.player_name[player]}, error(s): {' '.join(err)}",
+ f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}",
placement['force'])
for (item, location) in successful_pairs:
- world.push_item(location, item, collect=False)
+ multiworld.push_item(location, item, collect=False)
location.event = True # flag location to be checked during fill
location.locked = True
logging.debug(f"Plando placed {item} at {location}")
if from_pool:
try:
- world.itempool.remove(item)
+ multiworld.itempool.remove(item)
except ValueError:
warn(
- f"Could not remove {item} from pool for {world.player_name[player]} as it's already missing from it.",
+ f"Could not remove {item} from pool for {multiworld.player_name[player]} as it's already missing from it.",
placement['force'])
except Exception as e:
raise Exception(
- f"Error running plando for player {player} ({world.player_name[player]})") from e
+ f"Error running plando for player {player} ({multiworld.player_name[player]})") from e
diff --git a/Generate.py b/Generate.py
index e19a7a973f23..ecdc81833a15 100644
--- a/Generate.py
+++ b/Generate.py
@@ -302,7 +302,9 @@ def handle_name(name: str, player: int, name_counter: Counter):
NUMBER=(number if number > 1 else ''),
player=player,
PLAYER=(player if player > 1 else '')))
- new_name = new_name.strip()[:16]
+ # Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace.
+ # Could cause issues for some clients that cannot handle the additional whitespace.
+ new_name = new_name.strip()[:16].strip()
if new_name == "Archipelago":
raise Exception(f"You cannot name yourself \"{new_name}\"")
return new_name
@@ -315,20 +317,6 @@ def prefer_int(input_data: str) -> Union[str, int]:
return input_data
-goals = {
- 'ganon': 'ganon',
- 'crystals': 'crystals',
- 'bosses': 'bosses',
- 'pedestal': 'pedestal',
- 'ganon_pedestal': 'ganonpedestal',
- 'triforce_hunt': 'triforcehunt',
- 'local_triforce_hunt': 'localtriforcehunt',
- 'ganon_triforce_hunt': 'ganontriforcehunt',
- 'local_ganon_triforce_hunt': 'localganontriforcehunt',
- 'ice_rod_hunt': 'icerodhunt',
-}
-
-
def roll_percentage(percentage: Union[int, float]) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
@@ -357,15 +345,6 @@ def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
if options[option_key].supports_weighting:
return get_choice(option_key, category_dict)
return category_dict[option_key]
- if game == "A Link to the Past": # TODO wow i hate this
- if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode",
- "triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra",
- "triforce_pieces_required", "shop_shuffle", "mode", "item_pool", "item_functionality",
- "boss_shuffle", "enemy_damage", "enemy_health", "timer", "countdown_start_time",
- "red_clock_time", "blue_clock_time", "green_clock_time", "dungeon_counters", "shuffle_prizes",
- "misery_mire_medallion", "turtle_rock_medallion", "sprite_pool", "sprite",
- "random_sprite_on_event"}:
- return get_choice(option_key, category_dict)
raise Exception(f"Error generating meta option {option_key} for {game}.")
@@ -485,120 +464,23 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
handle_option(ret, game_weights, option_key, option, plando_options)
if PlandoOptions.items in plando_options:
ret.plando_items = game_weights.get("plando_items", [])
- if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
- # bad hardcoded behavior to make this work for now
- ret.plando_connections = []
- if PlandoOptions.connections in plando_options:
- options = game_weights.get("plando_connections", [])
- for placement in options:
- if roll_percentage(get_choice("percentage", placement, 100)):
- ret.plando_connections.append(PlandoConnection(
- get_choice("entrance", placement),
- get_choice("exit", placement),
- get_choice("direction", placement)
- ))
- elif ret.game == "A Link to the Past":
+ if ret.game == "A Link to the Past":
roll_alttp_settings(ret, game_weights, plando_options)
+ if PlandoOptions.connections in plando_options:
+ ret.plando_connections = []
+ options = game_weights.get("plando_connections", [])
+ for placement in options:
+ if roll_percentage(get_choice("percentage", placement, 100)):
+ ret.plando_connections.append(PlandoConnection(
+ get_choice("entrance", placement),
+ get_choice("exit", placement),
+ get_choice("direction", placement, "both")
+ ))
return ret
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
- if "dungeon_items" in weights and get_choice_legacy('dungeon_items', weights, "none") != "none":
- raise Exception(f"dungeon_items key in A Link to the Past was removed, but is present in these weights as {get_choice_legacy('dungeon_items', weights, False)}.")
- glitches_required = get_choice_legacy('glitches_required', weights)
- if glitches_required not in [None, 'none', 'no_logic', 'overworld_glitches', 'hybrid_major_glitches', 'minor_glitches']:
- logging.warning("Only NMG, OWG, HMG and No Logic supported")
- glitches_required = 'none'
- ret.logic = {None: 'noglitches', 'none': 'noglitches', 'no_logic': 'nologic', 'overworld_glitches': 'owglitches',
- 'minor_glitches': 'minorglitches', 'hybrid_major_glitches': 'hybridglitches'}[
- glitches_required]
-
- ret.dark_room_logic = get_choice_legacy("dark_room_logic", weights, "lamp")
- if not ret.dark_room_logic: # None/False
- ret.dark_room_logic = "none"
- if ret.dark_room_logic == "sconces":
- ret.dark_room_logic = "torches"
- if ret.dark_room_logic not in {"lamp", "torches", "none"}:
- raise ValueError(f"Unknown Dark Room Logic: \"{ret.dark_room_logic}\"")
-
- entrance_shuffle = get_choice_legacy('entrance_shuffle', weights, 'vanilla')
- if entrance_shuffle.startswith('none-'):
- ret.shuffle = 'vanilla'
- else:
- ret.shuffle = entrance_shuffle if entrance_shuffle != 'none' else 'vanilla'
-
- goal = get_choice_legacy('goals', weights, 'ganon')
-
- ret.goal = goals[goal]
-
-
- extra_pieces = get_choice_legacy('triforce_pieces_mode', weights, 'available')
-
- ret.triforce_pieces_required = LttPOptions.TriforcePieces.from_any(get_choice_legacy('triforce_pieces_required', weights, 20))
-
- # sum a percentage to required
- if extra_pieces == 'percentage':
- percentage = max(100, float(get_choice_legacy('triforce_pieces_percentage', weights, 150))) / 100
- ret.triforce_pieces_available = int(round(ret.triforce_pieces_required * percentage, 0))
- # vanilla mode (specify how many pieces are)
- elif extra_pieces == 'available':
- ret.triforce_pieces_available = LttPOptions.TriforcePieces.from_any(
- get_choice_legacy('triforce_pieces_available', weights, 30))
- # required pieces + fixed extra
- elif extra_pieces == 'extra':
- extra_pieces = max(0, int(get_choice_legacy('triforce_pieces_extra', weights, 10)))
- ret.triforce_pieces_available = ret.triforce_pieces_required + extra_pieces
-
- # change minimum to required pieces to avoid problems
- ret.triforce_pieces_available = min(max(ret.triforce_pieces_required, int(ret.triforce_pieces_available)), 90)
-
- ret.shop_shuffle = get_choice_legacy('shop_shuffle', weights, '')
- if not ret.shop_shuffle:
- ret.shop_shuffle = ''
-
- ret.mode = get_choice_legacy("mode", weights)
-
- ret.difficulty = get_choice_legacy('item_pool', weights)
-
- ret.item_functionality = get_choice_legacy('item_functionality', weights)
-
-
- ret.enemy_damage = {None: 'default',
- 'default': 'default',
- 'shuffled': 'shuffled',
- 'random': 'chaos', # to be removed
- 'chaos': 'chaos',
- }[get_choice_legacy('enemy_damage', weights)]
-
- ret.enemy_health = get_choice_legacy('enemy_health', weights)
-
- ret.timer = {'none': False,
- None: False,
- False: False,
- 'timed': 'timed',
- 'timed_ohko': 'timed-ohko',
- 'ohko': 'ohko',
- 'timed_countdown': 'timed-countdown',
- 'display': 'display'}[get_choice_legacy('timer', weights, False)]
-
- ret.countdown_start_time = int(get_choice_legacy('countdown_start_time', weights, 10))
- ret.red_clock_time = int(get_choice_legacy('red_clock_time', weights, -2))
- ret.blue_clock_time = int(get_choice_legacy('blue_clock_time', weights, 2))
- ret.green_clock_time = int(get_choice_legacy('green_clock_time', weights, 4))
-
- ret.dungeon_counters = get_choice_legacy('dungeon_counters', weights, 'default')
-
- ret.shuffle_prizes = get_choice_legacy('shuffle_prizes', weights, "g")
-
- ret.required_medallions = [get_choice_legacy("misery_mire_medallion", weights, "random"),
- get_choice_legacy("turtle_rock_medallion", weights, "random")]
-
- for index, medallion in enumerate(ret.required_medallions):
- ret.required_medallions[index] = {"ether": "Ether", "quake": "Quake", "bombos": "Bombos", "random": "random"} \
- .get(medallion.lower(), None)
- if not ret.required_medallions[index]:
- raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
ret.plando_texts = {}
if PlandoOptions.texts in plando_options:
@@ -612,17 +494,6 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
raise Exception(f"No text target \"{at}\" found.")
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
- ret.plando_connections = []
- if PlandoOptions.connections in plando_options:
- options = weights.get("plando_connections", [])
- for placement in options:
- if roll_percentage(get_choice_legacy("percentage", placement, 100)):
- ret.plando_connections.append(PlandoConnection(
- get_choice_legacy("entrance", placement),
- get_choice_legacy("exit", placement),
- get_choice_legacy("direction", placement, "both")
- ))
-
ret.sprite_pool = weights.get('sprite_pool', [])
ret.sprite = get_choice_legacy('sprite', weights, "Link")
if 'random_sprite_on_event' in weights:
diff --git a/Launcher.py b/Launcher.py
index 9e184bf1088d..890957958391 100644
--- a/Launcher.py
+++ b/Launcher.py
@@ -161,7 +161,7 @@ def launch(exe, in_terminal=False):
def run_gui():
- from kvui import App, ContainerLayout, GridLayout, Button, Label
+ from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget
from kivy.uix.image import AsyncImage
from kivy.uix.relativelayout import RelativeLayout
@@ -185,11 +185,16 @@ def build(self):
self.container = ContainerLayout()
self.grid = GridLayout(cols=2)
self.container.add_widget(self.grid)
- self.grid.add_widget(Label(text="General"))
- self.grid.add_widget(Label(text="Clients"))
- button_layout = self.grid # make buttons fill the window
-
- def build_button(component: Component):
+ self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
+ self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
+ tool_layout = ScrollBox()
+ tool_layout.layout.orientation = "vertical"
+ self.grid.add_widget(tool_layout)
+ client_layout = ScrollBox()
+ client_layout.layout.orientation = "vertical"
+ self.grid.add_widget(client_layout)
+
+ def build_button(component: Component) -> Widget:
"""
Builds a button widget for a given component.
@@ -200,31 +205,26 @@ def build_button(component: Component):
None. The button is added to the parent grid layout.
"""
- button = Button(text=component.display_name)
+ button = Button(text=component.display_name, size_hint_y=None, height=40)
button.component = component
button.bind(on_release=self.component_action)
if component.icon != "icon":
image = AsyncImage(source=icon_paths[component.icon],
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
- box_layout = RelativeLayout()
+ box_layout = RelativeLayout(size_hint_y=None, height=40)
box_layout.add_widget(button)
box_layout.add_widget(image)
- button_layout.add_widget(box_layout)
- else:
- button_layout.add_widget(button)
+ return box_layout
+ return button
for (tool, client) in itertools.zip_longest(itertools.chain(
self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()):
# column 1
if tool:
- build_button(tool[1])
- else:
- button_layout.add_widget(Label())
+ tool_layout.layout.add_widget(build_button(tool[1]))
# column 2
if client:
- build_button(client[1])
- else:
- button_layout.add_widget(Label())
+ client_layout.layout.add_widget(build_button(client[1]))
return self.container
diff --git a/LinksAwakeningClient.py b/LinksAwakeningClient.py
index f3fc9d2cdb72..a51645feac92 100644
--- a/LinksAwakeningClient.py
+++ b/LinksAwakeningClient.py
@@ -348,7 +348,8 @@ async def wait_for_retroarch_connection(self):
await asyncio.sleep(1.0)
continue
self.stop_bizhawk_spam = False
- logger.info(f"Connected to Retroarch {version.decode('ascii')} running {rom_name.decode('ascii')}")
+ logger.info(f"Connected to Retroarch {version.decode('ascii', errors='replace')} "
+ f"running {rom_name.decode('ascii', errors='replace')}")
return
except (BlockingIOError, TimeoutError, ConnectionResetError):
await asyncio.sleep(1.0)
diff --git a/Main.py b/Main.py
index b64650478bfe..f1d2f63692d6 100644
--- a/Main.py
+++ b/Main.py
@@ -30,49 +30,49 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
output_path.cached_path = args.outputpath
start = time.perf_counter()
- # initialize the world
- world = MultiWorld(args.multi)
+ # initialize the multiworld
+ multiworld = MultiWorld(args.multi)
logger = logging.getLogger()
- world.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
- world.plando_options = args.plando_options
-
- world.shuffle = args.shuffle.copy()
- world.logic = args.logic.copy()
- world.mode = args.mode.copy()
- world.difficulty = args.difficulty.copy()
- world.item_functionality = args.item_functionality.copy()
- world.timer = args.timer.copy()
- world.goal = args.goal.copy()
- world.boss_shuffle = args.shufflebosses.copy()
- world.enemy_health = args.enemy_health.copy()
- world.enemy_damage = args.enemy_damage.copy()
- world.beemizer_total_chance = args.beemizer_total_chance.copy()
- world.beemizer_trap_chance = args.beemizer_trap_chance.copy()
- world.countdown_start_time = args.countdown_start_time.copy()
- world.red_clock_time = args.red_clock_time.copy()
- world.blue_clock_time = args.blue_clock_time.copy()
- world.green_clock_time = args.green_clock_time.copy()
- world.dungeon_counters = args.dungeon_counters.copy()
- world.triforce_pieces_available = args.triforce_pieces_available.copy()
- world.triforce_pieces_required = args.triforce_pieces_required.copy()
- world.shop_shuffle = args.shop_shuffle.copy()
- world.shuffle_prizes = args.shuffle_prizes.copy()
- world.sprite_pool = args.sprite_pool.copy()
- world.dark_room_logic = args.dark_room_logic.copy()
- world.plando_items = args.plando_items.copy()
- world.plando_texts = args.plando_texts.copy()
- world.plando_connections = args.plando_connections.copy()
- world.required_medallions = args.required_medallions.copy()
- world.game = args.game.copy()
- world.player_name = args.name.copy()
- world.sprite = args.sprite.copy()
- world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
-
- world.set_options(args)
- world.set_item_links()
- world.state = CollectionState(world)
- logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
+ multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
+ multiworld.plando_options = args.plando_options
+
+ multiworld.shuffle = args.shuffle.copy()
+ multiworld.logic = args.logic.copy()
+ multiworld.mode = args.mode.copy()
+ multiworld.difficulty = args.difficulty.copy()
+ multiworld.item_functionality = args.item_functionality.copy()
+ multiworld.timer = args.timer.copy()
+ multiworld.goal = args.goal.copy()
+ multiworld.boss_shuffle = args.shufflebosses.copy()
+ multiworld.enemy_health = args.enemy_health.copy()
+ multiworld.enemy_damage = args.enemy_damage.copy()
+ multiworld.beemizer_total_chance = args.beemizer_total_chance.copy()
+ multiworld.beemizer_trap_chance = args.beemizer_trap_chance.copy()
+ multiworld.countdown_start_time = args.countdown_start_time.copy()
+ multiworld.red_clock_time = args.red_clock_time.copy()
+ multiworld.blue_clock_time = args.blue_clock_time.copy()
+ multiworld.green_clock_time = args.green_clock_time.copy()
+ multiworld.dungeon_counters = args.dungeon_counters.copy()
+ multiworld.triforce_pieces_available = args.triforce_pieces_available.copy()
+ multiworld.triforce_pieces_required = args.triforce_pieces_required.copy()
+ multiworld.shop_shuffle = args.shop_shuffle.copy()
+ multiworld.shuffle_prizes = args.shuffle_prizes.copy()
+ multiworld.sprite_pool = args.sprite_pool.copy()
+ multiworld.dark_room_logic = args.dark_room_logic.copy()
+ multiworld.plando_items = args.plando_items.copy()
+ multiworld.plando_texts = args.plando_texts.copy()
+ multiworld.plando_connections = args.plando_connections.copy()
+ multiworld.required_medallions = args.required_medallions.copy()
+ multiworld.game = args.game.copy()
+ multiworld.player_name = args.name.copy()
+ multiworld.sprite = args.sprite.copy()
+ multiworld.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
+
+ multiworld.set_options(args)
+ multiworld.set_item_links()
+ multiworld.state = CollectionState(multiworld)
+ logger.info('Archipelago Version %s - Seed: %s\n', __version__, multiworld.seed)
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
@@ -103,76 +103,93 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
# This assertion method should not be necessary to run if we are not outputting any multidata.
if not args.skip_output:
- AutoWorld.call_stage(world, "assert_generate")
+ AutoWorld.call_stage(multiworld, "assert_generate")
- AutoWorld.call_all(world, "generate_early")
+ AutoWorld.call_all(multiworld, "generate_early")
logger.info('')
- for player in world.player_ids:
- for item_name, count in world.worlds[player].options.start_inventory.value.items():
+ for player in multiworld.player_ids:
+ for item_name, count in multiworld.worlds[player].options.start_inventory.value.items():
for _ in range(count):
- world.push_precollected(world.create_item(item_name, player))
+ multiworld.push_precollected(multiworld.create_item(item_name, player))
- for item_name, count in world.start_inventory_from_pool.setdefault(player, StartInventoryPool({})).value.items():
+ for item_name, count in getattr(multiworld.worlds[player].options,
+ "start_inventory_from_pool",
+ StartInventoryPool({})).value.items():
for _ in range(count):
- world.push_precollected(world.create_item(item_name, player))
-
- logger.info('Creating World.')
- AutoWorld.call_all(world, "create_regions")
+ multiworld.push_precollected(multiworld.create_item(item_name, player))
+ # remove from_pool items also from early items handling, as starting is plenty early.
+ early = multiworld.early_items[player].get(item_name, 0)
+ if early:
+ multiworld.early_items[player][item_name] = max(0, early-count)
+ remaining_count = count-early
+ if remaining_count > 0:
+ local_early = multiworld.early_local_items[player].get(item_name, 0)
+ if local_early:
+ multiworld.early_items[player][item_name] = max(0, local_early - remaining_count)
+ del local_early
+ del early
+
+ logger.info('Creating MultiWorld.')
+ AutoWorld.call_all(multiworld, "create_regions")
logger.info('Creating Items.')
- AutoWorld.call_all(world, "create_items")
+ AutoWorld.call_all(multiworld, "create_items")
logger.info('Calculating Access Rules.')
- for player in world.player_ids:
+ for player in multiworld.player_ids:
# items can't be both local and non-local, prefer local
- world.worlds[player].options.non_local_items.value -= world.worlds[player].options.local_items.value
- world.worlds[player].options.non_local_items.value -= set(world.local_early_items[player])
+ multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
+ multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
- AutoWorld.call_all(world, "set_rules")
+ AutoWorld.call_all(multiworld, "set_rules")
- for player in world.player_ids:
- exclusion_rules(world, player, world.worlds[player].options.exclude_locations.value)
- world.worlds[player].options.priority_locations.value -= world.worlds[player].options.exclude_locations.value
- for location_name in world.worlds[player].options.priority_locations.value:
+ 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
+ for location_name in multiworld.worlds[player].options.priority_locations.value:
try:
- location = world.get_location(location_name, player)
+ 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 world.worlds[player].location_name_to_id:
+ 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:
location.progress_type = LocationProgressType.PRIORITY
# Set local and non-local item rules.
- if world.players > 1:
- locality_rules(world)
+ if multiworld.players > 1:
+ locality_rules(multiworld)
else:
- world.worlds[1].options.non_local_items.value = set()
- world.worlds[1].options.local_items.value = set()
+ multiworld.worlds[1].options.non_local_items.value = set()
+ multiworld.worlds[1].options.local_items.value = set()
- AutoWorld.call_all(world, "generate_basic")
+ AutoWorld.call_all(multiworld, "generate_basic")
# remove starting inventory from pool items.
# Because some worlds don't actually create items during create_items this has to be as late as possible.
- if any(world.start_inventory_from_pool[player].value for player in world.player_ids):
+ if any(getattr(multiworld.worlds[player].options, "start_inventory_from_pool", None) for player in multiworld.player_ids):
new_items: List[Item] = []
depletion_pool: Dict[int, Dict[str, int]] = {
- player: world.start_inventory_from_pool[player].value.copy() for player in world.player_ids}
+ player: getattr(multiworld.worlds[player].options,
+ "start_inventory_from_pool",
+ StartInventoryPool({})).value.copy()
+ for player in multiworld.player_ids
+ }
for player, items in depletion_pool.items():
- player_world: AutoWorld.World = world.worlds[player]
+ player_world: AutoWorld.World = multiworld.worlds[player]
for count in items.values():
for _ in range(count):
new_items.append(player_world.create_filler())
target: int = sum(sum(items.values()) for items in depletion_pool.values())
- for i, item in enumerate(world.itempool):
+ for i, item in enumerate(multiworld.itempool):
if depletion_pool[item.player].get(item.name, 0):
target -= 1
depletion_pool[item.player][item.name] -= 1
# quick abort if we have found all items
if not target:
- new_items.extend(world.itempool[i+1:])
+ new_items.extend(multiworld.itempool[i+1:])
break
else:
new_items.append(item)
@@ -182,19 +199,19 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player, remaining_items in depletion_pool.items():
remaining_items = {name: count for name, count in remaining_items.items() if count}
if remaining_items:
- raise Exception(f"{world.get_player_name(player)}"
+ raise Exception(f"{multiworld.get_player_name(player)}"
f" is trying to remove items from their pool that don't exist: {remaining_items}")
- assert len(world.itempool) == len(new_items), "Item Pool amounts should not change."
- world.itempool[:] = new_items
+ 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 world.groups.items():
+ 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 world.itempool:
+ 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
@@ -229,13 +246,13 @@ def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
new_item.classification |= classifications[item_name]
new_itempool.append(new_item)
- region = Region("Menu", group_id, world, "ItemLink")
- world.regions.append(region)
+ region = Region("Menu", group_id, multiworld, "ItemLink")
+ multiworld.regions.append(region)
locations = region.locations
- for item in world.itempool:
+ 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} -> {world.player_name[item.player]} {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_)
@@ -246,10 +263,10 @@ def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
else:
new_itempool.append(item)
- itemcount = len(world.itempool)
- world.itempool = new_itempool
+ itemcount = len(multiworld.itempool)
+ multiworld.itempool = new_itempool
- while itemcount > len(world.itempool):
+ while itemcount > len(multiworld.itempool):
items_to_add = []
for player in group["players"]:
if group["link_replacement"]:
@@ -257,64 +274,64 @@ def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
else:
item_player = player
if group["replacement_items"][player]:
- items_to_add.append(AutoWorld.call_single(world, "create_item", item_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(world, "create_filler", item_player))
- world.random.shuffle(items_to_add)
- world.itempool.extend(items_to_add[:itemcount - len(world.itempool)])
+ 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)])
- if any(world.item_links.values()):
- world._all_state = None
+ if any(multiworld.item_links.values()):
+ multiworld._all_state = None
logger.info("Running Item Plando.")
- distribute_planned(world)
+ distribute_planned(multiworld)
logger.info('Running Pre Main Fill.')
- AutoWorld.call_all(world, "pre_fill")
+ AutoWorld.call_all(multiworld, "pre_fill")
- logger.info(f'Filling the world with {len(world.itempool)} items.')
+ logger.info(f'Filling the multiworld with {len(multiworld.itempool)} items.')
- if world.algorithm == 'flood':
- flood_items(world) # different algo, biased towards early game progress items
- elif world.algorithm == 'balanced':
- distribute_items_restrictive(world)
+ if multiworld.algorithm == 'flood':
+ flood_items(multiworld) # different algo, biased towards early game progress items
+ elif multiworld.algorithm == 'balanced':
+ distribute_items_restrictive(multiworld)
- AutoWorld.call_all(world, 'post_fill')
+ AutoWorld.call_all(multiworld, 'post_fill')
- if world.players > 1 and not args.skip_prog_balancing:
- balance_multiworld_progression(world)
+ if multiworld.players > 1 and not args.skip_prog_balancing:
+ balance_multiworld_progression(multiworld)
else:
logger.info("Progression balancing skipped.")
# we're about to output using multithreading, so we're removing the global random state to prevent accidental use
- world.random.passthrough = False
+ multiworld.random.passthrough = False
if args.skip_output:
logger.info('Done. Skipped output/spoiler generation. Total Time: %s', time.perf_counter() - start)
- return world
+ return multiworld
logger.info(f'Beginning output...')
- outfilebase = 'AP_' + world.seed_name
+ outfilebase = 'AP_' + multiworld.seed_name
output = tempfile.TemporaryDirectory()
with output as temp_dir:
- output_players = [player for player in world.player_ids if AutoWorld.World.generate_output.__code__
- is not world.worlds[player].generate_output.__code__]
+ output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__
+ is not multiworld.worlds[player].generate_output.__code__]
with concurrent.futures.ThreadPoolExecutor(len(output_players) + 2) as pool:
- check_accessibility_task = pool.submit(world.fulfills_accessibility)
+ check_accessibility_task = pool.submit(multiworld.fulfills_accessibility)
- output_file_futures = [pool.submit(AutoWorld.call_stage, world, "generate_output", temp_dir)]
+ output_file_futures = [pool.submit(AutoWorld.call_stage, multiworld, "generate_output", temp_dir)]
for player in output_players:
# skip starting a thread for methods that say "pass".
output_file_futures.append(
- pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
+ pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
# collect ER hint info
er_hint_data: Dict[int, Dict[int, str]] = {}
- AutoWorld.call_all(world, 'extend_hint_information', er_hint_data)
+ AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
def write_multidata():
import NetUtils
@@ -323,38 +340,38 @@ def write_multidata():
games = {}
minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions}
slot_info = {}
- names = [[name for player, name in sorted(world.player_name.items())]]
- for slot in world.player_ids:
- player_world: AutoWorld.World = world.worlds[slot]
+ names = [[name for player, name in sorted(multiworld.player_name.items())]]
+ for slot in multiworld.player_ids:
+ player_world: AutoWorld.World = multiworld.worlds[slot]
minimum_versions["server"] = max(minimum_versions["server"], player_world.required_server_version)
client_versions[slot] = player_world.required_client_version
- games[slot] = world.game[slot]
- slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], world.game[slot],
- world.player_types[slot])
- for slot, group in world.groups.items():
- games[slot] = world.game[slot]
- slot_info[slot] = NetUtils.NetworkSlot(group["name"], world.game[slot], world.player_types[slot],
+ games[slot] = multiworld.game[slot]
+ slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], multiworld.game[slot],
+ multiworld.player_types[slot])
+ for slot, group in multiworld.groups.items():
+ games[slot] = multiworld.game[slot]
+ slot_info[slot] = NetUtils.NetworkSlot(group["name"], multiworld.game[slot], multiworld.player_types[slot],
group_members=sorted(group["players"]))
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
- for player, world_precollected in world.precollected_items.items()}
- precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))}
+ for player, world_precollected in multiworld.precollected_items.items()}
+ precollected_hints = {player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))}
- for slot in world.player_ids:
- slot_data[slot] = world.worlds[slot].fill_slot_data()
+ for slot in multiworld.player_ids:
+ slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
def precollect_hint(location):
entrance = er_hint_data.get(location.player, {}).get(location.address, "")
hint = NetUtils.Hint(location.item.player, location.player, location.address,
location.item.code, False, entrance, location.item.flags)
precollected_hints[location.player].add(hint)
- if location.item.player not in world.groups:
+ if location.item.player not in multiworld.groups:
precollected_hints[location.item.player].add(hint)
else:
- for player in world.groups[location.item.player]["players"]:
+ for player in multiworld.groups[location.item.player]["players"]:
precollected_hints[player].add(hint)
- locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in world.player_ids}
- for location in world.get_filled_locations():
+ locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
+ for location in multiworld.get_filled_locations():
if type(location.address) == int:
assert location.item.code is not None, "item code None should be event, " \
"location.address should then also be None. Location: " \
@@ -364,18 +381,18 @@ def precollect_hint(location):
f"{locations_data[location.player][location.address]}")
locations_data[location.player][location.address] = \
location.item.code, location.item.player, location.item.flags
- if location.name in world.worlds[location.player].options.start_location_hints:
+ if location.name in multiworld.worlds[location.player].options.start_location_hints:
precollect_hint(location)
- elif location.item.name in world.worlds[location.item.player].options.start_hints:
+ elif location.item.name in multiworld.worlds[location.item.player].options.start_hints:
precollect_hint(location)
- elif any([location.item.name in world.worlds[player].options.start_hints
- for player in world.groups.get(location.item.player, {}).get("players", [])]):
+ elif any([location.item.name in multiworld.worlds[player].options.start_hints
+ for player in multiworld.groups.get(location.item.player, {}).get("players", [])]):
precollect_hint(location)
# embedded data package
data_package = {
game_world.game: worlds.network_data_package["games"][game_world.game]
- for game_world in world.worlds.values()
+ for game_world in multiworld.worlds.values()
}
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
@@ -383,7 +400,7 @@ def precollect_hint(location):
multidata = {
"slot_data": slot_data,
"slot_info": slot_info,
- "connect_names": {name: (0, player) for player, name in world.player_name.items()},
+ "connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
"locations": locations_data,
"checks_in_area": checks_in_area,
"server_options": baked_server_options,
@@ -393,10 +410,10 @@ def precollect_hint(location):
"version": tuple(version_tuple),
"tags": ["AP"],
"minimum_versions": minimum_versions,
- "seed_name": world.seed_name,
+ "seed_name": multiworld.seed_name,
"datapackage": data_package,
}
- AutoWorld.call_all(world, "modify_multidata", multidata)
+ AutoWorld.call_all(multiworld, "modify_multidata", multidata)
multidata = zlib.compress(pickle.dumps(multidata), 9)
@@ -406,7 +423,7 @@ def precollect_hint(location):
output_file_futures.append(pool.submit(write_multidata))
if not check_accessibility_task.result():
- if not world.can_beat_game():
+ if not multiworld.can_beat_game():
raise Exception("Game appears as unbeatable. Aborting.")
else:
logger.warning("Location Accessibility requirements not fulfilled.")
@@ -419,12 +436,12 @@ def precollect_hint(location):
if args.spoiler > 1:
logger.info('Calculating playthrough.')
- world.spoiler.create_playthrough(create_paths=args.spoiler > 2)
+ multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2)
if args.spoiler:
- world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
+ multiworld.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
- zipfilename = output_path(f"AP_{world.seed_name}.zip")
+ zipfilename = output_path(f"AP_{multiworld.seed_name}.zip")
logger.info(f"Creating final archive at {zipfilename}")
with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED,
compresslevel=9) as zf:
@@ -432,4 +449,4 @@ def precollect_hint(location):
zf.write(file.path, arcname=file.name)
logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start)
- return world
+ return multiworld
diff --git a/ModuleUpdate.py b/ModuleUpdate.py
index c33e894e8b5f..c3dc8c8a87b2 100644
--- a/ModuleUpdate.py
+++ b/ModuleUpdate.py
@@ -4,14 +4,29 @@
import multiprocessing
import warnings
-local_dir = os.path.dirname(__file__)
-requirements_files = {os.path.join(local_dir, 'requirements.txt')}
if sys.version_info < (3, 8, 6):
raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
-update_ran = getattr(sys, "frozen", False) or multiprocessing.parent_process()
+_skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process())
+update_ran = _skip_update
+
+
+class RequirementsSet(set):
+ def add(self, e):
+ global update_ran
+ update_ran &= _skip_update
+ super().add(e)
+
+ def update(self, *s):
+ global update_ran
+ update_ran &= _skip_update
+ super().update(*s)
+
+
+local_dir = os.path.dirname(__file__)
+requirements_files = RequirementsSet((os.path.join(local_dir, 'requirements.txt'),))
if not update_ran:
for entry in os.scandir(os.path.join(local_dir, "worlds")):
diff --git a/MultiServer.py b/MultiServer.py
index 9d2e9b564e75..62dab3298e6b 100644
--- a/MultiServer.py
+++ b/MultiServer.py
@@ -656,7 +656,8 @@ def get_aliased_name(self, team: int, slot: int):
else:
return self.player_names[team, slot]
- def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False):
+ def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False,
+ recipients: typing.Sequence[int] = None):
"""Send and remember hints."""
if only_new:
hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]]
@@ -685,12 +686,13 @@ def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: b
for slot in new_hint_events:
self.on_new_hint(team, slot)
for slot, hint_data in concerns.items():
- clients = self.clients[team].get(slot)
- if not clients:
- continue
- client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
- for client in clients:
- async_start(self.send_msgs(client, client_hints))
+ if recipients is None or slot in recipients:
+ clients = self.clients[team].get(slot)
+ if not clients:
+ continue
+ client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
+ for client in clients:
+ async_start(self.send_msgs(client, client_hints))
# "events"
@@ -1429,9 +1431,13 @@ def get_hints(self, input_text: str, for_location: bool = False) -> bool:
hints = {hint.re_check(self.ctx, self.client.team) for hint in
self.ctx.hints[self.client.team, self.client.slot]}
self.ctx.hints[self.client.team, self.client.slot] = hints
- self.ctx.notify_hints(self.client.team, list(hints))
+ self.ctx.notify_hints(self.client.team, list(hints), recipients=(self.client.slot,))
self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. "
f"You have {points_available} points.")
+ if hints and Utils.version_tuple < (0, 5, 0):
+ self.output("It was recently changed, so that the above hints are only shown to you. "
+ "If you meant to alert another player of an above hint, "
+ "please let them know of the content or to run !hint themselves.")
return True
elif input_text.isnumeric():
@@ -2210,25 +2216,24 @@ def parse_args() -> argparse.Namespace:
async def auto_shutdown(ctx, to_cancel=None):
await asyncio.sleep(ctx.auto_shutdown)
+
+ def inactivity_shutdown():
+ ctx.server.ws_server.close()
+ ctx.exit_event.set()
+ if to_cancel:
+ for task in to_cancel:
+ task.cancel()
+ logging.info("Shutting down due to inactivity.")
+
while not ctx.exit_event.is_set():
if not ctx.client_activity_timers.values():
- ctx.server.ws_server.close()
- ctx.exit_event.set()
- if to_cancel:
- for task in to_cancel:
- task.cancel()
- logging.info("Shutting down due to inactivity.")
+ inactivity_shutdown()
else:
newest_activity = max(ctx.client_activity_timers.values())
delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity
seconds = ctx.auto_shutdown - delta.total_seconds()
if seconds < 0:
- ctx.server.ws_server.close()
- ctx.exit_event.set()
- if to_cancel:
- for task in to_cancel:
- task.cancel()
- logging.info("Shutting down due to inactivity.")
+ inactivity_shutdown()
else:
await asyncio.sleep(seconds)
diff --git a/OoTAdjuster.py b/OoTAdjuster.py
index 38ebe62e2ae1..9519b191e704 100644
--- a/OoTAdjuster.py
+++ b/OoTAdjuster.py
@@ -195,10 +195,10 @@ def set_icon(window):
window.tk.call('wm', 'iconphoto', window._w, logo)
def adjust(args):
- # Create a fake world and OOTWorld to use as a base
- world = MultiWorld(1)
- world.per_slot_randoms = {1: random}
- ootworld = OOTWorld(world, 1)
+ # Create a fake multiworld and OOTWorld to use as a base
+ multiworld = MultiWorld(1)
+ multiworld.per_slot_randoms = {1: random}
+ ootworld = OOTWorld(multiworld, 1)
# Set options in the fake OOTWorld
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
result = getattr(args, name, None)
diff --git a/Options.py b/Options.py
index 2e3927aae3f3..ff8ad11c5a5a 100644
--- a/Options.py
+++ b/Options.py
@@ -1,19 +1,18 @@
from __future__ import annotations
import abc
-import logging
-from copy import deepcopy
-from dataclasses import dataclass
import functools
+import logging
import math
import numbers
import random
import typing
from copy import deepcopy
+from dataclasses import dataclass
from schema import And, Optional, Or, Schema
-from Utils import get_fuzzy_results
+from Utils import get_fuzzy_results, is_iterable_of_str
if typing.TYPE_CHECKING:
from BaseClasses import PlandoOptions
@@ -59,6 +58,7 @@ def __new__(mcs, name, bases, attrs):
def verify(self, *args, **kwargs) -> None:
for f in verifiers:
f(self, *args, **kwargs)
+
attrs["verify"] = verify
else:
assert verifiers, "class Option is supposed to implement def verify"
@@ -183,6 +183,7 @@ def get_option_name(cls, value: str) -> str:
class NumericOption(Option[int], numbers.Integral, abc.ABC):
default = 0
+
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards
# `int` is not a `numbers.Integral` according to the official typestubs
# (even though isinstance(5, numbers.Integral) == True)
@@ -598,7 +599,7 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P
if isinstance(self.value, int):
return
from BaseClasses import PlandoOptions
- if not(PlandoOptions.bosses & plando_options):
+ if not (PlandoOptions.bosses & plando_options):
# plando is disabled but plando options were given so pull the option and change it to an int
option = self.value.split(";")[-1]
self.value = self.options[option]
@@ -727,7 +728,7 @@ def __new__(cls, value: int) -> SpecialRange:
"Consider switching to NamedRange, which supports all use-cases of SpecialRange, and more. In "
"NamedRange, range_start specifies the lower end of the regular range, while special values can be "
"placed anywhere (below, inside, or above the regular range).")
- return super().__new__(cls, value)
+ return super().__new__(cls)
@classmethod
def weighted_range(cls, text) -> Range:
@@ -765,7 +766,7 @@ class VerifyKeys(metaclass=FreezeValidKeys):
value: typing.Any
@classmethod
- def verify_keys(cls, data: typing.List[str]):
+ 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)
@@ -843,11 +844,11 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
# If only unique entries are needed and input order of elements does not matter, OptionSet should be used instead.
# Not a docstring so it doesn't get grabbed by the options system.
- default: typing.List[typing.Any] = []
+ default: typing.Union[typing.List[typing.Any], typing.Tuple[typing.Any, ...]] = ()
supports_weighting = False
- def __init__(self, value: typing.List[typing.Any]):
- self.value = deepcopy(value)
+ def __init__(self, value: typing.Iterable[str]):
+ self.value = list(deepcopy(value))
super(OptionList, self).__init__()
@classmethod
@@ -856,7 +857,7 @@ def from_text(cls, text: str):
@classmethod
def from_any(cls, data: typing.Any):
- if type(data) == list:
+ if is_iterable_of_str(data):
cls.verify_keys(data)
return cls(data)
return cls.from_text(str(data))
@@ -882,7 +883,7 @@ def from_text(cls, text: str):
@classmethod
def from_any(cls, data: typing.Any):
- if isinstance(data, (list, set, frozenset)):
+ if is_iterable_of_str(data):
cls.verify_keys(data)
return cls(data)
return cls.from_text(str(data))
@@ -932,7 +933,7 @@ def __new__(mcs,
bases: typing.Tuple[type, ...],
attrs: typing.Dict[str, typing.Any]) -> "OptionsMetaProperty":
for attr_type in attrs.values():
- assert not isinstance(attr_type, AssembleOptions),\
+ assert not isinstance(attr_type, AssembleOptions), \
f"Options for {name} should be type hinted on the class, not assigned"
return super().__new__(mcs, name, bases, attrs)
@@ -1110,6 +1111,11 @@ class PerGameCommonOptions(CommonOptions):
item_links: ItemLinks
+@dataclass
+class DeathLinkMixin:
+ death_link: DeathLink
+
+
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True):
import os
diff --git a/Patch.py b/Patch.py
index 113d0658c6b7..091545700059 100644
--- a/Patch.py
+++ b/Patch.py
@@ -8,7 +8,7 @@
import ModuleUpdate
ModuleUpdate.update()
-from worlds.Files import AutoPatchRegister, APDeltaPatch
+from worlds.Files import AutoPatchRegister, APPatch
class RomMeta(TypedDict):
@@ -20,7 +20,7 @@ class RomMeta(TypedDict):
def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]:
auto_handler = AutoPatchRegister.get_handler(patch_file)
if auto_handler:
- handler: APDeltaPatch = auto_handler(patch_file)
+ handler: APPatch = auto_handler(patch_file)
target = os.path.splitext(patch_file)[0]+handler.result_file_ending
handler.patch(target)
return {"server": handler.server,
diff --git a/README.md b/README.md
index a1e03293d587..3c3c41475bab 100644
--- a/README.md
+++ b/README.md
@@ -58,6 +58,9 @@ Currently, the following games are supported:
* Heretic
* Landstalker: The Treasures of King Nole
* Final Fantasy Mystic Quest
+* TUNIC
+* Kirby's Dream Land 3
+* Celeste 64
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/Utils.py b/Utils.py
index 5955e924322f..cea6405a38b4 100644
--- a/Utils.py
+++ b/Utils.py
@@ -19,14 +19,13 @@
from argparse import Namespace
from settings import Settings, get_settings
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
-from yaml import load, load_all, dump, SafeLoader
+from typing_extensions import TypeGuard
+from yaml import load, load_all, dump
try:
- from yaml import CLoader as UnsafeLoader
- from yaml import CDumper as Dumper
+ from yaml import CLoader as UnsafeLoader, CSafeLoader as SafeLoader, CDumper as Dumper
except ImportError:
- from yaml import Loader as UnsafeLoader
- from yaml import Dumper
+ from yaml import Loader as UnsafeLoader, SafeLoader, Dumper
if typing.TYPE_CHECKING:
import tkinter
@@ -779,6 +778,25 @@ def deprecate(message: str):
import warnings
warnings.warn(message)
+
+class DeprecateDict(dict):
+ log_message: str
+ should_error: bool
+
+ def __init__(self, message, error: bool = False) -> None:
+ self.log_message = message
+ self.should_error = error
+ super().__init__()
+
+ def __getitem__(self, item: Any) -> Any:
+ if self.should_error:
+ deprecate(self.log_message)
+ elif __debug__:
+ import warnings
+ warnings.warn(self.log_message)
+ return super().__getitem__(item)
+
+
def _extend_freeze_support() -> None:
"""Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
# upstream issue: https://github.com/python/cpython/issues/76327
@@ -852,8 +870,8 @@ def visualize_regions(root_region: Region, file_name: str, *,
Example usage in Main code:
from Utils import visualize_regions
- for player in world.player_ids:
- visualize_regions(world.get_region("Menu", player), f"{world.get_out_file_name_base(player)}.puml")
+ for player in multiworld.player_ids:
+ visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml")
"""
assert root_region.multiworld, "The multiworld attribute of root_region has to be filled"
from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region
@@ -949,3 +967,13 @@ def __bool__(self):
def __len__(self):
return sum(len(iterable) for iterable in self.iterable)
+
+
+def is_iterable_of_str(obj: object) -> TypeGuard[typing.Iterable[str]]:
+ """ but not a `str` (because technically, `str` is `Iterable[str]`) """
+ if isinstance(obj, str):
+ return False
+ if not isinstance(obj, typing.Iterable):
+ return False
+ obj_it: typing.Iterable[object] = obj
+ return all(isinstance(v, str) for v in obj_it)
diff --git a/WebHostLib/api/generate.py b/WebHostLib/api/generate.py
index 61e9164e2652..5a66d1e69331 100644
--- a/WebHostLib/api/generate.py
+++ b/WebHostLib/api/generate.py
@@ -20,8 +20,8 @@ def generate_api():
race = False
meta_options_source = {}
if 'file' in request.files:
- file = request.files['file']
- options = get_yaml_data(file)
+ files = request.files.getlist('file')
+ options = get_yaml_data(files)
if isinstance(options, Markup):
return {"text": options.striptags()}, 400
if isinstance(options, str):
diff --git a/WebHostLib/check.py b/WebHostLib/check.py
index 4db2ec2ce35e..e739dda02d79 100644
--- a/WebHostLib/check.py
+++ b/WebHostLib/check.py
@@ -1,3 +1,4 @@
+import os
import zipfile
import base64
from typing import Union, Dict, Set, Tuple
@@ -6,13 +7,7 @@
from markupsafe import Markup
from WebHostLib import app
-
-banned_zip_contents = (".sfc",)
-
-
-def allowed_file(filename):
- return filename.endswith(('.txt', ".yaml", ".zip"))
-
+from WebHostLib.upload import allowed_options, allowed_options_extensions, banned_file
from Generate import roll_settings, PlandoOptions
from Utils import parse_yamls
@@ -51,33 +46,41 @@ def mysterycheck():
def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
options = {}
for uploaded_file in files:
- # if user does not select file, browser also
- # submit an empty part without filename
- if uploaded_file.filename == '':
- return 'No selected file'
+ if banned_file(uploaded_file.filename):
+ return ("Uploaded data contained a rom file, which is likely to contain copyrighted material. "
+ "Your file was deleted.")
+ # If the user does not select file, the browser will still submit an empty string without a file name.
+ elif uploaded_file.filename == "":
+ return "No selected file."
elif uploaded_file.filename in options:
- return f'Conflicting files named {uploaded_file.filename} submitted'
- elif uploaded_file and allowed_file(uploaded_file.filename):
+ return f"Conflicting files named {uploaded_file.filename} submitted."
+ elif uploaded_file and allowed_options(uploaded_file.filename):
if uploaded_file.filename.endswith(".zip"):
-
- with zipfile.ZipFile(uploaded_file, 'r') as zfile:
- infolist = zfile.infolist()
-
- if any(file.filename.endswith(".archipelago") for file in infolist):
- return Markup("Error: Your .zip file contains an .archipelago file. "
- 'Did you mean to host a game?')
-
- for file in infolist:
- if file.filename.endswith(banned_zip_contents):
- return ("Uploaded data contained a rom file, "
- "which is likely to contain copyrighted material. "
- "Your file was deleted.")
- elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")):
+ if not zipfile.is_zipfile(uploaded_file):
+ return f"Uploaded file {uploaded_file.filename} is not a valid .zip file and cannot be opened."
+
+ uploaded_file.seek(0) # offset from is_zipfile check
+ with zipfile.ZipFile(uploaded_file, "r") as zfile:
+ for file in zfile.infolist():
+ # Remove folder pathing from str (e.g. "__MACOSX/" folder paths from archives created by macOS).
+ base_filename = os.path.basename(file.filename)
+
+ if base_filename.endswith(".archipelago"):
+ return Markup("Error: Your .zip file contains an .archipelago file. "
+ 'Did you mean to host a game?')
+ elif base_filename.endswith(".zip"):
+ return "Nested .zip files inside a .zip are not supported."
+ elif banned_file(base_filename):
+ return ("Uploaded data contained a rom file, which is likely to contain copyrighted "
+ "material. Your file was deleted.")
+ # Ignore dot-files.
+ elif not base_filename.startswith(".") and allowed_options(base_filename):
options[file.filename] = zfile.open(file, "r").read()
else:
options[uploaded_file.filename] = uploaded_file.read()
+
if not options:
- return "Did not find a .yaml file to process."
+ return f"Did not find any valid files to process. Accepted formats: {allowed_options_extensions}"
return options
diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt
index 654104252cec..62707d78cf1f 100644
--- a/WebHostLib/requirements.txt
+++ b/WebHostLib/requirements.txt
@@ -5,5 +5,5 @@ Flask-Caching>=2.1.0
Flask-Compress>=1.14
Flask-Limiter>=3.5.0
bokeh>=3.1.1; python_version <= '3.8'
-bokeh>=3.2.2; python_version >= '3.9'
+bokeh>=3.3.2; python_version >= '3.9'
markupsafe>=2.1.3
diff --git a/WebHostLib/templates/generate.html b/WebHostLib/templates/generate.html
index 33f8dbc09e6c..53d98dfae6ba 100644
--- a/WebHostLib/templates/generate.html
+++ b/WebHostLib/templates/generate.html
@@ -69,8 +69,8 @@
Generate Game{% if race %} (Race Mode){% endif %}
|
@@ -185,12 +185,12 @@ Generate Game{% if race %} (Race Mode){% endif %}
+
+
+
-
-
-
diff --git a/WebHostLib/templates/hostRoom.html b/WebHostLib/templates/hostRoom.html
index ba15d64acac1..2981c41452f0 100644
--- a/WebHostLib/templates/hostRoom.html
+++ b/WebHostLib/templates/hostRoom.html
@@ -3,6 +3,16 @@
{% block head %}
Multiworld {{ room.id|suuid }}
{% if should_refresh %}{% endif %}
+
+
+
+ {% if room.seed.slots|length < 2 %}
+
+ {% else %}
+
+ {% endif %}
{% endblock %}
diff --git a/WebHostLib/templates/islandFooter.html b/WebHostLib/templates/islandFooter.html
index 7b89c4a9e079..08cf227990b8 100644
--- a/WebHostLib/templates/islandFooter.html
+++ b/WebHostLib/templates/islandFooter.html
@@ -1,6 +1,6 @@
{% block footer %}
|