Compare commits

..

1 Commits

Author SHA1 Message Date
Fabian Dill
1db6b67953 Tests: load custom tests from apworld 2023-07-01 02:41:51 +02:00
484 changed files with 16321 additions and 49265 deletions

View File

@@ -38,13 +38,12 @@ jobs:
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
python setup.py build_exe --yes python setup.py build_exe --yes
$NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1] $NAME="$(ls build)".Split('.',2)[1]
$ZIP_NAME="Archipelago_$NAME.7z" $ZIP_NAME="Archipelago_$NAME.7z"
echo "$NAME -> $ZIP_NAME"
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
New-Item -Path dist -ItemType Directory -Force New-Item -Path dist -ItemType Directory -Force
cd build cd build
Rename-Item "exe.$NAME" Archipelago Rename-Item exe.$NAME Archipelago
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago 7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
- name: Store 7z - name: Store 7z
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
@@ -66,10 +65,10 @@ jobs:
- name: Get a recent python - name: Get a recent python
uses: actions/setup-python@v4 uses: actions/setup-python@v4
with: with:
python-version: '3.11' python-version: '3.9'
- name: Install build-time dependencies - name: Install build-time dependencies
run: | run: |
echo "PYTHON=python3.11" >> $GITHUB_ENV echo "PYTHON=python3.9" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
chmod a+rx appimagetool-x86_64.AppImage chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract ./appimagetool-x86_64.AppImage --appimage-extract

View File

@@ -44,10 +44,10 @@ jobs:
- name: Get a recent python - name: Get a recent python
uses: actions/setup-python@v4 uses: actions/setup-python@v4
with: with:
python-version: '3.11' python-version: '3.9'
- name: Install build-time dependencies - name: Install build-time dependencies
run: | run: |
echo "PYTHON=python3.11" >> $GITHUB_ENV echo "PYTHON=python3.9" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
chmod a+rx appimagetool-x86_64.AppImage chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract ./appimagetool-x86_64.AppImage --appimage-extract

View File

@@ -36,13 +36,12 @@ jobs:
- {version: '3.8'} - {version: '3.8'}
- {version: '3.9'} - {version: '3.9'}
- {version: '3.10'} - {version: '3.10'}
- {version: '3.11'}
include: include:
- python: {version: '3.8'} # win7 compat - python: {version: '3.8'} # win7 compat
os: windows-latest os: windows-latest
- python: {version: '3.11'} # current - python: {version: '3.10'} # current
os: windows-latest os: windows-latest
- python: {version: '3.11'} # current - python: {version: '3.10'} # current
os: macos-latest os: macos-latest
steps: steps:
@@ -54,9 +53,8 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install pytest pytest-subtests pytest-xdist pip install pytest pytest-subtests
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt" python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
python Launcher.py --update_settings # make sure host.yaml exists for tests
- name: Unittests - name: Unittests
run: | run: |
pytest -n auto pytest

10
.gitignore vendored
View File

@@ -27,21 +27,16 @@
*.archipelago *.archipelago
*.apsave *.apsave
*.BIN *.BIN
*.puml
setups setups
build build
bundle/components.wxs bundle/components.wxs
dist dist
/prof/
README.html README.html
.vs/ .vs/
EnemizerCLI/ EnemizerCLI/
/Players/ /Players/
/SNI/ /SNI/
/sni-*/
/appimagetool*
/host.yaml
/options.yaml /options.yaml
/config.yaml /config.yaml
/logs/ /logs/
@@ -143,7 +138,6 @@ ipython_config.py
.venv* .venv*
env/ env/
venv/ venv/
/venv*/
ENV/ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
@@ -174,10 +168,6 @@ dmypy.json
# Cython debug symbols # Cython debug symbols
cython_debug/ cython_debug/
# Cython intermediates
_speedups.cpp
_speedups.html
# minecraft server stuff # minecraft server stuff
jdk*/ jdk*/
minecraft*/ minecraft*/

View File

@@ -8,10 +8,8 @@ import secrets
import typing # this can go away when Python 3.8 support is dropped import typing # this can go away when Python 3.8 support is dropped
from argparse import Namespace from argparse import Namespace
from collections import ChainMap, Counter, deque from collections import ChainMap, Counter, deque
from collections.abc import Collection
from enum import IntEnum, IntFlag from enum import IntEnum, IntFlag
from typing import Any, Callable, Dict, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union, \ from typing import Any, Callable, Dict, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union
Type, ClassVar
import NetUtils import NetUtils
import Options import Options
@@ -83,7 +81,6 @@ class MultiWorld():
random: random.Random random: random.Random
per_slot_randoms: Dict[int, random.Random] per_slot_randoms: Dict[int, random.Random]
"""Deprecated. Please use `self.random` instead."""
class AttributeProxy(): class AttributeProxy():
def __init__(self, rule): def __init__(self, rule):
@@ -203,7 +200,14 @@ class MultiWorld():
self.player_types[new_id] = NetUtils.SlotType.group self.player_types[new_id] = NetUtils.SlotType.group
self._region_cache[new_id] = {} self._region_cache[new_id] = {}
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) for option_key, option in world_type.option_definitions.items():
getattr(self, option_key)[new_id] = option(option.default)
for option_key, option in Options.common_options.items():
getattr(self, option_key)[new_id] = option(option.default)
for option_key, option in Options.per_game_common_options.items():
getattr(self, option_key)[new_id] = option(option.default)
self.worlds[new_id] = world_type(self, new_id)
self.worlds[new_id].collect_item = classmethod(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.player_name[new_id] = name self.player_name[new_id] = name
@@ -238,7 +242,6 @@ class MultiWorld():
setattr(self, option_key, getattr(args, option_key, {})) setattr(self, option_key, getattr(args, option_key, {}))
self.worlds[player] = world_type(self, player) self.worlds[player] = world_type(self, player)
self.worlds[player].random = self.per_slot_randoms[player]
def set_item_links(self): def set_item_links(self):
item_links = {} item_links = {}
@@ -358,7 +361,7 @@ class MultiWorld():
for r_location in region.locations: for r_location in region.locations:
self._location_cache[r_location.name, player] = r_location self._location_cache[r_location.name, player] = r_location
def get_regions(self, player: Optional[int] = None) -> Collection[Region]: def get_regions(self, player=None):
return self.regions if player is None else self._region_cache[player].values() return self.regions if player is None else self._region_cache[player].values()
def get_region(self, regionname: str, player: int) -> Region: def get_region(self, regionname: str, player: int) -> Region:
@@ -481,10 +484,8 @@ class MultiWorld():
def get_unfilled_locations_for_players(self, location_names: List[str], players: Iterable[int]): def get_unfilled_locations_for_players(self, location_names: List[str], players: Iterable[int]):
for player in players: for player in players:
if not location_names: if not location_names:
valid_locations = [location.name for location in self.get_unfilled_locations(player)] location_names = [location.name for location in self.get_unfilled_locations(player)]
else: for location_name in location_names:
valid_locations = location_names
for location_name in valid_locations:
location = self._location_cache.get((location_name, player), None) location = self._location_cache.get((location_name, player), None)
if location is not None and location.item is None: if location is not None and location.item is None:
yield location yield location
@@ -785,6 +786,78 @@ class CollectionState():
self.stale[item.player] = True self.stale[item.player] = True
class Region:
name: str
_hint_text: str
player: int
multiworld: Optional[MultiWorld]
entrances: List[Entrance]
exits: List[Entrance]
locations: List[Location]
def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None):
self.name = name
self.entrances = []
self.exits = []
self.locations = []
self.multiworld = multiworld
self._hint_text = hint
self.player = player
def can_reach(self, state: CollectionState) -> bool:
if state.stale[self.player]:
state.update_reachable_regions(self.player)
return self in state.reachable_regions[self.player]
def can_reach_private(self, state: CollectionState) -> bool:
for entrance in self.entrances:
if entrance.can_reach(state):
if not self in state.path:
state.path[self] = (self.name, state.path.get(entrance, None))
return True
return False
@property
def hint_text(self) -> str:
return self._hint_text if self._hint_text else self.name
def get_connecting_entrance(self, is_main_entrance: typing.Callable[[Entrance], bool]) -> Entrance:
for entrance in self.entrances:
if is_main_entrance(entrance):
return entrance
for entrance in self.entrances: # BFS might be better here, trying DFS for now.
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
def add_locations(self, locations: Dict[str, Optional[int]], location_type: Optional[typing.Type[Location]] = None) -> None:
"""Adds locations to the Region object, where location_type is your Location class and locations is a dict of
location names to address."""
if location_type is None:
location_type = Location
for location, address in locations.items():
self.locations.append(location_type(self.player, location, address, self))
def add_exits(self, exits: Dict[str, Optional[str]], rules: Dict[str, Callable[[CollectionState], bool]] = None) -> None:
"""
Connects current region to regions in exit dictionary. Passed region names must exist first.
:param exits: exits from the region. format is {"connecting_region", "exit_name"}
:param rules: rules for the exits from this region. format is {"connecting_region", rule}
"""
for exiting_region, name in exits.items():
ret = Entrance(self.player, name, self) if name \
else Entrance(self.player, f"{self.name} -> {exiting_region}", self)
if rules and exiting_region in rules:
ret.access_rule = rules[exiting_region]
self.exits.append(ret)
ret.connect(self.multiworld.get_region(exiting_region, self.player))
def __repr__(self):
return self.__str__()
def __str__(self):
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
class Entrance: class Entrance:
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
hide_path: bool = False hide_path: bool = False
@@ -823,100 +896,6 @@ class Entrance:
return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})' return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
class Region:
name: str
_hint_text: str
player: int
multiworld: Optional[MultiWorld]
entrances: List[Entrance]
exits: List[Entrance]
locations: List[Location]
entrance_type: ClassVar[Type[Entrance]] = Entrance
def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None):
self.name = name
self.entrances = []
self.exits = []
self.locations = []
self.multiworld = multiworld
self._hint_text = hint
self.player = player
def can_reach(self, state: CollectionState) -> bool:
if state.stale[self.player]:
state.update_reachable_regions(self.player)
return self in state.reachable_regions[self.player]
@property
def hint_text(self) -> str:
return self._hint_text if self._hint_text else self.name
def get_connecting_entrance(self, is_main_entrance: Callable[[Entrance], bool]) -> Entrance:
for entrance in self.entrances:
if is_main_entrance(entrance):
return entrance
for entrance in self.entrances: # BFS might be better here, trying DFS for now.
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
def add_locations(self, locations: Dict[str, Optional[int]],
location_type: Optional[Type[Location]] = None) -> None:
"""
Adds locations to the Region object, where location_type is your Location class and locations is a dict of
location names to address.
:param locations: dictionary of locations to be created and added to this Region `{name: ID}`
:param location_type: Location class to be used to create the locations with"""
if location_type is None:
location_type = Location
for location, address in locations.items():
self.locations.append(location_type(self.player, location, address, self))
def connect(self, connecting_region: Region, name: Optional[str] = None,
rule: Optional[Callable[[CollectionState], bool]] = None) -> None:
"""
Connects this Region to another Region, placing the provided rule on the connection.
:param connecting_region: Region object to connect to path is `self -> exiting_region`
:param name: name of the connection being created
:param rule: callable to determine access of this connection to go from self to the exiting_region"""
exit_ = self.create_exit(name if name else f"{self.name} -> {connecting_region.name}")
if rule:
exit_.access_rule = rule
exit_.connect(connecting_region)
def create_exit(self, name: str) -> Entrance:
"""
Creates and returns an Entrance object as an exit of this region.
:param name: name of the Entrance being created
"""
exit_ = self.entrance_type(self.player, name, self)
self.exits.append(exit_)
return exit_
def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]],
rules: Dict[str, Callable[[CollectionState], bool]] = None) -> None:
"""
Connects current region to regions in exit dictionary. Passed region names must exist first.
:param exits: exits from the region. format is {"connecting_region": "exit_name"}. if a non dict is provided,
created entrances will be named "self.name -> connecting_region"
:param rules: rules for the exits from this region. format is {"connecting_region", rule}
"""
if not isinstance(exits, Dict):
exits = dict.fromkeys(exits)
for connecting_region, name in exits.items():
self.connect(self.multiworld.get_region(connecting_region, self.player),
name,
rules[connecting_region] if rules and connecting_region in rules else None)
def __repr__(self):
return self.__str__()
def __str__(self):
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
class LocationProgressType(IntEnum): class LocationProgressType(IntEnum):
DEFAULT = 1 DEFAULT = 1
PRIORITY = 2 PRIORITY = 2

View File

@@ -1,9 +0,0 @@
from __future__ import annotations
import ModuleUpdate
ModuleUpdate.update()
from worlds._bizhawk.context import launch
if __name__ == "__main__":
launch()

View File

@@ -833,7 +833,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
elif cmd == "SetReply": elif cmd == "SetReply":
ctx.stored_data[args["key"]] = args["value"] ctx.stored_data[args["key"]] = args["value"]
if args["key"].startswith("EnergyLink"): if args["key"] == "EnergyLink":
ctx.current_energy_link_value = args["value"] ctx.current_energy_link_value = args["value"]
if ctx.ui: if ctx.ui:
ctx.ui.set_new_energy_link_value() ctx.ui.set_new_energy_link_value()

21
Fill.py
View File

@@ -51,10 +51,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
items_to_place = [items.pop() items_to_place = [items.pop()
for items in reachable_items.values() if items] for items in reachable_items.values() if items]
for item in items_to_place: for item in items_to_place:
for p, pool_item in enumerate(item_pool): item_pool.remove(item)
if pool_item is item:
item_pool.pop(p)
break
maximum_exploration_state = sweep_from_pool( maximum_exploration_state = sweep_from_pool(
base_state, item_pool + unplaced_items) base_state, item_pool + unplaced_items)
@@ -155,8 +152,8 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
if cleanup_required: if cleanup_required:
# validate all placements and remove invalid ones # validate all placements and remove invalid ones
state = sweep_from_pool(base_state, [])
for placement in placements: for placement in placements:
state = sweep_from_pool(base_state, [])
if world.accessibility[placement.item.player] != "minimal" and not placement.can_reach(state): if world.accessibility[placement.item.player] != "minimal" and not placement.can_reach(state):
placement.item.location = None placement.item.location = None
unplaced_items.append(placement.item) unplaced_items.append(placement.item)
@@ -753,6 +750,8 @@ def distribute_planned(world: MultiWorld) -> None:
else: # not reachable with swept state else: # not reachable with swept state
non_early_locations[loc.player].append(loc.name) non_early_locations[loc.player].append(loc.name)
# TODO: remove. Preferably by implementing key drop
from worlds.alttp.Regions import key_drop_data
world_name_lookup = world.world_name_lookup world_name_lookup = world.world_name_lookup
block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str] block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
@@ -838,12 +837,12 @@ def distribute_planned(world: MultiWorld) -> None:
if "early_locations" in locations: if "early_locations" in locations:
locations.remove("early_locations") locations.remove("early_locations")
for target_player in worlds: for player in worlds:
locations += early_locations[target_player] locations += early_locations[player]
if "non_early_locations" in locations: if "non_early_locations" in locations:
locations.remove("non_early_locations") locations.remove("non_early_locations")
for target_player in worlds: for player in worlds:
locations += non_early_locations[target_player] locations += non_early_locations[player]
block['locations'] = locations block['locations'] = locations
@@ -895,6 +894,10 @@ def distribute_planned(world: MultiWorld) -> None:
for item_name in items: for item_name in items:
item = world.worlds[player].create_item(item_name) item = world.worlds[player].create_item(item_name)
for location in reversed(candidates): for location in reversed(candidates):
if location in key_drop_data:
warn(
f"Can't place '{item_name}' at '{placement.location}', as key drop shuffle locations are not supported yet.")
continue
if not location.item: if not location.item:
if location.item_rule(item): if location.item_rule(item):
if location.can_fill(world.state, item, False): if location.can_fill(world.state, item, False):

View File

@@ -14,42 +14,44 @@ import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
import copy
import Utils import Utils
import Options
from BaseClasses import seeddigits, get_seed, PlandoOptions
from Main import main as ERmain
from settings import get_settings
from Utils import parse_yamls, version_tuple, __version__, tuplize_version, user_path
from worlds.alttp import Options as LttPOptions from worlds.alttp import Options as LttPOptions
from worlds.generic import PlandoConnection
from Utils import parse_yamls, version_tuple, __version__, tuplize_version, get_options, user_path
from worlds.alttp.EntranceRandomizer import parse_arguments from worlds.alttp.EntranceRandomizer import parse_arguments
from Main import main as ERmain
from BaseClasses import seeddigits, get_seed, PlandoOptions
import Options
from worlds.alttp.Text import TextTable from worlds.alttp.Text import TextTable
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
from worlds.generic import PlandoConnection import copy
def mystery_argparse(): def mystery_argparse():
options = get_settings() options = get_options()
defaults = options.generator defaults = options["generator"]
def resolve_path(path: str, resolver: Callable[[str], str]) -> str:
return path if os.path.isabs(path) else resolver(path)
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.") parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
parser.add_argument('--weights_file_path', default=defaults.weights_file_path, parser.add_argument('--weights_file_path', default=defaults["weights_file_path"],
help='Path to the weights file to use for rolling game settings, urls are also valid') help='Path to the weights file to use for rolling game settings, urls are also valid')
parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player', parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player',
action='store_true') action='store_true')
parser.add_argument('--player_files_path', default=defaults.player_files_path, parser.add_argument('--player_files_path', default=resolve_path(defaults["player_files_path"], user_path),
help="Input directory for player files.") help="Input directory for player files.")
parser.add_argument('--seed', help='Define seed number to generate.', type=int) parser.add_argument('--seed', help='Define seed number to generate.', type=int)
parser.add_argument('--multi', default=defaults.players, type=lambda value: max(int(value), 1)) parser.add_argument('--multi', default=defaults["players"], type=lambda value: max(int(value), 1))
parser.add_argument('--spoiler', type=int, default=defaults.spoiler) parser.add_argument('--spoiler', type=int, default=defaults["spoiler"])
parser.add_argument('--outputpath', default=options.general_options.output_path, parser.add_argument('--outputpath', default=resolve_path(options["general_options"]["output_path"], user_path),
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
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('--yaml_output', default=0, type=lambda value: max(int(value), 0), parser.add_argument('--yaml_output', default=0, type=lambda value: max(int(value), 0),
help='Output rolled mystery results to yaml up to specified number (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.")
@@ -69,8 +71,6 @@ def get_seed_name(random_source) -> str:
def main(args=None, callback=ERmain): def main(args=None, callback=ERmain):
if not args: if not args:
args, options = mystery_argparse() args, options = mystery_argparse()
else:
options = get_settings()
seed = get_seed(args.seed) seed = get_seed(args.seed)
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level) Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
@@ -86,7 +86,7 @@ def main(args=None, callback=ERmain):
try: try:
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path) weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
except Exception as e: except Exception as e:
raise ValueError(f"File {args.weights_file_path} is invalid. Please fix your yaml.") from e raise ValueError(f"File {args.weights_file_path} is destroyed. Please fix your yaml.") from e
logging.info(f"Weights: {args.weights_file_path} >> " logging.info(f"Weights: {args.weights_file_path} >> "
f"{get_choice('description', weights_cache[args.weights_file_path][-1], 'No description specified')}") f"{get_choice('description', weights_cache[args.weights_file_path][-1], 'No description specified')}")
@@ -94,7 +94,7 @@ def main(args=None, callback=ERmain):
try: try:
meta_weights = read_weights_yamls(args.meta_file_path)[-1] meta_weights = read_weights_yamls(args.meta_file_path)[-1]
except Exception as e: except Exception as e:
raise ValueError(f"File {args.meta_file_path} is invalid. Please fix your yaml.") from e raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
logging.info(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}") logging.info(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
try: # meta description allows us to verify that the file named meta.yaml is intentionally a meta file try: # meta description allows us to verify that the file named meta.yaml is intentionally a meta file
del(meta_weights["meta_description"]) del(meta_weights["meta_description"])
@@ -114,7 +114,7 @@ def main(args=None, callback=ERmain):
try: try:
weights_cache[fname] = read_weights_yamls(path) weights_cache[fname] = read_weights_yamls(path)
except Exception as e: except Exception as e:
raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e raise ValueError(f"File {fname} is destroyed. Please fix your yaml.") from e
# sort dict for consistent results across platforms: # sort dict for consistent results across platforms:
weights_cache = {key: value for key, value in sorted(weights_cache.items())} weights_cache = {key: value for key, value in sorted(weights_cache.items())}
@@ -137,7 +137,7 @@ def main(args=None, callback=ERmain):
erargs = parse_arguments(['--multi', str(args.multi)]) erargs = parse_arguments(['--multi', str(args.multi)])
erargs.seed = seed erargs.seed = seed
erargs.plando_options = args.plando erargs.plando_options = args.plando
erargs.glitch_triforce = options.generator.glitch_triforce_room erargs.glitch_triforce = options["generator"]["glitch_triforce_room"]
erargs.spoiler = args.spoiler erargs.spoiler = args.spoiler
erargs.race = args.race erargs.race = args.race
erargs.outputname = seed_name erargs.outputname = seed_name
@@ -195,7 +195,7 @@ def main(args=None, callback=ERmain):
player += 1 player += 1
except Exception as e: except Exception as e:
raise ValueError(f"File {path} is invalid. Please fix your yaml.") from e raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
else: else:
raise RuntimeError(f'No weights specified for player {player}') raise RuntimeError(f'No weights specified for player {player}')
@@ -374,7 +374,7 @@ def roll_linked_options(weights: dict) -> dict:
else: else:
logging.debug(f"linked option {option_set['name']} skipped.") logging.debug(f"linked option {option_set['name']} skipped.")
except Exception as e: except Exception as e:
raise ValueError(f"Linked option {option_set['name']} is invalid. " raise ValueError(f"Linked option {option_set['name']} is destroyed. "
f"Please fix your linked option.") from e f"Please fix your linked option.") from e
return weights return weights
@@ -404,7 +404,7 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"]) update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"])
except Exception as e: except Exception as e:
raise ValueError(f"Your trigger number {i + 1} is invalid. " raise ValueError(f"Your trigger number {i + 1} is destroyed. "
f"Please fix your triggers.") from e f"Please fix your triggers.") from e
return weights return weights

View File

@@ -22,7 +22,6 @@ from shutil import which
from typing import Sequence, Union, Optional from typing import Sequence, Union, Optional
import Utils import Utils
import settings
from worlds.LauncherComponents import Component, components, Type, SuffixIdentifier, icon_paths from worlds.LauncherComponents import Component, components, Type, SuffixIdentifier, icon_paths
if __name__ == "__main__": if __name__ == "__main__":
@@ -34,8 +33,7 @@ from Utils import is_frozen, user_path, local_path, init_logging, open_filename,
def open_host_yaml(): def open_host_yaml():
file = settings.get_settings().filename file = user_path('host.yaml')
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 \
which('xdg-open') or which('gnome-open') or which('kde-open') which('xdg-open') or which('gnome-open') or which('kde-open')
@@ -50,22 +48,17 @@ def open_host_yaml():
def open_patch(): def open_patch():
suffixes = [] suffixes = []
for c in components: for c in components:
if c.type == Type.CLIENT and \ if isfile(get_exe(c)[-1]):
isinstance(c.file_identifier, SuffixIdentifier) and \ suffixes += c.file_identifier.suffixes if c.type == Type.CLIENT and \
(c.script_name is None or isfile(get_exe(c)[-1])): isinstance(c.file_identifier, SuffixIdentifier) else []
suffixes += c.file_identifier.suffixes
try: try:
filename = open_filename("Select patch", (("Patches", suffixes),)) filename = open_filename('Select patch', (('Patches', suffixes),))
except Exception as e: except Exception as e:
messagebox("Error", str(e), error=True) messagebox('Error', str(e), error=True)
else: else:
file, component = identify(filename) file, component = identify(filename)
if file and component: if file and component:
exe = get_exe(component) launch([*get_exe(component), file], component.cli)
if exe is None or not isfile(exe[-1]):
exe = get_exe("Launcher")
launch([*exe, file], component.cli)
def generate_yamls(): def generate_yamls():
@@ -91,11 +84,6 @@ def open_folder(folder_path):
webbrowser.open(folder_path) webbrowser.open(folder_path)
def update_settings():
from settings import get_settings
get_settings().save()
components.extend([ components.extend([
# Functions # Functions
Component("Open host.yaml", func=open_host_yaml), Component("Open host.yaml", func=open_host_yaml),
@@ -112,7 +100,7 @@ def identify(path: Union[None, str]):
return None, None return None, None
for component in components: for component in components:
if component.handles_file(path): if component.handles_file(path):
return path, component return path, component
elif path == component.display_name or path == component.script_name: elif path == component.display_name or path == component.script_name:
return None, component return None, component
return None, None return None, None
@@ -122,25 +110,25 @@ def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
if isinstance(component, str): if isinstance(component, str):
name = component name = component
component = None component = None
if name.startswith("Archipelago"): if name.startswith('Archipelago'):
name = name[11:] name = name[11:]
if name.endswith(".exe"): if name.endswith('.exe'):
name = name[:-4] name = name[:-4]
if name.endswith(".py"): if name.endswith('.py'):
name = name[:-3] name = name[:-3]
if not name: if not name:
return None return None
for c in components: for c in components:
if c.script_name == name or c.frozen_name == f"Archipelago{name}": if c.script_name == name or c.frozen_name == f'Archipelago{name}':
component = c component = c
break break
if not component: if not component:
return None return None
if is_frozen(): if is_frozen():
suffix = ".exe" if is_windows else "" suffix = '.exe' if is_windows else ''
return [local_path(f"{component.frozen_name}{suffix}")] if component.frozen_name else None return [local_path(f'{component.frozen_name}{suffix}')]
else: else:
return [sys.executable, local_path(f"{component.script_name}.py")] if component.script_name else None return [sys.executable, local_path(f'{component.script_name}.py')]
def launch(exe, in_terminal=False): def launch(exe, in_terminal=False):
@@ -268,13 +256,11 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
if not component: if not component:
logging.warning(f"Could not identify Component responsible for {args['Patch|Game|Component']}") logging.warning(f"Could not identify Component responsible for {args['Patch|Game|Component']}")
if args["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"]: else:
run_gui() run_gui()
@@ -283,13 +269,9 @@ if __name__ == '__main__':
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(description='Archipelago Launcher') parser = argparse.ArgumentParser(description='Archipelago Launcher')
run_group = parser.add_argument_group("Run") parser.add_argument('Patch|Game|Component', type=str, nargs='?',
run_group.add_argument("--update_settings", action="store_true", help="Pass either a patch file, a generated game or the name of a component to run.")
help="Update host.yaml and exit.") parser.add_argument('args', nargs="*", help="Arguments to pass to component.")
run_group.add_argument("Patch|Game|Component", type=str, nargs="?",
help="Pass either a patch file, a generated game or the name of a component to run.")
run_group.add_argument("args", nargs="*",
help="Arguments to pass to component.")
main(parser.parse_args()) main(parser.parse_args())
from worlds.LauncherComponents import processes from worlds.LauncherComponents import processes

View File

@@ -9,19 +9,16 @@ if __name__ == "__main__":
import asyncio import asyncio
import base64 import base64
import binascii import binascii
import colorama
import io import io
import os import logging
import re
import select import select
import shlex
import socket import socket
import struct
import sys
import subprocess
import time import time
import typing import typing
import urllib
import colorama
import struct
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger, from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
server_loop) server_loop)
@@ -33,7 +30,6 @@ from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
from worlds.ladx.Locations import get_locations_to_id, meta_to_name from worlds.ladx.Locations import get_locations_to_id, meta_to_name
from worlds.ladx.Tracker import LocationTracker, MagpieBridge from worlds.ladx.Tracker import LocationTracker, MagpieBridge
class GameboyException(Exception): class GameboyException(Exception):
pass pass
@@ -119,17 +115,17 @@ class RAGameboy():
assert (self.socket) assert (self.socket)
self.socket.setblocking(False) self.socket.setblocking(False)
async def send_command(self, command, timeout=1.0): def get_retroarch_version(self):
self.send(f'{command}\n') self.send(b'VERSION\n')
response_str = await self.async_recv() select.select([self.socket], [], [])
self.check_command_response(command, response_str) response_str, addr = self.socket.recvfrom(16)
return response_str.rstrip() return response_str.rstrip()
async def get_retroarch_version(self): def get_retroarch_status(self, timeout):
return await self.send_command("VERSION") self.send(b'GET_STATUS\n')
select.select([self.socket], [], [], timeout)
async def get_retroarch_status(self): response_str, addr = self.socket.recvfrom(1000, )
return await self.send_command("GET_STATUS") return response_str.rstrip()
def set_cache_limits(self, cache_start, cache_size): def set_cache_limits(self, cache_start, cache_size):
self.cache_start = cache_start self.cache_start = cache_start
@@ -145,8 +141,8 @@ class RAGameboy():
response, _ = self.socket.recvfrom(4096) response, _ = self.socket.recvfrom(4096)
return response return response
async def async_recv(self, timeout=1.0): async def async_recv(self):
response = await asyncio.wait_for(asyncio.get_event_loop().sock_recv(self.socket, 4096), timeout) response = await asyncio.get_event_loop().sock_recv(self.socket, 4096)
return response return response
async def check_safe_gameplay(self, throw=True): async def check_safe_gameplay(self, throw=True):
@@ -173,8 +169,6 @@ class RAGameboy():
raise InvalidEmulatorStateError() raise InvalidEmulatorStateError()
return False return False
if not await check_wram(): if not await check_wram():
if throw:
raise InvalidEmulatorStateError()
return False return False
return True return True
@@ -233,30 +227,20 @@ class RAGameboy():
return r return r
def check_command_response(self, command: str, response: bytes):
if command == "VERSION":
ok = re.match("\d+\.\d+\.\d+", response.decode('ascii')) is not None
else:
ok = response.startswith(command.encode())
if not ok:
logger.warning(f"Bad response to command {command} - {response}")
raise BadRetroArchResponse()
def read_memory(self, address, size=1): def read_memory(self, address, size=1):
command = "READ_CORE_MEMORY" command = "READ_CORE_MEMORY"
self.send(f'{command} {hex(address)} {size}\n') self.send(f'{command} {hex(address)} {size}\n')
response = self.recv() response = self.recv()
self.check_command_response(command, response)
splits = response.decode().split(" ", 2) splits = response.decode().split(" ", 2)
assert (splits[0] == command)
# Ignore the address for now # Ignore the address for now
if splits[2][:2] == "-1":
# TODO: transform to bytes
if splits[2][:2] == "-1" or splits[0] != "READ_CORE_MEMORY":
raise BadRetroArchResponse() raise BadRetroArchResponse()
# TODO: check response address, check hex behavior between RA and BH
return bytearray.fromhex(splits[2]) return bytearray.fromhex(splits[2])
async def async_read_memory(self, address, size=1): async def async_read_memory(self, address, size=1):
@@ -264,21 +248,14 @@ class RAGameboy():
self.send(f'{command} {hex(address)} {size}\n') self.send(f'{command} {hex(address)} {size}\n')
response = await self.async_recv() response = await self.async_recv()
self.check_command_response(command, response)
response = response[:-1] response = response[:-1]
splits = response.decode().split(" ", 2) splits = response.decode().split(" ", 2)
try:
response_addr = int(splits[1], 16)
except ValueError:
raise BadRetroArchResponse()
if response_addr != address: assert (splits[0] == command)
raise BadRetroArchResponse() # Ignore the address for now
ret = bytearray.fromhex(splits[2]) # TODO: transform to bytes
if len(ret) > size: return bytearray.fromhex(splits[2])
raise BadRetroArchResponse()
return ret
def write_memory(self, address, bytes): def write_memory(self, address, bytes):
command = "WRITE_CORE_MEMORY" command = "WRITE_CORE_MEMORY"
@@ -286,7 +263,7 @@ class RAGameboy():
self.send(f'{command} {hex(address)} {" ".join(hex(b) for b in bytes)}') self.send(f'{command} {hex(address)} {" ".join(hex(b) for b in bytes)}')
select.select([self.socket], [], []) select.select([self.socket], [], [])
response, _ = self.socket.recvfrom(4096) response, _ = self.socket.recvfrom(4096)
self.check_command_response(command, response)
splits = response.decode().split(" ", 3) splits = response.decode().split(" ", 3)
assert (splits[0] == command) assert (splits[0] == command)
@@ -304,9 +281,6 @@ class LinksAwakeningClient():
pending_deathlink = False pending_deathlink = False
deathlink_debounce = True deathlink_debounce = True
recvd_checks = {} recvd_checks = {}
retroarch_address = None
retroarch_port = None
gameboy = None
def msg(self, m): def msg(self, m):
logger.info(m) logger.info(m)
@@ -314,48 +288,50 @@ class LinksAwakeningClient():
self.gameboy.send(s) self.gameboy.send(s)
def __init__(self, retroarch_address="127.0.0.1", retroarch_port=55355): def __init__(self, retroarch_address="127.0.0.1", retroarch_port=55355):
self.retroarch_address = retroarch_address self.gameboy = RAGameboy(retroarch_address, retroarch_port)
self.retroarch_port = retroarch_port
pass
stop_bizhawk_spam = False
async def wait_for_retroarch_connection(self): async def wait_for_retroarch_connection(self):
if not self.stop_bizhawk_spam: logger.info("Waiting on connection to Retroarch...")
logger.info("Waiting on connection to Retroarch...")
self.stop_bizhawk_spam = True
self.gameboy = RAGameboy(self.retroarch_address, self.retroarch_port)
while True: while True:
try: try:
version = await self.gameboy.get_retroarch_version() version = self.gameboy.get_retroarch_version()
NO_CONTENT = b"GET_STATUS CONTENTLESS" NO_CONTENT = b"GET_STATUS CONTENTLESS"
status = NO_CONTENT status = NO_CONTENT
core_type = None core_type = None
GAME_BOY = b"game_boy" GAME_BOY = b"game_boy"
while status == NO_CONTENT or core_type != GAME_BOY: while status == NO_CONTENT or core_type != GAME_BOY:
status = await self.gameboy.get_retroarch_status() try:
if status.count(b" ") < 2: status = self.gameboy.get_retroarch_status(0.1)
await asyncio.sleep(1.0) if status.count(b" ") < 2:
continue await asyncio.sleep(1.0)
GET_STATUS, PLAYING, info = status.split(b" ", 2) continue
if status.count(b",") < 2:
await asyncio.sleep(1.0) GET_STATUS, PLAYING, info = status.split(b" ", 2)
continue if status.count(b",") < 2:
core_type, rom_name, self.game_crc = info.split(b",", 2) await asyncio.sleep(1.0)
if core_type != GAME_BOY: continue
logger.info( core_type, rom_name, self.game_crc = info.split(b",", 2)
f"Core type should be '{GAME_BOY}', found {core_type} instead - wrong type of ROM?") if core_type != GAME_BOY:
await asyncio.sleep(1.0) logger.info(
continue f"Core type should be '{GAME_BOY}', found {core_type} instead - wrong type of ROM?")
self.stop_bizhawk_spam = False await asyncio.sleep(1.0)
logger.info(f"Connected to Retroarch {version.decode('ascii')} running {rom_name.decode('ascii')}") continue
except (BlockingIOError, TimeoutError) as e:
await asyncio.sleep(0.1)
pass
logger.info(f"Connected to Retroarch {version} {info}")
self.gameboy.read_memory(0x1000)
return return
except (BlockingIOError, TimeoutError, ConnectionResetError): except ConnectionResetError:
await asyncio.sleep(1.0) await asyncio.sleep(1.0)
pass pass
async def reset_auth(self): def reset_auth(self):
auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode() auth = binascii.hexlify(self.gameboy.read_memory(0x0134, 12)).decode()
if self.auth:
assert (auth == self.auth)
self.auth = auth self.auth = auth
async def wait_and_init_tracker(self): async def wait_and_init_tracker(self):
@@ -391,14 +367,11 @@ class LinksAwakeningClient():
status = self.gameboy.write_memory(LAClientConstants.wLinkStatusBits, [status]) status = self.gameboy.write_memory(LAClientConstants.wLinkStatusBits, [status])
self.gameboy.write_memory(LAClientConstants.wRecvIndex, struct.pack(">H", next_index)) self.gameboy.write_memory(LAClientConstants.wRecvIndex, struct.pack(">H", next_index))
should_reset_auth = False
async def wait_for_game_ready(self): async def wait_for_game_ready(self):
logger.info("Waiting on game to be in valid state...") logger.info("Waiting on game to be in valid state...")
while not await self.gameboy.check_safe_gameplay(throw=False): while not await self.gameboy.check_safe_gameplay(throw=False):
if self.should_reset_auth: pass
self.should_reset_auth = False logger.info("Ready!")
raise GameboyException("Resetting due to wrong archipelago server")
logger.info("Game connection ready!")
async def is_victory(self): async def is_victory(self):
return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1 return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1
@@ -425,7 +398,7 @@ class LinksAwakeningClient():
if await self.is_victory(): if await self.is_victory():
await win_cb() await win_cb()
recv_index = struct.unpack(">H", await self.gameboy.async_read_memory(LAClientConstants.wRecvIndex, 2))[0] recv_index = struct.unpack(">H", self.gameboy.read_memory(LAClientConstants.wRecvIndex, 2))[0]
# Play back one at a time # Play back one at a time
if recv_index in self.recvd_checks: if recv_index in self.recvd_checks:
@@ -507,15 +480,6 @@ class LinksAwakeningContext(CommonContext):
message = [{"cmd": 'LocationChecks', "locations": self.found_checks}] message = [{"cmd": 'LocationChecks', "locations": self.found_checks}]
await self.send_msgs(message) await self.send_msgs(message)
had_invalid_slot_data = None
def event_invalid_slot(self):
# The next time we try to connect, reset the game loop for new auth
self.had_invalid_slot_data = True
self.auth = None
# Don't try to autoreconnect, it will just fail
self.disconnected_intentionally = True
CommonContext.event_invalid_slot(self)
ENABLE_DEATHLINK = False ENABLE_DEATHLINK = False
async def send_deathlink(self): async def send_deathlink(self):
if self.ENABLE_DEATHLINK: if self.ENABLE_DEATHLINK:
@@ -547,17 +511,8 @@ class LinksAwakeningContext(CommonContext):
async def server_auth(self, password_requested: bool = False): async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password: if password_requested and not self.password:
await super(LinksAwakeningContext, self).server_auth(password_requested) await super(LinksAwakeningContext, self).server_auth(password_requested)
if self.had_invalid_slot_data:
# We are connecting when previously we had the wrong ROM or server - just in case
# re-read the ROM so that if the user had the correct address but wrong ROM, we
# allow a successful reconnect
self.client.should_reset_auth = True
self.had_invalid_slot_data = False
while self.client.auth == None:
await asyncio.sleep(0.1)
self.auth = self.client.auth self.auth = self.client.auth
await self.get_username()
await self.send_connect() await self.send_connect()
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
@@ -565,13 +520,9 @@ class LinksAwakeningContext(CommonContext):
self.game = self.slot_info[self.slot].game self.game = self.slot_info[self.slot].game
# 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"], args["index"]):
self.client.recvd_checks[index] = item self.client.recvd_checks[index] = item
async def sync(self):
sync_msg = [{'cmd': 'Sync'}]
await self.send_msgs(sync_msg)
item_id_lookup = get_locations_to_id() item_id_lookup = get_locations_to_id()
async def run_game_loop(self): async def run_game_loop(self):
@@ -588,31 +539,17 @@ class LinksAwakeningContext(CommonContext):
if self.magpie_enabled: if self.magpie_enabled:
self.magpie_task = asyncio.create_task(self.magpie.serve()) self.magpie_task = asyncio.create_task(self.magpie.serve())
# yield to allow UI to start # yield to allow UI to start
await asyncio.sleep(0) await asyncio.sleep(0)
while True: while True:
try: try:
# TODO: cancel all client tasks # TODO: cancel all client tasks
if not self.client.stop_bizhawk_spam: logger.info("(Re)Starting game loop")
logger.info("(Re)Starting game loop")
self.found_checks.clear() self.found_checks.clear()
# On restart of game loop, clear all checks, just in case we swapped ROMs
# this isn't totally neccessary, but is extra safety against cross-ROM contamination
self.client.recvd_checks.clear()
await self.client.wait_for_retroarch_connection() await self.client.wait_for_retroarch_connection()
await self.client.reset_auth() self.client.reset_auth()
# If we find ourselves with new auth after the reset, reconnect
if self.auth and self.client.auth != self.auth:
# It would be neat to reconnect here, but connection needs this loop to be running
logger.info("Detected new ROM, disconnecting...")
await self.disconnect()
continue
if not self.client.recvd_checks:
await self.sync()
await self.client.wait_and_init_tracker() await self.client.wait_and_init_tracker()
while True: while True:
@@ -623,59 +560,39 @@ class LinksAwakeningContext(CommonContext):
self.last_resend = now self.last_resend = now
await self.send_checks() await self.send_checks()
if self.magpie_enabled: if self.magpie_enabled:
try: 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)
except Exception:
# Don't let magpie errors take out the client
pass
if self.client.should_reset_auth:
self.client.should_reset_auth = False
raise GameboyException("Resetting due to wrong archipelago server")
except (GameboyException, asyncio.TimeoutError, TimeoutError, ConnectionResetError):
await asyncio.sleep(1.0)
def run_game(romfile: str) -> None: except GameboyException:
auto_start = typing.cast(typing.Union[bool, str], time.sleep(1.0)
Utils.get_options()["ladx_options"].get("rom_start", True)) pass
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
elif isinstance(auto_start, str):
args = shlex.split(auto_start)
# Specify full path to ROM as we are going to cd in popen
full_rom_path = os.path.realpath(romfile)
args.append(full_rom_path)
try:
# set cwd so that paths to lua scripts are always relative to our client
if getattr(sys, 'frozen', False):
# The application is frozen
script_dir = os.path.dirname(sys.executable)
else:
script_dir = os.path.dirname(os.path.realpath(__file__))
subprocess.Popen(args, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=script_dir)
except FileNotFoundError:
logger.error(f"Couldn't launch ROM, {args[0]} is missing")
async def main(): async def main():
parser = get_base_parser(description="Link's Awakening Client.") parser = get_base_parser(description="Link's Awakening Client.")
parser.add_argument("--url", help="Archipelago connection url") parser.add_argument("--url", help="Archipelago connection url")
parser.add_argument("--no-magpie", dest='magpie', default=True, action='store_false', help="Disable magpie bridge") parser.add_argument("--no-magpie", dest='magpie', default=True, action='store_false', help="Disable magpie bridge")
parser.add_argument('diff_file', default="", type=str, nargs="?", parser.add_argument('diff_file', default="", type=str, nargs="?",
help='Path to a .apladx Archipelago Binary Patch file') help='Path to a .apladx Archipelago Binary Patch file')
args = parser.parse_args() args = parser.parse_args()
logger.info(args)
if args.diff_file: if args.diff_file:
import Patch import Patch
logger.info("patch file was supplied - creating rom...") logger.info("patch file was supplied - creating rom...")
meta, rom_file = Patch.create_rom_file(args.diff_file) meta, rom_file = Patch.create_rom_file(args.diff_file)
if "server" in meta and not args.connect: if "server" in meta:
args.connect = meta["server"] args.url = meta["server"]
logger.info(f"wrote rom file to {rom_file}") logger.info(f"wrote rom file to {rom_file}")
if args.url:
url = urllib.parse.urlparse(args.url)
args.connect = url.netloc
if url.password:
args.password = urllib.parse.unquote(url.password)
ctx = LinksAwakeningContext(args.connect, args.password, args.magpie) ctx = LinksAwakeningContext(args.connect, args.password, args.magpie)
@@ -687,10 +604,6 @@ async def main():
ctx.run_gui() ctx.run_gui()
ctx.run_cli() ctx.run_cli()
# Down below run_gui so that we get errors out of the process
if args.diff_file:
run_game(rom_file)
await ctx.exit_event.wait() await ctx.exit_event.wait()
await ctx.shutdown() await ctx.shutdown()

View File

@@ -25,7 +25,7 @@ ModuleUpdate.update()
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \ from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
get_adjuster_settings, get_adjuster_settings_no_defaults, tkinter_center_window, init_logging get_adjuster_settings, tkinter_center_window, init_logging
GAME_ALTTP = "A Link to the Past" GAME_ALTTP = "A Link to the Past"
@@ -43,47 +43,6 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
def _get_help_string(self, action): def _get_help_string(self, action):
return textwrap.dedent(action.help) return textwrap.dedent(action.help)
# See argparse.BooleanOptionalAction
class BooleanOptionalActionWithDisable(argparse.Action):
def __init__(self,
option_strings,
dest,
default=None,
type=None,
choices=None,
required=False,
help=None,
metavar=None):
_option_strings = []
for option_string in option_strings:
_option_strings.append(option_string)
if option_string.startswith('--'):
option_string = '--disable' + option_string[2:]
_option_strings.append(option_string)
if help is not None and default is not None:
help += " (default: %(default)s)"
super().__init__(
option_strings=_option_strings,
dest=dest,
nargs=0,
default=default,
type=type,
choices=choices,
required=required,
help=help,
metavar=metavar)
def __call__(self, parser, namespace, values, option_string=None):
if option_string in self.option_strings:
setattr(namespace, self.dest, not option_string.startswith('--disable'))
def format_usage(self):
return ' | '.join(self.option_strings)
def get_argparser() -> argparse.ArgumentParser: def get_argparser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter) parser = argparse.ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
@@ -93,8 +52,6 @@ def get_argparser() -> argparse.ArgumentParser:
help='Path to an ALttP Japan(1.0) rom to use as a base.') help='Path to an ALttP Japan(1.0) rom to use as a base.')
parser.add_argument('--loglevel', default='info', const='info', nargs='?', parser.add_argument('--loglevel', default='info', const='info', nargs='?',
choices=['error', 'info', 'warning', 'debug'], help='Select level of logging for output.') choices=['error', 'info', 'warning', 'debug'], help='Select level of logging for output.')
parser.add_argument('--auto_apply', default='ask',
choices=['ask', 'always', 'never'], help='Whether or not to apply settings automatically in the future.')
parser.add_argument('--menuspeed', default='normal', const='normal', nargs='?', parser.add_argument('--menuspeed', default='normal', const='normal', nargs='?',
choices=['normal', 'instant', 'double', 'triple', 'quadruple', 'half'], choices=['normal', 'instant', 'double', 'triple', 'quadruple', 'half'],
help='''\ help='''\
@@ -104,7 +61,7 @@ def get_argparser() -> argparse.ArgumentParser:
parser.add_argument('--quickswap', help='Enable quick item swapping with L and R.', action='store_true') parser.add_argument('--quickswap', help='Enable quick item swapping with L and R.', action='store_true')
parser.add_argument('--deathlink', help='Enable DeathLink system.', action='store_true') parser.add_argument('--deathlink', help='Enable DeathLink system.', action='store_true')
parser.add_argument('--allowcollect', help='Allow collection of other player items', action='store_true') parser.add_argument('--allowcollect', help='Allow collection of other player items', action='store_true')
parser.add_argument('--music', default=True, help='Enables/Disables game music.', action=BooleanOptionalActionWithDisable) parser.add_argument('--disablemusic', help='Disables game music.', action='store_true')
parser.add_argument('--triforcehud', default='hide_goal', const='hide_goal', nargs='?', parser.add_argument('--triforcehud', default='hide_goal', const='hide_goal', nargs='?',
choices=['normal', 'hide_goal', 'hide_required', 'hide_both'], choices=['normal', 'hide_goal', 'hide_required', 'hide_both'],
help='''\ help='''\
@@ -147,23 +104,21 @@ def get_argparser() -> argparse.ArgumentParser:
Alternatively, can be a ALttP Rom patched with a Link Alternatively, can be a ALttP Rom patched with a Link
sprite that will be extracted. sprite that will be extracted.
''') ''')
parser.add_argument('--sprite_pool', nargs='+', default=[], help='''
A list of sprites to pull from.
''')
parser.add_argument('--oof', help='''\ parser.add_argument('--oof', help='''\
Path to a sound effect to replace Link's "oof" sound. Path to a sound effect to replace Link's "oof" sound.
Needs to be in a .brr format and have a length of no Needs to be in a .brr format and have a length of no
more than 2673 bytes, created from a 16-bit signed PCM more than 2673 bytes, created from a 16-bit signed PCM
.wav at 12khz. https://github.com/boldowa/snesbrr .wav at 12khz. https://github.com/boldowa/snesbrr
''') ''')
parser.add_argument('--names', default='', type=str)
parser.add_argument('--update_sprites', action='store_true', help='Update Sprite Database, then exit.') parser.add_argument('--update_sprites', action='store_true', help='Update Sprite Database, then exit.')
return parser return parser
def main(): def main():
parser = get_argparser() parser = get_argparser()
args = parser.parse_args(namespace=get_adjuster_settings_no_defaults(GAME_ALTTP)) args = parser.parse_args()
args.music = not args.disablemusic
# set up logger # set up logger
loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[ loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[
args.loglevel] args.loglevel]
@@ -575,6 +530,9 @@ class AttachTooltip(object):
def get_rom_frame(parent=None): def get_rom_frame(parent=None):
adjuster_settings = get_adjuster_settings(GAME_ALTTP) adjuster_settings = get_adjuster_settings(GAME_ALTTP)
if not adjuster_settings:
adjuster_settings = Namespace()
adjuster_settings.baserom = "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"
romFrame = Frame(parent) romFrame = Frame(parent)
baseRomLabel = Label(romFrame, text='LttP Base Rom: ') baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
@@ -602,8 +560,33 @@ def get_rom_frame(parent=None):
return romFrame, romVar return romFrame, romVar
def get_rom_options_frame(parent=None): def get_rom_options_frame(parent=None):
adjuster_settings = get_adjuster_settings(GAME_ALTTP) adjuster_settings = get_adjuster_settings(GAME_ALTTP)
defaults = {
"auto_apply": 'ask',
"music": True,
"reduceflashing": True,
"deathlink": False,
"sprite": None,
"oof": None,
"quickswap": True,
"menuspeed": 'normal',
"heartcolor": 'red',
"heartbeep": 'normal',
"ow_palettes": 'default',
"uw_palettes": 'default',
"hud_palettes": 'default',
"sword_palettes": 'default',
"shield_palettes": 'default',
"sprite_pool": [],
"allowcollect": False,
}
if not adjuster_settings:
adjuster_settings = Namespace()
for key, defaultvalue in defaults.items():
if not hasattr(adjuster_settings, key):
setattr(adjuster_settings, key, defaultvalue)
romOptionsFrame = LabelFrame(parent, text="Rom options") romOptionsFrame = LabelFrame(parent, text="Rom options")
romOptionsFrame.columnconfigure(0, weight=1) romOptionsFrame.columnconfigure(0, weight=1)

View File

@@ -71,7 +71,6 @@ class MMBN3Context(CommonContext):
self.auth_name = None self.auth_name = None
self.slot_data = dict() self.slot_data = dict()
self.patching_error = False self.patching_error = False
self.sent_hints = []
async def server_auth(self, password_requested: bool = False): async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password: if password_requested and not self.password:
@@ -176,16 +175,13 @@ async def parse_payload(payload: dict, ctx: MMBN3Context, force: bool):
# If trade hinting is enabled, send scout checks # If trade hinting is enabled, send scout checks
if ctx.slot_data.get("trade_quest_hinting", 0) == 2: if ctx.slot_data.get("trade_quest_hinting", 0) == 2:
trade_bits = [loc.id for loc in scoutable_locations scouted_locs = [loc.id for loc in scoutable_locations
if check_location_scouted(loc, payload["locations"])] if check_location_scouted(loc, payload["locations"])]
scouted_locs = [loc for loc in trade_bits if loc not in ctx.sent_hints] await ctx.send_msgs([{
if len(scouted_locs) > 0: "cmd": "LocationScouts",
ctx.sent_hints.extend(scouted_locs) "locations": scouted_locs,
await ctx.send_msgs([{ "create_as_hint": 2
"cmd": "LocationScouts", }])
"locations": scouted_locs,
"create_as_hint": 2
}])
def check_location_packet(location, memory): def check_location_packet(location, memory):

77
Main.py
View File

@@ -7,24 +7,31 @@ import tempfile
import time import time
import zipfile import zipfile
import zlib import zlib
from typing import Dict, List, Optional, Set, Tuple, Union from typing import Dict, List, Optional, Set, Tuple
import worlds import worlds
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
from Options import StartInventoryPool from Options import StartInventoryPool
from settings import get_settings from Utils import __version__, get_options, output_path, version_tuple
from Utils import __version__, output_path, version_tuple
from worlds import AutoWorld from worlds import AutoWorld
from worlds.alttp.Regions import is_main_entrance
from worlds.alttp.Shops import FillDisabledShopSlots
from worlds.alttp.SubClasses import LTTPRegionType
from worlds.generic.Rules import exclusion_rules, locality_rules from worlds.generic.Rules import exclusion_rules, locality_rules
__all__ = ["main"] __all__ = ["main"]
ordered_areas = (
'Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace',
'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace',
'Misery Mire', 'Turtle Rock', 'Ganons Tower', "Total"
)
def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None): def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None):
if not baked_server_options: if not baked_server_options:
baked_server_options = get_settings().server_options.as_dict() baked_server_options = get_options()["server_options"]
assert isinstance(baked_server_options, dict)
if args.outputpath: if args.outputpath:
os.makedirs(args.outputpath, exist_ok=True) os.makedirs(args.outputpath, exist_ok=True)
output_path.cached_path = args.outputpath output_path.cached_path = args.outputpath
@@ -133,27 +140,20 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.non_local_items[player].value -= world.local_items[player].value world.non_local_items[player].value -= world.local_items[player].value
world.non_local_items[player].value -= set(world.local_early_items[player]) world.non_local_items[player].value -= set(world.local_early_items[player])
if world.players > 1:
locality_rules(world)
else:
world.non_local_items[1].value = set()
world.local_items[1].value = set()
AutoWorld.call_all(world, "set_rules") AutoWorld.call_all(world, "set_rules")
for player in world.player_ids: for player in world.player_ids:
exclusion_rules(world, player, world.exclude_locations[player].value) exclusion_rules(world, player, world.exclude_locations[player].value)
world.priority_locations[player].value -= world.exclude_locations[player].value world.priority_locations[player].value -= world.exclude_locations[player].value
for location_name in world.priority_locations[player].value: for location_name in world.priority_locations[player].value:
try: world.get_location(location_name, player).progress_type = LocationProgressType.PRIORITY
location = world.get_location(location_name, player)
except KeyError as e: # failed to find the given location. Check if it's a legitimate location
if location_name not in world.worlds[player].location_name_to_id:
raise Exception(f"Unable to prioritize location {location_name} in player {player}'s world.") from e
else:
location.progress_type = LocationProgressType.PRIORITY
# Set local and non-local item rules.
if world.players > 1:
locality_rules(world)
else:
world.non_local_items[1].value = set()
world.local_items[1].value = set()
AutoWorld.call_all(world, "generate_basic") AutoWorld.call_all(world, "generate_basic")
# remove starting inventory from pool items. # remove starting inventory from pool items.
@@ -165,8 +165,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player, items in depletion_pool.items(): for player, items in depletion_pool.items():
player_world: AutoWorld.World = world.worlds[player] player_world: AutoWorld.World = world.worlds[player]
for count in items.values(): for count in items.values():
for _ in range(count): new_items.append(player_world.create_filler())
new_items.append(player_world.create_filler())
target: int = sum(sum(items.values()) for items in depletion_pool.values()) target: int = sum(sum(items.values()) for items in depletion_pool.values())
for i, item in enumerate(world.itempool): for i, item in enumerate(world.itempool):
if depletion_pool[item.player].get(item.name, 0): if depletion_pool[item.player].get(item.name, 0):
@@ -186,7 +185,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
if remaining_items: if remaining_items:
raise Exception(f"{world.get_player_name(player)}" raise Exception(f"{world.get_player_name(player)}"
f" is trying to remove items from their pool that don't exist: {remaining_items}") f" is trying to remove items from their pool that don't exist: {remaining_items}")
assert len(world.itempool) == len(new_items), "Item Pool amounts should not change."
world.itempool[:] = new_items world.itempool[:] = new_items
# temporary home for item links, should be moved out of Main # temporary home for item links, should be moved out of Main
@@ -315,6 +313,35 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
er_hint_data: Dict[int, Dict[int, str]] = {} er_hint_data: Dict[int, Dict[int, str]] = {}
AutoWorld.call_all(world, 'extend_hint_information', er_hint_data) AutoWorld.call_all(world, 'extend_hint_information', er_hint_data)
checks_in_area = {player: {area: list() for area in ordered_areas}
for player in range(1, world.players + 1)}
for player in range(1, world.players + 1):
checks_in_area[player]["Total"] = 0
for location in world.get_filled_locations():
if type(location.address) is int:
if location.game != "A Link to the Past":
checks_in_area[location.player]["Light World"].append(location.address)
else:
main_entrance = location.parent_region.get_connecting_entrance(is_main_entrance)
if location.parent_region.dungeon:
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
'Inverted Ganons Tower': 'Ganons Tower'} \
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
checks_in_area[location.player][dungeonname].append(location.address)
elif location.parent_region.type == LTTPRegionType.LightWorld:
checks_in_area[location.player]["Light World"].append(location.address)
elif location.parent_region.type == LTTPRegionType.DarkWorld:
checks_in_area[location.player]["Dark World"].append(location.address)
elif main_entrance.parent_region.type == LTTPRegionType.LightWorld:
checks_in_area[location.player]["Light World"].append(location.address)
elif main_entrance.parent_region.type == LTTPRegionType.DarkWorld:
checks_in_area[location.player]["Dark World"].append(location.address)
checks_in_area[location.player]["Total"] += 1
FillDisabledShopSlots(world)
def write_multidata(): def write_multidata():
import NetUtils import NetUtils
slot_data = {} slot_data = {}
@@ -374,11 +401,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for game_world in world.worlds.values() for game_world in world.worlds.values()
} }
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
multidata = { multidata = {
"slot_data": slot_data, "slot_data": slot_data,
"slot_info": slot_info, "slot_info": slot_info,
"names": names, # TODO: remove after 0.3.9
"connect_names": {name: (0, player) for player, name in world.player_name.items()}, "connect_names": {name: (0, player) for player, name in world.player_name.items()},
"locations": locations_data, "locations": locations_data,
"checks_in_area": checks_in_area, "checks_in_area": checks_in_area,
@@ -400,7 +426,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
f.write(bytes([3])) # version of format f.write(bytes([3])) # version of format
f.write(multidata) f.write(multidata)
output_file_futures.append(pool.submit(write_multidata)) multidata_task = pool.submit(write_multidata)
if not check_accessibility_task.result(): if not check_accessibility_task.result():
if not world.can_beat_game(): if not world.can_beat_game():
raise Exception("Game appears as unbeatable. Aborting.") raise Exception("Game appears as unbeatable. Aborting.")
@@ -408,6 +434,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.warning("Location Accessibility requirements not fulfilled.") logger.warning("Location Accessibility requirements not fulfilled.")
# retrieve exceptions via .result() if they occurred. # retrieve exceptions via .result() if they occurred.
multidata_task.result()
for i, future in enumerate(concurrent.futures.as_completed(output_file_futures), start=1): for i, future in enumerate(concurrent.futures.as_completed(output_file_futures), start=1):
if i % 10 == 0 or i == len(output_file_futures): if i % 10 == 0 or i == len(output_file_futures):
logger.info(f'Generating output files ({i}/{len(output_file_futures)}).') logger.info(f'Generating output files ({i}/{len(output_file_futures)}).')

View File

@@ -299,7 +299,7 @@ if __name__ == '__main__':
versions = get_minecraft_versions(data_version, channel) versions = get_minecraft_versions(data_version, channel)
forge_dir = options["minecraft_options"]["forge_directory"] forge_dir = Utils.user_path(options["minecraft_options"]["forge_directory"])
max_heap = options["minecraft_options"]["max_heap_size"] max_heap = options["minecraft_options"]["max_heap_size"]
forge_version = args.forge or versions["forge"] forge_version = args.forge or versions["forge"]
java_version = args.java or versions["java"] java_version = args.java or versions["java"]

View File

@@ -38,7 +38,7 @@ import NetUtils
import Utils import Utils
from Utils import version_tuple, restricted_loads, Version, async_start from Utils import version_tuple, restricted_loads, Version, async_start
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \ from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
SlotType, LocationStore SlotType
min_client_version = Version(0, 1, 6) min_client_version = Version(0, 1, 6)
colorama.init() colorama.init()
@@ -152,9 +152,7 @@ class Context:
"compatibility": int} "compatibility": int}
# team -> slot id -> list of clients authenticated to slot. # team -> slot id -> list of clients authenticated to slot.
clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]] clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]]
locations: LocationStore # typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]] locations: typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]]
hints_used: typing.Dict[typing.Tuple[int, int], int]
groups: typing.Dict[int, typing.Set[int]] groups: typing.Dict[int, typing.Set[int]]
save_version = 2 save_version = 2
stored_data: typing.Dict[str, object] stored_data: typing.Dict[str, object]
@@ -189,6 +187,8 @@ class Context:
self.player_name_lookup: typing.Dict[str, team_slot] = {} self.player_name_lookup: typing.Dict[str, team_slot] = {}
self.connect_names = {} # names of slots clients can connect to self.connect_names = {} # names of slots clients can connect to
self.allow_releases = {} self.allow_releases = {}
# player location_id item_id target_player_id
self.locations = {}
self.host = host self.host = host
self.port = port self.port = port
self.server_password = server_password self.server_password = server_password
@@ -284,7 +284,6 @@ class Context:
except websockets.ConnectionClosed: except websockets.ConnectionClosed:
logging.exception(f"Exception during send_msgs, could not send {msg}") logging.exception(f"Exception during send_msgs, could not send {msg}")
await self.disconnect(endpoint) await self.disconnect(endpoint)
return False
else: else:
if self.log_network: if self.log_network:
logging.info(f"Outgoing message: {msg}") logging.info(f"Outgoing message: {msg}")
@@ -298,7 +297,6 @@ class Context:
except websockets.ConnectionClosed: except websockets.ConnectionClosed:
logging.exception("Exception during send_encoded_msgs") logging.exception("Exception during send_encoded_msgs")
await self.disconnect(endpoint) await self.disconnect(endpoint)
return False
else: else:
if self.log_network: if self.log_network:
logging.info(f"Outgoing message: {msg}") logging.info(f"Outgoing message: {msg}")
@@ -313,7 +311,6 @@ class Context:
websockets.broadcast(sockets, msg) websockets.broadcast(sockets, msg)
except RuntimeError: except RuntimeError:
logging.exception("Exception during broadcast_send_encoded_msgs") logging.exception("Exception during broadcast_send_encoded_msgs")
return False
else: else:
if self.log_network: if self.log_network:
logging.info(f"Outgoing broadcast: {msg}") logging.info(f"Outgoing broadcast: {msg}")
@@ -416,7 +413,7 @@ class Context:
self.seed_name = decoded_obj["seed_name"] self.seed_name = decoded_obj["seed_name"]
self.random.seed(self.seed_name) self.random.seed(self.seed_name)
self.connect_names = decoded_obj['connect_names'] self.connect_names = decoded_obj['connect_names']
self.locations = LocationStore(decoded_obj.pop("locations")) # pre-emptively free memory self.locations = decoded_obj['locations']
self.slot_data = decoded_obj['slot_data'] self.slot_data = decoded_obj['slot_data']
for slot, data in self.slot_data.items(): for slot, data in self.slot_data.items():
self.read_data[f"slot_data_{slot}"] = lambda data=data: data self.read_data[f"slot_data_{slot}"] = lambda data=data: data
@@ -795,7 +792,7 @@ async def on_client_joined(ctx: Context, client: Client):
ctx.broadcast_text_all( ctx.broadcast_text_all(
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) " f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) "
f"{verb} {ctx.games[client.slot]} has joined. " f"{verb} {ctx.games[client.slot]} has joined. "
f"Client({version_str}), {client.tags}.", f"Client({version_str}), {client.tags}).",
{"type": "Join", "team": client.team, "slot": client.slot, "tags": client.tags}) {"type": "Join", "team": client.team, "slot": client.slot, "tags": client.tags})
ctx.notify_client(client, "Now that you are connected, " ctx.notify_client(client, "Now that you are connected, "
"you can use !help to list commands to run via the server. " "you can use !help to list commands to run via the server. "
@@ -905,7 +902,11 @@ def release_player(ctx: Context, team: int, slot: int):
def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False): def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False):
"""register any locations that are in the multidata, pointing towards this player""" """register any locations that are in the multidata, pointing towards this player"""
all_locations = ctx.locations.get_for_player(slot) all_locations = collections.defaultdict(set)
for source_slot, location_data in ctx.locations.items():
for location_id, values in location_data.items():
if values[1] == slot:
all_locations[source_slot].add(location_id)
ctx.broadcast_text_all("%s (Team #%d) has collected their items from other worlds." ctx.broadcast_text_all("%s (Team #%d) has collected their items from other worlds."
% (ctx.player_names[(team, slot)], team + 1), % (ctx.player_names[(team, slot)], team + 1),
@@ -924,7 +925,11 @@ def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False):
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]: def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
return ctx.locations.get_remaining(ctx.location_checks, team, slot) items = []
for location_id in ctx.locations[slot]:
if location_id not in ctx.location_checks[team, slot]:
items.append(ctx.locations[slot][location_id][0]) # item ID
return sorted(items)
def send_items_to(ctx: Context, team: int, target_slot: int, *items: NetworkItem): def send_items_to(ctx: Context, team: int, target_slot: int, *items: NetworkItem):
@@ -972,12 +977,13 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
slots.add(group_id) slots.add(group_id)
seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item] seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item]
for finding_player, location_id, item_id, receiving_player, item_flags \ for finding_player, check_data in ctx.locations.items():
in ctx.locations.find_item(slots, seeked_item_id): for location_id, (item_id, receiving_player, item_flags) in check_data.items():
found = location_id in ctx.location_checks[team, finding_player] if receiving_player in slots and item_id == seeked_item_id:
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "") found = location_id in ctx.location_checks[team, finding_player]
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance, entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
item_flags)) hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
item_flags))
return hints return hints
@@ -1549,11 +1555,15 @@ class ClientMessageProcessor(CommonCommandProcessor):
def get_checked_checks(ctx: Context, team: int, slot: int) -> typing.List[int]: def get_checked_checks(ctx: Context, team: int, slot: int) -> typing.List[int]:
return ctx.locations.get_checked(ctx.location_checks, team, slot) return [location_id for
location_id in ctx.locations[slot] if
location_id in ctx.location_checks[team, slot]]
def get_missing_checks(ctx: Context, team: int, slot: int) -> typing.List[int]: def get_missing_checks(ctx: Context, team: int, slot: int) -> typing.List[int]:
return ctx.locations.get_missing(ctx.location_checks, team, slot) return [location_id for
location_id in ctx.locations[slot] if
location_id not in ctx.location_checks[team, slot]]
def get_client_points(ctx: Context, client: Client) -> int: def get_client_points(ctx: Context, client: Client) -> int:
@@ -2118,15 +2128,13 @@ class ServerCommandProcessor(CommonCommandProcessor):
async def console(ctx: Context): async def console(ctx: Context):
import sys import sys
queue = asyncio.Queue() queue = asyncio.Queue()
worker = Utils.stream_input(sys.stdin, queue) Utils.stream_input(sys.stdin, queue)
while not ctx.exit_event.is_set(): while not ctx.exit_event.is_set():
try: try:
# I don't get why this while loop is needed. Works fine without it on clients, # I don't get why this while loop is needed. Works fine without it on clients,
# but the queue.get() for server never fulfills if the queue is empty when entering the await. # but the queue.get() for server never fulfills if the queue is empty when entering the await.
while queue.qsize() == 0: while queue.qsize() == 0:
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
if not worker.is_alive():
return
input_text = await queue.get() input_text = await queue.get()
queue.task_done() queue.task_done()
ctx.commandprocessor(input_text) ctx.commandprocessor(input_text)
@@ -2137,7 +2145,7 @@ async def console(ctx: Context):
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
defaults = Utils.get_options()["server_options"].as_dict() defaults = Utils.get_options()["server_options"]
parser.add_argument('multidata', nargs="?", default=defaults["multidata"]) parser.add_argument('multidata', nargs="?", default=defaults["multidata"])
parser.add_argument('--host', default=defaults["host"]) parser.add_argument('--host', default=defaults["host"])
parser.add_argument('--port', default=defaults["port"], type=int) parser.add_argument('--port', default=defaults["port"], type=int)
@@ -2246,15 +2254,12 @@ async def main(args: argparse.Namespace):
if not isinstance(e, ImportError): if not isinstance(e, ImportError):
logging.error(f"Failed to load tkinter ({e})") logging.error(f"Failed to load tkinter ({e})")
logging.info("Pass a multidata filename on command line to run headless.") logging.info("Pass a multidata filename on command line to run headless.")
# when cx_Freeze'd the built-in exit is not available, so we import sys.exit instead exit(1)
import sys
sys.exit(1)
raise raise
if not data_filename: if not data_filename:
logging.info("No file selected. Exiting.") logging.info("No file selected. Exiting.")
import sys exit(1)
sys.exit(1)
try: try:
ctx.load(data_filename, args.use_embedded_options) ctx.load(data_filename, args.use_embedded_options)

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
import typing import typing
import enum import enum
import warnings
from json import JSONEncoder, JSONDecoder from json import JSONEncoder, JSONDecoder
import websockets import websockets
@@ -344,85 +343,3 @@ class Hint(typing.NamedTuple):
@property @property
def local(self): def local(self):
return self.receiving_player == self.finding_player return self.receiving_player == self.finding_player
class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tuple[int, int, int]]]):
def __init__(self, values: typing.MutableMapping[int, typing.Dict[int, typing.Tuple[int, int, int]]]):
super().__init__(values)
if not self:
raise ValueError(f"Rejecting game with 0 players")
if len(self) != max(self):
raise ValueError("Player IDs not continuous")
if len(self.get(0, {})):
raise ValueError("Invalid player id 0 for location")
def find_item(self, slots: typing.Set[int], seeked_item_id: int
) -> typing.Generator[typing.Tuple[int, int, int, int, int], None, None]:
for finding_player, check_data in self.items():
for location_id, (item_id, receiving_player, item_flags) in check_data.items():
if receiving_player in slots and item_id == seeked_item_id:
yield finding_player, location_id, item_id, receiving_player, item_flags
def get_for_player(self, slot: int) -> typing.Dict[int, typing.Set[int]]:
import collections
all_locations: typing.Dict[int, typing.Set[int]] = collections.defaultdict(set)
for source_slot, location_data in self.items():
for location_id, values in location_data.items():
if values[1] == slot:
all_locations[source_slot].add(location_id)
return all_locations
def get_checked(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int
) -> typing.List[int]:
checked = state[team, slot]
if not checked:
# This optimizes the case where everyone connects to a fresh game at the same time.
return []
return [location_id for
location_id in self[slot] if
location_id in checked]
def get_missing(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int
) -> typing.List[int]:
checked = state[team, slot]
if not checked:
# This optimizes the case where everyone connects to a fresh game at the same time.
return list(self[slot])
return [location_id for
location_id in self[slot] if
location_id not in checked]
def get_remaining(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int
) -> typing.List[int]:
checked = state[team, slot]
player_locations = self[slot]
return sorted([player_locations[location_id][0] for
location_id in player_locations if
location_id not in checked])
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
LocationStore = _LocationStore
else:
try:
from _speedups import LocationStore
import _speedups
import os.path
if os.path.isfile("_speedups.pyx") and os.path.getctime(_speedups.__file__) < os.path.getctime("_speedups.pyx"):
warnings.warn(f"{_speedups.__file__} outdated! "
f"Please rebuild with `cythonize -b -i _speedups.pyx` or delete it!")
except ImportError:
try:
import pyximport
pyximport.install()
except ImportError:
pyximport = None
try:
from _speedups import LocationStore
except ImportError:
warnings.warn("_speedups not available. Falling back to pure python LocationStore. "
"Install a matching C++ compiler for your platform to compile _speedups.")
LocationStore = _LocationStore

View File

@@ -296,6 +296,8 @@ async def patch_and_run_game(apz5_file):
comp_path = base_name + '.z64' comp_path = base_name + '.z64'
# Load vanilla ROM, patch file, compress ROM # Load vanilla ROM, patch file, compress ROM
rom_file_name = Utils.get_options()["oot_options"]["rom_file"] rom_file_name = Utils.get_options()["oot_options"]["rom_file"]
if not os.path.exists(rom_file_name):
rom_file_name = Utils.user_path(rom_file_name)
rom = Rom(rom_file_name) rom = Rom(rom_file_name)
sub_file = None sub_file = None

View File

@@ -1,15 +1,13 @@
from __future__ import annotations from __future__ import annotations
import abc import abc
import logging import logging
from copy import deepcopy
import math import math
import numbers import numbers
import random
import typing import typing
from copy import deepcopy import random
from schema import And, Optional, Or, Schema
from schema import Schema, And, Or, Optional
from Utils import get_fuzzy_results from Utils import get_fuzzy_results
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
@@ -771,7 +769,7 @@ class VerifyKeys(metaclass=FreezeValidKeys):
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)") f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]): class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
default: typing.Dict[str, typing.Any] = {} default: typing.Dict[str, typing.Any] = {}
supports_weighting = False supports_weighting = False
@@ -789,14 +787,8 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
def get_option_name(self, value): def get_option_name(self, value):
return ", ".join(f"{key}: {v}" for key, v in value.items()) return ", ".join(f"{key}: {v}" for key, v in value.items())
def __getitem__(self, item: str) -> typing.Any: def __contains__(self, item):
return self.value.__getitem__(item) return item in self.value
def __iter__(self) -> typing.Iterator[str]:
return self.value.__iter__()
def __len__(self) -> int:
return self.value.__len__()
class ItemDict(OptionDict): class ItemDict(OptionDict):
@@ -957,7 +949,6 @@ class DeathLink(Toggle):
class ItemLinks(OptionList): class ItemLinks(OptionList):
"""Share part of your item pool with other players.""" """Share part of your item pool with other players."""
display_name = "Item Links"
default = [] default = []
schema = Schema([ schema = Schema([
{ {

View File

@@ -29,9 +29,6 @@ for location in location_data:
location_map[type(location.ram_address).__name__][location.ram_address.flag] = location.address location_map[type(location.ram_address).__name__][location.ram_address.flag] = location.address
location_bytes_bits[location.address] = {'byte': location.ram_address.byte, 'bit': location.ram_address.bit} location_bytes_bits[location.address] = {'byte': location.ram_address.byte, 'bit': location.ram_address.bit}
location_name_to_id = {location.name: location.address for location in location_data if location.type == "Item"
and location.address is not None}
SYSTEM_MESSAGE_ID = 0 SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart pkmn_rb.lua" CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart pkmn_rb.lua"
@@ -75,7 +72,6 @@ class GBContext(CommonContext):
self.items_handling = 0b001 self.items_handling = 0b001
self.sent_release = False self.sent_release = False
self.sent_collect = False self.sent_collect = False
self.auto_hints = set()
async def server_auth(self, password_requested: bool = False): async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password: if password_requested and not self.password:
@@ -157,33 +153,6 @@ async def parse_locations(data: List, ctx: GBContext):
locations.append(loc_id) locations.append(loc_id)
elif flags[flag_type][location_bytes_bits[loc_id]['byte']] & 1 << location_bytes_bits[loc_id]['bit']: elif flags[flag_type][location_bytes_bits[loc_id]['byte']] & 1 << location_bytes_bits[loc_id]['bit']:
locations.append(loc_id) locations.append(loc_id)
hints = []
if flags["EventFlag"][280] & 16:
hints.append("Cerulean Bicycle Shop")
if flags["EventFlag"][280] & 32:
hints.append("Route 2 Gate - Oak's Aide")
if flags["EventFlag"][280] & 64:
hints.append("Route 11 Gate 2F - Oak's Aide")
if flags["EventFlag"][280] & 128:
hints.append("Route 15 Gate 2F - Oak's Aide")
if flags["EventFlag"][281] & 1:
hints += ["Celadon Prize Corner - Item Prize 1", "Celadon Prize Corner - Item Prize 2",
"Celadon Prize Corner - Item Prize 3"]
if (location_name_to_id["Fossil - Choice A"] in ctx.checked_locations and location_name_to_id["Fossil - Choice B"]
not in ctx.checked_locations):
hints.append("Fossil - Choice B")
elif (location_name_to_id["Fossil - Choice B"] in ctx.checked_locations and location_name_to_id["Fossil - Choice A"]
not in ctx.checked_locations):
hints.append("Fossil - Choice A")
hints = [
location_name_to_id[loc] for loc in hints if location_name_to_id[loc] not in ctx.auto_hints and
location_name_to_id[loc] in ctx.missing_locations and location_name_to_id[loc] not in ctx.locations_checked
]
if hints:
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": hints, "create_as_hint": 2}])
ctx.auto_hints.update(hints)
if flags["EventFlag"][280] & 1 and not ctx.finished_game: if flags["EventFlag"][280] & 1 and not ctx.finished_game:
await ctx.send_msgs([ await ctx.send_msgs([
{"cmd": "StatusUpdate", {"cmd": "StatusUpdate",

View File

@@ -49,8 +49,6 @@ Currently, the following games are supported:
* Bumper Stickers * Bumper Stickers
* Mega Man Battle Network 3: Blue Version * Mega Man Battle Network 3: Blue Version
* Muse Dash * Muse Dash
* DOOM 1993
* Terraria
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled

View File

@@ -68,11 +68,12 @@ class SNIClientCommandProcessor(ClientCommandProcessor):
options = snes_options.split() options = snes_options.split()
num_options = len(options) num_options = len(options)
if num_options > 0:
snes_device_number = int(options[0])
if num_options > 1: if num_options > 1:
snes_address = options[0] snes_address = options[0]
snes_device_number = int(options[1]) snes_device_number = int(options[1])
elif num_options > 0:
snes_device_number = int(options[0])
self.ctx.snes_reconnect_address = None self.ctx.snes_reconnect_address = None
if self.ctx.snes_connect_task: if self.ctx.snes_connect_task:
@@ -564,16 +565,14 @@ async def snes_write(ctx: SNIContext, write_list: typing.List[typing.Tuple[int,
PutAddress_Request: SNESRequest = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'} PutAddress_Request: SNESRequest = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'}
try: try:
for address, data in write_list: for address, data in write_list:
while data: PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]]
# Divide the write into packets of 256 bytes. # REVIEW: above: `if snes_socket is None: return False`
PutAddress_Request['Operands'] = [hex(address)[2:], hex(min(len(data), 256))[2:]] # Does it need to be checked again?
if ctx.snes_socket is not None: if ctx.snes_socket is not None:
await ctx.snes_socket.send(dumps(PutAddress_Request)) await ctx.snes_socket.send(dumps(PutAddress_Request))
await ctx.snes_socket.send(data[:256]) await ctx.snes_socket.send(data)
address += 256 else:
data = data[256:] snes_logger.warning(f"Could not send data to SNES: {data}")
else:
snes_logger.warning(f"Could not send data to SNES: {data}")
except ConnectionClosed: except ConnectionClosed:
return False return False

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import os import os
import sys
import asyncio import asyncio
import typing import typing
import bsdiff4 import bsdiff4
@@ -12,7 +11,7 @@ from NetUtils import NetworkItem, ClientStatus
from worlds import undertale from worlds import undertale
from MultiServer import mark_raw from MultiServer import mark_raw
from CommonClient import CommonContext, server_loop, \ from CommonClient import CommonContext, server_loop, \
gui_enabled, ClientCommandProcessor, logger, get_base_parser gui_enabled, ClientCommandProcessor, get_base_parser
from Utils import async_start from Utils import async_start
@@ -29,31 +28,25 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
def _cmd_patch(self): def _cmd_patch(self):
"""Patch the game.""" """Patch the game."""
if isinstance(self.ctx, UndertaleContext): if isinstance(self.ctx, UndertaleContext):
os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True) os.makedirs(name=os.getcwd() + "\\Undertale", exist_ok=True)
self.ctx.patch_game() self.ctx.patch_game()
self.output("Patched.") self.output("Patched.")
def _cmd_savepath(self, directory: str):
"""Redirect to proper save data folder. (Use before connecting!)"""
if isinstance(self.ctx, UndertaleContext):
self.ctx.save_game_folder = directory
self.output("Changed to the following directory: " + self.ctx.save_game_folder)
@mark_raw @mark_raw
def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None): def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None):
"""Patch the game automatically.""" """Patch the game automatically."""
if isinstance(self.ctx, UndertaleContext): if isinstance(self.ctx, UndertaleContext):
os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True) os.makedirs(name=os.getcwd() + "\\Undertale", exist_ok=True)
tempInstall = steaminstall tempInstall = steaminstall
if not os.path.isfile(os.path.join(tempInstall, "data.win")): if not os.path.isfile(os.path.join(tempInstall, "data.win")):
tempInstall = None tempInstall = None
if tempInstall is None: if tempInstall is None:
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale" tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
if not os.path.exists(tempInstall): if not os.path.exists("C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"):
tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale" tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale"
elif not os.path.exists(tempInstall): elif not os.path.exists(tempInstall):
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale" tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
if not os.path.exists(tempInstall): if not os.path.exists("C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"):
tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale" tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale"
if not os.path.exists(tempInstall) or not os.path.exists(tempInstall) or not os.path.isfile(os.path.join(tempInstall, "data.win")): if not os.path.exists(tempInstall) or not os.path.exists(tempInstall) or not os.path.isfile(os.path.join(tempInstall, "data.win")):
self.output("ERROR: Cannot find Undertale. Please rerun the command with the correct folder." self.output("ERROR: Cannot find Undertale. Please rerun the command with the correct folder."
@@ -61,8 +54,8 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
else: else:
for file_name in os.listdir(tempInstall): for file_name in os.listdir(tempInstall):
if file_name != "steam_api.dll": if file_name != "steam_api.dll":
shutil.copy(os.path.join(tempInstall, file_name), shutil.copy(tempInstall+"\\"+file_name,
os.path.join(os.getcwd(), "Undertale", file_name)) os.getcwd() + "\\Undertale\\" + file_name)
self.ctx.patch_game() self.ctx.patch_game()
self.output("Patching successful!") self.output("Patching successful!")
@@ -99,7 +92,6 @@ class UndertaleContext(CommonContext):
def __init__(self, server_address, password): def __init__(self, server_address, password):
super().__init__(server_address, password) super().__init__(server_address, password)
self.pieces_needed = 0 self.pieces_needed = 0
self.finished_game = False
self.game = "Undertale" self.game = "Undertale"
self.got_deathlink = False self.got_deathlink = False
self.syncing = False self.syncing = False
@@ -107,17 +99,15 @@ class UndertaleContext(CommonContext):
self.tem_armor = False self.tem_armor = False
self.completed_count = 0 self.completed_count = 0
self.completed_routes = {"pacifist": 0, "genocide": 0, "neutral": 0} self.completed_routes = {"pacifist": 0, "genocide": 0, "neutral": 0}
# self.save_game_folder: files go in this path to pass data between us and the actual game
self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE")
def patch_game(self): def patch_game(self):
with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "rb") as f: with open(os.getcwd() + "/Undertale/data.win", "rb") as f:
patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff")) patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff"))
with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "wb") as f: with open(os.getcwd() + "/Undertale/data.win", "wb") as f:
f.write(patchedFile) f.write(patchedFile)
os.makedirs(name=os.path.join(os.getcwd(), "Undertale", "Custom Sprites"), exist_ok=True) os.makedirs(name=os.getcwd() + "\\Undertale\\" + "Custom Sprites", exist_ok=True)
with open(os.path.expandvars(os.path.join(os.getcwd(), "Undertale", "Custom Sprites", with open(os.path.expandvars(os.getcwd() + "\\Undertale\\" + "Custom Sprites\\" +
"Which Character.txt")), "w") as f: "Which Character.txt"), "w") as f:
f.writelines(["// Put the folder name of the sprites you want to play as, make sure it is the only " f.writelines(["// Put the folder name of the sprites you want to play as, make sure it is the only "
"line other than this one.\n", "frisk"]) "line other than this one.\n", "frisk"])
f.close() f.close()
@@ -237,7 +227,7 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
f.close() f.close()
filename = f"check.spot" filename = f"check.spot"
with open(os.path.join(ctx.save_game_folder, filename), "a") as f: with open(os.path.join(ctx.save_game_folder, filename), "a") as f:
for ss in set(args["checked_locations"]): for ss in ctx.checked_locations:
f.write(str(ss-12000)+"\n") f.write(str(ss-12000)+"\n")
f.close() f.close()
elif cmd == "LocationInfo": elif cmd == "LocationInfo":
@@ -363,14 +353,14 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
if "checked_locations" in args: if "checked_locations" in args:
filename = f"check.spot" filename = f"check.spot"
with open(os.path.join(ctx.save_game_folder, filename), "a") as f: with open(os.path.join(ctx.save_game_folder, filename), "a") as f:
for ss in set(args["checked_locations"]): for ss in ctx.checked_locations:
f.write(str(ss-12000)+"\n") f.write(str(ss-12000)+"\n")
f.close() f.close()
elif cmd == "Bounced": elif cmd == "Bounced":
tags = args.get("tags", []) tags = args.get("tags", [])
if "Online" in tags: if "Online" in tags:
data = args.get("data", {}) data = args.get("worlds/undertale/data", {})
if data["player"] != ctx.slot and data["player"] is not None: if data["player"] != ctx.slot and data["player"] is not None:
filename = f"FRISK" + str(data["player"]) + ".playerspot" filename = f"FRISK" + str(data["player"]) + ".playerspot"
with open(os.path.join(ctx.save_game_folder, filename), "w") as f: with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
@@ -385,7 +375,7 @@ async def multi_watcher(ctx: UndertaleContext):
for root, dirs, files in os.walk(path): for root, dirs, files in os.walk(path):
for file in files: for file in files:
if "spots.mine" in file and "Online" in ctx.tags: if "spots.mine" in file and "Online" in ctx.tags:
with open(os.path.join(root, file), "r") as mine: with open(root + "/" + file, "r") as mine:
this_x = mine.readline() this_x = mine.readline()
this_y = mine.readline() this_y = mine.readline()
this_room = mine.readline() this_room = mine.readline()
@@ -408,7 +398,7 @@ async def game_watcher(ctx: UndertaleContext):
for root, dirs, files in os.walk(path): for root, dirs, files in os.walk(path):
for file in files: for file in files:
if ".item" in file: if ".item" in file:
os.remove(os.path.join(root, file)) os.remove(root+"/"+file)
sync_msg = [{"cmd": "Sync"}] sync_msg = [{"cmd": "Sync"}]
if ctx.locations_checked: if ctx.locations_checked:
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)}) sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
@@ -416,42 +406,38 @@ async def game_watcher(ctx: UndertaleContext):
ctx.syncing = False ctx.syncing = False
if ctx.got_deathlink: if ctx.got_deathlink:
ctx.got_deathlink = False ctx.got_deathlink = False
with open(os.path.join(ctx.save_game_folder, "WelcomeToTheDead.youDied"), "w") as f: with open(os.path.join(ctx.save_game_folder, "/WelcomeToTheDead.youDied"), "w") as f:
f.close() f.close()
sending = [] sending = []
victory = False victory = False
found_routes = 0 found_routes = 0
for root, dirs, files in os.walk(path): for root, dirs, files in os.walk(path):
for file in files: for file in files:
if "DontBeMad.mad" in file: if "DontBeMad.mad" in file and "DeathLink" in ctx.tags:
os.remove(os.path.join(root, file)) os.remove(root+"/"+file)
if "DeathLink" in ctx.tags: await ctx.send_death()
await ctx.send_death()
if "scout" == file: if "scout" == file:
sending = [] sending = []
try: with open(root+"/"+file, "r") as f:
with open(os.path.join(root, file), "r") as f: lines = f.readlines()
lines = f.readlines()
for l in lines: for l in lines:
if ctx.server_locations.__contains__(int(l)+12000): if ctx.server_locations.__contains__(int(l)+12000):
sending = sending + [int(l.rstrip('\n'))+12000] sending = sending + [int(l)+12000]
finally: await ctx.send_msgs([{"cmd": "LocationScouts", "locations": sending,
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": sending, "create_as_hint": int(2)}])
"create_as_hint": int(2)}]) os.remove(root+"/"+file)
os.remove(os.path.join(root, file))
if "check.spot" in file: if "check.spot" in file:
sending = [] sending = []
try: with open(root+"/"+file, "r") as f:
with open(os.path.join(root, file), "r") as f: lines = f.readlines()
lines = f.readlines()
for l in lines: for l in lines:
sending = sending+[(int(l.rstrip('\n')))+12000] sending = sending+[(int(l))+12000]
finally: message = [{"cmd": "LocationChecks", "locations": sending}]
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": sending}]) await ctx.send_msgs(message)
if "victory" in file and str(ctx.route) in file: if "victory" in file and str(ctx.route) in file:
victory = True victory = True
if ".playerspot" in file and "Online" not in ctx.tags: if ".playerspot" in file and "Online" not in ctx.tags:
os.remove(os.path.join(root, file)) os.remove(root+"/"+file)
if "victory" in file: if "victory" in file:
if str(ctx.route) == "all_routes": if str(ctx.route) == "all_routes":
if "neutral" in file and ctx.completed_routes["neutral"] != 1: if "neutral" in file and ctx.completed_routes["neutral"] != 1:

373
Utils.py
View File

@@ -13,11 +13,8 @@ import io
import collections import collections
import importlib import importlib
import logging import logging
import warnings
from argparse import Namespace
from settings import Settings, get_settings
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
from yaml import load, load_all, dump, SafeLoader from yaml import load, load_all, dump, SafeLoader
try: try:
@@ -30,7 +27,6 @@ except ImportError:
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
import tkinter import tkinter
import pathlib import pathlib
from BaseClasses import Region
def tuplize_version(version: str) -> Version: def tuplize_version(version: str) -> Version:
@@ -46,7 +42,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self) return ".".join(str(item) for item in self)
__version__ = "0.4.3" __version__ = "0.4.2"
version_tuple = tuplize_version(__version__) version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux") is_linux = sys.platform.startswith("linux")
@@ -142,16 +138,13 @@ def user_path(*path: str) -> str:
user_path.cached_path = local_path() user_path.cached_path = local_path()
else: else:
user_path.cached_path = home_path() user_path.cached_path = home_path()
# populate home from local # populate home from local - TODO: upgrade feature
if user_path.cached_path != local_path(): if user_path.cached_path != local_path() and not os.path.exists(user_path("host.yaml")):
import filecmp import shutil
if not os.path.exists(user_path("manifest.json")) or \ for dn in ("Players", "data/sprites"):
not filecmp.cmp(local_path("manifest.json"), user_path("manifest.json"), shallow=True): shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True)
import shutil for fn in ("manifest.json", "host.yaml"):
for dn in ("Players", "data/sprites"): shutil.copy2(local_path(fn), user_path(fn))
shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True)
for fn in ("manifest.json",):
shutil.copy2(local_path(fn), user_path(fn))
return os.path.join(user_path.cached_path, *path) return os.path.join(user_path.cached_path, *path)
@@ -217,13 +210,7 @@ def get_cert_none_ssl_context():
def get_public_ipv4() -> str: def get_public_ipv4() -> str:
import socket import socket
import urllib.request import urllib.request
try: ip = socket.gethostbyname(socket.gethostname())
ip = socket.gethostbyname(socket.gethostname())
except socket.gaierror:
# if hostname or resolvconf is not set up properly, this may fail
warnings.warn("Could not resolve own hostname, falling back to 127.0.0.1")
ip = "127.0.0.1"
ctx = get_cert_none_ssl_context() ctx = get_cert_none_ssl_context()
try: try:
ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx, timeout=10).read().decode("utf8").strip() ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx, timeout=10).read().decode("utf8").strip()
@@ -241,13 +228,7 @@ def get_public_ipv4() -> str:
def get_public_ipv6() -> str: def get_public_ipv6() -> str:
import socket import socket
import urllib.request import urllib.request
try: ip = socket.gethostbyname(socket.gethostname())
ip = socket.gethostbyname(socket.gethostname())
except socket.gaierror:
# if hostname or resolvconf is not set up properly, this may fail
warnings.warn("Could not resolve own hostname, falling back to ::1")
ip = "::1"
ctx = get_cert_none_ssl_context() ctx = get_cert_none_ssl_context()
try: try:
ip = urllib.request.urlopen("https://v6.ident.me", context=ctx, timeout=10).read().decode("utf8").strip() ip = urllib.request.urlopen("https://v6.ident.me", context=ctx, timeout=10).read().decode("utf8").strip()
@@ -257,15 +238,155 @@ def get_public_ipv6() -> str:
return ip return ip
OptionsType = Settings # TODO: remove ~2 versions after 0.4.1 OptionsType = typing.Dict[str, typing.Dict[str, typing.Any]]
@cache_argsless @cache_argsless
def get_default_options() -> Settings: # TODO: remove ~2 versions after 0.4.1 def get_default_options() -> OptionsType:
return Settings(None) # Refer to host.yaml for comments as to what all these options mean.
options = {
"general_options": {
"output_path": "output",
},
"factorio_options": {
"executable": os.path.join("factorio", "bin", "x64", "factorio"),
"filter_item_sends": False,
"bridge_chat_out": True,
},
"sni_options": {
"sni_path": "SNI",
"snes_rom_start": True,
},
"sm_options": {
"rom_file": "Super Metroid (JU).sfc",
},
"soe_options": {
"rom_file": "Secret of Evermore (USA).sfc",
},
"lttp_options": {
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
},
"ladx_options": {
"rom_file": "Legend of Zelda, The - Link's Awakening DX (USA, Europe) (SGB Enhanced).gbc",
},
"server_options": {
"host": None,
"port": 38281,
"password": None,
"multidata": None,
"savefile": None,
"disable_save": False,
"loglevel": "info",
"server_password": None,
"disable_item_cheat": False,
"location_check_points": 1,
"hint_cost": 10,
"release_mode": "goal",
"collect_mode": "disabled",
"remaining_mode": "goal",
"auto_shutdown": 0,
"compatibility": 2,
"log_network": 0
},
"generator": {
"enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core"),
"player_files_path": "Players",
"players": 0,
"weights_file_path": "weights.yaml",
"meta_file_path": "meta.yaml",
"spoiler": 3,
"glitch_triforce_room": 1,
"race": 0,
"plando_options": "bosses",
},
"minecraft_options": {
"forge_directory": "Minecraft Forge server",
"max_heap_size": "2G",
"release_channel": "release"
},
"oot_options": {
"rom_file": "The Legend of Zelda - Ocarina of Time.z64",
"rom_start": True
},
"dkc3_options": {
"rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc",
},
"smw_options": {
"rom_file": "Super Mario World (USA).sfc",
},
"zillion_options": {
"rom_file": "Zillion (UE) [!].sms",
# RetroArch doesn't make it easy to launch a game from the command line.
# You have to know the path to the emulator core library on the user's computer.
"rom_start": "retroarch",
},
"pokemon_rb_options": {
"red_rom_file": "Pokemon Red (UE) [S][!].gb",
"blue_rom_file": "Pokemon Blue (UE) [S][!].gb",
"rom_start": True
},
"ffr_options": {
"display_msgs": True,
},
"lufia2ac_options": {
"rom_file": "Lufia II - Rise of the Sinistrals (USA).sfc",
},
"tloz_options": {
"rom_file": "Legend of Zelda, The (U) (PRG0) [!].nes",
"rom_start": True,
"display_msgs": True,
},
"wargroove_options": {
"root_directory": "C:/Program Files (x86)/Steam/steamapps/common/Wargroove"
},
"mmbn3_options": {
"rom_file": "Mega Man Battle Network 3 - Blue Version (USA).gba",
"rom_start": True
},
"adventure_options": {
"rom_file": "ADVNTURE.BIN",
"display_msgs": True,
"rom_start": True,
"rom_args": ""
},
}
return options
get_options = get_settings # TODO: add a warning ~2 versions after 0.4.1 and remove once all games are ported def update_options(src: dict, dest: dict, filename: str, keys: list) -> OptionsType:
for key, value in src.items():
new_keys = keys.copy()
new_keys.append(key)
option_name = '.'.join(new_keys)
if key not in dest:
dest[key] = value
if filename.endswith("options.yaml"):
logging.info(f"Warning: {filename} is missing {option_name}")
elif isinstance(value, dict):
if not isinstance(dest.get(key, None), dict):
if filename.endswith("options.yaml"):
logging.info(f"Warning: {filename} has {option_name}, but it is not a dictionary. overwriting.")
dest[key] = value
else:
dest[key] = update_options(value, dest[key], filename, new_keys)
return dest
@cache_argsless
def get_options() -> OptionsType:
filenames = ("options.yaml", "host.yaml")
locations: typing.List[str] = []
if os.path.join(os.getcwd()) != local_path():
locations += filenames # use files from cwd only if it's not the local_path
locations += [user_path(filename) for filename in filenames]
for location in locations:
if os.path.exists(location):
with open(location) as f:
options = parse_yaml(f.read())
return update_options(get_default_options(), options, location, list())
raise FileNotFoundError(f"Could not find {filenames[1]} to load options.")
def persistent_store(category: str, key: typing.Any, value: typing.Any): def persistent_store(category: str, key: typing.Any, value: typing.Any):
@@ -333,27 +454,12 @@ def store_data_package_for_checksum(game: str, data: typing.Dict[str, Any]) -> N
except Exception as e: except Exception as e:
logging.debug(f"Could not store data package: {e}") logging.debug(f"Could not store data package: {e}")
def get_default_adjuster_settings(game_name: str) -> Namespace:
import LttPAdjuster
adjuster_settings = Namespace()
if game_name == LttPAdjuster.GAME_ALTTP:
return LttPAdjuster.get_argparser().parse_known_args(args=[])[0]
def get_adjuster_settings(game_name: str) -> typing.Dict[str, typing.Any]:
adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {})
return adjuster_settings return adjuster_settings
def get_adjuster_settings_no_defaults(game_name: str) -> Namespace:
return persistent_load().get("adjuster", {}).get(game_name, Namespace())
def get_adjuster_settings(game_name: str) -> Namespace:
adjuster_settings = get_adjuster_settings_no_defaults(game_name)
default_settings = get_default_adjuster_settings(game_name)
# Fill in any arguments from the argparser that we haven't seen before
return Namespace(**vars(adjuster_settings), **{k:v for k,v in vars(default_settings).items() if k not in vars(adjuster_settings)})
@cache_argsless @cache_argsless
def get_unique_identifier(): def get_unique_identifier():
uuid = persistent_load().get("client", {}).get("uuid", None) uuid = persistent_load().get("client", {}).get("uuid", None)
@@ -373,13 +479,11 @@ safe_builtins = frozenset((
class RestrictedUnpickler(pickle.Unpickler): class RestrictedUnpickler(pickle.Unpickler):
generic_properties_module: Optional[object]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(RestrictedUnpickler, self).__init__(*args, **kwargs) super(RestrictedUnpickler, self).__init__(*args, **kwargs)
self.options_module = importlib.import_module("Options") self.options_module = importlib.import_module("Options")
self.net_utils_module = importlib.import_module("NetUtils") self.net_utils_module = importlib.import_module("NetUtils")
self.generic_properties_module = None self.generic_properties_module = importlib.import_module("worlds.generic")
def find_class(self, module, name): def find_class(self, module, name):
if module == "builtins" and name in safe_builtins: if module == "builtins" and name in safe_builtins:
@@ -389,8 +493,6 @@ class RestrictedUnpickler(pickle.Unpickler):
return getattr(self.net_utils_module, name) return getattr(self.net_utils_module, name)
# Options and Plando are unpickled by WebHost -> Generate # Options and Plando are unpickled by WebHost -> Generate
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}: if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
if not self.generic_properties_module:
self.generic_properties_module = importlib.import_module("worlds.generic")
return getattr(self.generic_properties_module, name) return getattr(self.generic_properties_module, name)
# pep 8 specifies that modules should have "all-lowercase names" (options, not Options) # pep 8 specifies that modules should have "all-lowercase names" (options, not Options)
if module.lower().endswith("options"): if module.lower().endswith("options"):
@@ -575,7 +677,7 @@ def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: ty
) )
def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]], suggest: str = "") \ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]) \
-> typing.Optional[str]: -> typing.Optional[str]:
def run(*args: str): def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
@@ -586,12 +688,11 @@ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typin
kdialog = which("kdialog") kdialog = which("kdialog")
if kdialog: if kdialog:
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes)) k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
return run(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters) return run(kdialog, f"--title={title}", "--getopenfilename", ".", k_filters)
zenity = which("zenity") zenity = which("zenity")
if zenity: if zenity:
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes) z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
selection = (f"--filename={suggest}",) if suggest else () return run(zenity, f"--title={title}", "--file-selection", *z_filters)
return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
# fall back to tk # fall back to tk
try: try:
@@ -602,47 +703,9 @@ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typin
f'This attempt was made because open_filename was used for "{title}".') f'This attempt was made because open_filename was used for "{title}".')
raise e raise e
else: else:
try: root = tkinter.Tk()
root = tkinter.Tk()
except tkinter.TclError:
return None # GUI not available. None is the same as a user clicking "cancel"
root.withdraw() root.withdraw()
return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes), return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes))
initialfile=suggest or None)
def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
if is_linux:
# prefer native dialog
from shutil import which
kdialog = which("kdialog")
if kdialog:
return run(kdialog, f"--title={title}", "--getexistingdirectory",
os.path.abspath(suggest) if suggest else ".")
zenity = which("zenity")
if zenity:
z_filters = ("--directory",)
selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else ()
return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
# fall back to tk
try:
import tkinter
import tkinter.filedialog
except Exception as e:
logging.error('Could not load tkinter, which is likely not installed. '
f'This attempt was made because open_filename was used for "{title}".')
raise e
else:
try:
root = tkinter.Tk()
except tkinter.TclError:
return None # GUI not available. None is the same as a user clicking "cancel"
root.withdraw()
return tkinter.filedialog.askdirectory(title=title, mustexist=True, initialdir=suggest or None)
def messagebox(title: str, text: str, error: bool = False) -> None: def messagebox(title: str, text: str, error: bool = False) -> None:
@@ -780,113 +843,3 @@ def freeze_support() -> None:
import multiprocessing import multiprocessing
_extend_freeze_support() _extend_freeze_support()
multiprocessing.freeze_support() multiprocessing.freeze_support()
def visualize_regions(root_region: Region, file_name: str, *,
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
linetype_ortho: bool = True) -> None:
"""Visualize the layout of a world as a PlantUML diagram.
:param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
:param file_name: The name of the destination .puml file.
:param show_entrance_names: (default False) If enabled, the name of the entrance will be shown near each connection.
:param show_locations: (default True) If enabled, the locations will be listed inside each region.
Priority locations will be shown in bold.
Excluded locations will be stricken out.
Locations without ID will be shown in italics.
Locked locations will be shown with a padlock icon.
For filled locations, the item name will be shown after the location name.
Progression items will be shown in bold.
Items without ID will be shown in italics.
:param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown.
:param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines.
Example usage in World code:
from Utils import visualize_regions
visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml")
Example usage in Main code:
from Utils import visualize_regions
for player in world.player_ids:
visualize_regions(world.get_region("Menu", player), f"{world.get_out_file_name_base(player)}.puml")
"""
assert root_region.multiworld, "The multiworld attribute of root_region has to be filled"
from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region
from collections import deque
import re
uml: typing.List[str] = list()
seen: typing.Set[Region] = set()
regions: typing.Deque[Region] = deque((root_region,))
multiworld: MultiWorld = root_region.multiworld
def fmt(obj: Union[Entrance, Item, Location, Region]) -> str:
name = obj.name
if isinstance(obj, Item):
name = multiworld.get_name_string_for_object(obj)
if obj.advancement:
name = f"**{name}**"
if obj.code is None:
name = f"//{name}//"
if isinstance(obj, Location):
if obj.progress_type == LocationProgressType.PRIORITY:
name = f"**{name}**"
elif obj.progress_type == LocationProgressType.EXCLUDED:
name = f"--{name}--"
if obj.address is None:
name = f"//{name}//"
return re.sub("[\".:]", "", name)
def visualize_exits(region: Region) -> None:
for exit_ in region.exits:
if exit_.connected_region:
if show_entrance_names:
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"")
else:
try:
uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"")
uml.append(f"\"{fmt(exit_.connected_region)}\" <--> \"{fmt(region)}\"")
except ValueError:
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\"")
else:
uml.append(f"circle \"unconnected exit:\\n{fmt(exit_)}\"")
uml.append(f"\"{fmt(region)}\" --> \"unconnected exit:\\n{fmt(exit_)}\"")
def visualize_locations(region: Region) -> None:
any_lock = any(location.locked for location in region.locations)
for location in region.locations:
lock = "<&lock-locked> " if location.locked else "<&lock-unlocked,color=transparent> " if any_lock else ""
if location.item:
uml.append(f"\"{fmt(region)}\" : {{method}} {lock}{fmt(location)}: {fmt(location.item)}")
else:
uml.append(f"\"{fmt(region)}\" : {{field}} {lock}{fmt(location)}")
def visualize_region(region: Region) -> None:
uml.append(f"class \"{fmt(region)}\"")
if show_locations:
visualize_locations(region)
visualize_exits(region)
def visualize_other_regions() -> None:
if other_regions := [region for region in multiworld.get_regions(root_region.player) if region not in seen]:
uml.append("package \"other regions\" <<Cloud>> {")
for region in other_regions:
uml.append(f"class \"{fmt(region)}\"")
uml.append("}")
uml.append("@startuml")
uml.append("hide circle")
uml.append("hide empty members")
if linetype_ortho:
uml.append("skinparam linetype ortho")
while regions:
if (current_region := regions.popleft()) not in seen:
seen.add(current_region)
visualize_region(current_region)
regions.extend(exit_.connected_region for exit_ in current_region.exits if exit_.connected_region)
if show_other_regions:
visualize_other_regions()
uml.append("@enduml")
with open(file_name, "wt", encoding="utf-8") as f:
f.write("\n".join(uml))

View File

@@ -10,19 +10,23 @@ ModuleUpdate.update()
# in case app gets imported by something like gunicorn # in case app gets imported by something like gunicorn
import Utils import Utils
import settings
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8 Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
settings.no_gui = True
from WebHostLib import register, app as raw_app
from waitress import serve
from WebHostLib.models import db
from WebHostLib.autolauncher import autohost, autogen
from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.options import create as create_options_files
configpath = os.path.abspath("config.yaml") configpath = os.path.abspath("config.yaml")
if not os.path.exists(configpath): # fall back to config.yaml in home if not os.path.exists(configpath): # fall back to config.yaml in home
configpath = os.path.abspath(Utils.user_path('config.yaml')) configpath = os.path.abspath(Utils.user_path('config.yaml'))
def get_app(): def get_app():
from WebHostLib import register, cache, app as raw_app
from WebHostLib.models import db
register() register()
app = raw_app app = raw_app
if os.path.exists(configpath) and not app.config["TESTING"]: if os.path.exists(configpath) and not app.config["TESTING"]:
@@ -34,7 +38,6 @@ def get_app():
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4() app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}") logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}")
cache.init_app(app)
db.bind(**app.config["PONY"]) db.bind(**app.config["PONY"])
db.generate_mapping(create_tables=True) db.generate_mapping(create_tables=True)
return app return app
@@ -69,7 +72,6 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
with zipfile.ZipFile(zipfile_path) as zf: with zipfile.ZipFile(zipfile_path) as zf:
for zfile in zf.infolist(): for zfile in zf.infolist():
if not zfile.is_dir() and "/docs/" in zfile.filename: if not zfile.is_dir() and "/docs/" in zfile.filename:
zfile.filename = os.path.basename(zfile.filename)
zf.extract(zfile, target_path) zf.extract(zfile, target_path)
else: else:
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs") source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
@@ -115,11 +117,6 @@ if __name__ == "__main__":
multiprocessing.freeze_support() multiprocessing.freeze_support()
multiprocessing.set_start_method('spawn') multiprocessing.set_start_method('spawn')
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO) logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.autolauncher import autohost, autogen
from WebHostLib.options import create as create_options_files
try: try:
update_sprites_lttp() update_sprites_lttp()
except Exception as e: except Exception as e:
@@ -136,5 +133,4 @@ if __name__ == "__main__":
if app.config["DEBUG"]: if app.config["DEBUG"]:
app.run(debug=True, port=app.config["PORT"]) app.run(debug=True, port=app.config["PORT"])
else: else:
from waitress import serve
serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"]) serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"])

View File

@@ -49,11 +49,11 @@ app.config["PONY"] = {
'create_db': True 'create_db': True
} }
app.config["MAX_ROLL"] = 20 app.config["MAX_ROLL"] = 20
app.config["CACHE_TYPE"] = "SimpleCache" app.config["CACHE_TYPE"] = "flask_caching.backends.SimpleCache"
app.config["JSON_AS_ASCII"] = False app.config["JSON_AS_ASCII"] = False
app.config["HOST_ADDRESS"] = "" app.config["HOST_ADDRESS"] = ""
cache = Cache() cache = Cache(app)
Compress(app) Compress(app)

View File

@@ -3,6 +3,8 @@ from __future__ import annotations
import json import json
import logging import logging
import multiprocessing import multiprocessing
import os
import sys
import threading import threading
import time import time
import typing import typing
@@ -11,7 +13,55 @@ from datetime import timedelta, datetime
from pony.orm import db_session, select, commit from pony.orm import db_session, select, commit
from Utils import restricted_loads from Utils import restricted_loads
from .locker import Locker, AlreadyRunningException
class CommonLocker():
"""Uses a file lock to signal that something is already running"""
lock_folder = "file_locks"
def __init__(self, lockname: str, folder=None):
if folder:
self.lock_folder = folder
os.makedirs(self.lock_folder, exist_ok=True)
self.lockname = lockname
self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck")
class AlreadyRunningException(Exception):
pass
if sys.platform == 'win32':
class Locker(CommonLocker):
def __enter__(self):
try:
if os.path.exists(self.lockfile):
os.unlink(self.lockfile)
self.fp = os.open(
self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR)
except OSError as e:
raise AlreadyRunningException() from e
def __exit__(self, _type, value, tb):
fp = getattr(self, "fp", None)
if fp:
os.close(self.fp)
os.unlink(self.lockfile)
else: # unix
import fcntl
class Locker(CommonLocker):
def __enter__(self):
try:
self.fp = open(self.lockfile, "wb")
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as e:
raise AlreadyRunningException() from e
def __exit__(self, _type, value, tb):
fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN)
self.fp.close()
def launch_room(room: Room, config: dict): def launch_room(room: Room, config: dict):

View File

@@ -24,8 +24,8 @@ def check():
if 'file' not in request.files: if 'file' not in request.files:
flash('No file part') flash('No file part')
else: else:
files = request.files.getlist('file') file = request.files['file']
options = get_yaml_data(files) options = get_yaml_data(file)
if isinstance(options, str): if isinstance(options, str):
flash(options) flash(options)
else: else:
@@ -39,33 +39,30 @@ def mysterycheck():
return redirect(url_for("check"), 301) return redirect(url_for("check"), 301)
def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]: def get_yaml_data(file) -> Union[Dict[str, str], str, Markup]:
options = {} options = {}
for file in files: # if user does not select file, browser also
# if user does not select file, browser also # submit an empty part without filename
# submit an empty part without filename if file.filename == '':
if file.filename == '': return 'No selected file'
return 'No selected file' elif file and allowed_file(file.filename):
elif file.filename in options: if file.filename.endswith(".zip"):
return f'Conflicting files named {file.filename} submitted'
elif file and allowed_file(file.filename):
if file.filename.endswith(".zip"):
with zipfile.ZipFile(file, 'r') as zfile: with zipfile.ZipFile(file, 'r') as zfile:
infolist = zfile.infolist() infolist = zfile.infolist()
if any(file.filename.endswith(".archipelago") for file in infolist): if any(file.filename.endswith(".archipelago") for file in infolist):
return Markup("Error: Your .zip file contains an .archipelago file. " return Markup("Error: Your .zip file contains an .archipelago file. "
'Did you mean to <a href="/uploads">host a game</a>?') 'Did you mean to <a href="/uploads">host a game</a>?')
for file in infolist: for file in infolist:
if file.filename.endswith(banned_zip_contents): if file.filename.endswith(banned_zip_contents):
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \ return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \
"Your file was deleted." "Your file was deleted."
elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")): elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")):
options[file.filename] = zfile.open(file, "r").read() options[file.filename] = zfile.open(file, "r").read()
else: else:
options[file.filename] = file.read() options = {file.filename: file.read()}
if not options: if not options:
return "Did not find a .yaml file to process." return "Did not find a .yaml file to process."
return options return options

View File

@@ -11,7 +11,6 @@ import socket
import threading import threading
import time import time
import typing import typing
import sys
import websockets import websockets
from pony.orm import commit, db_session, select from pony.orm import commit, db_session, select
@@ -20,7 +19,6 @@ import Utils
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
from Utils import restricted_loads, cache_argsless from Utils import restricted_loads, cache_argsless
from .locker import Locker
from .models import Command, GameDataPackage, Room, db from .models import Command, GameDataPackage, Room, db
@@ -165,21 +163,16 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
db.generate_mapping(check_tables=False) db.generate_mapping(check_tables=False)
async def main(): async def main():
if "worlds" in sys.modules:
raise Exception("Worlds system should not be loaded in the custom server.")
import gc
Utils.init_logging(str(room_id), write_mode="a") Utils.init_logging(str(room_id), write_mode="a")
ctx = WebHostContext(static_server_data) ctx = WebHostContext(static_server_data)
ctx.load(room_id) ctx.load(room_id)
ctx.init_save() ctx.init_save()
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
gc.collect() # free intermediate objects used during setup
try: try:
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context) ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
await ctx.server await ctx.server
except OSError: # likely port in use except Exception: # likely port in use - in windows this is OSError, but I didn't check the others
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context) ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
await ctx.server await ctx.server
@@ -205,15 +198,16 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
await ctx.shutdown_task await ctx.shutdown_task
logging.info("Shutting down") logging.info("Shutting down")
from .autolauncher import Locker
with Locker(room_id): with Locker(room_id):
try: try:
asyncio.run(main()) asyncio.run(main())
except (KeyboardInterrupt, SystemExit): except KeyboardInterrupt:
with db_session: with db_session:
room = Room.get(id=room_id) room = Room.get(id=room_id)
# ensure the Room does not spin up again on its own, minute of safety buffer # ensure the Room does not spin up again on its own, minute of safety buffer
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout) room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
except Exception: except:
with db_session: with db_session:
room = Room.get(id=room_id) room = Room.get(id=room_id)
room.last_port = -1 room.last_port = -1

View File

@@ -64,8 +64,8 @@ def generate(race=False):
if 'file' not in request.files: if 'file' not in request.files:
flash('No file part') flash('No file part')
else: else:
files = request.files.getlist('file') file = request.files['file']
options = get_yaml_data(files) options = get_yaml_data(file)
if isinstance(options, str): if isinstance(options, str):
flash(options) flash(options)
else: else:
@@ -130,7 +130,6 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
erargs.teams = 1 erargs.teams = 1
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options", erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
{"bosses", "items", "connections", "texts"})) {"bosses", "items", "connections", "texts"}))
erargs.skip_prog_balancing = 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

@@ -1,51 +0,0 @@
import os
import sys
class CommonLocker:
"""Uses a file lock to signal that something is already running"""
lock_folder = "file_locks"
def __init__(self, lockname: str, folder=None):
if folder:
self.lock_folder = folder
os.makedirs(self.lock_folder, exist_ok=True)
self.lockname = lockname
self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck")
class AlreadyRunningException(Exception):
pass
if sys.platform == 'win32':
class Locker(CommonLocker):
def __enter__(self):
try:
if os.path.exists(self.lockfile):
os.unlink(self.lockfile)
self.fp = os.open(
self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR)
except OSError as e:
raise AlreadyRunningException() from e
def __exit__(self, _type, value, tb):
fp = getattr(self, "fp", None)
if fp:
os.close(self.fp)
os.unlink(self.lockfile)
else: # unix
import fcntl
class Locker(CommonLocker):
def __enter__(self):
try:
self.fp = open(self.lockfile, "wb")
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as e:
raise AlreadyRunningException() from e
def __exit__(self, _type, value, tb):
fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN)
self.fp.close()

View File

@@ -32,34 +32,29 @@ def page_not_found(err):
# Start Playing Page # Start Playing Page
@app.route('/start-playing') @app.route('/start-playing')
@cache.cached()
def start_playing(): def start_playing():
return render_template(f"startPlaying.html") return render_template(f"startPlaying.html")
@app.route('/weighted-settings') @app.route('/weighted-settings')
@cache.cached()
def weighted_settings(): def weighted_settings():
return render_template(f"weighted-settings.html") return render_template(f"weighted-settings.html")
# Player settings pages # Player settings pages
@app.route('/games/<string:game>/player-settings') @app.route('/games/<string:game>/player-settings')
@cache.cached()
def player_settings(game): def player_settings(game):
return render_template(f"player-settings.html", game=game, theme=get_world_theme(game)) return render_template(f"player-settings.html", game=game, theme=get_world_theme(game))
# Game Info Pages # Game Info Pages
@app.route('/games/<string:game>/info/<string:lang>') @app.route('/games/<string:game>/info/<string:lang>')
@cache.cached()
def game_info(game, lang): def game_info(game, lang):
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game)) return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
# List of supported games # List of supported games
@app.route('/games') @app.route('/games')
@cache.cached()
def games(): def games():
worlds = {} worlds = {}
for game, world in AutoWorldRegister.world_types.items(): for game, world in AutoWorldRegister.world_types.items():
@@ -69,25 +64,21 @@ def games():
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>') @app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
@cache.cached()
def tutorial(game, file, lang): def tutorial(game, file, lang):
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game)) return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
@app.route('/tutorial/') @app.route('/tutorial/')
@cache.cached()
def tutorial_landing(): def tutorial_landing():
return render_template("tutorialLanding.html") return render_template("tutorialLanding.html")
@app.route('/faq/<string:lang>/') @app.route('/faq/<string:lang>/')
@cache.cached()
def faq(lang): def faq(lang):
return render_template("faq.html", lang=lang) return render_template("faq.html", lang=lang)
@app.route('/glossary/<string:lang>/') @app.route('/glossary/<string:lang>/')
@cache.cached()
def terms(lang): def terms(lang):
return render_template("glossary.html", lang=lang) return render_template("glossary.html", lang=lang)
@@ -156,7 +147,7 @@ def host_room(room: UUID):
@app.route('/favicon.ico') @app.route('/favicon.ico')
def favicon(): def favicon():
return send_from_directory(os.path.join(app.root_path, "static", "static"), return send_from_directory(os.path.join(app.root_path, 'static/static'),
'favicon.ico', mimetype='image/vnd.microsoft.icon') 'favicon.ico', mimetype='image/vnd.microsoft.icon')
@@ -176,7 +167,6 @@ def get_datapackage():
@app.route('/index') @app.route('/index')
@app.route('/sitemap') @app.route('/sitemap')
@cache.cached()
def get_sitemap(): def get_sitemap():
available_games: List[Dict[str, Union[str, bool]]] = [] available_games: List[Dict[str, Union[str, bool]]] = []
for game, world in AutoWorldRegister.world_types.items(): for game, world in AutoWorldRegister.world_types.items():

View File

@@ -1,9 +1,9 @@
flask>=2.2.3 flask>=2.2.3
pony>=0.7.17 pony>=0.7.16; python_version <= '3.10'
pony @ https://github.com/Berserker66/pony/releases/download/v0.7.16/pony-0.7.16-py3-none-any.whl#0.7.16 ; python_version >= '3.11'
waitress>=2.1.2 waitress>=2.1.2
Flask-Caching>=2.0.2 Flask-Caching>=2.0.2
Flask-Compress>=1.14 Flask-Compress>=1.13
Flask-Limiter>=3.5.0 Flask-Limiter>=3.3.0
bokeh>=3.1.1; python_version <= '3.8' bokeh>=3.1.1
bokeh>=3.2.2; python_version >= '3.9'
markupsafe>=2.1.3 markupsafe>=2.1.3

View File

@@ -1,84 +0,0 @@
window.addEventListener('load', () => {
document.getElementById('js-enabled').style.display = 'block';
const gameHeaders = document.getElementsByClassName('collapse-toggle');
Array.from(gameHeaders).forEach((header) => {
const gameName = header.getAttribute('data-game');
header.addEventListener('click', () => {
const gameArrow = document.getElementById(`${gameName}-arrow`);
const gameInfo = document.getElementById(gameName);
if (gameInfo.classList.contains('collapsed')) {
gameArrow.innerText = '▼';
gameInfo.classList.remove('collapsed');
} else {
gameArrow.innerText = '▶';
gameInfo.classList.add('collapsed');
}
});
});
// Handle game filter input
const gameSearch = document.getElementById('game-search');
gameSearch.value = '';
gameSearch.addEventListener('input', (evt) => {
if (!evt.target.value.trim()) {
// If input is empty, display all collapsed games
return Array.from(gameHeaders).forEach((header) => {
header.style.display = null;
const gameName = header.getAttribute('data-game');
document.getElementById(`${gameName}-arrow`).innerText = '▶';
document.getElementById(gameName).classList.add('collapsed');
});
}
// Loop over all the games
Array.from(gameHeaders).forEach((header) => {
const gameName = header.getAttribute('data-game');
const gameArrow = document.getElementById(`${gameName}-arrow`);
const gameInfo = document.getElementById(gameName);
// If the game name includes the search string, display the game. If not, hide it
if (gameName.toLowerCase().includes(evt.target.value.toLowerCase())) {
header.style.display = null;
gameArrow.innerText = '▼';
gameInfo.classList.remove('collapsed');
} else {
console.log(header);
header.style.display = 'none';
gameArrow.innerText = '▶';
gameInfo.classList.add('collapsed');
}
});
});
document.getElementById('expand-all').addEventListener('click', expandAll);
document.getElementById('collapse-all').addEventListener('click', collapseAll);
});
const expandAll = () => {
const gameHeaders = document.getElementsByClassName('collapse-toggle');
// Loop over all the games
Array.from(gameHeaders).forEach((header) => {
const gameName = header.getAttribute('data-game');
const gameArrow = document.getElementById(`${gameName}-arrow`);
const gameInfo = document.getElementById(gameName);
if (header.style.display === 'none') { return; }
gameArrow.innerText = '▼';
gameInfo.classList.remove('collapsed');
});
};
const collapseAll = () => {
const gameHeaders = document.getElementsByClassName('collapse-toggle');
// Loop over all the games
Array.from(gameHeaders).forEach((header) => {
const gameName = header.getAttribute('data-game');
const gameArrow = document.getElementById(`${gameName}-arrow`);
const gameInfo = document.getElementById(gameName);
if (header.style.display === 'none') { return; }
gameArrow.innerText = '▶';
gameInfo.classList.add('collapsed');
});
};

View File

@@ -14,17 +14,6 @@ const adjustTableHeight = () => {
} }
}; };
/**
* Convert an integer number of seconds into a human readable HH:MM format
* @param {Number} seconds
* @returns {string}
*/
const secondsToHours = (seconds) => {
let hours = Math.floor(seconds / 3600);
let minutes = Math.floor((seconds - (hours * 3600)) / 60).toString().padStart(2, '0');
return `${hours}:${minutes}`;
};
window.addEventListener('load', () => { window.addEventListener('load', () => {
const tables = $(".table").DataTable({ const tables = $(".table").DataTable({
paging: false, paging: false,
@@ -38,18 +27,7 @@ window.addEventListener('load', () => {
stateLoadCallback: function(settings) { stateLoadCallback: function(settings) {
return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`)); return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`));
}, },
footerCallback: function(tfoot, data, start, end, display) {
if (tfoot) {
const activityData = this.api().column('lastActivity:name').data().toArray().filter(x => !isNaN(x));
Array.from(tfoot?.children).find(td => td.classList.contains('last-activity')).innerText =
(activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None';
}
},
columnDefs: [ columnDefs: [
{
targets: 'last-activity',
name: 'lastActivity'
},
{ {
targets: 'hours', targets: 'hours',
render: function (data, type, row) { render: function (data, type, row) {
@@ -62,7 +40,11 @@ window.addEventListener('load', () => {
if (data === "None") if (data === "None")
return data; return data;
return secondsToHours(data); let hours = Math.floor(data / 3600);
let minutes = Math.floor((data - (hours * 3600)) / 60);
if (minutes < 10) {minutes = "0"+minutes;}
return hours+':'+minutes;
} }
}, },
{ {
@@ -132,16 +114,11 @@ window.addEventListener('load', () => {
if (status === "success") { if (status === "success") {
target.find(".table").each(function (i, new_table) { target.find(".table").each(function (i, new_table) {
const new_trs = $(new_table).find("tbody>tr"); const new_trs = $(new_table).find("tbody>tr");
const footer_tr = $(new_table).find("tfoot>tr");
const old_table = tables.eq(i); const old_table = tables.eq(i);
const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop(); const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft(); const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
old_table.clear(); old_table.clear();
if (footer_tr.length) { old_table.rows.add(new_trs).draw();
$(old_table.table).find("tfoot").html(footer_tr);
}
old_table.rows.add(new_trs);
old_table.draw();
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll); $(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll); $(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
}); });

View File

@@ -160,7 +160,6 @@ const buildUI = (settingData) => {
weightedSettingsDiv.classList.add('invisible'); weightedSettingsDiv.classList.add('invisible');
itemPoolDiv.classList.add('invisible'); itemPoolDiv.classList.add('invisible');
hintsDiv.classList.add('invisible'); hintsDiv.classList.add('invisible');
locationsDiv.classList.add('invisible');
expandButton.classList.remove('invisible'); expandButton.classList.remove('invisible');
}); });
@@ -169,7 +168,6 @@ const buildUI = (settingData) => {
weightedSettingsDiv.classList.remove('invisible'); weightedSettingsDiv.classList.remove('invisible');
itemPoolDiv.classList.remove('invisible'); itemPoolDiv.classList.remove('invisible');
hintsDiv.classList.remove('invisible'); hintsDiv.classList.remove('invisible');
locationsDiv.classList.remove('invisible');
expandButton.classList.add('invisible'); expandButton.classList.add('invisible');
}); });
}); });
@@ -1136,8 +1134,8 @@ const validateSettings = () => {
return; return;
} }
// Remove any disabled options
Object.keys(settings[game]).forEach((setting) => { Object.keys(settings[game]).forEach((setting) => {
// Remove any disabled options
Object.keys(settings[game][setting]).forEach((option) => { Object.keys(settings[game][setting]).forEach((option) => {
if (settings[game][setting][option] === 0) { if (settings[game][setting][option] === 0) {
delete settings[game][setting][option]; delete settings[game][setting][option];
@@ -1151,32 +1149,6 @@ const validateSettings = () => {
) { ) {
errorMessage = `${game} // ${setting} has no values above zero!`; errorMessage = `${game} // ${setting} has no values above zero!`;
} }
// Remove weights from options with only one possibility
if (
Object.keys(settings[game][setting]).length === 1 &&
!Array.isArray(settings[game][setting]) &&
setting !== 'start_inventory'
) {
settings[game][setting] = Object.keys(settings[game][setting])[0];
}
// Remove empty arrays
else if (
['exclude_locations', 'priority_locations', 'local_items',
'non_local_items', 'start_hints', 'start_location_hints'].includes(setting) &&
settings[game][setting].length === 0
) {
delete settings[game][setting];
}
// Remove empty start inventory
else if (
setting === 'start_inventory' &&
Object.keys(settings[game]['start_inventory']).length === 0
) {
delete settings[game]['start_inventory'];
}
}); });
}); });
@@ -1184,11 +1156,6 @@ const validateSettings = () => {
errorMessage = 'You have not chosen a game to play!'; errorMessage = 'You have not chosen a game to play!';
} }
// Remove weights if there is only one game
else if (Object.keys(settings.game).length === 1) {
settings.game = Object.keys(settings.game)[0];
}
// If an error occurred, alert the user and do not export the file // If an error occurred, alert the user and do not export the file
if (errorMessage) { if (errorMessage) {
userMessage.innerText = errorMessage; userMessage.innerText = errorMessage;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -235,6 +235,9 @@ html{
line-height: 30px; line-height: 30px;
} }
#landing .variable{
color: #ffff00;
}
.landing-deco{ .landing-deco{
position: absolute; position: absolute;

View File

@@ -5,8 +5,7 @@ html{
} }
#player-settings{ #player-settings{
box-sizing: border-box; max-width: 1000px;
max-width: 1024px;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
background-color: rgba(0, 0, 0, 0.15); background-color: rgba(0, 0, 0, 0.15);
@@ -164,11 +163,6 @@ html{
background-color: #ffef00; /* Same as .interactive in globalStyles.css */ background-color: #ffef00; /* Same as .interactive in globalStyles.css */
} }
#player-settings table .randomize-button[data-tooltip]::after {
left: unset;
right: 0;
}
#player-settings table label{ #player-settings table label{
display: block; display: block;
min-width: 200px; min-width: 200px;
@@ -183,31 +177,18 @@ html{
vertical-align: top; vertical-align: top;
} }
@media all and (max-width: 1024px) { @media all and (max-width: 1000px), all and (orientation: portrait){
#player-settings {
border-radius: 0;
}
#player-settings #game-options{ #player-settings #game-options{
justify-content: flex-start; justify-content: flex-start;
flex-wrap: wrap; flex-wrap: wrap;
} }
#player-settings .left, #player-settings .left, #player-settings .right{
#player-settings .right { flex-grow: unset;
margin: 0;
}
#game-options table {
margin-bottom: 0;
} }
#game-options table label{ #game-options table label{
display: block; display: block;
min-width: 200px; min-width: 200px;
} }
#game-options table tr td {
width: 50%;
}
} }

View File

@@ -9,7 +9,7 @@
border-top-left-radius: 4px; border-top-left-radius: 4px;
border-top-right-radius: 4px; border-top-right-radius: 4px;
padding: 3px 3px 10px; padding: 3px 3px 10px;
width: 710px; width: 500px;
background-color: #525494; background-color: #525494;
} }
@@ -34,12 +34,10 @@
max-height: 40px; max-height: 40px;
border: 1px solid #000000; border: 1px solid #000000;
filter: grayscale(100%) contrast(75%) brightness(20%); filter: grayscale(100%) contrast(75%) brightness(20%);
background-color: black;
} }
#inventory-table img.acquired{ #inventory-table img.acquired{
filter: none; filter: none;
background-color: black;
} }
#inventory-table div.counted-item { #inventory-table div.counted-item {
@@ -54,7 +52,7 @@
} }
#location-table{ #location-table{
width: 710px; width: 500px;
border-left: 2px solid #000000; border-left: 2px solid #000000;
border-right: 2px solid #000000; border-right: 2px solid #000000;
border-bottom: 2px solid #000000; border-bottom: 2px solid #000000;

View File

@@ -18,20 +18,6 @@
margin-bottom: 2px; margin-bottom: 2px;
} }
#games h2 .collapse-arrow{
font-size: 20px;
vertical-align: middle;
cursor: pointer;
}
#games h2 .game-name{
cursor: pointer;
}
#games p.collapsed{
display: none;
}
#games a{ #games a{
font-size: 16px; font-size: 16px;
} }
@@ -45,17 +31,3 @@
line-height: 25px; line-height: 25px;
margin-bottom: 7px; margin-bottom: 7px;
} }
#games #page-controls{
display: flex;
flex-direction: row;
margin-top: 0.25rem;
}
#games #page-controls button{
margin-left: 0.5rem;
}
#games #js-enabled{
display: none;
}

View File

@@ -55,16 +55,16 @@ table.dataTable thead{
font-family: LexendDeca-Regular, sans-serif; font-family: LexendDeca-Regular, sans-serif;
} }
table.dataTable tbody, table.dataTable tfoot{ table.dataTable tbody{
background-color: #dce2bd; background-color: #dce2bd;
font-family: LexendDeca-Light, sans-serif; font-family: LexendDeca-Light, sans-serif;
} }
table.dataTable tbody tr:hover, table.dataTable tfoot tr:hover{ table.dataTable tbody tr:hover{
background-color: #e2eabb; background-color: #e2eabb;
} }
table.dataTable tbody td, table.dataTable tfoot td{ table.dataTable tbody td{
padding: 4px 6px; padding: 4px 6px;
} }
@@ -97,14 +97,10 @@ table.dataTable thead th.lower-row{
top: 46px; top: 46px;
} }
table.dataTable tbody td, table.dataTable tfoot td{ table.dataTable tbody td{
border: 1px solid #bba967; border: 1px solid #bba967;
} }
table.dataTable tfoot td{
font-weight: bold;
}
div.dataTables_scrollBody{ div.dataTables_scrollBody{
background-color: inherit !important; background-color: inherit !important;
} }

View File

@@ -17,9 +17,9 @@
</p> </p>
<div id="check-form-wrapper"> <div id="check-form-wrapper">
<form id="check-form" method="post" enctype="multipart/form-data"> <form id="check-form" method="post" enctype="multipart/form-data">
<input id="file-input" type="file" name="file" multiple> <input id="file-input" type="file" name="file">
</form> </form>
<button id="check-button">Upload File(s)</button> <button id="check-button">Upload</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -203,10 +203,10 @@ Warning: playthrough can take a significant amount of time for larger multiworld
</div> </div>
</div> </div>
<div id="generate-form-button-row"> <div id="generate-form-button-row">
<input id="file-input" type="file" name="file" multiple> <input id="file-input" type="file" name="file">
</div> </div>
</form> </form>
<button id="generate-game-button">Upload File(s)</button> <button id="generate-game-button">Upload File</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,28 +0,0 @@
{% for team, hints in hints.items() %}
<div class="table-wrapper">
<table id="hints-table" class="table non-unique-item-table" data-order='[[5, "asc"], [0, "asc"]]'>
<thead>
<tr>
<th>Finder</th>
<th>Receiver</th>
<th>Item</th>
<th>Location</th>
<th>Entrance</th>
<th>Found</th>
</tr>
</thead>
<tbody>
{%- for hint in hints -%}
<tr>
<td>{{ long_player_names[team, hint.finding_player] }}</td>
<td>{{ long_player_names[team, hint.receiving_player] }}</td>
<td>{{ hint.item|item_name }}</td>
<td>{{ hint.location|location_name }}</td>
<td>{% if hint.entrance %}{{ hint.entrance }}{% else %}Vanilla{% endif %}</td>
<td>{% if hint.found %}✔{% endif %}</td>
</tr>
{%- endfor -%}
</tbody>
</table>
</div>
{% endfor %}

View File

@@ -1,6 +1,6 @@
{% block footer %} {% block footer %}
<footer id="island-footer"> <footer id="island-footer">
<div id="copyright-notice">Copyright 2023 Archipelago</div> <div id="copyright-notice">Copyright 2022 Archipelago</div>
<div id="links"> <div id="links">
<a href="/sitemap">Site Map</a> <a href="/sitemap">Site Map</a>
- -

View File

@@ -49,9 +49,9 @@
our crazy idea into a reality. our crazy idea into a reality.
</p> </p>
<p> <p>
<a href="{{ url_for("stats") }}">{{ seeds }}</a> <span class="variable">{{ seeds }}</span>
games were generated and games were generated and
<a href="{{ url_for("stats") }}">{{ rooms }}</a> <span class="variable">{{ rooms }}</span>
were hosted in the last 7 days. were hosted in the last 7 days.
</p> </p>
</div> </div>

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