Skip to content

Commit 603b3b5

Browse files
authored
Merge branch 'ArchipelagoMW:main' into main
2 parents 327ff59 + ec5b4e7 commit 603b3b5

File tree

105 files changed

+2261
-1763
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

105 files changed

+2261
-1763
lines changed

.github/workflows/label-pull-requests.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ on:
66
permissions:
77
contents: read
88
pull-requests: write
9+
env:
10+
GH_REPO: ${{ github.repository }}
911

1012
jobs:
1113
labeler:

BaseClasses.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,7 @@ def get_location(self, location_name: str, player: int) -> Location:
439439
return self.regions.location_cache[player][location_name]
440440

441441
def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False,
442-
collect_pre_fill_items: bool = True) -> CollectionState:
442+
collect_pre_fill_items: bool = True, perform_sweep: bool = True) -> CollectionState:
443443
cached = getattr(self, "_all_state", None)
444444
if use_cache and cached:
445445
return cached.copy()
@@ -453,7 +453,8 @@ def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False,
453453
subworld = self.worlds[player]
454454
for item in subworld.get_pre_fill_items():
455455
subworld.collect(ret, item)
456-
ret.sweep_for_advancements()
456+
if perform_sweep:
457+
ret.sweep_for_advancements()
457458

458459
if use_cache:
459460
self._all_state = ret
@@ -558,7 +559,9 @@ def has_beaten_game(self, state: CollectionState, player: Optional[int] = None)
558559
else:
559560
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1)))
560561

561-
def can_beat_game(self, starting_state: Optional[CollectionState] = None) -> bool:
562+
def can_beat_game(self,
563+
starting_state: Optional[CollectionState] = None,
564+
locations: Optional[Iterable[Location]] = None) -> bool:
562565
if starting_state:
563566
if self.has_beaten_game(starting_state):
564567
return True
@@ -567,7 +570,9 @@ def can_beat_game(self, starting_state: Optional[CollectionState] = None) -> boo
567570
state = CollectionState(self)
568571
if self.has_beaten_game(state):
569572
return True
570-
prog_locations = {location for location in self.get_locations() if location.item
573+
574+
base_locations = self.get_locations() if locations is None else locations
575+
prog_locations = {location for location in base_locations if location.item
571576
and location.item.advancement and location not in state.locations_checked}
572577

573578
while prog_locations:
@@ -1602,21 +1607,19 @@ def create_playthrough(self, create_paths: bool = True) -> None:
16021607

16031608
# in the second phase, we cull each sphere such that the game is still beatable,
16041609
# reducing each range of influence to the bare minimum required inside it
1605-
restore_later: Dict[Location, Item] = {}
1610+
required_locations = {location for sphere in collection_spheres for location in sphere}
16061611
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
16071612
to_delete: Set[Location] = set()
16081613
for location in sphere:
1609-
# we remove the item at location and check if game is still beatable
1614+
# we remove the location from required_locations to sweep from, and check if the game is still beatable
16101615
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
16111616
location.item.player)
1612-
old_item = location.item
1613-
location.item = None
1614-
if multiworld.can_beat_game(state_cache[num]):
1617+
required_locations.remove(location)
1618+
if multiworld.can_beat_game(state_cache[num], required_locations):
16151619
to_delete.add(location)
1616-
restore_later[location] = old_item
16171620
else:
16181621
# still required, got to keep it around
1619-
location.item = old_item
1622+
required_locations.add(location)
16201623

16211624
# cull entries in spheres for spoiler walkthrough at end
16221625
sphere -= to_delete
@@ -1633,7 +1636,7 @@ def create_playthrough(self, create_paths: bool = True) -> None:
16331636
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
16341637
precollected_items.remove(item)
16351638
multiworld.state.remove(item)
1636-
if not multiworld.can_beat_game():
1639+
if not multiworld.can_beat_game(multiworld.state, required_locations):
16371640
# Add the item back into `precollected_items` and collect it into `multiworld.state`.
16381641
multiworld.push_precollected(item)
16391642
else:
@@ -1675,9 +1678,6 @@ def create_playthrough(self, create_paths: bool = True) -> None:
16751678
self.create_paths(state, collection_spheres)
16761679

16771680
# repair the multiworld again
1678-
for location, item in restore_later.items():
1679-
location.item = item
1680-
16811681
for item in removed_precollected:
16821682
multiworld.push_precollected(item)
16831683

Fill.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -890,7 +890,7 @@ def failed(warning: str, force: bool | str) -> None:
890890
worlds = set()
891891
for listed_world in target_world:
892892
if listed_world not in world_name_lookup:
893-
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
893+
failed(f"Cannot place item to {listed_world}'s world as that world does not exist.",
894894
block.force)
895895
continue
896896
worlds.add(world_name_lookup[listed_world])
@@ -923,9 +923,9 @@ def failed(warning: str, force: bool | str) -> None:
923923
if isinstance(locations, str):
924924
locations = [locations]
925925

926-
locations_from_groups: list[str] = []
927926
resolved_locations: list[Location] = []
928927
for target_player in worlds:
928+
locations_from_groups: list[str] = []
929929
world_locations = multiworld.get_unfilled_locations(target_player)
930930
for group in multiworld.worlds[target_player].location_name_groups:
931931
if group in locations:
@@ -937,13 +937,16 @@ def failed(warning: str, force: bool | str) -> None:
937937

938938
count = block.count
939939
if not count:
940-
count = len(new_block.items)
940+
count = (min(len(new_block.items), len(new_block.resolved_locations))
941+
if new_block.resolved_locations else len(new_block.items))
941942
if isinstance(count, int):
942943
count = {"min": count, "max": count}
943944
if "min" not in count:
944945
count["min"] = 0
945946
if "max" not in count:
946-
count["max"] = len(new_block.items)
947+
count["max"] = (min(len(new_block.items), len(new_block.resolved_locations))
948+
if new_block.resolved_locations else len(new_block.items))
949+
947950

948951
new_block.count = count
949952
plando_blocks[player].append(new_block)

Launcher.py

Lines changed: 59 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import argparse
1212
import logging
1313
import multiprocessing
14+
import os
1415
import shlex
1516
import subprocess
1617
import sys
@@ -41,13 +42,17 @@ def open_host_yaml():
4142
if is_linux:
4243
exe = which('sensible-editor') or which('gedit') or \
4344
which('xdg-open') or which('gnome-open') or which('kde-open')
44-
subprocess.Popen([exe, file])
4545
elif is_macos:
4646
exe = which("open")
47-
subprocess.Popen([exe, file])
4847
else:
4948
webbrowser.open(file)
49+
return
5050

51+
env = os.environ
52+
if "LD_LIBRARY_PATH" in env:
53+
env = env.copy()
54+
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
55+
subprocess.Popen([exe, file], env=env)
5156

5257
def open_patch():
5358
suffixes = []
@@ -92,7 +97,11 @@ def open_folder(folder_path):
9297
return
9398

9499
if exe:
95-
subprocess.Popen([exe, folder_path])
100+
env = os.environ
101+
if "LD_LIBRARY_PATH" in env:
102+
env = env.copy()
103+
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
104+
subprocess.Popen([exe, folder_path], env=env)
96105
else:
97106
logging.warning(f"No file browser available to open {folder_path}")
98107

@@ -104,45 +113,48 @@ def update_settings():
104113

105114
components.extend([
106115
# Functions
107-
Component("Open host.yaml", func=open_host_yaml),
108-
Component("Open Patch", func=open_patch),
109-
Component("Generate Template Options", func=generate_yamls),
110-
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")),
111-
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
116+
Component("Open host.yaml", func=open_host_yaml,
117+
description="Open the host.yaml file to change settings for generation, games, and more."),
118+
Component("Open Patch", func=open_patch,
119+
description="Open a patch file, downloaded from the room page or provided by the host."),
120+
Component("Generate Template Options", func=generate_yamls,
121+
description="Generate template YAMLs for currently installed games."),
122+
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/"),
123+
description="Open archipelago.gg in your browser."),
124+
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2"),
125+
description="Join the Discord server to play public multiworlds, report issues, or just chat!"),
112126
Component("Unrated/18+ Discord Server", icon="discord",
113-
func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
114-
Component("Browse Files", func=browse_files),
127+
func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4"),
128+
description="Find unrated and 18+ games in the After Dark Discord server."),
129+
Component("Browse Files", func=browse_files,
130+
description="Open the Archipelago installation folder in your file browser."),
115131
])
116132

117133

118-
def handle_uri(path: str, launch_args: tuple[str, ...]) -> None:
134+
def handle_uri(path: str) -> tuple[list[Component], Component]:
119135
url = urllib.parse.urlparse(path)
120136
queries = urllib.parse.parse_qs(url.query)
121-
launch_args = (path, *launch_args)
122-
client_component = []
137+
client_components = []
123138
text_client_component = None
124139
game = queries["game"][0]
125140
for component in components:
126141
if component.supports_uri and component.game_name == game:
127-
client_component.append(component)
142+
client_components.append(component)
128143
elif component.display_name == "Text Client":
129144
text_client_component = component
145+
return client_components, text_client_component
130146

131147

132-
if not client_component:
133-
run_component(text_client_component, *launch_args)
134-
return
135-
else:
136-
from kvui import ButtonsPrompt
137-
component_options = {
138-
text_client_component.display_name: text_client_component,
139-
**{component.display_name: component for component in client_component}
140-
}
141-
popup = ButtonsPrompt("Connect to Multiworld",
142-
"Select client to open and connect with.",
143-
lambda component_name: run_component(component_options[component_name], *launch_args),
144-
*component_options.keys())
145-
popup.open()
148+
def build_uri_popup(component_list: list[Component], launch_args: tuple[str, ...]) -> None:
149+
from kvui import ButtonsPrompt
150+
component_options = {
151+
component.display_name: component for component in component_list
152+
}
153+
popup = ButtonsPrompt("Connect to Multiworld",
154+
"Select client to open and connect with.",
155+
lambda component_name: run_component(component_options[component_name], *launch_args),
156+
*component_options.keys())
157+
popup.open()
146158

147159

148160
def identify(path: None | str) -> tuple[None | str, None | Component]:
@@ -184,7 +196,8 @@ def get_exe(component: str | Component) -> Sequence[str] | None:
184196
def launch(exe, in_terminal=False):
185197
if in_terminal:
186198
if is_windows:
187-
subprocess.Popen(['start', *exe], shell=True)
199+
# intentionally using a window title with a space so it gets quoted and treated as a title
200+
subprocess.Popen(["start", "Running Archipelago", *exe], shell=True)
188201
return
189202
elif is_linux:
190203
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
@@ -212,7 +225,7 @@ def create_shortcut(button: Any, component: Component) -> None:
212225
refresh_components: Callable[[], None] | None = None
213226

214227

215-
def run_gui(path: str, args: Any) -> None:
228+
def run_gui(launch_components: list[Component], args: Any) -> None:
216229
from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox)
217230
from kivy.properties import ObjectProperty
218231
from kivy.core.window import Window
@@ -245,12 +258,12 @@ class Launcher(ThemedApp):
245258
cards: list[LauncherCard]
246259
current_filter: Sequence[str | Type] | None
247260

248-
def __init__(self, ctx=None, path=None, args=None):
261+
def __init__(self, ctx=None, components=None, args=None):
249262
self.title = self.base_title + " " + Utils.__version__
250263
self.ctx = ctx
251264
self.icon = r"data/icon.png"
252265
self.favorites = []
253-
self.launch_uri = path
266+
self.launch_components = components
254267
self.launch_args = args
255268
self.cards = []
256269
self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
@@ -372,9 +385,9 @@ def build(self):
372385
return self.top_screen
373386

374387
def on_start(self):
375-
if self.launch_uri:
376-
handle_uri(self.launch_uri, self.launch_args)
377-
self.launch_uri = None
388+
if self.launch_components:
389+
build_uri_popup(self.launch_components, self.launch_args)
390+
self.launch_components = None
378391
self.launch_args = None
379392

380393
@staticmethod
@@ -415,7 +428,7 @@ def on_stop(self):
415428
for filter in self.current_filter))
416429
super().on_stop()
417430

418-
Launcher(path=path, args=args).run()
431+
Launcher(components=launch_components, args=args).run()
419432

420433
# avoiding Launcher reference leak
421434
# and don't try to do something with widgets after window closed
@@ -442,7 +455,15 @@ def main(args: argparse.Namespace | dict | None = None):
442455

443456
path = args.get("Patch|Game|Component|url", None)
444457
if path is not None:
445-
if not path.startswith("archipelago://"):
458+
if path.startswith("archipelago://"):
459+
args["args"] = (path, *args.get("args", ()))
460+
# add the url arg to the passthrough args
461+
components, text_client_component = handle_uri(path)
462+
if not components:
463+
args["component"] = text_client_component
464+
else:
465+
args['launch_components'] = [text_client_component, *components]
466+
else:
446467
file, component = identify(path)
447468
if file:
448469
args['file'] = file
@@ -458,7 +479,7 @@ def main(args: argparse.Namespace | dict | None = None):
458479
elif "component" in args:
459480
run_component(args["component"], *args["args"])
460481
elif not args["update_settings"]:
461-
run_gui(path, args.get("args", ()))
482+
run_gui(args.get("launch_components", None), args.get("args", ()))
462483

463484

464485
if __name__ == '__main__':

MultiServer.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -458,8 +458,12 @@ def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.A
458458
self.generator_version = Version(*decoded_obj["version"])
459459
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
460460
self.minimum_client_versions = {}
461+
if self.generator_version < Version(0, 6, 2):
462+
min_version = Version(0, 1, 6)
463+
else:
464+
min_version = min_client_version
461465
for player, version in clients_ver.items():
462-
self.minimum_client_versions[player] = max(Version(*version), min_client_version)
466+
self.minimum_client_versions[player] = max(Version(*version), min_version)
463467

464468
self.slot_info = decoded_obj["slot_info"]
465469
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}

Options.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1524,9 +1524,11 @@ def from_any(cls, data: typing.Any) -> Option[typing.List[PlandoItem]]:
15241524
f"dictionary, not {type(items)}")
15251525
locations = item.get("locations", [])
15261526
if not locations:
1527-
locations = item.get("location", ["Everywhere"])
1527+
locations = item.get("location", [])
15281528
if locations:
15291529
count = 1
1530+
else:
1531+
locations = ["Everywhere"]
15301532
if isinstance(locations, str):
15311533
locations = [locations]
15321534
if not isinstance(locations, list):

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ Currently, the following games are supported:
8282
* The Legend of Zelda: The Wind Waker
8383
* Jak and Daxter: The Precursor Legacy
8484
* Super Mario Land 2: 6 Golden Coins
85+
* shapez
8586

8687
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
8788
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled

0 commit comments

Comments
 (0)