Skip to content

Commit a1754bf

Browse files
Merge branch 'ArchipelagoMW:main' into main
2 parents 01f7aa6 + f3389f5 commit a1754bf

29 files changed

Lines changed: 559 additions & 270 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ Output Logs/
6565
/datapackage
6666
/datapackage_export.json
6767
/custom_worlds
68+
# stubgen output
69+
/out/
6870

6971
# Byte-compiled / optimized / DLL files
7072
__pycache__/

Generate.py

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
2424

2525

26-
def mystery_argparse(argv: list[str] | None = None):
26+
def mystery_argparse(argv: list[str] | None = None) -> argparse.Namespace:
2727
from settings import get_settings
2828
settings = get_settings()
2929
defaults = settings.generator
@@ -68,7 +68,7 @@ def mystery_argparse(argv: list[str] | None = None):
6868
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
6969
if not os.path.isabs(args.meta_file_path):
7070
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
71-
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
71+
args.plando = PlandoOptions.from_option_string(args.plando)
7272

7373
return args
7474

@@ -135,7 +135,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
135135
else:
136136
weights_for_file.append(yaml)
137137
weights_cache[fname] = tuple(weights_for_file)
138-
138+
139139
except Exception as e:
140140
logging.exception(f"Exception reading weights in file {fname}")
141141
player_errors.append(
@@ -205,7 +205,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
205205
else:
206206
yaml[category_name][key] = option
207207

208-
settings_cache: dict[str, tuple[argparse.Namespace, ...]] = {fname: None for fname in weights_cache}
208+
settings_cache: dict[str, tuple[argparse.Namespace, ...] | None] = {fname: None for fname in weights_cache}
209209
if args.sameoptions:
210210
for fname, yamls in weights_cache.items():
211211
try:
@@ -225,7 +225,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
225225
player_path_cache: dict[int, str] = {}
226226
for player in range(1, args.multi + 1):
227227
player_path_cache[player] = player_files.get(player, args.weights_file_path)
228-
name_counter = Counter()
228+
name_counter: Counter[str] = Counter()
229229
args.player_options = {}
230230

231231
player = 1
@@ -241,13 +241,10 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
241241
try:
242242
# Use the cached settings object if it exists, otherwise roll settings within the try-catch
243243
# Invariant: settings_cache[path] and weights_cache[path] have the same length
244-
settingsObject: argparse.Namespace = (
245-
settings_cache[path][doc_index]
246-
if settings_cache[path]
247-
else roll_settings(yaml, args.plando)
248-
)
249-
250-
for k, v in vars(settingsObject).items():
244+
cached = settings_cache[path]
245+
settings_object: argparse.Namespace = (cached[doc_index] if cached else roll_settings(yaml, args.plando))
246+
247+
for k, v in vars(settings_object).items():
251248
if v is not None:
252249
try:
253250
getattr(args, k)[player] = v
@@ -365,7 +362,7 @@ def get_value(self, key, args, kwargs):
365362
return kwargs.get(key, "{" + key + "}")
366363

367364

368-
def handle_name(name: str, player: int, name_counter: Counter):
365+
def handle_name(name: str, player: int, name_counter: Counter[str]):
369366
name_counter[name.lower()] += 1
370367
number = name_counter[name.lower()]
371368
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
@@ -503,7 +500,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
503500
return weights
504501

505502

506-
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions):
503+
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type[Options.Option], plando_options: PlandoOptions):
507504
try:
508505
if option_key in game_weights:
509506
if not option.supports_weighting:

Options.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -523,9 +523,9 @@ def __ge__(self, other: typing.Union[Choice, int, str]):
523523

524524
class TextChoice(Choice):
525525
"""Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string"""
526-
value: typing.Union[str, int]
526+
value: str | int
527527

528-
def __init__(self, value: typing.Union[str, int]):
528+
def __init__(self, value: str | int):
529529
assert isinstance(value, str) or isinstance(value, int), \
530530
f"'{value}' is not a valid option for '{self.__class__.__name__}'"
531531
self.value = value
@@ -546,7 +546,7 @@ def from_text(cls, text: str) -> TextChoice:
546546
return cls(text)
547547

548548
@classmethod
549-
def get_option_name(cls, value: T) -> str:
549+
def get_option_name(cls, value: str | int) -> str:
550550
if isinstance(value, str):
551551
return value
552552
return super().get_option_name(value)
@@ -891,7 +891,7 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P
891891
def __iter__(self) -> typing.Iterator[typing.Any]:
892892
return self.value.__iter__()
893893

894-
894+
895895
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
896896
default = {}
897897
supports_weighting = False
@@ -906,7 +906,8 @@ def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
906906
else:
907907
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
908908

909-
def get_option_name(self, value):
909+
@classmethod
910+
def get_option_name(cls, value):
910911
return ", ".join(f"{key}: {v}" for key, v in value.items())
911912

912913
def __getitem__(self, item: str) -> typing.Any:
@@ -986,7 +987,8 @@ def from_any(cls, data: typing.Any):
986987
return cls(data)
987988
return cls.from_text(str(data))
988989

989-
def get_option_name(self, value):
990+
@classmethod
991+
def get_option_name(cls, value):
990992
return ", ".join(map(str, value))
991993

992994
def __contains__(self, item):
@@ -1011,7 +1013,8 @@ def from_any(cls, data: typing.Any):
10111013
return cls(data)
10121014
return cls.from_text(str(data))
10131015

1014-
def get_option_name(self, value):
1016+
@classmethod
1017+
def get_option_name(cls, value):
10151018
return ", ".join(sorted(value))
10161019

10171020
def __contains__(self, item):
@@ -1656,7 +1659,7 @@ def __iter__(self) -> typing.Iterator[PlandoItem]:
16561659
def __len__(self) -> int:
16571660
return len(self.value)
16581661

1659-
1662+
16601663
class Removed(FreeText):
16611664
"""This Option has been Removed."""
16621665
rich_text_doc = True

OptionsCreator.py

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from kvui import (ThemedApp, ScrollBox, MainLayout, ContainerLayout, dp, Widget, MDBoxLayout, TooltipLabel, MDLabel,
88
ToggleButton, MarkupDropdown, ResizableTextField)
9+
from kivy.clock import Clock
910
from kivy.uix.behaviors.button import ButtonBehavior
1011
from kivymd.uix.behaviors import RotateBehavior
1112
from kivymd.uix.anchorlayout import MDAnchorLayout
@@ -269,34 +270,53 @@ def __init__(self):
269270
self.options = {}
270271
super().__init__()
271272

272-
def export_options(self, button: Widget):
273-
if 0 < len(self.name_input.text) < 17 and self.current_game:
274-
file_name = Utils.save_filename("Export Options File As...", [("YAML", ["*.yaml"])],
273+
@staticmethod
274+
def show_result_snack(text: str) -> None:
275+
MDSnackbar(MDSnackbarText(text=text), y=dp(24), pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
276+
277+
def on_export_result(self, text: str | None) -> None:
278+
self.container.disabled = False
279+
if text is not None:
280+
Clock.schedule_once(lambda _: self.show_result_snack(text), 0)
281+
282+
def export_options_background(self, options: dict[str, typing.Any]) -> None:
283+
try:
284+
file_name = Utils.save_filename("Export Options File As...", [("YAML", [".yaml"])],
275285
Utils.get_file_safe_name(f"{self.name_input.text}.yaml"))
286+
except Exception:
287+
self.on_export_result("Could not open dialog. Already open?")
288+
raise
289+
290+
if not file_name:
291+
self.on_export_result(None) # No file selected. No need to show a message for this.
292+
return
293+
294+
try:
295+
with open(file_name, 'w') as f:
296+
f.write(Utils.dump(options, sort_keys=False))
297+
f.close()
298+
self.on_export_result("File saved successfully.")
299+
except Exception:
300+
self.on_export_result("Could not save file.")
301+
raise
302+
303+
def export_options(self, button: Widget) -> None:
304+
if 0 < len(self.name_input.text) < 17 and self.current_game:
305+
import threading
276306
options = {
277307
"name": self.name_input.text,
278308
"description": f"YAML generated by Archipelago {Utils.__version__}.",
279309
"game": self.current_game,
280310
self.current_game: {k: check_random(v) for k, v in self.options.items()}
281311
}
282-
try:
283-
with open(file_name, 'w') as f:
284-
f.write(Utils.dump(options, sort_keys=False))
285-
f.close()
286-
MDSnackbar(MDSnackbarText(text="File saved successfully."), y=dp(24), pos_hint={"center_x": 0.5},
287-
size_hint_x=0.5).open()
288-
except FileNotFoundError:
289-
MDSnackbar(MDSnackbarText(text="Saving cancelled."), y=dp(24), pos_hint={"center_x": 0.5},
290-
size_hint_x=0.5).open()
312+
threading.Thread(target=self.export_options_background, args=(options,), daemon=True).start()
313+
self.container.disabled = True
291314
elif not self.name_input.text:
292-
MDSnackbar(MDSnackbarText(text="Name must not be empty."), y=dp(24), pos_hint={"center_x": 0.5},
293-
size_hint_x=0.5).open()
315+
self.show_result_snack("Name must not be empty.")
294316
elif not self.current_game:
295-
MDSnackbar(MDSnackbarText(text="You must select a game to play."), y=dp(24), pos_hint={"center_x": 0.5},
296-
size_hint_x=0.5).open()
317+
self.show_result_snack("You must select a game to play.")
297318
else:
298-
MDSnackbar(MDSnackbarText(text="Name cannot be longer than 16 characters."), y=dp(24),
299-
pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
319+
self.show_result_snack("Name cannot be longer than 16 characters.")
300320

301321
def create_range(self, option: typing.Type[Range], name: str):
302322
def update_text(range_box: VisualRange):

Utils.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -811,29 +811,32 @@ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin
811811
except tkinter.TclError:
812812
return None # GUI not available. None is the same as a user clicking "cancel"
813813
root.withdraw()
814-
return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
815-
initialfile=suggest or None)
814+
try:
815+
return tkinter.filedialog.askopenfilename(
816+
title=title,
817+
filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
818+
initialfile=suggest or None,
819+
)
820+
finally:
821+
root.destroy()
816822

817823

818824
def save_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
819825
-> typing.Optional[str]:
820826
logging.info(f"Opening file save dialog for {title}.")
821827

822-
def run(*args: str):
823-
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
824-
825828
if is_linux:
826829
# prefer native dialog
827830
from shutil import which
828831
kdialog = which("kdialog")
829832
if kdialog:
830833
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
831-
return run(kdialog, f"--title={title}", "--getsavefilename", suggest or ".", k_filters)
834+
return _run_for_stdout(kdialog, f"--title={title}", "--getsavefilename", suggest or ".", k_filters)
832835
zenity = which("zenity")
833836
if zenity:
834837
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
835838
selection = (f"--filename={suggest}",) if suggest else ()
836-
return run(zenity, f"--title={title}", "--file-selection", "--save", *z_filters, *selection)
839+
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", "--save", *z_filters, *selection)
837840

838841
# fall back to tk
839842
try:
@@ -856,8 +859,14 @@ def run(*args: str):
856859
except tkinter.TclError:
857860
return None # GUI not available. None is the same as a user clicking "cancel"
858861
root.withdraw()
859-
return tkinter.filedialog.asksaveasfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
860-
initialfile=suggest or None)
862+
try:
863+
return tkinter.filedialog.asksaveasfilename(
864+
title=title,
865+
filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
866+
initialfile=suggest or None,
867+
)
868+
finally:
869+
root.destroy()
861870

862871

863872
def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:

WebHostLib/README.md

Lines changed: 13 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,20 @@
11
# WebHost
22

3-
## Contribution Guidelines
4-
**Thank you for your interest in contributing to the Archipelago website!**
5-
Much of the content on the website is generated automatically, but there are some things
6-
that need a personal touch. For those things, we rely on contributions from both the core
7-
team and the community. The current primary maintainer of the website is Farrak Kilhn.
8-
He may be found on Discord as `Farrak Kilhn#0418`, or on GitHub as `LegendaryLinux`.
3+
## Asset License
4+
5+
The image files used in the page design were specifically designed for archipelago.gg and are **not** covered by the top
6+
level LICENSE.
7+
See individual LICENSE files in `./static/static/**`.
98

10-
### Small Changes
11-
Little changes like adding a button or a couple new select elements are perfectly fine.
12-
Tweaks to style specific to a PR's content are also probably not a problem. For example, if
13-
you build a new page which needs two side by side tables, and you need to write a CSS file
14-
specific to your page, that is perfectly reasonable.
9+
You are only allowed to use them for personal use, testing and development.
10+
If the site is reachable over the internet, have a robots.txt in place (see `ASSET_RIGHTS` in `config.yaml`)
11+
and do not promote it publicly. Alternatively replace or remove the assets.
1512

16-
### Content Additions
17-
Once you develop a new feature or add new content the website, make a pull request. It will
18-
be reviewed by the community and there will probably be some discussion around it. Depending
19-
on the size of the feature, and if new styles are required, there may be an additional step
20-
before the PR is accepted wherein Farrak works with the designer to implement styles.
13+
## Contribution Guidelines
2114

22-
### Restrictions on Style Changes
23-
A professional designer is paid to develop the styles and assets for the Archipelago website.
24-
In an effort to maintain a consistent look and feel, pull requests which *exclusively*
25-
change site styles are rejected. Please note this applies to code which changes the overall
26-
look and feel of the site, not to small tweaks to CSS for your custom page. The intention
27-
behind these restrictions is to maintain a curated feel for the design of the site. If
28-
any PR affects the overall feel of the site but includes additive changes, there will
29-
likely be a conversation about how to implement those changes without compromising the
30-
curated site style. It is therefore worth noting there are a couple files which, if
31-
changed in your pull request, will cause it to draw additional scrutiny.
15+
Pages should preferably be rendered on the server side with Jinja. Features should work with noscript if feasible.
16+
Design changes have to fit the overall design.
3217

33-
These closely guarded files are:
34-
- `globalStyles.css`
35-
- `islandFooter.css`
36-
- `landing.css`
37-
- `markdown.css`
38-
- `tooltip.css`
18+
Introduction of JS dependencies should first be discussed on Discord or in a draft PR.
3919

40-
### Site Themes
41-
There are several themes available for game pages. It is possible to request a new theme in
42-
the `#art-and-design` channel on Discord. Because themes are created by the designer, they
43-
are not free, and take some time to create. Farrak works closely with the designer to implement
44-
these themes, and pays for the assets out of pocket. Therefore, only a couple themes per year
45-
are added. If a proposed theme seems like a cool idea and the community likes it, there is a
46-
good chance it will become a reality.
20+
See also [docs/style.md](/docs/style.md) for the style guide.

docs/apworld specification.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ There are also the following optional fields:
4141
If the APWorld is packaged as an `.apworld` zip file, it also needs to have `version` and `compatible_version`,
4242
which refer to the version of the APContainer packaging scheme defined in [Files.py](../worlds/Files.py).
4343
These get automatically added to the `archipelago.json` of an .apworld if it is packaged using the
44-
["Build apworlds" launcher component](#build-apworlds-launcher-component),
44+
["Build APWorlds" launcher component](#build-apworlds-launcher-component),
4545
which is the correct way to package your `.apworld` as a world developer. Do not write these fields yourself.
4646

4747
### "Build APWorlds" Launcher Component
@@ -50,7 +50,9 @@ In the Archipelago Launcher, there is a "Build APWorlds" component that will pac
5050
and add `archipelago.json` manifest files to them.
5151
These .apworld files will be output to `build/apworlds` (relative to the Archipelago root directory).
5252
The `archipelago.json` file in each .apworld will automatically include the appropriate
53-
`version` and `compatible_version`.
53+
`version` and `compatible_version`.
54+
The component can also be called from the command line to allow for specifying a certain list of worlds to build.
55+
For example, running `Launcher.py "Build APWorlds" -- "Game Name"` will build only the game called `Game Name`.
5456

5557
If a world folder has an `archipelago.json` in its root, any fields it contains will be carried over.
5658
So, a world folder with an `archipelago.json` that looks like this:

docs/running from source.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,9 @@ use that version. These steps are for developers or platforms without compiled r
77
## General
88

99
What you'll need:
10-
* [Python 3.11.9 or newer](https://www.python.org/downloads/), not the Windows Store version
10+
* [Python 3.11.9 or newer but less than 3.14](https://www.python.org/downloads/), not the Windows Store version
1111
* On Windows, please consider only using the latest supported version in production environments since security
1212
updates for older versions are not easily available.
13-
* Python 3.13.x is currently the newest supported version
1413
* pip: included in downloads from python.org, separate in many Linux distributions
1514
* Matching C compiler
1615
* possibly optional, read operating system specific sections

0 commit comments

Comments
 (0)