Compare commits

..

1 Commits

Author SHA1 Message Date
NewSoupVi
d729b00b22 CODEOWNERS: Move OoT to "unmaintained"
https://discord.com/channels/731205301247803413/1214608557077700720/1253206955879694336

Espeon might come back, but still, this world acts as unmaintained right now, so we should make this change, and then change it back if/when he's back.

@espeon65536 Just so you're aware of this change as well
2024-09-06 18:35:50 +02:00
246 changed files with 6208 additions and 6858 deletions

View File

@@ -194,9 +194,7 @@ class MultiWorld():
self.player_types[new_id] = NetUtils.SlotType.group self.player_types[new_id] = NetUtils.SlotType.group
world_type = AutoWorld.AutoWorldRegister.world_types[game] world_type = AutoWorld.AutoWorldRegister.world_types[game]
self.worlds[new_id] = world_type.create_group(self, new_id, players) self.worlds[new_id] = world_type.create_group(self, new_id, players)
self.worlds[new_id].collect_item = AutoWorld.World.collect_item.__get__(self.worlds[new_id]) self.worlds[new_id].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id])
self.worlds[new_id].collect = AutoWorld.World.collect.__get__(self.worlds[new_id])
self.worlds[new_id].remove = AutoWorld.World.remove.__get__(self.worlds[new_id])
self.player_name[new_id] = name self.player_name[new_id] = name
new_group = self.groups[new_id] = Group(name=name, game=game, players=players, new_group = self.groups[new_id] = Group(name=name, game=game, players=players,
@@ -344,8 +342,6 @@ class MultiWorld():
region = Region("Menu", group_id, self, "ItemLink") region = Region("Menu", group_id, self, "ItemLink")
self.regions.append(region) self.regions.append(region)
locations = region.locations locations = region.locations
# ensure that progression items are linked first, then non-progression
self.itempool.sort(key=lambda item: item.advancement)
for item in self.itempool: for item in self.itempool:
count = common_item_count.get(item.player, {}).get(item.name, 0) count = common_item_count.get(item.player, {}).get(item.name, 0)
if count: if count:
@@ -722,7 +718,7 @@ class CollectionState():
if new_region in reachable_regions: if new_region in reachable_regions:
blocked_connections.remove(connection) blocked_connections.remove(connection)
elif connection.can_reach(self): elif connection.can_reach(self):
assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region" assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
reachable_regions.add(new_region) reachable_regions.add(new_region)
blocked_connections.remove(connection) blocked_connections.remove(connection)
blocked_connections.update(new_region.exits) blocked_connections.update(new_region.exits)
@@ -948,7 +944,6 @@ class Entrance:
self.player = player self.player = player
def can_reach(self, state: CollectionState) -> bool: def can_reach(self, state: CollectionState) -> bool:
assert self.parent_region, f"called can_reach on an Entrance \"{self}\" with no parent_region"
if self.parent_region.can_reach(state) and self.access_rule(state): if self.parent_region.can_reach(state) and self.access_rule(state):
if not self.hide_path and not self in state.path: if not self.hide_path and not self in state.path:
state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None))) state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None)))
@@ -1169,7 +1164,7 @@ class Location:
def can_reach(self, state: CollectionState) -> bool: def can_reach(self, state: CollectionState) -> bool:
# Region.can_reach is just a cache lookup, so placing it first for faster abort on average # Region.can_reach is just a cache lookup, so placing it first for faster abort on average
assert self.parent_region, f"called can_reach on a Location \"{self}\" with no parent_region" assert self.parent_region, "Can't reach location without region"
return self.parent_region.can_reach(state) and self.access_rule(state) return self.parent_region.can_reach(state) and self.access_rule(state)
def place_locked_item(self, item: Item): def place_locked_item(self, item: Item):
@@ -1212,7 +1207,7 @@ class ItemClassification(IntFlag):
filler = 0b0000 # aka trash, as in filler items like ammo, currency etc, filler = 0b0000 # aka trash, as in filler items like ammo, currency etc,
progression = 0b0001 # Item that is logically relevant progression = 0b0001 # Item that is logically relevant
useful = 0b0010 # Item that is generally quite useful, but not required for anything logical useful = 0b0010 # Item that is generally quite useful, but not required for anything logical
trap = 0b0100 # detrimental item trap = 0b0100 # detrimental or entirely useless (nothing) item
skip_balancing = 0b1000 # should technically never occur on its own skip_balancing = 0b1000 # should technically never occur on its own
# Item that is logically relevant, but progression balancing should not touch. # Item that is logically relevant, but progression balancing should not touch.
# Typically currency or other counted items. # Typically currency or other counted items.
@@ -1264,10 +1259,6 @@ class Item:
def trap(self) -> bool: def trap(self) -> bool:
return ItemClassification.trap in self.classification return ItemClassification.trap in self.classification
@property
def excludable(self) -> bool:
return not (self.advancement or self.useful)
@property @property
def flags(self) -> int: def flags(self) -> int:
return self.classification.as_flag() return self.classification.as_flag()

View File

@@ -1,10 +1,9 @@
from __future__ import annotations from __future__ import annotations
import sys
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
from worlds._bizhawk.context import launch from worlds._bizhawk.context import launch
if __name__ == "__main__": if __name__ == "__main__":
launch(*sys.argv[1:]) launch()

View File

@@ -45,21 +45,10 @@ def get_ssl_context():
class ClientCommandProcessor(CommandProcessor): class ClientCommandProcessor(CommandProcessor):
"""
The Command Processor will parse every method of the class that starts with "_cmd_" as a command to be called
when parsing user input, i.e. _cmd_exit will be called when the user sends the command "/exit".
The decorator @mark_raw can be imported from MultiServer and tells the parser to only split on the first
space after the command i.e. "/exit one two three" will be passed in as method("one two three") with mark_raw
and method("one", "two", "three") without.
In addition all docstrings for command methods will be displayed to the user on launch and when using "/help"
"""
def __init__(self, ctx: CommonContext): def __init__(self, ctx: CommonContext):
self.ctx = ctx self.ctx = ctx
def output(self, text: str): def output(self, text: str):
"""Helper function to abstract logging to the CommonClient UI"""
logger.info(text) logger.info(text)
def _cmd_exit(self) -> bool: def _cmd_exit(self) -> bool:
@@ -175,14 +164,13 @@ class ClientCommandProcessor(CommandProcessor):
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate") async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
def default(self, raw: str): def default(self, raw: str):
"""The default message parser to be used when parsing any messages that do not match a command"""
raw = self.ctx.on_user_say(raw) raw = self.ctx.on_user_say(raw)
if raw: if raw:
async_start(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say") async_start(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
class CommonContext: class CommonContext:
# The following attributes are used to Connect and should be adjusted as needed in subclasses # Should be adjusted as needed in subclasses
tags: typing.Set[str] = {"AP"} tags: typing.Set[str] = {"AP"}
game: typing.Optional[str] = None game: typing.Optional[str] = None
items_handling: typing.Optional[int] = None items_handling: typing.Optional[int] = None
@@ -355,8 +343,6 @@ class CommonContext:
self.item_names = self.NameLookupDict(self, "item") self.item_names = self.NameLookupDict(self, "item")
self.location_names = self.NameLookupDict(self, "location") self.location_names = self.NameLookupDict(self, "location")
self.versions = {}
self.checksums = {}
self.jsontotextparser = JSONtoTextParser(self) self.jsontotextparser = JSONtoTextParser(self)
self.rawjsontotextparser = RawJSONtoTextParser(self) self.rawjsontotextparser = RawJSONtoTextParser(self)
@@ -443,10 +429,7 @@ class CommonContext:
self.auth = await self.console_input() self.auth = await self.console_input()
async def send_connect(self, **kwargs: typing.Any) -> None: async def send_connect(self, **kwargs: typing.Any) -> None:
""" """ send `Connect` packet to log in to server """
Send a `Connect` packet to log in to the server,
additional keyword args can override any value in the connection packet
"""
payload = { payload = {
'cmd': 'Connect', 'cmd': 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple, 'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
@@ -456,7 +439,6 @@ class CommonContext:
if kwargs: if kwargs:
payload.update(kwargs) payload.update(kwargs)
await self.send_msgs([payload]) await self.send_msgs([payload])
await self.send_msgs([{"cmd": "Get", "keys": ["_read_race_mode"]}])
async def console_input(self) -> str: async def console_input(self) -> str:
if self.ui: if self.ui:
@@ -477,7 +459,6 @@ class CommonContext:
return False return False
def slot_concerns_self(self, slot) -> bool: def slot_concerns_self(self, slot) -> bool:
"""Helper function to abstract player groups, should be used instead of checking slot == self.slot directly."""
if slot == self.slot: if slot == self.slot:
return True return True
if slot in self.slot_info: if slot in self.slot_info:
@@ -485,7 +466,6 @@ class CommonContext:
return False return False
def is_echoed_chat(self, print_json_packet: dict) -> bool: def is_echoed_chat(self, print_json_packet: dict) -> bool:
"""Helper function for filtering out messages sent by self."""
return print_json_packet.get("type", "") == "Chat" \ return print_json_packet.get("type", "") == "Chat" \
and print_json_packet.get("team", None) == self.team \ and print_json_packet.get("team", None) == self.team \
and print_json_packet.get("slot", None) == self.slot and print_json_packet.get("slot", None) == self.slot
@@ -517,14 +497,13 @@ class CommonContext:
"""Gets called before sending a Say to the server from the user. """Gets called before sending a Say to the server from the user.
Returned text is sent, or sending is aborted if None is returned.""" Returned text is sent, or sending is aborted if None is returned."""
return text return text
def on_ui_command(self, text: str) -> None: def on_ui_command(self, text: str) -> None:
"""Gets called by kivy when the user executes a command starting with `/` or `!`. """Gets called by kivy when the user executes a command starting with `/` or `!`.
The command processor is still called; this is just intended for command echoing.""" The command processor is still called; this is just intended for command echoing."""
self.ui.print_json([{"text": text, "type": "color", "color": "orange"}]) self.ui.print_json([{"text": text, "type": "color", "color": "orange"}])
def update_permissions(self, permissions: typing.Dict[str, int]): def update_permissions(self, permissions: typing.Dict[str, int]):
"""Internal method to parse and save server permissions from RoomInfo"""
for permission_name, permission_flag in permissions.items(): for permission_name, permission_flag in permissions.items():
try: try:
flag = Permission(permission_flag) flag = Permission(permission_flag)
@@ -573,34 +552,26 @@ class CommonContext:
needed_updates.add(game) needed_updates.add(game)
continue continue
cached_version: int = self.versions.get(game, 0) local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
cached_checksum: typing.Optional[str] = self.checksums.get(game) local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
# no action required if cached version is new enough # no action required if local version is new enough
if (not remote_checksum and (remote_version > cached_version or remote_version == 0)) \ if (not remote_checksum and (remote_version > local_version or remote_version == 0)) \
or remote_checksum != cached_checksum: or remote_checksum != local_checksum:
local_version: int = network_data_package["games"].get(game, {}).get("version", 0) cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum") cache_version: int = cached_game.get("version", 0)
if ((remote_checksum or remote_version <= local_version and remote_version != 0) cache_checksum: typing.Optional[str] = cached_game.get("checksum")
and remote_checksum == local_checksum): # download remote version if cache is not new enough
self.update_game(network_data_package["games"][game], game) if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
or remote_checksum != cache_checksum:
needed_updates.add(game)
else: else:
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum) self.update_game(cached_game, game)
cache_version: int = cached_game.get("version", 0)
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
# download remote version if cache is not new enough
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
or remote_checksum != cache_checksum:
needed_updates.add(game)
else:
self.update_game(cached_game, game)
if needed_updates: if needed_updates:
await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates]) await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates])
def update_game(self, game_package: dict, game: str): def update_game(self, game_package: dict, game: str):
self.item_names.update_game(game, game_package["item_name_to_id"]) self.item_names.update_game(game, game_package["item_name_to_id"])
self.location_names.update_game(game, game_package["location_name_to_id"]) self.location_names.update_game(game, game_package["location_name_to_id"])
self.versions[game] = game_package.get("version", 0)
self.checksums[game] = game_package.get("checksum")
def update_data_package(self, data_package: dict): def update_data_package(self, data_package: dict):
for game, game_data in data_package["games"].items(): for game, game_data in data_package["games"].items():
@@ -642,7 +613,6 @@ class CommonContext:
logger.info(f"DeathLink: Received from {data['source']}") logger.info(f"DeathLink: Received from {data['source']}")
async def send_death(self, death_text: str = ""): async def send_death(self, death_text: str = ""):
"""Helper function to send a deathlink using death_text as the unique death cause string."""
if self.server and self.server.socket: if self.server and self.server.socket:
logger.info("DeathLink: Sending death to your friends...") logger.info("DeathLink: Sending death to your friends...")
self.last_death_link = time.time() self.last_death_link = time.time()
@@ -656,7 +626,6 @@ class CommonContext:
}]) }])
async def update_death_link(self, death_link: bool): async def update_death_link(self, death_link: bool):
"""Helper function to set Death Link connection tag on/off and update the connection if already connected."""
old_tags = self.tags.copy() old_tags = self.tags.copy()
if death_link: if death_link:
self.tags.add("DeathLink") self.tags.add("DeathLink")
@@ -666,7 +635,7 @@ class CommonContext:
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}]) await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Optional["kvui.MessageBox"]: def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Optional["kvui.MessageBox"]:
"""Displays an error messagebox in the loaded Kivy UI. Override if using a different UI framework""" """Displays an error messagebox"""
if not self.ui: if not self.ui:
return None return None
title = title or "Error" title = title or "Error"
@@ -693,19 +662,17 @@ class CommonContext:
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True}) logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1]) self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
def make_gui(self) -> typing.Type["kvui.GameManager"]: def run_gui(self):
"""To return the Kivy App class needed for run_gui so it can be overridden before being built""" """Import kivy UI system and start running it as self.ui_task."""
from kvui import GameManager from kvui import GameManager
class TextManager(GameManager): class TextManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Text Client" base_title = "Archipelago Text Client"
return TextManager self.ui = TextManager(self)
def run_gui(self):
"""Import kivy UI system from make_gui() and start running it as self.ui_task."""
ui_class = self.make_gui()
self.ui = ui_class(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def run_cli(self): def run_cli(self):
@@ -1018,7 +985,6 @@ async def console_loop(ctx: CommonContext):
def get_base_parser(description: typing.Optional[str] = None): def get_base_parser(description: typing.Optional[str] = None):
"""Base argument parser to be reused for components subclassing off of CommonClient"""
import argparse import argparse
parser = argparse.ArgumentParser(description=description) parser = argparse.ArgumentParser(description=description)
parser.add_argument('--connect', default=None, help='Address of the multiworld host.') parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
@@ -1028,7 +994,7 @@ def get_base_parser(description: typing.Optional[str] = None):
return parser return parser
def run_as_textclient(*args): def run_as_textclient():
class TextContext(CommonContext): class TextContext(CommonContext):
# Text Mode to use !hint and such with games that have no text entry # Text Mode to use !hint and such with games that have no text entry
tags = CommonContext.tags | {"TextOnly"} tags = CommonContext.tags | {"TextOnly"}
@@ -1067,21 +1033,16 @@ def run_as_textclient(*args):
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.") parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
parser.add_argument('--name', default=None, help="Slot Name to connect as.") parser.add_argument('--name', default=None, help="Slot Name to connect as.")
parser.add_argument("url", nargs="?", help="Archipelago connection url") parser.add_argument("url", nargs="?", help="Archipelago connection url")
args = parser.parse_args(args) args = parser.parse_args()
# handle if text client is launched using the "archipelago://name:pass@host:port" url from webhost
if args.url: if args.url:
url = urllib.parse.urlparse(args.url) url = urllib.parse.urlparse(args.url)
if url.scheme == "archipelago": args.connect = url.netloc
args.connect = url.netloc if url.username:
if url.username: args.name = urllib.parse.unquote(url.username)
args.name = urllib.parse.unquote(url.username) if url.password:
if url.password: args.password = urllib.parse.unquote(url.password)
args.password = urllib.parse.unquote(url.password)
else:
parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")
# use colorama to display colored text highlighting on windows
colorama.init() colorama.init()
asyncio.run(main(args)) asyncio.run(main(args))
@@ -1090,4 +1051,4 @@ def run_as_textclient(*args):
if __name__ == '__main__': if __name__ == '__main__':
logging.getLogger().setLevel(logging.INFO) # force log-level to work around log level resetting to WARNING logging.getLogger().setLevel(logging.INFO) # force log-level to work around log level resetting to WARNING
run_as_textclient(*sys.argv[1:]) # default value for parse_args run_as_textclient()

20
Fill.py
View File

@@ -475,26 +475,28 @@ def distribute_items_restrictive(multiworld: MultiWorld,
nonlocal lock_later nonlocal lock_later
lock_later.append(location) lock_later.append(location)
single_player = multiworld.players == 1 and not multiworld.groups
if prioritylocations: if prioritylocations:
# "priority fill" # "priority fill"
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool, fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
single_player_placement=single_player, swap=False, on_place=mark_for_locking, name="Priority") single_player_placement=multiworld.players == 1, swap=False, on_place=mark_for_locking,
name="Priority")
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool) accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations defaultlocations = prioritylocations + defaultlocations
if progitempool: if progitempool:
# "advancement/progression fill" # "advancement/progression fill"
if panic_method == "swap": if panic_method == "swap":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=True, fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
name="Progression", single_player_placement=single_player) swap=True,
name="Progression", single_player_placement=multiworld.players == 1)
elif panic_method == "raise": elif panic_method == "raise":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False, fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
name="Progression", single_player_placement=single_player) swap=False,
name="Progression", single_player_placement=multiworld.players == 1)
elif panic_method == "start_inventory": elif panic_method == "start_inventory":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False, fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
allow_partial=True, name="Progression", single_player_placement=single_player) swap=False, allow_partial=True,
name="Progression", single_player_placement=multiworld.players == 1)
if progitempool: if progitempool:
for item in progitempool: for item in progitempool:
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.") logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")

View File

@@ -43,10 +43,10 @@ def mystery_argparse():
parser.add_argument('--race', action='store_true', default=defaults.race) parser.add_argument('--race', action='store_true', default=defaults.race)
parser.add_argument('--meta_file_path', default=defaults.meta_file_path) parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
parser.add_argument('--log_level', default='info', help='Sets log level') parser.add_argument('--log_level', default='info', help='Sets log level')
parser.add_argument("--csv_output", action="store_true", parser.add_argument('--yaml_output', default=0, type=lambda value: max(int(value), 0),
help="Output rolled player options to csv (made for async multiworld).") help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
parser.add_argument("--plando", default=defaults.plando_options, parser.add_argument('--plando', default=defaults.plando_options,
help="List of options that can be set manually. Can be combined, for example \"bosses, items\"") help='List of options that can be set manually. Can be combined, for example "bosses, items"')
parser.add_argument("--skip_prog_balancing", action="store_true", parser.add_argument("--skip_prog_balancing", action="store_true",
help="Skip progression balancing step during generation.") help="Skip progression balancing step during generation.")
parser.add_argument("--skip_output", action="store_true", parser.add_argument("--skip_output", action="store_true",
@@ -155,8 +155,6 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
erargs.outputpath = args.outputpath erargs.outputpath = args.outputpath
erargs.skip_prog_balancing = args.skip_prog_balancing erargs.skip_prog_balancing = args.skip_prog_balancing
erargs.skip_output = args.skip_output erargs.skip_output = args.skip_output
erargs.name = {}
erargs.csv_output = args.csv_output
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \ settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None) {fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
@@ -204,7 +202,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
if path == args.weights_file_path: # if name came from the weights file, just use base player name if path == args.weights_file_path: # if name came from the weights file, just use base player name
erargs.name[player] = f"Player{player}" erargs.name[player] = f"Player{player}"
elif player not in erargs.name: # if name was not specified, generate it from filename elif not erargs.name[player]: # if name was not specified, generate it from filename
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0] erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter) erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
@@ -217,6 +215,28 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name): if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}") raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
if args.yaml_output:
import yaml
important = {}
for option, player_settings in vars(erargs).items():
if type(player_settings) == dict:
if all(type(value) != list for value in player_settings.values()):
if len(player_settings.values()) > 1:
important[option] = {player: value for player, value in player_settings.items() if
player <= args.yaml_output}
else:
logging.debug(f"No player settings defined for option '{option}'")
else:
if player_settings != "": # is not empty name
important[option] = player_settings
else:
logging.debug(f"No player settings defined for option '{option}'")
if args.outputpath:
os.makedirs(args.outputpath, exist_ok=True)
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
yaml.dump(important, f)
return erargs, seed return erargs, seed

View File

@@ -16,11 +16,10 @@ import multiprocessing
import shlex import shlex
import subprocess import subprocess
import sys import sys
import urllib.parse
import webbrowser import webbrowser
from os.path import isfile from os.path import isfile
from shutil import which from shutil import which
from typing import Callable, Optional, Sequence, Tuple, Union from typing import Callable, Sequence, Union, Optional
import Utils import Utils
import settings import settings
@@ -35,9 +34,7 @@ from Utils import is_frozen, user_path, local_path, init_logging, open_filename,
def open_host_yaml(): def open_host_yaml():
s = settings.get_settings() file = settings.get_settings().filename
file = s.filename
s.save()
assert file, "host.yaml missing" assert file, "host.yaml missing"
if is_linux: if is_linux:
exe = which('sensible-editor') or which('gedit') or \ exe = which('sensible-editor') or which('gedit') or \
@@ -110,81 +107,7 @@ components.extend([
]) ])
def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None: def identify(path: Union[None, str]):
url = urllib.parse.urlparse(path)
queries = urllib.parse.parse_qs(url.query)
launch_args = (path, *launch_args)
client_component = None
text_client_component = None
if "game" in queries:
game = queries["game"][0]
else: # TODO around 0.6.0 - this is for pre this change webhost uri's
game = "Archipelago"
for component in components:
if component.supports_uri and component.game_name == game:
client_component = component
elif component.display_name == "Text Client":
text_client_component = component
from kvui import App, Button, BoxLayout, Label, Clock, Window
class Popup(App):
timer_label: Label
remaining_time: Optional[int]
def __init__(self):
self.title = "Connect to Multiworld"
self.icon = r"data/icon.png"
super().__init__()
def build(self):
layout = BoxLayout(orientation="vertical")
if client_component is None:
self.remaining_time = 7
label_text = (f"A game client able to parse URIs was not detected for {game}.\n"
f"Launching Text Client in 7 seconds...")
self.timer_label = Label(text=label_text)
layout.add_widget(self.timer_label)
Clock.schedule_interval(self.update_label, 1)
else:
layout.add_widget(Label(text="Select client to open and connect with."))
button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4))
text_client_button = Button(
text=text_client_component.display_name,
on_release=lambda *args: run_component(text_client_component, *launch_args)
)
button_row.add_widget(text_client_button)
game_client_button = Button(
text=client_component.display_name,
on_release=lambda *args: run_component(client_component, *launch_args)
)
button_row.add_widget(game_client_button)
layout.add_widget(button_row)
return layout
def update_label(self, dt):
if self.remaining_time > 1:
# countdown the timer and string replace the number
self.remaining_time -= 1
self.timer_label.text = self.timer_label.text.replace(
str(self.remaining_time + 1), str(self.remaining_time)
)
else:
# our timer is finished so launch text client and close down
run_component(text_client_component, *launch_args)
Clock.unschedule(self.update_label)
App.get_running_app().stop()
Window.close()
Popup().run()
def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
if path is None: if path is None:
return None, None return None, None
for component in components: for component in components:
@@ -376,24 +299,20 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
elif not args: elif not args:
args = {} args = {}
path = args.get("Patch|Game|Component|url", None) if args.get("Patch|Game|Component", None) is not None:
if path is not None: file, component = identify(args["Patch|Game|Component"])
if path.startswith("archipelago://"):
handle_uri(path, args.get("args", ()))
return
file, component = identify(path)
if file: if file:
args['file'] = file args['file'] = file
if component: if component:
args['component'] = component args['component'] = component
if not component: if not component:
logging.warning(f"Could not identify Component responsible for {path}") logging.warning(f"Could not identify Component responsible for {args['Patch|Game|Component']}")
if args["update_settings"]: if args["update_settings"]:
update_settings() update_settings()
if "file" in args: if 'file' in args:
run_component(args["component"], args["file"], *args["args"]) run_component(args["component"], args["file"], *args["args"])
elif "component" in args: elif 'component' in args:
run_component(args["component"], *args["args"]) run_component(args["component"], *args["args"])
elif not args["update_settings"]: elif not args["update_settings"]:
run_gui() run_gui()
@@ -403,16 +322,12 @@ if __name__ == '__main__':
init_logging('Launcher') init_logging('Launcher')
Utils.freeze_support() Utils.freeze_support()
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(description='Archipelago Launcher')
description='Archipelago Launcher',
usage="[-h] [--update_settings] [Patch|Game|Component] [-- component args here]"
)
run_group = parser.add_argument_group("Run") run_group = parser.add_argument_group("Run")
run_group.add_argument("--update_settings", action="store_true", run_group.add_argument("--update_settings", action="store_true",
help="Update host.yaml and exit.") help="Update host.yaml and exit.")
run_group.add_argument("Patch|Game|Component|url", type=str, nargs="?", run_group.add_argument("Patch|Game|Component", type=str, nargs="?",
help="Pass either a patch file, a generated game, the component name to run, or a url to " help="Pass either a patch file, a generated game or the name of a component to run.")
"connect with.")
run_group.add_argument("args", nargs="*", run_group.add_argument("args", nargs="*",
help="Arguments to pass to component.") help="Arguments to pass to component.")
main(parser.parse_args()) main(parser.parse_args())

View File

@@ -467,8 +467,6 @@ class LinksAwakeningContext(CommonContext):
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None: def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
self.client = LinksAwakeningClient() self.client = LinksAwakeningClient()
self.slot_data = {}
if magpie: if magpie:
self.magpie_enabled = True self.magpie_enabled = True
self.magpie = MagpieBridge() self.magpie = MagpieBridge()
@@ -566,8 +564,6 @@ class LinksAwakeningContext(CommonContext):
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
if cmd == "Connected": if cmd == "Connected":
self.game = self.slot_info[self.slot].game self.game = self.slot_info[self.slot].game
self.slot_data = args.get("slot_data", {})
# TODO - use watcher_event # TODO - use watcher_event
if cmd == "ReceivedItems": if cmd == "ReceivedItems":
for index, item in enumerate(args["items"], start=args["index"]): for index, item in enumerate(args["items"], start=args["index"]):
@@ -632,7 +628,6 @@ class LinksAwakeningContext(CommonContext):
self.magpie.set_checks(self.client.tracker.all_checks) self.magpie.set_checks(self.client.tracker.all_checks)
await self.magpie.set_item_tracker(self.client.item_tracker) await self.magpie.set_item_tracker(self.client.item_tracker)
await self.magpie.send_gps(self.client.gps_tracker) await self.magpie.send_gps(self.client.gps_tracker)
self.magpie.slot_data = self.slot_data
except Exception: except Exception:
# Don't let magpie errors take out the client # Don't let magpie errors take out the client
pass pass

View File

@@ -46,9 +46,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
multiworld.sprite_pool = args.sprite_pool.copy() multiworld.sprite_pool = args.sprite_pool.copy()
multiworld.set_options(args) multiworld.set_options(args)
if args.csv_output:
from Options import dump_player_options
dump_player_options(multiworld)
multiworld.set_item_links() multiworld.set_item_links()
multiworld.state = CollectionState(multiworld) multiworld.state = CollectionState(multiworld)
logger.info('Archipelago Version %s - Seed: %s\n', __version__, multiworld.seed) logger.info('Archipelago Version %s - Seed: %s\n', __version__, multiworld.seed)
@@ -338,7 +335,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
"seed_name": multiworld.seed_name, "seed_name": multiworld.seed_name,
"spheres": spheres, "spheres": spheres,
"datapackage": data_package, "datapackage": data_package,
"race_mode": int(multiworld.is_race),
} }
AutoWorld.call_all(multiworld, "modify_multidata", multidata) AutoWorld.call_all(multiworld, "modify_multidata", multidata)

View File

@@ -15,7 +15,6 @@ import math
import operator import operator
import pickle import pickle
import random import random
import shlex
import threading import threading
import time import time
import typing import typing
@@ -185,9 +184,11 @@ class Context:
slot_info: typing.Dict[int, NetworkSlot] slot_info: typing.Dict[int, NetworkSlot]
generator_version = Version(0, 0, 0) generator_version = Version(0, 0, 0)
checksums: typing.Dict[str, str] checksums: typing.Dict[str, str]
item_names: typing.Dict[str, typing.Dict[int, str]] item_names: typing.Dict[str, typing.Dict[int, str]] = (
collections.defaultdict(lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')))
item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]] item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
location_names: typing.Dict[str, typing.Dict[int, str]] location_names: typing.Dict[str, typing.Dict[int, str]] = (
collections.defaultdict(lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')))
location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]] location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
all_item_and_group_names: typing.Dict[str, typing.Set[str]] all_item_and_group_names: typing.Dict[str, typing.Set[str]]
all_location_and_group_names: typing.Dict[str, typing.Set[str]] all_location_and_group_names: typing.Dict[str, typing.Set[str]]
@@ -196,6 +197,7 @@ class Context:
""" each sphere is { player: { location_id, ... } } """ """ each sphere is { player: { location_id, ... } } """
logger: logging.Logger logger: logging.Logger
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int, def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled", hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2, remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
@@ -266,10 +268,6 @@ class Context:
self.location_name_groups = {} self.location_name_groups = {}
self.all_item_and_group_names = {} self.all_item_and_group_names = {}
self.all_location_and_group_names = {} self.all_location_and_group_names = {}
self.item_names = collections.defaultdict(
lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})'))
self.location_names = collections.defaultdict(
lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})'))
self.non_hintable_names = collections.defaultdict(frozenset) self.non_hintable_names = collections.defaultdict(frozenset)
self._load_game_data() self._load_game_data()
@@ -429,8 +427,6 @@ class Context:
use_embedded_server_options: bool): use_embedded_server_options: bool):
self.read_data = {} self.read_data = {}
# there might be a better place to put this.
self.read_data["race_mode"] = lambda: decoded_obj.get("race_mode", 0)
mdata_ver = decoded_obj["minimum_versions"]["server"] mdata_ver = decoded_obj["minimum_versions"]["server"]
if mdata_ver > version_tuple: if mdata_ver > version_tuple:
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver}," raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
@@ -1154,10 +1150,7 @@ class CommandProcessor(metaclass=CommandMeta):
if not raw: if not raw:
return return
try: try:
try: command = raw.split()
command = shlex.split(raw, comments=False)
except ValueError: # most likely: "ValueError: No closing quotation"
command = raw.split()
basecommand = command[0] basecommand = command[0]
if basecommand[0] == self.marker: if basecommand[0] == self.marker:
method = self.commands.get(basecommand[1:].lower(), None) method = self.commands.get(basecommand[1:].lower(), None)

View File

@@ -273,8 +273,7 @@ class RawJSONtoTextParser(JSONtoTextParser):
color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34, color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34,
'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43, 'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43,
'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47, 'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47}
'plum': 35, 'slateblue': 34, 'salmon': 31,} # convert ui colors to terminal colors
def color_code(*args): def color_code(*args):

View File

@@ -8,17 +8,16 @@ import numbers
import random import random
import typing import typing
import enum import enum
from collections import defaultdict
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass from dataclasses import dataclass
from schema import And, Optional, Or, Schema from schema import And, Optional, Or, Schema
from typing_extensions import Self from typing_extensions import Self
from Utils import get_fuzzy_results, is_iterable_except_str, output_path from Utils import get_fuzzy_results, is_iterable_except_str
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from BaseClasses import MultiWorld, PlandoOptions from BaseClasses import PlandoOptions
from worlds.AutoWorld import World from worlds.AutoWorld import World
import pathlib import pathlib
@@ -974,19 +973,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
if random.random() < float(text.get("percentage", 100)/100): if random.random() < float(text.get("percentage", 100)/100):
at = text.get("at", None) at = text.get("at", None)
if at is not None: if at is not None:
if isinstance(at, dict):
if at:
at = random.choices(list(at.keys()),
weights=list(at.values()), k=1)[0]
else:
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
given_text = text.get("text", []) given_text = text.get("text", [])
if isinstance(given_text, dict):
if not given_text:
given_text = []
else:
given_text = random.choices(list(given_text.keys()),
weights=list(given_text.values()), k=1)
if isinstance(given_text, str): if isinstance(given_text, str):
given_text = [given_text] given_text = [given_text]
texts.append(PlandoText( texts.append(PlandoText(
@@ -994,8 +981,6 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
given_text, given_text,
text.get("percentage", 100) text.get("percentage", 100)
)) ))
else:
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
elif isinstance(text, PlandoText): elif isinstance(text, PlandoText):
if random.random() < float(text.percentage/100): if random.random() < float(text.percentage/100):
texts.append(text) texts.append(text)
@@ -1336,7 +1321,7 @@ class PriorityLocations(LocationSet):
class DeathLink(Toggle): class DeathLink(Toggle):
"""When you die, everyone who enabled death link dies. Of course, the reverse is true too.""" """When you die, everyone dies. Of course the reverse is true too."""
display_name = "Death Link" display_name = "Death Link"
rich_text_doc = True rich_text_doc = True
@@ -1533,42 +1518,3 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
with open(os.path.join(target_folder, game_name + ".yaml"), "w", encoding="utf-8-sig") as f: with open(os.path.join(target_folder, game_name + ".yaml"), "w", encoding="utf-8-sig") as f:
f.write(res) f.write(res)
def dump_player_options(multiworld: MultiWorld) -> None:
from csv import DictWriter
game_players = defaultdict(list)
for player, game in multiworld.game.items():
game_players[game].append(player)
game_players = dict(sorted(game_players.items()))
output = []
per_game_option_names = [
getattr(option, "display_name", option_key)
for option_key, option in PerGameCommonOptions.type_hints.items()
]
all_option_names = per_game_option_names.copy()
for game, players in game_players.items():
game_option_names = per_game_option_names.copy()
for player in players:
world = multiworld.worlds[player]
player_output = {
"Game": multiworld.game[player],
"Name": multiworld.get_player_name(player),
}
output.append(player_output)
for option_key, option in world.options_dataclass.type_hints.items():
if issubclass(Removed, option):
continue
display_name = getattr(option, "display_name", option_key)
player_output[display_name] = getattr(world.options, option_key).current_option_name
if display_name not in game_option_names:
all_option_names.append(display_name)
game_option_names.append(display_name)
with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file:
fields = ["Game", "Name", *all_option_names]
writer = DictWriter(file, fields)
writer.writeheader()
writer.writerows(output)

View File

@@ -46,7 +46,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self) return ".".join(str(item) for item in self)
__version__ = "0.5.1" __version__ = "0.5.0"
version_tuple = tuplize_version(__version__) version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux") is_linux = sys.platform.startswith("linux")

View File

@@ -267,7 +267,9 @@ class WargrooveContext(CommonContext):
def build(self): def build(self):
container = super().build() container = super().build()
self.add_client_tab("Wargroove", self.build_tracker()) panel = TabbedPanelItem(text="Wargroove")
panel.content = self.build_tracker()
self.tabs.add_widget(panel)
return container return container
def build_tracker(self) -> TrackerLayout: def build_tracker(self) -> TrackerLayout:

View File

@@ -1,15 +1,51 @@
"""API endpoints package.""" """API endpoints package."""
from typing import List, Tuple from typing import List, Tuple
from uuid import UUID
from flask import Blueprint from flask import Blueprint, abort, url_for
from ..models import Seed import worlds.Files
from ..models import Room, Seed
api_endpoints = Blueprint('api', __name__, url_prefix="/api") api_endpoints = Blueprint('api', __name__, url_prefix="/api")
# unsorted/misc endpoints
def get_players(seed: Seed) -> List[Tuple[str, str]]: def get_players(seed: Seed) -> List[Tuple[str, str]]:
return [(slot.player_name, slot.game) for slot in seed.slots] return [(slot.player_name, slot.game) for slot in seed.slots]
from . import datapackage, generate, room, user # trigger registration @api_endpoints.route('/room_status/<suuid:room>')
def room_info(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
def supports_apdeltapatch(game: str):
return game in worlds.Files.AutoPatchRegister.patch_types
downloads = []
for slot in sorted(room.seed.slots):
if slot.data and not supports_apdeltapatch(slot.game):
slot_download = {
"slot": slot.player_id,
"download": url_for("download_slot_file", room_id=room.id, player_id=slot.player_id)
}
downloads.append(slot_download)
elif slot.data:
slot_download = {
"slot": slot.player_id,
"download": url_for("download_patch", patch_id=slot.id, room_id=room.id)
}
downloads.append(slot_download)
return {
"tracker": room.tracker,
"players": get_players(room.seed),
"last_port": room.last_port,
"last_activity": room.last_activity,
"timeout": room.timeout,
"downloads": downloads,
}
from . import generate, user, datapackage # trigger registration

View File

@@ -1,42 +0,0 @@
from typing import Any, Dict
from uuid import UUID
from flask import abort, url_for
import worlds.Files
from . import api_endpoints, get_players
from ..models import Room
@api_endpoints.route('/room_status/<suuid:room_id>')
def room_info(room_id: UUID) -> Dict[str, Any]:
room = Room.get(id=room_id)
if room is None:
return abort(404)
def supports_apdeltapatch(game: str) -> bool:
return game in worlds.Files.AutoPatchRegister.patch_types
downloads = []
for slot in sorted(room.seed.slots):
if slot.data and not supports_apdeltapatch(slot.game):
slot_download = {
"slot": slot.player_id,
"download": url_for("download_slot_file", room_id=room.id, player_id=slot.player_id)
}
downloads.append(slot_download)
elif slot.data:
slot_download = {
"slot": slot.player_id,
"download": url_for("download_patch", patch_id=slot.id, room_id=room.id)
}
downloads.append(slot_download)
return {
"tracker": room.tracker,
"players": get_players(room.seed),
"last_port": room.last_port,
"last_activity": room.last_activity,
"timeout": room.timeout,
"downloads": downloads,
}

View File

@@ -81,7 +81,6 @@ def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any])
elif len(gen_options) > app.config["MAX_ROLL"]: elif len(gen_options) > app.config["MAX_ROLL"]:
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. " flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. "
f"If you have a larger group, please generate it yourself and upload it.") f"If you have a larger group, please generate it yourself and upload it.")
return redirect(url_for(request.endpoint, **(request.view_args or {})))
elif len(gen_options) >= app.config["JOB_THRESHOLD"]: elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
gen = Generation( gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}), options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
@@ -135,7 +134,6 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
{"bosses", "items", "connections", "texts"})) {"bosses", "items", "connections", "texts"}))
erargs.skip_prog_balancing = False erargs.skip_prog_balancing = False
erargs.skip_output = False erargs.skip_output = False
erargs.csv_output = False
name_counter = Counter() name_counter = Counter()
for player, (playerfile, settings) in enumerate(gen_options.items(), 1): for player, (playerfile, settings) in enumerate(gen_options.items(), 1):

View File

@@ -5,7 +5,6 @@ from typing import Any, IO, Dict, Iterator, List, Tuple, Union
import jinja2.exceptions import jinja2.exceptions
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
from pony.orm import count, commit, db_session from pony.orm import count, commit, db_session
from werkzeug.utils import secure_filename
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
from . import app, cache from . import app, cache
@@ -70,28 +69,14 @@ def tutorial_landing():
@app.route('/faq/<string:lang>/') @app.route('/faq/<string:lang>/')
@cache.cached() @cache.cached()
def faq(lang: str): def faq(lang):
import markdown return render_template("faq.html", lang=lang)
with open(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md")) as f:
document = f.read()
return render_template(
"markdown_document.html",
title="Frequently Asked Questions",
html_from_markdown=markdown.markdown(document, extensions=["mdx_breakless_lists"]),
)
@app.route('/glossary/<string:lang>/') @app.route('/glossary/<string:lang>/')
@cache.cached() @cache.cached()
def glossary(lang: str): def terms(lang):
import markdown return render_template("glossary.html", lang=lang)
with open(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md")) as f:
document = f.read()
return render_template(
"markdown_document.html",
title="Glossary",
html_from_markdown=markdown.markdown(document, extensions=["mdx_breakless_lists"]),
)
@app.route('/seed/<suuid:seed>') @app.route('/seed/<suuid:seed>')
@@ -147,41 +132,26 @@ def display_log(room: UUID) -> Union[str, Response, Tuple[str, int]]:
return "Access Denied", 403 return "Access Denied", 403
@app.post("/room/<suuid:room>") @app.route('/room/<suuid:room>', methods=['GET', 'POST'])
def host_room_command(room: UUID):
room: Room = Room.get(id=room)
if room is None:
return abort(404)
if room.owner == session["_id"]:
cmd = request.form["cmd"]
if cmd:
Command(room=room, commandtext=cmd)
commit()
return redirect(url_for("host_room", room=room.id))
@app.get("/room/<suuid:room>")
def host_room(room: UUID): def host_room(room: UUID):
room: Room = Room.get(id=room) room: Room = Room.get(id=room)
if room is None: if room is None:
return abort(404) return abort(404)
if request.method == "POST":
if room.owner == session["_id"]:
cmd = request.form["cmd"]
if cmd:
Command(room=room, commandtext=cmd)
commit()
return redirect(url_for("host_room", room=room.id))
now = datetime.datetime.utcnow() now = datetime.datetime.utcnow()
# indicate that the page should reload to get the assigned port # indicate that the page should reload to get the assigned port
should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3)) should_refresh = not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3)
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
with db_session: with db_session:
room.last_activity = now # will trigger a spinup, if it's not already running room.last_activity = now # will trigger a spinup, if it's not already running
browser_tokens = "Mozilla", "Chrome", "Safari" def get_log(max_size: int = 1024000) -> str:
automated = ("update" in request.args
or "Discordbot" in request.user_agent.string
or not any(browser_token in request.user_agent.string for browser_token in browser_tokens))
def get_log(max_size: int = 0 if automated else 1024000) -> str:
if max_size == 0:
return ""
try: try:
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log: with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
raw_size = 0 raw_size = 0

View File

@@ -9,5 +9,3 @@ bokeh>=3.1.1; python_version <= '3.8'
bokeh>=3.4.3; python_version == '3.9' bokeh>=3.4.3; python_version == '3.9'
bokeh>=3.5.2; python_version >= '3.10' bokeh>=3.5.2; python_version >= '3.10'
markupsafe>=2.1.5 markupsafe>=2.1.5
Markdown>=3.7
mdx-breakless-lists>=1.0.1

View File

@@ -0,0 +1,51 @@
window.addEventListener('load', () => {
const tutorialWrapper = document.getElementById('faq-wrapper');
new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status === 404) {
reject("Sorry, the tutorial is not available in that language yet.");
return;
}
if (ajax.status !== 200) {
reject("Something went wrong while loading the tutorial.");
return;
}
resolve(ajax.responseText);
};
ajax.open('GET', `${window.location.origin}/static/assets/faq/` +
`faq_${tutorialWrapper.getAttribute('data-lang')}.md`, true);
ajax.send();
}).then((results) => {
// Populate page with HTML generated from markdown
showdown.setOption('tables', true);
showdown.setOption('strikethrough', true);
showdown.setOption('literalMidWordUnderscores', true);
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
adjustHeaderWidth();
// Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
header.setAttribute('id', headerId);
header.addEventListener('click', () => {
window.location.hash = `#${headerId}`;
header.scrollIntoView();
});
}
// Manually scroll the user to the appropriate header if anchor navigation is used
document.fonts.ready.finally(() => {
if (window.location.hash) {
const scrollTarget = document.getElementById(window.location.hash.substring(1));
scrollTarget?.scrollIntoView();
}
});
}).catch((error) => {
console.error(error);
tutorialWrapper.innerHTML =
`<h2>This page is out of logic!</h2>
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
});
});

View File

@@ -0,0 +1,51 @@
window.addEventListener('load', () => {
const tutorialWrapper = document.getElementById('glossary-wrapper');
new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status === 404) {
reject("Sorry, the glossary page is not available in that language yet.");
return;
}
if (ajax.status !== 200) {
reject("Something went wrong while loading the glossary.");
return;
}
resolve(ajax.responseText);
};
ajax.open('GET', `${window.location.origin}/static/assets/faq/` +
`glossary_${tutorialWrapper.getAttribute('data-lang')}.md`, true);
ajax.send();
}).then((results) => {
// Populate page with HTML generated from markdown
showdown.setOption('tables', true);
showdown.setOption('strikethrough', true);
showdown.setOption('literalMidWordUnderscores', true);
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
adjustHeaderWidth();
// Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
header.setAttribute('id', headerId);
header.addEventListener('click', () => {
window.location.hash = `#${headerId}`;
header.scrollIntoView();
});
}
// Manually scroll the user to the appropriate header if anchor navigation is used
document.fonts.ready.finally(() => {
if (window.location.hash) {
const scrollTarget = document.getElementById(window.location.hash.substring(1));
scrollTarget?.scrollIntoView();
}
});
}).catch((error) => {
console.error(error);
tutorialWrapper.innerHTML =
`<h2>This page is out of logic!</h2>
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
});
});

View File

@@ -288,11 +288,6 @@ const applyPresets = (presetName) => {
} }
}); });
namedRangeSelect.value = trueValue; namedRangeSelect.value = trueValue;
// It is also possible for a preset to use an unnamed value. If this happens, set the dropdown to "Custom"
if (namedRangeSelect.selectedIndex == -1)
{
namedRangeSelect.value = "custom";
}
} }
// Handle options whose presets are "random" // Handle options whose presets are "random"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 229 KiB

View File

@@ -1,66 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 240 38" style="enable-background:new 0 0 240 38;" xml:space="preserve">
<style type="text/css">
.st0{fill:#316B84;}
</style>
<g>
<g>
<path class="st0" d="M59.72,27.96L53.03,4.21L42.25,4.04l1.42,4.37l1.41-0.26l-7.9,24.22h8.44l-0.56-2.27l-0.81-3.27l8.9-5.7
l1.78,11.24h7.97v-4.73L59.72,27.96z M45.62,20.21l3.13-10.84h1.5l2.02,7.44L45.62,20.21z"/>
<path class="st0" d="M78.67,27.96V20.4l-4.11-2.5l3.29-3.78l-0.47-7.46l-2.82-2.45H56.65v5.27l3.81-1.11l2.31,13.36l-2.79,0.73
L61,26.34l5.06-0.52l0.36-6.15l4.32,0.13l3.16,3.62v8.94l12.89,1.49v-5.34L78.67,27.96z M73.27,13.33l-2.18,1.45h-4.64l-0.42-6.57
h5.68l1.55,1.37V13.33z"/>
<polygon class="st0" points="84.65,4.21 93.01,4.21 95.75,6.46 96.26,10.9 92.23,12.43 91.77,9.74 88.97,8.28 85.86,9.82
83.88,15.02 85.51,20.94 88.49,22.38 91.99,20.59 91.99,18.59 96.26,16.96 95.85,22.85 91.81,26.87 84.17,26.55 80.79,23.58
78.87,14.87 80.79,6.94 "/>
<polygon class="st0" points="97.62,4.21 103.33,4.21 102.96,21.08 108.7,20.14 108.34,6.42 113.85,3.28 113.9,19.9 115.75,19.71
115.27,25.86 113.88,25.86 114.27,32.36 108.7,32.36 108.7,26.39 102.96,26.39 102.96,32.36 91.77,33.85 92.2,28.85 97.88,27.96
"/>
<polygon class="st0" points="147.43,28.86 147.43,32.36 162.85,32.36 162.48,25.36 159.5,26 158.89,27.68 154.1,27.24
154.1,21.51 160.81,20.85 160.81,16.48 153.86,16.54 153.86,9.18 158.62,8.43 159.22,9.77 161.85,10.06 162.59,4 147.43,4
147.43,6.54 148.68,7.46 148.68,28.4 "/>
<polygon class="st0" points="163.89,9.24 163.89,4 172.31,4 170.35,26.87 179.55,24.74 179.55,32.36 164.51,32.34 164.65,28.71
165.73,27.84 165.73,9.59 "/>
<path class="st0" d="M193.69,32.36l-0.63-2.51l-2.84-1.89l-4.29-20.14L185.9,4h-11.27l-0.03,3.2l1.87-0.34l-2.79,14.07l-1.37,0.57
v2.85l6.29-1.33l0.4-2.7l4.65-0.89l1.69,12.93H193.69z M179.39,15.11l1.65-6.52l0.89,0.25l0.92,5.45L179.39,15.11z"/>
<polygon class="st0" points="208.47,21.68 210.62,21.12 210.04,18.15 200.51,17.46 198.87,21.13 203.56,21.9 203.32,23.91
200.58,25.19 196.44,23.77 194.48,17.19 196.2,10.02 200.08,8.52 203.31,9.62 202.85,11.75 207.79,13.6 208.83,9.69 204.71,4.21
195.57,4.21 191.24,7.36 189.29,16.87 192.06,27.54 199.03,30.53 203.2,29.3 203.09,32.36 209.01,32.36 209.4,29.95 207.38,28.99
"/>
<path class="st0" d="M230.45,6.26L226.39,4l-8.59-0.01l-4.07,2.86l-2.58,8.9l1.52,11.82l5.61,4.73l7.65,0.01l5.72-4.59l2.47-12.46
L230.45,6.26z M228.23,21.75l-3.95,5.45l-2.16,0.43l-4.6-3.46L216,15.72l2.4-7.02l5.14-0.48l2.97,1.79l1.74,5.83L228.23,21.75z"/>
<path class="st0" d="M116.13,27.48l-0.24,4.88l12.26,0.09l-0.83-5.01l-2.86-0.48l0.14-17.62l2.45-0.42l-0.14-4.85l-10.92,0.36
l0.1,4.6l3.2,0.63l-0.42,17.67L116.13,27.48z"/>
<path class="st0" d="M141.34,4.21l-12.88-0.39v4.26l1.95,0.62v25.15l-1.8,1.41l-0.02,2.63h8.23L136,27.96h6.09l4.57-4.46V7.27
L141.34,4.21z M141.38,20.51l-2.54,1.89l-3.23,0.16L135.4,9.32h3.88l2.1,1.68V20.51z"/>
</g>
<g>
<path class="st0" d="M14.14,11.28c0,0.35-0.02,0.71-0.07,1.05c0.38,0.07,0.76,0.11,1.16,0.11s0.79-0.04,1.16-0.11
c-0.05-0.34-0.07-0.7-0.07-1.05c0-3.23,1.94-6.02,4.72-7.25C20.17,1.68,17.9,0,15.24,0S10.3,1.68,9.42,4.03
C12.2,5.26,14.14,8.04,14.14,11.28z"/>
<path class="st0" d="M18.04,11.28c0,0.16,0.01,0.32,0.02,0.48c0.02,0.3,0.06,0.6,0.13,0.88c0.06,0.28,0.15,0.56,0.25,0.83
c0.11,0.3,0.24,0.58,0.39,0.85c1.42-1.33,3.33-2.15,5.42-2.15s4.01,0.82,5.42,2.15c0.51-0.9,0.79-1.94,0.79-3.04
c0-3.42-2.79-6.22-6.22-6.22c-0.4,0-0.79,0.04-1.16,0.11c-0.28,0.06-0.56,0.13-0.83,0.22c-0.28,0.09-0.56,0.21-0.83,0.35
C19.42,6.77,18.04,8.87,18.04,11.28z"/>
<path class="st0" d="M6.22,12.16c2.1,0,4.01,0.82,5.42,2.15c0.15-0.27,0.28-0.55,0.39-0.85c0.1-0.27,0.19-0.54,0.25-0.83
c0.06-0.28,0.11-0.58,0.13-0.88c0.02-0.15,0.02-0.32,0.02-0.48c0-2.41-1.38-4.51-3.39-5.54C8.77,5.6,8.5,5.49,8.21,5.39
c-0.27-0.1-0.55-0.17-0.83-0.22C7,5.1,6.61,5.06,6.22,5.06C2.79,5.06,0,7.85,0,11.28c0,1.1,0.28,2.14,0.79,3.04
C2.21,12.98,4.12,12.16,6.22,12.16z"/>
<path class="st0" d="M29.21,16.33c-0.18-0.23-0.36-0.44-0.57-0.65c-1.12-1.12-2.67-1.81-4.38-1.81c-1.71,0-3.25,0.69-4.38,1.81
c-0.2,0.2-0.39,0.42-0.56,0.64c-0.18,0.23-0.34,0.47-0.47,0.72c-0.2,0.34-0.36,0.71-0.48,1.09c2.83,1.21,4.81,4.02,4.81,7.28
c0,0.26-0.01,0.52-0.04,0.78c0.37,0.07,0.75,0.1,1.13,0.1c3.43,0,6.22-2.79,6.22-6.22c0-1.11-0.29-2.14-0.8-3.04
C29.54,16.8,29.38,16.56,29.21,16.33z"/>
<path class="st0" d="M12.12,18.14c-0.13-0.38-0.28-0.75-0.48-1.09c-0.14-0.26-0.3-0.5-0.47-0.72c-0.17-0.23-0.36-0.44-0.56-0.64
c-1.12-1.12-2.67-1.81-4.38-1.81s-3.26,0.69-4.38,1.81c-0.21,0.2-0.39,0.42-0.56,0.64c-0.18,0.23-0.34,0.47-0.47,0.72
C0.29,17.94,0,18.98,0,20.08c0,3.43,2.79,6.22,6.22,6.22c0.39,0,0.76-0.03,1.13-0.1c-0.03-0.26-0.04-0.52-0.04-0.78
C7.31,22.15,9.29,19.34,12.12,18.14z"/>
<path class="st0" d="M18.04,19.87c-0.27-0.14-0.55-0.26-0.84-0.35c-0.27-0.09-0.55-0.17-0.84-0.22c-0.37-0.07-0.75-0.1-1.13-0.1
s-0.76,0.03-1.13,0.1c-0.28,0.05-0.57,0.13-0.84,0.22c-0.29,0.1-0.57,0.22-0.84,0.35C10.4,20.9,9.02,23,9.02,25.42
c0,0.07,0,0.14,0.01,0.21c0.01,0.31,0.04,0.61,0.1,0.9c0.05,0.28,0.12,0.57,0.21,0.84c0.82,2.48,3.16,4.27,5.9,4.27
s5.08-1.79,5.9-4.27c0.09-0.27,0.17-0.55,0.21-0.84c0.06-0.3,0.09-0.6,0.1-0.91c0.01-0.07,0.01-0.14,0.01-0.21
C21.45,23,20.07,20.9,18.04,19.87z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@@ -1 +1,66 @@
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0" y="0" viewBox="0 0 240 38" style="enable-background:new 0 0 240 38" xml:space="preserve"><style>.st0{fill:#316b84}</style><path class="st0" d="M59.72 27.96 53.03 4.21l-10.78-.17 1.42 4.37 1.41-.26-7.9 24.22h8.44l-.56-2.27-.81-3.27 8.9-5.7 1.78 11.24h7.97v-4.73l-3.18.32zm-14.1-7.75 3.13-10.84h1.5l2.02 7.44-6.65 3.4z"/><path class="st0" d="M78.67 27.96V20.4l-4.11-2.5 3.29-3.78-.47-7.46-2.82-2.45H56.65v5.27l3.81-1.11 2.31 13.36-2.79.73L61 26.34l5.06-.52.36-6.15 4.32.13 3.16 3.62v8.94l12.89 1.49v-5.34l-8.12-.55zm-5.4-14.63-2.18 1.45h-4.64l-.42-6.57h5.68l1.55 1.37v3.75z"/><path class="st0" d="M84.65 4.21h8.36l2.74 2.25.51 4.44-4.03 1.53-.46-2.69-2.8-1.46-3.11 1.54-1.98 5.2 1.63 5.92 2.98 1.44 3.5-1.79v-2l4.27-1.63-.41 5.89-4.04 4.02-7.64-.32-3.38-2.97-1.92-8.71 1.92-7.93z"/><path class="st0" d="M97.62 4.21h5.71l-.37 16.87 5.74-.94-.36-13.72 5.51-3.14.05 16.62 1.85-.19-.48 6.15h-1.39l.39 6.5h-5.57v-5.97h-5.74v5.97l-11.19 1.49.43-5 5.68-.89zm49.81 24.65v3.5h15.42l-.37-7-2.98.64-.61 1.68-4.79-.44v-5.73l6.71-.66v-4.37l-6.95.06V9.18l4.76-.75.6 1.34 2.63.29.74-6.06h-15.16v2.54l1.25.92V28.4zm16.46-19.62V4h8.42l-1.96 22.87 9.2-2.13v7.62l-15.04-.02.14-3.63 1.08-.87V9.59z"/><path class="st0" d="m193.69 32.36-.63-2.51-2.84-1.89-4.29-20.14L185.9 4h-11.27l-.03 3.2 1.87-.34-2.79 14.07-1.37.57v2.85l6.29-1.33.4-2.7 4.65-.89 1.69 12.93h8.35zm-14.3-17.25 1.65-6.52.89.25.92 5.45-3.46.82z"/><path class="st0" d="m208.47 21.68 2.15-.56-.58-2.97-9.53-.69-1.64 3.67 4.69.77-.24 2.01-2.74 1.28-4.14-1.42-1.96-6.58 1.72-7.17 3.88-1.5 3.23 1.1-.46 2.13 4.94 1.85 1.04-3.91-4.12-5.48h-9.14l-4.33 3.15-1.95 9.51 2.77 10.67 6.97 2.99 4.17-1.23-.11 3.06h5.92l.39-2.41-2.02-.96zm21.98-15.42L226.39 4l-8.59-.01-4.07 2.86-2.58 8.9 1.52 11.82 5.61 4.73 7.65.01 5.72-4.59 2.47-12.46-3.67-9zm-2.22 15.49-3.95 5.45-2.16.43-4.6-3.46-1.52-8.45 2.4-7.02 5.14-.48 2.97 1.79 1.74 5.83-.02 5.91zm-112.1 5.73-.24 4.88 12.26.09-.83-5.01-2.86-.48.14-17.62 2.45-.42-.14-4.85-10.92.36.1 4.6 3.2.63-.42 17.67-2.74.15zm25.21-23.27-12.88-.39v4.26l1.95.62v25.15l-1.8 1.41-.02 2.63h8.23l-.82-9.93h6.09l4.57-4.46V7.27l-5.32-3.06zm.04 16.3-2.54 1.89-3.23.16-.21-13.24h3.88l2.1 1.68v9.51zM14.14 11.28c0 .35-.02.71-.07 1.05.38.07.76.11 1.16.11s.79-.04 1.16-.11a7.933 7.933 0 0 1 4.65-8.3C20.17 1.68 17.9 0 15.24 0S10.3 1.68 9.42 4.03a7.922 7.922 0 0 1 4.72 7.25z"/><path class="st0" d="M18.04 11.28c0 .16.01.32.02.48.02.3.06.6.13.88.06.28.15.56.25.83.11.3.24.58.39.85 1.42-1.33 3.33-2.15 5.42-2.15s4.01.82 5.42 2.15c.51-.9.79-1.94.79-3.04 0-3.42-2.79-6.22-6.22-6.22-.4 0-.79.04-1.16.11-.28.06-.56.13-.83.22-.28.09-.56.21-.83.35a6.24 6.24 0 0 0-3.38 5.54zm-11.82.88c2.1 0 4.01.82 5.42 2.15.15-.27.28-.55.39-.85.1-.27.19-.54.25-.83.06-.28.11-.58.13-.88.02-.15.02-.32.02-.48a6.23 6.23 0 0 0-3.39-5.54c-.27-.13-.54-.24-.83-.34-.27-.1-.55-.17-.83-.22a6.42 6.42 0 0 0-1.16-.11 6.227 6.227 0 0 0-5.43 9.26 7.885 7.885 0 0 1 5.43-2.16z"/><path class="st0" d="M29.21 16.33c-.18-.23-.36-.44-.57-.65a6.174 6.174 0 0 0-4.38-1.81 6.192 6.192 0 0 0-4.94 2.45c-.18.23-.34.47-.47.72-.2.34-.36.71-.48 1.09a7.923 7.923 0 0 1 4.77 8.06c.37.07.75.1 1.13.1 3.43 0 6.22-2.79 6.22-6.22 0-1.11-.29-2.14-.8-3.04-.15-.23-.31-.47-.48-.7zm-17.09 1.81c-.13-.38-.28-.75-.48-1.09-.14-.26-.3-.5-.47-.72-.17-.23-.36-.44-.56-.64-1.12-1.12-2.67-1.81-4.38-1.81s-3.26.69-4.38 1.81c-.21.2-.39.42-.56.64-.18.23-.34.47-.47.72-.53.89-.82 1.93-.82 3.03 0 3.43 2.79 6.22 6.22 6.22.39 0 .76-.03 1.13-.1a7.902 7.902 0 0 1 4.77-8.06z"/><path class="st0" d="M18.04 19.87c-.27-.14-.55-.26-.84-.35-.27-.09-.55-.17-.84-.22-.37-.07-.75-.1-1.13-.1s-.76.03-1.13.1c-.28.05-.57.13-.84.22-.29.1-.57.22-.84.35a6.225 6.225 0 0 0-3.4 5.55c0 .07 0 .14.01.21.01.31.04.61.1.9.05.28.12.57.21.84.82 2.48 3.16 4.27 5.9 4.27s5.08-1.79 5.9-4.27c.09-.27.17-.55.21-.84.06-.3.09-.6.1-.91.01-.07.01-.14.01-.21a6.24 6.24 0 0 0-3.42-5.54z"/></svg> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 240 38" style="enable-background:new 0 0 240 38;" xml:space="preserve">
<style type="text/css">
.st0{fill:#316B84;}
</style>
<g>
<g>
<path class="st0" d="M59.72,27.96L53.03,4.21L42.25,4.04l1.42,4.37l1.41-0.26l-7.9,24.22h8.44l-0.56-2.27l-0.81-3.27l8.9-5.7
l1.78,11.24h7.97v-4.73L59.72,27.96z M45.62,20.21l3.13-10.84h1.5l2.02,7.44L45.62,20.21z"/>
<path class="st0" d="M78.67,27.96V20.4l-4.11-2.5l3.29-3.78l-0.47-7.46l-2.82-2.45H56.65v5.27l3.81-1.11l2.31,13.36l-2.79,0.73
L61,26.34l5.06-0.52l0.36-6.15l4.32,0.13l3.16,3.62v8.94l12.89,1.49v-5.34L78.67,27.96z M73.27,13.33l-2.18,1.45h-4.64l-0.42-6.57
h5.68l1.55,1.37V13.33z"/>
<polygon class="st0" points="84.65,4.21 93.01,4.21 95.75,6.46 96.26,10.9 92.23,12.43 91.77,9.74 88.97,8.28 85.86,9.82
83.88,15.02 85.51,20.94 88.49,22.38 91.99,20.59 91.99,18.59 96.26,16.96 95.85,22.85 91.81,26.87 84.17,26.55 80.79,23.58
78.87,14.87 80.79,6.94 "/>
<polygon class="st0" points="97.62,4.21 103.33,4.21 102.96,21.08 108.7,20.14 108.34,6.42 113.85,3.28 113.9,19.9 115.75,19.71
115.27,25.86 113.88,25.86 114.27,32.36 108.7,32.36 108.7,26.39 102.96,26.39 102.96,32.36 91.77,33.85 92.2,28.85 97.88,27.96
"/>
<polygon class="st0" points="147.43,28.86 147.43,32.36 162.85,32.36 162.48,25.36 159.5,26 158.89,27.68 154.1,27.24
154.1,21.51 160.81,20.85 160.81,16.48 153.86,16.54 153.86,9.18 158.62,8.43 159.22,9.77 161.85,10.06 162.59,4 147.43,4
147.43,6.54 148.68,7.46 148.68,28.4 "/>
<polygon class="st0" points="163.89,9.24 163.89,4 172.31,4 170.35,26.87 179.55,24.74 179.55,32.36 164.51,32.34 164.65,28.71
165.73,27.84 165.73,9.59 "/>
<path class="st0" d="M193.69,32.36l-0.63-2.51l-2.84-1.89l-4.29-20.14L185.9,4h-11.27l-0.03,3.2l1.87-0.34l-2.79,14.07l-1.37,0.57
v2.85l6.29-1.33l0.4-2.7l4.65-0.89l1.69,12.93H193.69z M179.39,15.11l1.65-6.52l0.89,0.25l0.92,5.45L179.39,15.11z"/>
<polygon class="st0" points="208.47,21.68 210.62,21.12 210.04,18.15 200.51,17.46 198.87,21.13 203.56,21.9 203.32,23.91
200.58,25.19 196.44,23.77 194.48,17.19 196.2,10.02 200.08,8.52 203.31,9.62 202.85,11.75 207.79,13.6 208.83,9.69 204.71,4.21
195.57,4.21 191.24,7.36 189.29,16.87 192.06,27.54 199.03,30.53 203.2,29.3 203.09,32.36 209.01,32.36 209.4,29.95 207.38,28.99
"/>
<path class="st0" d="M230.45,6.26L226.39,4l-8.59-0.01l-4.07,2.86l-2.58,8.9l1.52,11.82l5.61,4.73l7.65,0.01l5.72-4.59l2.47-12.46
L230.45,6.26z M228.23,21.75l-3.95,5.45l-2.16,0.43l-4.6-3.46L216,15.72l2.4-7.02l5.14-0.48l2.97,1.79l1.74,5.83L228.23,21.75z"/>
<path class="st0" d="M116.13,27.48l-0.24,4.88l12.26,0.09l-0.83-5.01l-2.86-0.48l0.14-17.62l2.45-0.42l-0.14-4.85l-10.92,0.36
l0.1,4.6l3.2,0.63l-0.42,17.67L116.13,27.48z"/>
<path class="st0" d="M141.34,4.21l-12.88-0.39v4.26l1.95,0.62v25.15l-1.8,1.41l-0.02,2.63h8.23L136,27.96h6.09l4.57-4.46V7.27
L141.34,4.21z M141.38,20.51l-2.54,1.89l-3.23,0.16L135.4,9.32h3.88l2.1,1.68V20.51z"/>
</g>
<g>
<path class="st0" d="M14.14,11.28c0,0.35-0.02,0.71-0.07,1.05c0.38,0.07,0.76,0.11,1.16,0.11s0.79-0.04,1.16-0.11
c-0.05-0.34-0.07-0.7-0.07-1.05c0-3.23,1.94-6.02,4.72-7.25C20.17,1.68,17.9,0,15.24,0S10.3,1.68,9.42,4.03
C12.2,5.26,14.14,8.04,14.14,11.28z"/>
<path class="st0" d="M18.04,11.28c0,0.16,0.01,0.32,0.02,0.48c0.02,0.3,0.06,0.6,0.13,0.88c0.06,0.28,0.15,0.56,0.25,0.83
c0.11,0.3,0.24,0.58,0.39,0.85c1.42-1.33,3.33-2.15,5.42-2.15s4.01,0.82,5.42,2.15c0.51-0.9,0.79-1.94,0.79-3.04
c0-3.42-2.79-6.22-6.22-6.22c-0.4,0-0.79,0.04-1.16,0.11c-0.28,0.06-0.56,0.13-0.83,0.22c-0.28,0.09-0.56,0.21-0.83,0.35
C19.42,6.77,18.04,8.87,18.04,11.28z"/>
<path class="st0" d="M6.22,12.16c2.1,0,4.01,0.82,5.42,2.15c0.15-0.27,0.28-0.55,0.39-0.85c0.1-0.27,0.19-0.54,0.25-0.83
c0.06-0.28,0.11-0.58,0.13-0.88c0.02-0.15,0.02-0.32,0.02-0.48c0-2.41-1.38-4.51-3.39-5.54C8.77,5.6,8.5,5.49,8.21,5.39
c-0.27-0.1-0.55-0.17-0.83-0.22C7,5.1,6.61,5.06,6.22,5.06C2.79,5.06,0,7.85,0,11.28c0,1.1,0.28,2.14,0.79,3.04
C2.21,12.98,4.12,12.16,6.22,12.16z"/>
<path class="st0" d="M29.21,16.33c-0.18-0.23-0.36-0.44-0.57-0.65c-1.12-1.12-2.67-1.81-4.38-1.81c-1.71,0-3.25,0.69-4.38,1.81
c-0.2,0.2-0.39,0.42-0.56,0.64c-0.18,0.23-0.34,0.47-0.47,0.72c-0.2,0.34-0.36,0.71-0.48,1.09c2.83,1.21,4.81,4.02,4.81,7.28
c0,0.26-0.01,0.52-0.04,0.78c0.37,0.07,0.75,0.1,1.13,0.1c3.43,0,6.22-2.79,6.22-6.22c0-1.11-0.29-2.14-0.8-3.04
C29.54,16.8,29.38,16.56,29.21,16.33z"/>
<path class="st0" d="M12.12,18.14c-0.13-0.38-0.28-0.75-0.48-1.09c-0.14-0.26-0.3-0.5-0.47-0.72c-0.17-0.23-0.36-0.44-0.56-0.64
c-1.12-1.12-2.67-1.81-4.38-1.81s-3.26,0.69-4.38,1.81c-0.21,0.2-0.39,0.42-0.56,0.64c-0.18,0.23-0.34,0.47-0.47,0.72
C0.29,17.94,0,18.98,0,20.08c0,3.43,2.79,6.22,6.22,6.22c0.39,0,0.76-0.03,1.13-0.1c-0.03-0.26-0.04-0.52-0.04-0.78
C7.31,22.15,9.29,19.34,12.12,18.14z"/>
<path class="st0" d="M18.04,19.87c-0.27-0.14-0.55-0.26-0.84-0.35c-0.27-0.09-0.55-0.17-0.84-0.22c-0.37-0.07-0.75-0.1-1.13-0.1
s-0.76,0.03-1.13,0.1c-0.28,0.05-0.57,0.13-0.84,0.22c-0.29,0.1-0.57,0.22-0.84,0.35C10.4,20.9,9.02,23,9.02,25.42
c0,0.07,0,0.14,0.01,0.21c0.01,0.31,0.04,0.61,0.1,0.9c0.05,0.28,0.12,0.57,0.21,0.84c0.82,2.48,3.16,4.27,5.9,4.27
s5.08-1.79,5.9-4.27c0.09-0.27,0.17-0.55,0.21-0.84c0.06-0.3,0.09-0.6,0.1-0.91c0.01-0.07,0.01-0.14,0.01-0.21
C21.45,23,20.07,20.9,18.04,19.87z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 B

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -58,28 +58,3 @@
overflow-y: auto; overflow-y: auto;
max-height: 400px; max-height: 400px;
} }
.loader{
display: inline-block;
visibility: hidden;
margin-left: 5px;
width: 40px;
aspect-ratio: 4;
--_g: no-repeat radial-gradient(circle closest-side,#fff 90%,#fff0);
background:
var(--_g) 0 50%,
var(--_g) 50% 50%,
var(--_g) 100% 50%;
background-size: calc(100%/3) 100%;
animation: l7 1s infinite linear;
}
.loader.loading{
visibility: visible;
}
@keyframes l7{
33%{background-size:calc(100%/3) 0% ,calc(100%/3) 100%,calc(100%/3) 100%}
50%{background-size:calc(100%/3) 100%,calc(100%/3) 0 ,calc(100%/3) 100%}
66%{background-size:calc(100%/3) 100%,calc(100%/3) 100%,calc(100%/3) 0 }
}

View File

@@ -0,0 +1,17 @@
{% extends 'pageWrapper.html' %}
{% block head %}
{% include 'header/grassHeader.html' %}
<title>Frequently Asked Questions</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
crossorigin="anonymous"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/faq.js") }}"></script>
{% endblock %}
{% block body %}
<div id="faq-wrapper" data-lang="{{ lang }}" class="markdown">
<!-- Content generated by JavaScript -->
</div>
{% endblock %}

View File

@@ -99,18 +99,14 @@
{% if hint.finding_player == player %} {% if hint.finding_player == player %}
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b> <b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b>
{% else %} {% else %}
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.finding_player) }}"> {{ player_names_with_alias[(team, hint.finding_player)] }}
{{ player_names_with_alias[(team, hint.finding_player)] }}
</a>
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if hint.receiving_player == player %} {% if hint.receiving_player == player %}
<b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b> <b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b>
{% else %} {% else %}
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.receiving_player) }}"> {{ player_names_with_alias[(team, hint.receiving_player)] }}
{{ player_names_with_alias[(team, hint.receiving_player)] }}
</a>
{% endif %} {% endif %}
</td> </td>
<td>{{ item_id_to_name[games[(team, hint.receiving_player)]][hint.item] }}</td> <td>{{ item_id_to_name[games[(team, hint.receiving_player)]][hint.item] }}</td>

View File

@@ -0,0 +1,17 @@
{% extends 'pageWrapper.html' %}
{% block head %}
{% include 'header/grassHeader.html' %}
<title>Glossary</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
crossorigin="anonymous"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/glossary.js") }}"></script>
{% endblock %}
{% block body %}
<div id="glossary-wrapper" data-lang="{{ lang }}" class="markdown">
<!-- Content generated by JavaScript -->
</div>
{% endblock %}

View File

@@ -19,30 +19,28 @@
{% block body %} {% block body %}
{% include 'header/grassHeader.html' %} {% include 'header/grassHeader.html' %}
<div id="host-room"> <div id="host-room">
<span id="host-room-info"> {% if room.owner == session["_id"] %}
{% if room.owner == session["_id"] %} Room created from <a href="{{ url_for("view_seed", seed=room.seed.id) }}">Seed #{{ room.seed.id|suuid }}</a>
Room created from <a href="{{ url_for("view_seed", seed=room.seed.id) }}">Seed #{{ room.seed.id|suuid }}</a> <br />
<br /> {% endif %}
{% endif %} {% if room.tracker %}
{% if room.tracker %} This room has a <a href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}">Multiworld Tracker</a>
This room has a <a href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}">Multiworld Tracker</a> and a <a href="{{ url_for("get_multiworld_sphere_tracker", tracker=room.tracker) }}">Sphere Tracker</a> enabled.
and a <a href="{{ url_for("get_multiworld_sphere_tracker", tracker=room.tracker) }}">Sphere Tracker</a> enabled. <br />
<br /> {% endif %}
{% endif %} The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity. Should you wish to continue later,
Should you wish to continue later, anyone can simply refresh this page and the server will resume.<br>
anyone can simply refresh this page and the server will resume.<br> {% if room.last_port == -1 %}
{% if room.last_port == -1 %} There was an error hosting this Room. Another attempt will be made on refreshing this page.
There was an error hosting this Room. Another attempt will be made on refreshing this page. The most likely failure reason is that the multiworld is too old to be loaded now.
The most likely failure reason is that the multiworld is too old to be loaded now. {% elif room.last_port %}
{% elif room.last_port %} You can connect to this room by using <span class="interactive"
You can connect to this room by using <span class="interactive" data-tooltip="This means address/ip is {{ config['HOST_ADDRESS'] }} and port is {{ room.last_port }}.">
data-tooltip="This means address/ip is {{ config['HOST_ADDRESS'] }} and port is {{ room.last_port }}."> '/connect {{ config['HOST_ADDRESS'] }}:{{ room.last_port }}'
'/connect {{ config['HOST_ADDRESS'] }}:{{ room.last_port }}' </span>
</span> in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br> {% endif %}
{% endif %}
</span>
{{ macros.list_patches_room(room) }} {{ macros.list_patches_room(room) }}
{% if room.owner == session["_id"] %} {% if room.owner == session["_id"] %}
<div style="display: flex; align-items: center;"> <div style="display: flex; align-items: center;">
@@ -51,7 +49,6 @@
<label for="cmd"></label> <label for="cmd"></label>
<input class="form-control" type="text" id="cmd" name="cmd" <input class="form-control" type="text" id="cmd" name="cmd"
placeholder="Server Command. /help to list them, list gets appended to log."> placeholder="Server Command. /help to list them, list gets appended to log.">
<span class="loader"></span>
</div> </div>
</form> </form>
<a href="{{ url_for("display_log", room=room.id) }}"> <a href="{{ url_for("display_log", room=room.id) }}">
@@ -65,7 +62,6 @@
let url = '{{ url_for('display_log', room = room.id) }}'; let url = '{{ url_for('display_log', room = room.id) }}';
let bytesReceived = {{ log_len }}; let bytesReceived = {{ log_len }};
let updateLogTimeout; let updateLogTimeout;
let updateLogImmediately = false;
let awaitingCommandResponse = false; let awaitingCommandResponse = false;
let logger = document.getElementById("logger"); let logger = document.getElementById("logger");
@@ -82,36 +78,29 @@
async function updateLog() { async function updateLog() {
try { try {
if (!document.hidden) { let res = await fetch(url, {
updateLogImmediately = false; headers: {
let res = await fetch(url, { 'Range': `bytes=${bytesReceived}-`,
headers: { }
'Range': `bytes=${bytesReceived}-`, });
} if (res.ok) {
}); let text = await res.text();
if (res.ok) { if (text.length > 0) {
let text = await res.text(); awaitingCommandResponse = false;
if (text.length > 0) { if (bytesReceived === 0 || res.status !== 206) {
awaitingCommandResponse = false; logger.innerHTML = '';
if (bytesReceived === 0 || res.status !== 206) { }
logger.innerHTML = ''; if (res.status !== 206) {
} bytesReceived = 0;
if (res.status !== 206) { } else {
bytesReceived = 0; bytesReceived += new Blob([text]).size;
} else { }
bytesReceived += new Blob([text]).size; if (logger.innerHTML.endsWith('…')) {
} logger.innerHTML = logger.innerHTML.substring(0, logger.innerHTML.length - 1);
if (logger.innerHTML.endsWith('…')) { }
logger.innerHTML = logger.innerHTML.substring(0, logger.innerHTML.length - 1); logger.appendChild(document.createTextNode(text));
} scrollToBottom(logger);
logger.appendChild(document.createTextNode(text));
scrollToBottom(logger);
let loader = document.getElementById("command-form").getElementsByClassName("loader")[0];
loader.classList.remove("loading");
}
} }
} else {
updateLogImmediately = true;
} }
} }
finally { finally {
@@ -136,62 +125,20 @@
}); });
ev.preventDefault(); // has to happen before first await ev.preventDefault(); // has to happen before first await
form.reset(); form.reset();
let loader = form.getElementsByClassName("loader")[0]; let res = await req;
loader.classList.add("loading"); if (res.ok || res.type === 'opaqueredirect') {
try { awaitingCommandResponse = true;
let res = await req; window.clearTimeout(updateLogTimeout);
if (res.ok || res.type === 'opaqueredirect') { updateLogTimeout = window.setTimeout(updateLog, 100);
awaitingCommandResponse = true; } else {
window.clearTimeout(updateLogTimeout); window.alert(res.statusText);
updateLogTimeout = window.setTimeout(updateLog, 100);
} else {
loader.classList.remove("loading");
window.alert(res.statusText);
}
} catch (e) {
console.error(e);
loader.classList.remove("loading");
window.alert(e.message);
} }
} }
document.getElementById("command-form").addEventListener("submit", postForm); document.getElementById("command-form").addEventListener("submit", postForm);
updateLogTimeout = window.setTimeout(updateLog, 1000); updateLogTimeout = window.setTimeout(updateLog, 1000);
logger.scrollTop = logger.scrollHeight; logger.scrollTop = logger.scrollHeight;
document.addEventListener("visibilitychange", () => {
if (!document.hidden && updateLogImmediately) {
updateLog();
}
})
</script> </script>
{% endif %} {% endif %}
<script>
function updateInfo() {
let url = new URL(window.location.href);
url.search = "?update";
fetch(url)
.then(res => {
if (!res.ok) {
throw new Error(`HTTP error ${res.status}`);
}
return res.text()
})
.then(text => new DOMParser().parseFromString(text, 'text/html'))
.then(newDocument => {
let el = newDocument.getElementById("host-room-info");
document.getElementById("host-room-info").innerHTML = el.innerHTML;
});
}
if (document.querySelector("meta[http-equiv='refresh']")) {
console.log("Refresh!");
window.addEventListener('load', function () {
for (let i=0; i<3; i++) {
window.setTimeout(updateInfo, Math.pow(2, i) * 2000); // 2, 4, 8s
}
window.stop(); // cancel meta refresh
})
}
</script>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -22,7 +22,7 @@
{% for patch in room.seed.slots|list|sort(attribute="player_id") %} {% for patch in room.seed.slots|list|sort(attribute="player_id") %}
<tr> <tr>
<td>{{ patch.player_id }}</td> <td>{{ patch.player_id }}</td>
<td data-tooltip="Connect via Game Client"><a href="archipelago://{{ patch.player_name | e}}:None@{{ config['HOST_ADDRESS'] }}:{{ room.last_port }}?game={{ patch.game }}&room={{ room.id | suuid }}">{{ patch.player_name }}</a></td> <td data-tooltip="Connect via TextClient"><a href="archipelago://{{ patch.player_name | e}}:None@{{ config['HOST_ADDRESS'] }}:{{ room.last_port }}">{{ patch.player_name }}</a></td>
<td>{{ patch.game }}</td> <td>{{ patch.game }}</td>
<td> <td>
{% if patch.data %} {% if patch.data %}

View File

@@ -1,13 +0,0 @@
{% extends 'pageWrapper.html' %}
{% block head %}
{% include 'header/grassHeader.html' %}
<title>{{ title }}</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
{% endblock %}
{% block body %}
<div class="markdown">
{{ html_from_markdown | safe}}
</div>
{% endblock %}

View File

@@ -1,21 +1,5 @@
{% extends 'tablepage.html' %} {% extends 'tablepage.html' %}
{%- macro games(slots) -%}
{%- set gameList = [] -%}
{%- set maxGamesToShow = 10 -%}
{%- for slot in (slots|list|sort(attribute="player_id"))[:maxGamesToShow] -%}
{% set player = "#" + slot["player_id"]|string + " " + slot["player_name"] + " : " + slot["game"] -%}
{% set _ = gameList.append(player) -%}
{%- endfor -%}
{%- if slots|length > maxGamesToShow -%}
{% set _ = gameList.append("... and " + (slots|length - maxGamesToShow)|string + " more") -%}
{%- endif -%}
{{ gameList|join('\n') }}
{%- endmacro -%}
{% block head %} {% block head %}
{{ super() }} {{ super() }}
<title>User Content</title> <title>User Content</title>
@@ -49,12 +33,10 @@
<tr> <tr>
<td><a href="{{ url_for("view_seed", seed=room.seed.id) }}">{{ room.seed.id|suuid }}</a></td> <td><a href="{{ url_for("view_seed", seed=room.seed.id) }}">{{ room.seed.id|suuid }}</a></td>
<td><a href="{{ url_for("host_room", room=room.id) }}">{{ room.id|suuid }}</a></td> <td><a href="{{ url_for("host_room", room=room.id) }}">{{ room.id|suuid }}</a></td>
<td title="{{ games(room.seed.slots) }}"> <td>{{ room.seed.slots|length }}</td>
{{ room.seed.slots|length }}
</td>
<td>{{ room.creation_time.strftime("%Y-%m-%d %H:%M") }}</td> <td>{{ room.creation_time.strftime("%Y-%m-%d %H:%M") }}</td>
<td>{{ room.last_activity.strftime("%Y-%m-%d %H:%M") }}</td> <td>{{ room.last_activity.strftime("%Y-%m-%d %H:%M") }}</td>
<td><a href="{{ url_for("disown_room", room=room.id) }}">Delete next maintenance.</a></td> <td><a href="{{ url_for("disown_room", room=room.id) }}">Delete next maintenance.</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -78,21 +60,16 @@
{% for seed in seeds %} {% for seed in seeds %}
<tr> <tr>
<td><a href="{{ url_for("view_seed", seed=seed.id) }}">{{ seed.id|suuid }}</a></td> <td><a href="{{ url_for("view_seed", seed=seed.id) }}">{{ seed.id|suuid }}</a></td>
<td title="{{ games(seed.slots) }}"> <td>{% if seed.multidata %}{{ seed.slots|length }}{% else %}1{% endif %}
{% if seed.multidata %}
{{ seed.slots|length }}
{% else %}
1
{% endif %}
</td> </td>
<td>{{ seed.creation_time.strftime("%Y-%m-%d %H:%M") }}</td> <td>{{ seed.creation_time.strftime("%Y-%m-%d %H:%M") }}</td>
<td><a href="{{ url_for("disown_seed", seed=seed.id) }}">Delete next maintenance.</a></td> <td><a href="{{ url_for("disown_seed", seed=seed.id) }}">Delete next maintenance.</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %} {% else %}
You have not generated any seeds yet! You have no generated any seeds yet!
{% endif %} {% endif %}
</div> </div>
</div> </div>

View File

@@ -46,7 +46,7 @@
/worlds/clique/ @ThePhar /worlds/clique/ @ThePhar
# Dark Souls III # Dark Souls III
/worlds/dark_souls_3/ @Marechal-L @nex3 /worlds/dark_souls_3/ @Marechal-L
# Donkey Kong Country 3 # Donkey Kong Country 3
/worlds/dkc3/ @PoryGone /worlds/dkc3/ @PoryGone

View File

@@ -268,7 +268,6 @@ Additional arguments added to the [Set](#Set) package that triggered this [SetRe
These packets are sent purely from client to server. They are not accepted by clients. These packets are sent purely from client to server. They are not accepted by clients.
* [Connect](#Connect) * [Connect](#Connect)
* [ConnectUpdate](#ConnectUpdate)
* [Sync](#Sync) * [Sync](#Sync)
* [LocationChecks](#LocationChecks) * [LocationChecks](#LocationChecks)
* [LocationScouts](#LocationScouts) * [LocationScouts](#LocationScouts)
@@ -396,7 +395,6 @@ Some special keys exist with specific return data, all of them have the prefix `
| item_name_groups_{game_name} | dict\[str, list\[str\]\] | item_name_groups belonging to the requested game. | | item_name_groups_{game_name} | dict\[str, list\[str\]\] | item_name_groups belonging to the requested game. |
| location_name_groups_{game_name} | dict\[str, list\[str\]\] | location_name_groups belonging to the requested game. | | location_name_groups_{game_name} | dict\[str, list\[str\]\] | location_name_groups belonging to the requested game. |
| client_status_{team}_{slot} | [ClientStatus](#ClientStatus) | The current game status of the requested player. | | client_status_{team}_{slot} | [ClientStatus](#ClientStatus) | The current game status of the requested player. |
| race_mode | int | 0 if race mode is disabled, and 1 if it's enabled. |
### Set ### Set
Used to write data to the server's data storage, that data can then be shared across worlds or just saved for later. Values for keys in the data storage can be retrieved with a [Get](#Get) package, or monitored with a [SetNotify](#SetNotify) package. Used to write data to the server's data storage, that data can then be shared across worlds or just saved for later. Values for keys in the data storage can be retrieved with a [Get](#Get) package, or monitored with a [SetNotify](#SetNotify) package.

View File

@@ -228,8 +228,8 @@ Root: HKCR; Subkey: "{#MyAppName}worlddata\shell\open\command"; ValueData: """{a
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey; Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey;
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: ""; Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: "";
Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0";
Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoTextClient.exe"" ""%1""";
[Code] [Code]
// See: https://stackoverflow.com/a/51614652/2287576 // See: https://stackoverflow.com/a/51614652/2287576

16
kvui.py
View File

@@ -243,9 +243,6 @@ class ServerLabel(HovererableLabel):
f"\nYou currently have {ctx.hint_points} points." f"\nYou currently have {ctx.hint_points} points."
elif ctx.hint_cost == 0: elif ctx.hint_cost == 0:
text += "\n!hint is free to use." text += "\n!hint is free to use."
if ctx.stored_data and "_read_race_mode" in ctx.stored_data:
text += "\nRace mode is enabled." \
if ctx.stored_data["_read_race_mode"] else "\nRace mode is disabled."
else: else:
text += f"\nYou are not authenticated yet." text += f"\nYou are not authenticated yet."
@@ -539,8 +536,9 @@ class GameManager(App):
# show Archipelago tab if other logging is present # show Archipelago tab if other logging is present
self.tabs.add_widget(panel) self.tabs.add_widget(panel)
hint_panel = self.add_client_tab("Hints", HintLog(self.json_to_kivy_parser)) hint_panel = TabbedPanelItem(text="Hints")
self.log_panels["Hints"] = hint_panel.content self.log_panels["Hints"] = hint_panel.content = HintLog(self.json_to_kivy_parser)
self.tabs.add_widget(hint_panel)
if len(self.logging_pairs) == 1: if len(self.logging_pairs) == 1:
self.tabs.default_tab_text = "Archipelago" self.tabs.default_tab_text = "Archipelago"
@@ -574,14 +572,6 @@ class GameManager(App):
return self.container return self.container
def add_client_tab(self, title: str, content: Widget) -> Widget:
"""Adds a new tab to the client window with a given title, and provides a given Widget as its content.
Returns the new tab widget, with the provided content being placed on the tab as content."""
new_tab = TabbedPanelItem(text=title)
new_tab.content = content
self.tabs.add_widget(new_tab)
return new_tab
def update_texts(self, dt): def update_texts(self, dt):
if hasattr(self.tabs.content.children[0], "fix_heights"): if hasattr(self.tabs.content.children[0], "fix_heights"):
self.tabs.content.children[0].fix_heights() # TODO: remove this when Kivy fixes this upstream self.tabs.content.children[0].fix_heights() # TODO: remove this when Kivy fixes this upstream

View File

@@ -59,12 +59,3 @@ class TestOptions(unittest.TestCase):
item_links = {1: ItemLinks.from_any(item_link_group), 2: ItemLinks.from_any(item_link_group)} item_links = {1: ItemLinks.from_any(item_link_group), 2: ItemLinks.from_any(item_link_group)}
for link in item_links.values(): for link in item_links.values():
self.assertEqual(link.value[0], item_link_group[0]) self.assertEqual(link.value[0], item_link_group[0])
def test_pickle_dumps(self):
"""Test options can be pickled into database for WebHost generation"""
import pickle
for gamename, world_type in AutoWorldRegister.world_types.items():
if not world_type.hidden:
for option_key, option in world_type.options_dataclass.type_hints.items():
with self.subTest(game=gamename, option=option_key):
pickle.dumps(option(option.default))

View File

@@ -71,7 +71,7 @@ class TestTwoPlayerMulti(MultiworldTestBase):
for world in self.multiworld.worlds.values(): for world in self.multiworld.worlds.values():
world.options.accessibility.value = Accessibility.option_full world.options.accessibility.value = Accessibility.option_full
self.assertSteps(gen_steps) self.assertSteps(gen_steps)
with self.subTest("filling multiworld", games=world_type.game, seed=self.multiworld.seed): with self.subTest("filling multiworld", seed=self.multiworld.seed):
distribute_items_restrictive(self.multiworld) distribute_items_restrictive(self.multiworld)
call_all(self.multiworld, "post_fill") call_all(self.multiworld, "post_fill")
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game") self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")

View File

@@ -1,73 +0,0 @@
import zipfile
from io import BytesIO
from flask import url_for
from . import TestBase
class TestGenerate(TestBase):
def test_valid_yaml(self) -> None:
"""
Verify that posting a valid yaml will start generating a game.
"""
with self.app.app_context(), self.app.test_request_context():
yaml_data = """
name: Player1
game: Archipelago
Archipelago: {}
"""
response = self.client.post(url_for("generate"),
data={"file": (BytesIO(yaml_data.encode("utf-8")), "test.yaml")},
follow_redirects=True)
self.assertEqual(response.status_code, 200)
self.assertTrue("/seed/" in response.request.path or
"/wait/" in response.request.path,
f"Response did not properly redirect ({response.request.path})")
def test_empty_zip(self) -> None:
"""
Verify that posting an empty zip will give an error.
"""
with self.app.app_context(), self.app.test_request_context():
zip_data = BytesIO()
zipfile.ZipFile(zip_data, "w").close()
zip_data.seek(0)
self.assertGreater(len(zip_data.read()), 0)
zip_data.seek(0)
response = self.client.post(url_for("generate"),
data={"file": (zip_data, "test.zip")},
follow_redirects=True)
self.assertIn("user-message", response.text,
"Request did not call flash()")
self.assertIn("not find any valid files", response.text,
"Response shows unexpected error")
self.assertIn("generate-game-form", response.text,
"Response did not get user back to the form")
def test_too_many_players(self) -> None:
"""
Verify that posting too many players will give an error.
"""
max_roll = self.app.config["MAX_ROLL"]
# validate that max roll has a sensible value, otherwise we probably changed how it works
self.assertIsInstance(max_roll, int)
self.assertGreater(max_roll, 1)
self.assertLess(max_roll, 100)
# create a yaml with max_roll+1 players and watch it fail
with self.app.app_context(), self.app.test_request_context():
yaml_data = "---\n".join([
f"name: Player{n}\n"
"game: Archipelago\n"
"Archipelago: {}\n"
for n in range(1, max_roll + 2)
])
response = self.client.post(url_for("generate"),
data={"file": (BytesIO(yaml_data.encode("utf-8")), "test.yaml")},
follow_redirects=True)
self.assertIn("user-message", response.text,
"Request did not call flash()")
self.assertIn("limited to", response.text,
"Response shows unexpected error")
self.assertIn("generate-game-form", response.text,
"Response did not get user back to the form")

View File

@@ -131,8 +131,7 @@ class TestHostFakeRoom(TestBase):
f.write(text) f.write(text)
with self.app.app_context(), self.app.test_request_context(): with self.app.app_context(), self.app.test_request_context():
response = self.client.get(url_for("host_room", room=self.room_id), response = self.client.get(url_for("host_room", room=self.room_id))
headers={"User-Agent": "Mozilla/5.0"})
response_text = response.get_data(True) response_text = response.get_data(True)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertIn("href=\"/seed/", response_text) self.assertIn("href=\"/seed/", response_text)

View File

@@ -342,7 +342,7 @@ class World(metaclass=AutoWorldRegister):
# overridable methods that get called by Main.py, sorted by execution order # overridable methods that get called by Main.py, sorted by execution order
# can also be implemented as a classmethod and called "stage_<original_name>", # can also be implemented as a classmethod and called "stage_<original_name>",
# in that case the MultiWorld object is passed as the first argument, and it gets called once for the entire multiworld. # in that case the MultiWorld object is passed as an argument, and it gets called once for the entire multiworld.
# An example of this can be found in alttp as stage_pre_fill # An example of this can be found in alttp as stage_pre_fill
@classmethod @classmethod

View File

@@ -26,13 +26,10 @@ class Component:
cli: bool cli: bool
func: Optional[Callable] func: Optional[Callable]
file_identifier: Optional[Callable[[str], bool]] file_identifier: Optional[Callable[[str], bool]]
game_name: Optional[str]
supports_uri: Optional[bool]
def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None, def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None,
cli: bool = False, icon: str = 'icon', component_type: Optional[Type] = None, cli: bool = False, icon: str = 'icon', component_type: Optional[Type] = None,
func: Optional[Callable] = None, file_identifier: Optional[Callable[[str], bool]] = None, func: Optional[Callable] = None, file_identifier: Optional[Callable[[str], bool]] = None):
game_name: Optional[str] = None, supports_uri: Optional[bool] = False):
self.display_name = display_name self.display_name = display_name
self.script_name = script_name self.script_name = script_name
self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None
@@ -48,8 +45,6 @@ class Component:
Type.ADJUSTER if "Adjuster" in display_name else Type.MISC) Type.ADJUSTER if "Adjuster" in display_name else Type.MISC)
self.func = func self.func = func
self.file_identifier = file_identifier self.file_identifier = file_identifier
self.game_name = game_name
self.supports_uri = supports_uri
def handles_file(self, path: str): def handles_file(self, path: str):
return self.file_identifier(path) if self.file_identifier else False return self.file_identifier(path) if self.file_identifier else False
@@ -61,10 +56,10 @@ class Component:
processes = weakref.WeakSet() processes = weakref.WeakSet()
def launch_subprocess(func: Callable, name: str = None, args: Tuple[str, ...] = ()) -> None: def launch_subprocess(func: Callable, name: str = None):
global processes global processes
import multiprocessing import multiprocessing
process = multiprocessing.Process(target=func, name=name, args=args) process = multiprocessing.Process(target=func, name=name)
process.start() process.start()
processes.add(process) processes.add(process)
@@ -83,9 +78,9 @@ class SuffixIdentifier:
return False return False
def launch_textclient(*args): def launch_textclient():
import CommonClient import CommonClient
launch_subprocess(CommonClient.run_as_textclient, name="TextClient", args=args) launch_subprocess(CommonClient.run_as_textclient, name="TextClient")
def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, pathlib.Path]]: def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, pathlib.Path]]:

View File

@@ -223,8 +223,8 @@ async def set_message_interval(ctx: BizHawkContext, value: float) -> None:
raise SyncError(f"Expected response of type SET_MESSAGE_INTERVAL_RESPONSE but got {res['type']}") raise SyncError(f"Expected response of type SET_MESSAGE_INTERVAL_RESPONSE but got {res['type']}")
async def guarded_read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tuple[int, int, str]], async def guarded_read(ctx: BizHawkContext, read_list: typing.List[typing.Tuple[int, int, str]],
guard_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]]) -> typing.Optional[typing.List[bytes]]: guard_list: typing.List[typing.Tuple[int, typing.Iterable[int], str]]) -> typing.Optional[typing.List[bytes]]:
"""Reads an array of bytes at 1 or more addresses if and only if every byte in guard_list matches its expected """Reads an array of bytes at 1 or more addresses if and only if every byte in guard_list matches its expected
value. value.
@@ -266,7 +266,7 @@ async def guarded_read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tu
return ret return ret
async def read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tuple[int, int, str]]) -> typing.List[bytes]: async def read(ctx: BizHawkContext, read_list: typing.List[typing.Tuple[int, int, str]]) -> typing.List[bytes]:
"""Reads data at 1 or more addresses. """Reads data at 1 or more addresses.
Items in `read_list` should be organized `(address, size, domain)` where Items in `read_list` should be organized `(address, size, domain)` where
@@ -278,8 +278,8 @@ async def read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tuple[int,
return await guarded_read(ctx, read_list, []) return await guarded_read(ctx, read_list, [])
async def guarded_write(ctx: BizHawkContext, write_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]], async def guarded_write(ctx: BizHawkContext, write_list: typing.List[typing.Tuple[int, typing.Iterable[int], str]],
guard_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]]) -> bool: guard_list: typing.List[typing.Tuple[int, typing.Iterable[int], str]]) -> bool:
"""Writes data to 1 or more addresses if and only if every byte in guard_list matches its expected value. """Writes data to 1 or more addresses if and only if every byte in guard_list matches its expected value.
Items in `write_list` should be organized `(address, value, domain)` where Items in `write_list` should be organized `(address, value, domain)` where
@@ -316,7 +316,7 @@ async def guarded_write(ctx: BizHawkContext, write_list: typing.Sequence[typing.
return True return True
async def write(ctx: BizHawkContext, write_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]]) -> None: async def write(ctx: BizHawkContext, write_list: typing.List[typing.Tuple[int, typing.Iterable[int], str]]) -> None:
"""Writes data to 1 or more addresses. """Writes data to 1 or more addresses.
Items in write_list should be organized `(address, value, domain)` where Items in write_list should be organized `(address, value, domain)` where

View File

@@ -15,7 +15,7 @@ if TYPE_CHECKING:
def launch_client(*args) -> None: def launch_client(*args) -> None:
from .context import launch from .context import launch
launch_subprocess(launch, name="BizHawkClient", args=args) launch_subprocess(launch, name="BizHawkClient")
component = Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client, component = Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client,

View File

@@ -59,10 +59,14 @@ class BizHawkClientContext(CommonContext):
self.bizhawk_ctx = BizHawkContext() self.bizhawk_ctx = BizHawkContext()
self.watcher_timeout = 0.5 self.watcher_timeout = 0.5
def make_gui(self): def run_gui(self):
ui = super().make_gui() from kvui import GameManager
ui.base_title = "Archipelago BizHawk Client"
return ui class BizHawkManager(GameManager):
base_title = "Archipelago BizHawk Client"
self.ui = BizHawkManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def on_package(self, cmd, args): def on_package(self, cmd, args):
if cmd == "Connected": if cmd == "Connected":
@@ -239,11 +243,11 @@ async def _patch_and_run_game(patch_file: str):
logger.exception(exc) logger.exception(exc)
def launch(*launch_args) -> None: def launch() -> None:
async def main(): async def main():
parser = get_base_parser() parser = get_base_parser()
parser.add_argument("patch_file", default="", type=str, nargs="?", help="Path to an Archipelago patch file") parser.add_argument("patch_file", default="", type=str, nargs="?", help="Path to an Archipelago patch file")
args = parser.parse_args(launch_args) args = parser.parse_args()
ctx = BizHawkClientContext(args.connect, args.password) ctx = BizHawkClientContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")

View File

@@ -4,7 +4,7 @@ import websockets
import functools import functools
from copy import deepcopy from copy import deepcopy
from typing import List, Any, Iterable from typing import List, Any, Iterable
from NetUtils import decode, encode, JSONtoTextParser, JSONMessagePart, NetworkItem, NetworkPlayer from NetUtils import decode, encode, JSONtoTextParser, JSONMessagePart, NetworkItem
from MultiServer import Endpoint from MultiServer import Endpoint
from CommonClient import CommonContext, gui_enabled, ClientCommandProcessor, logger, get_base_parser from CommonClient import CommonContext, gui_enabled, ClientCommandProcessor, logger, get_base_parser
@@ -101,35 +101,12 @@ class AHITContext(CommonContext):
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
if cmd == "Connected": if cmd == "Connected":
json = args self.connected_msg = encode([args])
# This data is not needed and causes the game to freeze for long periods of time in large asyncs.
if "slot_info" in json.keys():
json["slot_info"] = {}
if "players" in json.keys():
me: NetworkPlayer
for n in json["players"]:
if n.slot == json["slot"] and n.team == json["team"]:
me = n
break
# Only put our player info in there as we actually need it
json["players"] = [me]
if DEBUG:
print(json)
self.connected_msg = encode([json])
if self.awaiting_info: if self.awaiting_info:
self.server_msgs.append(self.room_info) self.server_msgs.append(self.room_info)
self.update_items() self.update_items()
self.awaiting_info = False self.awaiting_info = False
elif cmd == "RoomUpdate":
# Same story as above
json = args
if "players" in json.keys():
json["players"] = []
self.server_msgs.append(encode(json))
elif cmd == "ReceivedItems": elif cmd == "ReceivedItems":
if args["index"] == 0: if args["index"] == 0:
self.full_inventory.clear() self.full_inventory.clear()
@@ -189,17 +166,6 @@ async def proxy(websocket, path: str = "/", ctx: AHITContext = None):
await ctx.disconnect_proxy() await ctx.disconnect_proxy()
break break
if ctx.auth:
name = msg.get("name", "")
if name != "" and name != ctx.auth:
logger.info("Aborting proxy connection: player name mismatch from save file")
logger.info(f"Expected: {ctx.auth}, got: {name}")
text = encode([{"cmd": "PrintJSON",
"data": [{"text": "Connection aborted - player name mismatch"}]}])
await ctx.send_msgs_proxy(text)
await ctx.disconnect_proxy()
break
if ctx.connected_msg and ctx.is_connected(): if ctx.connected_msg and ctx.is_connected():
await ctx.send_msgs_proxy(ctx.connected_msg) await ctx.send_msgs_proxy(ctx.connected_msg)
ctx.update_items() ctx.update_items()

View File

@@ -152,10 +152,10 @@ def create_dw_regions(world: "HatInTimeWorld"):
for name in annoying_dws: for name in annoying_dws:
world.excluded_dws.append(name) world.excluded_dws.append(name)
if not world.options.DWEnableBonus and world.options.DWAutoCompleteBonuses: if not world.options.DWEnableBonus or world.options.DWAutoCompleteBonuses:
for name in death_wishes: for name in death_wishes:
world.excluded_bonuses.append(name) world.excluded_bonuses.append(name)
if world.options.DWExcludeAnnoyingBonuses and not world.options.DWAutoCompleteBonuses: elif world.options.DWExcludeAnnoyingBonuses:
for name in annoying_bonuses: for name in annoying_bonuses:
world.excluded_bonuses.append(name) world.excluded_bonuses.append(name)

View File

@@ -253,8 +253,7 @@ class HatInTimeWorld(World):
else: else:
item_name = loc.item.name item_name = loc.item.name
shop_item_names.setdefault(str(loc.address), shop_item_names.setdefault(str(loc.address), item_name)
f"{item_name} ({self.multiworld.get_player_name(loc.item.player)})")
slot_data["ShopItemNames"] = shop_item_names slot_data["ShopItemNames"] = shop_item_names

View File

@@ -728,7 +728,7 @@ class ALttPPlandoConnections(PlandoConnections):
entrances = set([connection[0] for connection in ( entrances = set([connection[0] for connection in (
*default_connections, *default_dungeon_connections, *inverted_default_connections, *default_connections, *default_dungeon_connections, *inverted_default_connections,
*inverted_default_dungeon_connections)]) *inverted_default_dungeon_connections)])
exits = set([connection[0] for connection in ( exits = set([connection[1] for connection in (
*default_connections, *default_dungeon_connections, *inverted_default_connections, *default_connections, *default_dungeon_connections, *inverted_default_connections,
*inverted_default_dungeon_connections)]) *inverted_default_dungeon_connections)])

View File

@@ -738,7 +738,9 @@ class AquariaRegions:
self.__connect_regions("Sun Temple left area", "Veil left of sun temple", self.__connect_regions("Sun Temple left area", "Veil left of sun temple",
self.sun_temple_l, self.veil_tr_l) self.sun_temple_l, self.veil_tr_l)
self.__connect_regions("Sun Temple left area", "Sun Temple before boss area", self.__connect_regions("Sun Temple left area", "Sun Temple before boss area",
self.sun_temple_l, self.sun_temple_boss_path) self.sun_temple_l, self.sun_temple_boss_path,
lambda state: _has_light(state, self.player) or
_has_sun_crystal(state, self.player))
self.__connect_regions("Sun Temple before boss area", "Sun Temple boss area", self.__connect_regions("Sun Temple before boss area", "Sun Temple boss area",
self.sun_temple_boss_path, self.sun_temple_boss, self.sun_temple_boss_path, self.sun_temple_boss,
lambda state: _has_energy_attack_item(state, self.player)) lambda state: _has_energy_attack_item(state, self.player))
@@ -773,11 +775,14 @@ class AquariaRegions:
self.abyss_l, self.king_jellyfish_cave, self.abyss_l, self.king_jellyfish_cave,
lambda state: (_has_energy_form(state, self.player) and lambda state: (_has_energy_form(state, self.player) and
_has_beast_form(state, self.player)) or _has_beast_form(state, self.player)) or
_has_dual_form(state, self.player)) _has_dual_form(state, self.player))
self.__connect_regions("Abyss left area", "Abyss right area", self.__connect_regions("Abyss left area", "Abyss right area",
self.abyss_l, self.abyss_r) self.abyss_l, self.abyss_r)
self.__connect_regions("Abyss right area", "Abyss right area, transturtle", self.__connect_one_way_regions("Abyss right area", "Abyss right area, transturtle",
self.abyss_r, self.abyss_r_transturtle) self.abyss_r, self.abyss_r_transturtle)
self.__connect_one_way_regions("Abyss right area, transturtle", "Abyss right area",
self.abyss_r_transturtle, self.abyss_r,
lambda state: _has_light(state, self.player))
self.__connect_regions("Abyss right area", "Inside the whale", self.__connect_regions("Abyss right area", "Inside the whale",
self.abyss_r, self.whale, self.abyss_r, self.whale,
lambda state: _has_spirit_form(state, self.player) and lambda state: _has_spirit_form(state, self.player) and
@@ -1087,10 +1092,12 @@ class AquariaRegions:
lambda state: _has_light(state, self.player)) lambda state: _has_light(state, self.player))
add_rule(self.multiworld.get_entrance("Open Water bottom left area to Abyss left area", self.player), add_rule(self.multiworld.get_entrance("Open Water bottom left area to Abyss left area", self.player),
lambda state: _has_light(state, self.player)) lambda state: _has_light(state, self.player))
add_rule(self.multiworld.get_entrance("Sun Temple left area to Sun Temple right area", self.player),
lambda state: _has_light(state, self.player) or _has_sun_crystal(state, self.player))
add_rule(self.multiworld.get_entrance("Sun Temple right area to Sun Temple left area", self.player),
lambda state: _has_light(state, self.player) or _has_sun_crystal(state, self.player))
add_rule(self.multiworld.get_entrance("Veil left of sun temple to Sun Temple left area", self.player), add_rule(self.multiworld.get_entrance("Veil left of sun temple to Sun Temple left area", self.player),
lambda state: _has_light(state, self.player) or _has_sun_crystal(state, self.player)) lambda state: _has_light(state, self.player) or _has_sun_crystal(state, self.player))
add_rule(self.multiworld.get_entrance("Abyss right area, transturtle to Abyss right area", self.player),
lambda state: _has_light(state, self.player))
def __adjusting_manual_rules(self) -> None: def __adjusting_manual_rules(self) -> None:
add_rule(self.multiworld.get_location("Mithalas Cathedral, Mithalan Dress", self.player), add_rule(self.multiworld.get_location("Mithalas Cathedral, Mithalan Dress", self.player),
@@ -1144,10 +1151,6 @@ class AquariaRegions:
lambda state: state.has("Sun God beated", self.player)) lambda state: state.has("Sun God beated", self.player))
add_rule(self.multiworld.get_location("The Body center area, breaking Li's cage", self.player), add_rule(self.multiworld.get_location("The Body center area, breaking Li's cage", self.player),
lambda state: _has_tongue_cleared(state, self.player)) lambda state: _has_tongue_cleared(state, self.player))
add_rule(self.multiworld.get_location(
"Open Water top right area, bulb in the small path before Mithalas",
self.player), lambda state: _has_bind_song(state, self.player)
)
def __no_progression_hard_or_hidden_location(self) -> None: def __no_progression_hard_or_hidden_location(self) -> None:
self.multiworld.get_location("Energy Temple boss area, Fallen God Tooth", self.multiworld.get_location("Energy Temple boss area, Fallen God Tooth",

View File

@@ -130,13 +130,12 @@ class AquariaWorld(World):
return result return result
def __pre_fill_item(self, item_name: str, location_name: str, precollected, def __pre_fill_item(self, item_name: str, location_name: str, precollected) -> None:
itemClassification: ItemClassification = ItemClassification.useful) -> None:
"""Pre-assign an item to a location""" """Pre-assign an item to a location"""
if item_name not in precollected: if item_name not in precollected:
self.exclude.append(item_name) self.exclude.append(item_name)
data = item_table[item_name] data = item_table[item_name]
item = AquariaItem(item_name, itemClassification, data.id, self.player) item = AquariaItem(item_name, ItemClassification.useful, data.id, self.player)
self.multiworld.get_location(location_name, self.player).place_locked_item(item) self.multiworld.get_location(location_name, self.player).place_locked_item(item)
def get_filler_item_name(self): def get_filler_item_name(self):
@@ -165,8 +164,7 @@ class AquariaWorld(World):
self.__pre_fill_item("Transturtle Abyss right", "Abyss right area, Transturtle", precollected) self.__pre_fill_item("Transturtle Abyss right", "Abyss right area, Transturtle", precollected)
self.__pre_fill_item("Transturtle Final Boss", "Final Boss area, Transturtle", precollected) self.__pre_fill_item("Transturtle Final Boss", "Final Boss area, Transturtle", precollected)
# The last two are inverted because in the original game, they are special turtle that communicate directly # The last two are inverted because in the original game, they are special turtle that communicate directly
self.__pre_fill_item("Transturtle Simon Says", "Arnassi Ruins, Transturtle", precollected, self.__pre_fill_item("Transturtle Simon Says", "Arnassi Ruins, Transturtle", precollected)
ItemClassification.progression)
self.__pre_fill_item("Transturtle Arnassi Ruins", "Simon Says area, Transturtle", precollected) self.__pre_fill_item("Transturtle Arnassi Ruins", "Simon Says area, Transturtle", precollected)
for name, data in item_table.items(): for name, data in item_table.items():
if name not in self.exclude: if name not in self.exclude:
@@ -214,8 +212,4 @@ class AquariaWorld(World):
"skip_first_vision": bool(self.options.skip_first_vision.value), "skip_first_vision": bool(self.options.skip_first_vision.value),
"unconfine_home_water_energy_door": self.options.unconfine_home_water.value in [1, 3], "unconfine_home_water_energy_door": self.options.unconfine_home_water.value in [1, 3],
"unconfine_home_water_transturtle": self.options.unconfine_home_water.value in [2, 3], "unconfine_home_water_transturtle": self.options.unconfine_home_water.value in [2, 3],
"bind_song_needed_to_get_under_rock_bulb": bool(self.options.bind_song_needed_to_get_under_rock_bulb),
"no_progression_hard_or_hidden_locations": bool(self.options.no_progression_hard_or_hidden_locations),
"light_needed_to_get_to_dark_places": bool(self.options.light_needed_to_get_to_dark_places),
"turtle_randomizer": self.options.turtle_randomizer.value,
} }

View File

@@ -8,8 +8,6 @@
## Optional Software ## Optional Software
- For sending [commands](/tutorial/Archipelago/commands/en) like `!hint`: the TextClient from [the most recent Archipelago release](https://github.com/ArchipelagoMW/Archipelago/releases) - For sending [commands](/tutorial/Archipelago/commands/en) like `!hint`: the TextClient from [the most recent Archipelago release](https://github.com/ArchipelagoMW/Archipelago/releases)
- [Aquaria AP Tracker](https://github.com/palex00/aquaria-ap-tracker/releases/latest), for use with
[PopTracker](https://github.com/black-sliver/PopTracker/releases/latest)
## Installation and execution Procedures ## Installation and execution Procedures
@@ -115,16 +113,3 @@ sure that your executable has executable permission:
```bash ```bash
chmod +x aquaria_randomizer chmod +x aquaria_randomizer
``` ```
## Auto-Tracking
Aquaria has a fully functional map tracker that supports auto-tracking.
1. Download [Aquaria AP Tracker](https://github.com/palex00/aquaria-ap-tracker/releases/latest) and
[PopTracker](https://github.com/black-sliver/PopTracker/releases/latest).
2. Put the tracker pack into /packs/ in your PopTracker install.
3. Open PopTracker, and load the Aquaria pack.
4. For autotracking, click on the "AP" symbol at the top.
5. Enter the Archipelago server address (the one you connected your client to), slot name, and password.
This pack will automatically prompt you to update if one is available.

View File

@@ -2,14 +2,9 @@
## Logiciels nécessaires ## Logiciels nécessaires
- Une copie du jeu Aquaria non-modifiée (disponible sur la majorité des sites de ventes de jeux vidéos en ligne) - Le jeu Aquaria original (trouvable sur la majorité des sites de ventes de jeux vidéo en ligne)
- Le client du Randomizer d'Aquaria [Aquaria randomizer] - Le client Randomizer d'Aquaria [Aquaria randomizer](https://github.com/tioui/Aquaria_Randomizer/releases)
(https://github.com/tioui/Aquaria_Randomizer/releases)
## Logiciels optionnels
- De manière optionnel, pour pouvoir envoyer des [commandes](/tutorial/Archipelago/commands/en) comme `!hint`: utilisez le client texte de [la version la plus récente d'Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) - De manière optionnel, pour pouvoir envoyer des [commandes](/tutorial/Archipelago/commands/en) comme `!hint`: utilisez le client texte de [la version la plus récente d'Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
- [Aquaria AP Tracker](https://github.com/palex00/aquaria-ap-tracker/releases/latest), pour utiliser avec [PopTracker](https://github.com/black-sliver/PopTracker/releases/latest)
## Procédures d'installation et d'exécution ## Procédures d'installation et d'exécution
@@ -121,15 +116,3 @@ pour vous assurer que votre fichier est exécutable:
```bash ```bash
chmod +x aquaria_randomizer chmod +x aquaria_randomizer
``` ```
## Tracking automatique
Aquaria a un tracker complet qui supporte le tracking automatique.
1. Téléchargez [Aquaria AP Tracker](https://github.com/palex00/aquaria-ap-tracker/releases/latest) et [PopTracker](https://github.com/black-sliver/PopTracker/releases/latest).
2. Mettre le fichier compressé du tracker dans le sous-répertoire /packs/ du répertoire d'installation de PopTracker.
3. Lancez PopTracker, et ouvrez le pack d'Aquaria.
4. Pour activer le tracking automatique, cliquez sur le symbole "AP" dans le haut de la fenêtre.
5. Entrez l'adresse du serveur Archipelago (le serveur auquel vous avez connecté le client), le nom de votre slot, et le mot de passe (si un mot de passe est nécessaire).
Le logiciel vous indiquera si une mise à jour du pack est disponible.

View File

@@ -199,6 +199,8 @@ class BlasphemousWorld(World):
self.multiworld.itempool += pool self.multiworld.itempool += pool
def pre_fill(self):
self.place_items_from_dict(unrandomized_dict) self.place_items_from_dict(unrandomized_dict)
if self.options.thorn_shuffle == "vanilla": if self.options.thorn_shuffle == "vanilla":
@@ -333,4 +335,4 @@ class BlasphemousItem(Item):
class BlasphemousLocation(Location): class BlasphemousLocation(Location):
game: str = "Blasphemous" game: str = "Blasphemous"

View File

@@ -125,6 +125,6 @@ class BumpStikWorld(World):
lambda state: state.has("Hazard Bumper", self.player, 25) lambda state: state.has("Hazard Bumper", self.player, 25)
self.multiworld.completion_condition[self.player] = \ self.multiworld.completion_condition[self.player] = \
lambda state: state.has_all_counts({"Booster Bumper": 5, "Treasure Bumper": 32, "Hazard Bumper": 25}, \ lambda state: state.has("Booster Bumper", self.player, 5) and \
self.player) state.has("Treasure Bumper", self.player, 32)

View File

@@ -63,9 +63,6 @@ all_bosses = [
DS3BossInfo("Deacons of the Deep", 3500800, locations = { DS3BossInfo("Deacons of the Deep", 3500800, locations = {
"CD: Soul of the Deacons of the Deep", "CD: Soul of the Deacons of the Deep",
"CD: Small Doll - boss drop", "CD: Small Doll - boss drop",
"CD: Archdeacon White Crown - boss room after killing boss",
"CD: Archdeacon Holy Garb - boss room after killing boss",
"CD: Archdeacon Skirt - boss room after killing boss",
"FS: Hawkwood's Shield - gravestone after Hawkwood leaves", "FS: Hawkwood's Shield - gravestone after Hawkwood leaves",
}), }),
DS3BossInfo("Abyss Watchers", 3300801, before_storm_ruler = True, locations = { DS3BossInfo("Abyss Watchers", 3300801, before_storm_ruler = True, locations = {

View File

@@ -89,7 +89,6 @@ class DarkSouls3World(World):
self.all_excluded_locations = set() self.all_excluded_locations = set()
def generate_early(self) -> None: def generate_early(self) -> None:
self.created_regions = set()
self.all_excluded_locations.update(self.options.exclude_locations.value) self.all_excluded_locations.update(self.options.exclude_locations.value)
# Inform Universal Tracker where Yhorm is being randomized to. # Inform Universal Tracker where Yhorm is being randomized to.
@@ -295,7 +294,6 @@ class DarkSouls3World(World):
new_region.locations.append(new_location) new_region.locations.append(new_location)
self.multiworld.regions.append(new_region) self.multiworld.regions.append(new_region)
self.created_regions.add(region_name)
return new_region return new_region
def create_items(self) -> None: def create_items(self) -> None:
@@ -614,7 +612,9 @@ class DarkSouls3World(World):
self._add_entrance_rule("Painted World of Ariandel (Before Contraption)", "Basin of Vows") self._add_entrance_rule("Painted World of Ariandel (Before Contraption)", "Basin of Vows")
# Define the access rules to some specific locations # Define the access rules to some specific locations
self._add_location_rule("HWL: Red Eye Orb - wall tower, miniboss", "Lift Chamber Key") if self._is_location_available("FS: Lift Chamber Key - Leonhard"):
self._add_location_rule("HWL: Red Eye Orb - wall tower, miniboss",
"Lift Chamber Key")
self._add_location_rule("ID: Bellowing Dragoncrest Ring - drop from B1 towards pit", self._add_location_rule("ID: Bellowing Dragoncrest Ring - drop from B1 towards pit",
"Jailbreaker's Key") "Jailbreaker's Key")
self._add_location_rule("ID: Covetous Gold Serpent Ring - Siegward's cell", "Old Cell Key") self._add_location_rule("ID: Covetous Gold Serpent Ring - Siegward's cell", "Old Cell Key")
@@ -1307,7 +1307,7 @@ class DarkSouls3World(World):
def _add_entrance_rule(self, region: str, rule: Union[CollectionRule, str]) -> None: def _add_entrance_rule(self, region: str, rule: Union[CollectionRule, str]) -> None:
"""Sets a rule for the entrance to the given region.""" """Sets a rule for the entrance to the given region."""
assert region in location_tables assert region in location_tables
if region not in self.created_regions: return if not any(region == reg for reg in self.multiworld.regions.region_cache[self.player]): return
if isinstance(rule, str): if isinstance(rule, str):
if " -> " not in rule: if " -> " not in rule:
assert item_dictionary[rule].classification == ItemClassification.progression assert item_dictionary[rule].classification == ItemClassification.progression

View File

@@ -3,7 +3,7 @@
## Required Software ## Required Software
- [Dark Souls III](https://store.steampowered.com/app/374320/DARK_SOULS_III/) - [Dark Souls III](https://store.steampowered.com/app/374320/DARK_SOULS_III/)
- [Dark Souls III AP Client](https://github.com/nex3/Dark-Souls-III-Archipelago-client/releases/latest) - [Dark Souls III AP Client](https://github.com/Marechal-L/Dark-Souls-III-Archipelago-client/releases)
## Optional Software ## Optional Software
@@ -11,9 +11,8 @@
## Setting Up ## Setting Up
First, download the client from the link above (`DS3.Archipelago.*.zip`). It doesn't need to go First, download the client from the link above. It doesn't need to go into any particular directory;
into any particular directory; it'll automatically locate _Dark Souls III_ in your Steam it'll automatically locate _Dark Souls III_ in your Steam installation folder.
installation folder.
Version 3.0.0 of the randomizer _only_ supports the latest version of _Dark Souls III_, 1.15.2. This Version 3.0.0 of the randomizer _only_ supports the latest version of _Dark Souls III_, 1.15.2. This
is the latest version, so you don't need to do any downpatching! However, if you've already is the latest version, so you don't need to do any downpatching! However, if you've already
@@ -36,9 +35,8 @@ randomized item and (optionally) enemy locations. You only need to do this once
To run _Dark Souls III_ in Archipelago mode: To run _Dark Souls III_ in Archipelago mode:
1. Start Steam. **Do not run in offline mode.** Running Steam in offline mode will make certain 1. Start Steam. **Do not run in offline mode.** The mod will make sure you don't connect to the
scripted invaders fail to spawn. Instead, change the game itself to offline mode on the menu DS3 servers, and running Steam in offline mode will make certain scripted invaders fail to spawn.
screen.
2. Run `launchmod_darksouls3.bat`. This will start _Dark Souls III_ as well as a command prompt that 2. Run `launchmod_darksouls3.bat`. This will start _Dark Souls III_ as well as a command prompt that
you can use to interact with the Archipelago server. you can use to interact with the Archipelago server.
@@ -54,21 +52,4 @@ To run _Dark Souls III_ in Archipelago mode:
### Where do I get a config file? ### Where do I get a config file?
The [Player Options](/games/Dark%20Souls%20III/player-options) page on the website allows you to The [Player Options](/games/Dark%20Souls%20III/player-options) page on the website allows you to
configure your personal options and export them into a config file. The [AP client archive] also configure your personal options and export them into a config file.
includes an options template.
[AP client archive]: https://github.com/nex3/Dark-Souls-III-Archipelago-client/releases/latest
### Does this work with Proton?
The *Dark Souls III* Archipelago randomizer supports running on Linux under Proton. There are a few
things to keep in mind:
* Because `DS3Randomizer.exe` relies on the .NET runtime, you'll need to install
the [.NET Runtime] under **plain [WINE]**, then run `DS3Randomizer.exe` under
plain WINE as well. It won't work as a Proton app!
* To run the game itself, just run `launchmod_darksouls3.bat` under Proton.
[.NET Runtime]: https://dotnet.microsoft.com/en-us/download/dotnet/8.0
[WINE]: https://www.winehq.org/

Some files were not shown because too many files have changed in this diff Show More