Compare commits

..

2 Commits

Author SHA1 Message Date
black-sliver
9eb2d3ea3e Setup: pin cx-Freeze version 2023-05-28 12:32:02 +02:00
black-sliver
f04054a177 CI: linux: built with py3.10 2023-05-28 12:25:59 +02:00
636 changed files with 19537 additions and 72111 deletions

View File

@@ -1,80 +0,0 @@
name: Analyze modified files
on:
pull_request:
paths:
- "**.py"
push:
paths:
- "**.py"
env:
BASE: ${{ github.event.pull_request.base.sha }}
HEAD: ${{ github.event.pull_request.head.sha }}
BEFORE: ${{ github.event.before }}
AFTER: ${{ github.event.after }}
jobs:
flake8-or-mypy:
strategy:
fail-fast: false
matrix:
task: [flake8, mypy]
name: ${{ matrix.task }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: "Determine modified files (pull_request)"
if: github.event_name == 'pull_request'
run: |
git fetch origin $BASE $HEAD
DIFF=$(git diff --diff-filter=d --name-only $BASE...$HEAD -- "*.py")
echo "modified files:"
echo "$DIFF"
echo "diff=${DIFF//$'\n'/$' '}" >> $GITHUB_ENV
- name: "Determine modified files (push)"
if: github.event_name == 'push' && github.event.before != '0000000000000000000000000000000000000000'
run: |
git fetch origin $BEFORE $AFTER
DIFF=$(git diff --diff-filter=d --name-only $BEFORE..$AFTER -- "*.py")
echo "modified files:"
echo "$DIFF"
echo "diff=${DIFF//$'\n'/$' '}" >> $GITHUB_ENV
- name: "Treat all files as modified (new branch)"
if: github.event_name == 'push' && github.event.before == '0000000000000000000000000000000000000000'
run: |
echo "diff=." >> $GITHUB_ENV
- uses: actions/setup-python@v4
if: env.diff != ''
with:
python-version: 3.8
- name: "Install dependencies"
if: env.diff != ''
run: |
python -m pip install --upgrade pip ${{ matrix.task }}
python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
- name: "flake8: Stop the build if there are Python syntax errors or undefined names"
continue-on-error: false
if: env.diff != '' && matrix.task == 'flake8'
run: |
flake8 --count --select=E9,F63,F7,F82 --show-source --statistics ${{ env.diff }}
- name: "flake8: Lint modified files"
continue-on-error: true
if: env.diff != '' && matrix.task == 'flake8'
run: |
flake8 --count --max-complexity=10 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }}
- name: "mypy: Type check modified files"
continue-on-error: true
if: env.diff != '' && matrix.task == 'mypy'
run: |
mypy --follow-imports=silent --install-types --non-interactive --strict ${{ env.diff }}

View File

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

35
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
# This workflow will install Python dependencies, run tests and lint with a single version of Python
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
name: lint
on:
push:
paths:
- '**.py'
pull_request:
paths:
- '**.py'
jobs:
flake8:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python 3.9
uses: actions/setup-python@v4
with:
python-version: 3.9
- name: Install dependencies
run: |
python -m pip install --upgrade pip wheel
pip install flake8
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics

View File

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

View File

@@ -36,13 +36,12 @@ jobs:
- {version: '3.8'}
- {version: '3.9'}
- {version: '3.10'}
- {version: '3.11'}
include:
- python: {version: '3.8'} # win7 compat
os: windows-latest
- python: {version: '3.11'} # current
- python: {version: '3.10'} # current
os: windows-latest
- python: {version: '3.11'} # current
- python: {version: '3.10'} # current
os: macos-latest
steps:
@@ -54,9 +53,8 @@ jobs:
- name: Install dependencies
run: |
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 Launcher.py --update_settings # make sure host.yaml exists for tests
- name: Unittests
run: |
pytest -n auto
pytest

14
.gitignore vendored
View File

@@ -27,21 +27,15 @@
*.archipelago
*.apsave
*.BIN
*.puml
setups
build
bundle/components.wxs
dist
/prof/
README.html
.vs/
EnemizerCLI/
/Players/
/SNI/
/sni-*/
/appimagetool*
/host.yaml
/options.yaml
/config.yaml
/logs/
@@ -143,7 +137,6 @@ ipython_config.py
.venv*
env/
venv/
/venv*/
ENV/
env.bak/
venv.bak/
@@ -174,10 +167,6 @@ dmypy.json
# Cython debug symbols
cython_debug/
# Cython intermediates
_speedups.cpp
_speedups.html
# minecraft server stuff
jdk*/
minecraft*/
@@ -187,9 +176,6 @@ minecraft_versions.json
# pyenv
.python-version
#undertale stuff
/Undertale/
# OS General Files
.DS_Store
.AppleDouble

View File

@@ -396,7 +396,7 @@ async def atari_sync_task(ctx: AdventureContext):
ctx.atari_streams = await asyncio.wait_for(
asyncio.open_connection("localhost",
port),
timeout=10)
timeout=10)
ctx.atari_status = CONNECTION_TENTATIVE_STATUS
except TimeoutError:
logger.debug("Connection Timed Out, Trying Again")

View File

@@ -7,11 +7,9 @@ import random
import secrets
import typing # this can go away when Python 3.8 support is dropped
from argparse import Namespace
from collections import ChainMap, Counter, deque
from collections.abc import Collection
from collections import ChainMap, Counter, OrderedDict, deque
from enum import IntEnum, IntFlag
from typing import Any, Callable, Dict, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union, \
Type, ClassVar
from typing import Any, Callable, Dict, Iterable, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union
import NetUtils
import Options
@@ -30,15 +28,15 @@ class Group(TypedDict, total=False):
link_replacement: bool
class ThreadBarrierProxy:
class ThreadBarrierProxy():
"""Passes through getattr while passthrough is True"""
def __init__(self, obj: object) -> None:
def __init__(self, obj: Any):
self.passthrough = True
self.obj = obj
def __getattr__(self, name: str) -> Any:
def __getattr__(self, item):
if self.passthrough:
return getattr(self.obj, name)
return getattr(self.obj, item)
else:
raise RuntimeError("You are in a threaded context and global random state was removed for your safety. "
"Please use multiworld.per_slot_randoms[player] or randomize ahead of output.")
@@ -83,7 +81,6 @@ class MultiWorld():
random: random.Random
per_slot_randoms: Dict[int, random.Random]
"""Deprecated. Please use `self.random` instead."""
class AttributeProxy():
def __init__(self, rule):
@@ -99,6 +96,7 @@ class MultiWorld():
self.player_types = {player: NetUtils.SlotType.player for player in self.player_ids}
self.glitch_triforce = False
self.algorithm = 'balanced'
self.dungeons: Dict[Tuple[str, int], Dungeon] = {}
self.groups = {}
self.regions = []
self.shops = []
@@ -203,7 +201,14 @@ class MultiWorld():
self.player_types[new_id] = NetUtils.SlotType.group
self._region_cache[new_id] = {}
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.player_name[new_id] = name
@@ -238,7 +243,6 @@ class MultiWorld():
setattr(self, option_key, getattr(args, option_key, {}))
self.worlds[player] = world_type(self, player)
self.worlds[player].random = self.per_slot_randoms[player]
def set_item_links(self):
item_links = {}
@@ -358,7 +362,7 @@ class MultiWorld():
for r_location in region.locations:
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()
def get_region(self, regionname: str, player: int) -> Region:
@@ -382,6 +386,12 @@ class MultiWorld():
self._recache()
return self._location_cache[location, player]
def get_dungeon(self, dungeonname: str, player: int) -> Dungeon:
try:
return self.dungeons[dungeonname, player]
except KeyError as e:
raise KeyError('No such dungeon %s for player %d' % (dungeonname, player)) from e
def get_all_state(self, use_cache: bool) -> CollectionState:
cached = getattr(self, "_all_state", None)
if use_cache and cached:
@@ -481,10 +491,8 @@ class MultiWorld():
def get_unfilled_locations_for_players(self, location_names: List[str], players: Iterable[int]):
for player in players:
if not location_names:
valid_locations = [location.name for location in self.get_unfilled_locations(player)]
else:
valid_locations = location_names
for location_name in valid_locations:
location_names = [location.name for location in self.get_unfilled_locations(player)]
for location_name in location_names:
location = self._location_cache.get((location_name, player), None)
if location is not None and location.item is None:
yield location
@@ -785,6 +793,79 @@ class CollectionState():
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]
dungeon: Optional[Dungeon] = None
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:
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
hide_path: bool = False
@@ -823,92 +904,41 @@ class Entrance:
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):
class Dungeon(object):
def __init__(self, name: str, regions: List[Region], big_key: Item, small_keys: List[Item],
dungeon_items: List[Item], player: int):
self.name = name
self.entrances = []
self.exits = []
self.locations = []
self.multiworld = multiworld
self._hint_text = hint
self.regions = regions
self.big_key = big_key
self.small_keys = small_keys
self.dungeon_items = dungeon_items
self.bosses = dict()
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]
self.multiworld = None
@property
def hint_text(self) -> str:
return self._hint_text if self._hint_text else self.name
def boss(self) -> Optional[Boss]:
return self.bosses.get(None, None)
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)
@boss.setter
def boss(self, value: Optional[Boss]):
self.bosses[None] = value
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_
@property
def keys(self) -> List[Item]:
return self.small_keys + ([self.big_key] if self.big_key else [])
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.
@property
def all_items(self) -> List[Item]:
return self.dungeon_items + self.keys
: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 is_dungeon_item(self, item: Item) -> bool:
return item.player == self.player and item.name in (dungeon_item.name for dungeon_item in self.all_items)
def __eq__(self, other: Dungeon) -> bool:
if not other:
return False
return self.name == other.name and self.player == other.player
def __repr__(self):
return self.__str__()
@@ -917,6 +947,20 @@ class Region:
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
class Boss():
def __init__(self, name: str, enemizer_name: str, defeat_rule: Callable, player: int):
self.name = name
self.enemizer_name = enemizer_name
self.defeat_rule = defeat_rule
self.player = player
def can_defeat(self, state) -> bool:
return self.defeat_rule(state, self.player)
def __repr__(self):
return f"Boss({self.name})"
class LocationProgressType(IntEnum):
DEFAULT = 1
PRIORITY = 2
@@ -1049,19 +1093,15 @@ class Item:
def flags(self) -> int:
return self.classification.as_flag()
def __eq__(self, other: object) -> bool:
if not isinstance(other, Item):
return NotImplemented
def __eq__(self, other):
return self.name == other.name and self.player == other.player
def __lt__(self, other: object) -> bool:
if not isinstance(other, Item):
return NotImplemented
def __lt__(self, other: Item) -> bool:
if other.player != self.player:
return other.player < self.player
return self.name < other.name
def __hash__(self) -> int:
def __hash__(self):
return hash((self.name, self.player))
def __repr__(self) -> str:
@@ -1073,44 +1113,33 @@ class Item:
return f"{self.name} (Player {self.player})"
class EntranceInfo(TypedDict, total=False):
player: int
entrance: str
exit: str
direction: str
class Spoiler:
class Spoiler():
multiworld: MultiWorld
hashes: Dict[int, str]
entrances: Dict[Tuple[str, str, int], EntranceInfo]
playthrough: Dict[str, Union[List[str], Dict[str, str]]] # sphere "0" is list, others are dict
unreachables: Set[Location]
paths: Dict[str, List[Union[Tuple[str, str], Tuple[str, None]]]] # last step takes no further exits
def __init__(self, multiworld: MultiWorld) -> None:
self.multiworld = multiworld
def __init__(self, world):
self.multiworld = world
self.hashes = {}
self.entrances = {}
self.entrances = OrderedDict()
self.playthrough = {}
self.unreachables = set()
self.paths = {}
def set_entrance(self, entrance: str, exit_: str, direction: str, player: int) -> None:
def set_entrance(self, entrance: str, exit_: str, direction: str, player: int):
if self.multiworld.players == 1:
self.entrances[(entrance, direction, player)] = \
{"entrance": entrance, "exit": exit_, "direction": direction}
self.entrances[(entrance, direction, player)] = OrderedDict(
[('entrance', entrance), ('exit', exit_), ('direction', direction)])
else:
self.entrances[(entrance, direction, player)] = \
{"player": player, "entrance": entrance, "exit": exit_, "direction": direction}
self.entrances[(entrance, direction, player)] = OrderedDict(
[('player', player), ('entrance', entrance), ('exit', exit_), ('direction', direction)])
def create_playthrough(self, create_paths: bool = True) -> None:
def create_playthrough(self, create_paths: bool = True):
"""Destructive to the world while it is run, damage gets repaired afterwards."""
from itertools import chain
# get locations containing progress items
multiworld = self.multiworld
prog_locations = {location for location in multiworld.get_filled_locations() if location.item.advancement}
state_cache: List[Optional[CollectionState]] = [None]
state_cache = [None]
collection_spheres: List[Set[Location]] = []
state = CollectionState(multiworld)
sphere_candidates = set(prog_locations)
@@ -1219,17 +1248,17 @@ class Spoiler:
for item in removed_precollected:
multiworld.push_precollected(item)
def create_paths(self, state: CollectionState, collection_spheres: List[Set[Location]]) -> None:
def create_paths(self, state: CollectionState, collection_spheres: List[Set[Location]]):
from itertools import zip_longest
multiworld = self.multiworld
def flist_to_iter(path_value: Optional[PathValue]) -> Iterator[str]:
while path_value:
region_or_entrance, path_value = path_value
yield region_or_entrance
def flist_to_iter(node):
while node:
value, node = node
yield value
def get_path(state: CollectionState, region: Region) -> List[Union[Tuple[str, str], Tuple[str, None]]]:
reversed_path_as_flist: PathValue = state.path.get(region, (str(region), None))
def get_path(state, region):
reversed_path_as_flist = state.path.get(region, (region, None))
string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist))))
# Now we combine the flat string list into (region, exit) pairs
pathsiter = iter(string_path_flat)
@@ -1255,11 +1284,14 @@ class Spoiler:
self.paths[str(multiworld.get_region('Inverted Big Bomb Shop', player))] = \
get_path(state, multiworld.get_region('Inverted Big Bomb Shop', player))
def to_file(self, filename: str) -> None:
def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None:
def to_file(self, filename: str):
def write_option(option_key: str, option_obj: type(Options.Option)):
res = getattr(self.multiworld, option_key)[player]
display_name = getattr(option_obj, "display_name", option_key)
outfile.write(f"{display_name + ':':33}{res.current_option_name}\n")
try:
outfile.write(f'{display_name + ":":33}{res.current_option_name}\n')
except:
raise Exception
with open(filename, 'w', encoding="utf-8-sig") as outfile:
outfile.write(
@@ -1292,15 +1324,15 @@ class Spoiler:
AutoWorld.call_all(self.multiworld, "write_spoiler", outfile)
locations = [(str(location), str(location.item) if location.item is not None else "Nothing")
for location in self.multiworld.get_locations() if location.show_in_spoiler]
for location in self.multiworld.get_locations() if location.show_in_spoiler]
outfile.write('\n\nLocations:\n\n')
outfile.write('\n'.join(
['%s: %s' % (location, item) for location, item in locations]))
outfile.write('\n\nPlaythrough:\n\n')
outfile.write('\n'.join(['%s: {\n%s\n}' % (sphere_nr, '\n'.join(
[f" {location}: {item}" for (location, item) in sphere.items()] if isinstance(sphere, dict) else
[f" {item}" for item in sphere])) for (sphere_nr, sphere) in self.playthrough.items()]))
[' %s: %s' % (location, item) for (location, item) in sphere.items()] if sphere_nr != '0' else [
f' {item}' for item in sphere])) for (sphere_nr, sphere) in self.playthrough.items()]))
if self.unreachables:
outfile.write('\n\nUnreachable Items:\n\n')
outfile.write(
@@ -1361,21 +1393,23 @@ class PlandoOptions(IntFlag):
@classmethod
def _handle_part(cls, part: str, base: PlandoOptions) -> PlandoOptions:
try:
return base | cls[part]
part = cls[part]
except Exception as e:
raise KeyError(f"{part} is not a recognized name for a plando module. "
f"Known options: {', '.join(str(flag.name) for flag in cls)}") from e
f"Known options: {', '.join(flag.name for flag in cls)}") from e
else:
return base | part
def __str__(self) -> str:
if self.value:
return ", ".join(str(flag.name) for flag in PlandoOptions if self.value & flag.value)
return ", ".join(flag.name for flag in PlandoOptions if self.value & flag.value)
return "None"
seeddigits = 20
def get_seed(seed: Optional[int] = None) -> int:
def get_seed(seed=None) -> int:
if seed is None:
random.seed(None)
return random.randint(0, pow(10, seeddigits) - 1)

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

@@ -23,7 +23,6 @@ from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
from Utils import Version, stream_input, async_start
from worlds import network_data_package, AutoWorldRegister
import os
import ssl
if typing.TYPE_CHECKING:
import kvui
@@ -34,12 +33,6 @@ logger = logging.getLogger("Client")
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
@Utils.cache_argsless
def get_ssl_context():
import certifi
return ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=certifi.where())
class ClientCommandProcessor(CommandProcessor):
def __init__(self, ctx: CommonContext):
self.ctx = ctx
@@ -191,10 +184,6 @@ class CommonContext:
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
locations_info: typing.Dict[int, NetworkItem]
# data storage
stored_data: typing.Dict[str, typing.Any]
stored_data_notification_keys: typing.Set[str]
# internals
# current message box through kvui
_messagebox: typing.Optional["kvui.MessageBox"] = None
@@ -230,9 +219,6 @@ class CommonContext:
self.server_locations = set() # all locations the server knows of, missing_location | checked_locations
self.locations_info = {}
self.stored_data = {}
self.stored_data_notification_keys = set()
self.input_queue = asyncio.Queue()
self.input_requests = 0
@@ -474,21 +460,6 @@ class CommonContext:
for game, game_data in data_package["games"].items():
Utils.store_data_package_for_checksum(game, game_data)
# data storage
def set_notify(self, *keys: str) -> None:
"""Subscribe to be notified of changes to selected data storage keys.
The values can be accessed via the "stored_data" attribute of this context, which is a dictionary mapping the
names of the data storage keys to the latest values received from the server.
"""
if new_keys := (set(keys) - self.stored_data_notification_keys):
self.stored_data_notification_keys.update(new_keys)
async_start(self.send_msgs([{"cmd": "Get",
"keys": list(new_keys)},
{"cmd": "SetNotify",
"keys": list(new_keys)}]))
# DeathLink hooks
def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
@@ -618,8 +589,7 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
logger.info(f'Connecting to Archipelago server at {address}')
try:
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None,
ssl=get_ssl_context() if address.startswith("wss://") else None)
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
if ctx.ui is not None:
ctx.ui.update_address_bar(server_url.netloc)
ctx.server = Endpoint(socket)
@@ -634,7 +604,6 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
except websockets.InvalidMessage:
# probably encrypted
if address.startswith("ws://"):
# try wss
await server_loop(ctx, "ws" + address[1:])
else:
ctx.handle_connection_loss(f"Lost connection to the multiworld server due to InvalidMessage"
@@ -759,11 +728,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
if ctx.locations_scouted:
msgs.append({"cmd": "LocationScouts",
"locations": list(ctx.locations_scouted)})
if ctx.stored_data_notification_keys:
msgs.append({"cmd": "Get",
"keys": list(ctx.stored_data_notification_keys)})
msgs.append({"cmd": "SetNotify",
"keys": list(ctx.stored_data_notification_keys)})
if msgs:
await ctx.send_msgs(msgs)
if ctx.finished_game:
@@ -827,13 +791,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
# we can skip checking "DeathLink" in ctx.tags, as otherwise we wouldn't have been send this
if "DeathLink" in tags and ctx.last_death_link != args["data"]["time"]:
ctx.on_deathlink(args["data"])
elif cmd == "Retrieved":
ctx.stored_data.update(args["keys"])
elif cmd == "SetReply":
ctx.stored_data[args["key"]] = args["value"]
if args["key"].startswith("EnergyLink"):
if args["key"] == "EnergyLink":
ctx.current_energy_link_value = args["value"]
if ctx.ui:
ctx.ui.set_new_energy_link_value()
@@ -873,9 +832,10 @@ def get_base_parser(description: typing.Optional[str] = None):
return parser
def run_as_textclient():
if __name__ == '__main__':
# Text Mode to use !hint and such with games that have no text entry
class TextContext(CommonContext):
# Text Mode to use !hint and such with games that have no text entry
tags = {"AP", "TextOnly"}
game = "" # empty matches any game since 0.3.2
items_handling = 0b111 # receive all items for /received
@@ -890,11 +850,12 @@ def run_as_textclient():
def on_package(self, cmd: str, args: dict):
if cmd == "Connected":
self.game = self.slot_info[self.slot].game
async def disconnect(self, allow_autoreconnect: bool = False):
self.game = ""
await super().disconnect(allow_autoreconnect)
async def main(args):
ctx = TextContext(args.connect, args.password)
ctx.auth = args.name
@@ -907,6 +868,7 @@ def run_as_textclient():
await ctx.exit_event.wait()
await ctx.shutdown()
import colorama
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
@@ -926,7 +888,3 @@ def run_as_textclient():
asyncio.run(main(args))
colorama.deinit()
if __name__ == '__main__':
run_as_textclient()

View File

@@ -33,7 +33,7 @@ class FF1CommandProcessor(ClientCommandProcessor):
logger.info(f"NES Status: {self.ctx.nes_status}")
def _cmd_toggle_msgs(self):
"""Toggle displaying messages in EmuHawk"""
"""Toggle displaying messages in bizhawk"""
global DISPLAY_MSGS
DISPLAY_MSGS = not DISPLAY_MSGS
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")

View File

@@ -1,12 +1,553 @@
from __future__ import annotations
import os
import logging
import json
import string
import copy
import re
import subprocess
import sys
import time
import random
import typing
import ModuleUpdate
ModuleUpdate.update()
from worlds.factorio.Client import check_stdin, launch
import factorio_rcon
import colorama
import asyncio
from queue import Queue
import Utils
def check_stdin() -> None:
if Utils.is_windows and sys.stdin:
print("WARNING: Console input is not routed reliably on Windows, use the GUI instead.")
if __name__ == "__main__":
Utils.init_logging("FactorioClient", exception_logger="Client")
check_stdin()
launch()
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser
from MultiServer import mark_raw
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
from Utils import async_start
from worlds.factorio import Factorio
class FactorioCommandProcessor(ClientCommandProcessor):
ctx: FactorioContext
def _cmd_energy_link(self):
"""Print the status of the energy link."""
self.output(f"Energy Link: {self.ctx.energy_link_status}")
@mark_raw
def _cmd_factorio(self, text: str) -> bool:
"""Send the following command to the bound Factorio Server."""
if self.ctx.rcon_client:
# TODO: Print the command non-silently only for race seeds, or otherwise block anything but /factorio /save in race seeds.
self.ctx.print_to_game(f"/factorio {text}")
result = self.ctx.rcon_client.send_command(text)
if result:
self.output(result)
return True
return False
def _cmd_resync(self):
"""Manually trigger a resync."""
self.ctx.awaiting_bridge = True
def _cmd_toggle_send_filter(self):
"""Toggle filtering of item sends that get displayed in-game to only those that involve you."""
self.ctx.toggle_filter_item_sends()
def _cmd_toggle_chat(self):
"""Toggle sending of chat messages from players on the Factorio server to Archipelago."""
self.ctx.toggle_bridge_chat_out()
class FactorioContext(CommonContext):
command_processor = FactorioCommandProcessor
game = "Factorio"
items_handling = 0b111 # full remote
# updated by spinup server
mod_version: Utils.Version = Utils.Version(0, 0, 0)
def __init__(self, server_address, password):
super(FactorioContext, self).__init__(server_address, password)
self.send_index: int = 0
self.rcon_client = None
self.awaiting_bridge = False
self.write_data_path = None
self.death_link_tick: int = 0 # last send death link on Factorio layer
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
self.energy_link_increment = 0
self.last_deplete = 0
self.filter_item_sends: bool = False
self.multiplayer: bool = False # whether multiple different players have connected
self.bridge_chat_out: bool = True
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(FactorioContext, self).server_auth(password_requested)
if self.rcon_client:
await get_info(self, self.rcon_client) # retrieve current auth code
else:
raise Exception("Cannot connect to a server with unknown own identity, "
"bridge to Factorio first.")
await self.send_connect()
def on_print(self, args: dict):
super(FactorioContext, self).on_print(args)
if self.rcon_client:
if not args['text'].startswith(self.player_names[self.slot] + ":"):
self.print_to_game(args['text'])
def on_print_json(self, args: dict):
if self.rcon_client:
if (not self.filter_item_sends or not self.is_uninteresting_item_send(args)) \
and not self.is_echoed_chat(args):
text = self.factorio_json_text_parser(copy.deepcopy(args["data"]))
if not text.startswith(self.player_names[self.slot] + ":"): # TODO: Remove string heuristic in the future.
self.print_to_game(text)
super(FactorioContext, self).on_print_json(args)
@property
def savegame_name(self) -> str:
return f"AP_{self.seed_name}_{self.auth}_Save.zip"
def print_to_game(self, text):
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
f"{text}")
@property
def energy_link_status(self) -> str:
if not self.energy_link_increment:
return "Disabled"
elif self.current_energy_link_value is None:
return "Standby"
else:
return f"{Utils.format_SI_prefix(self.current_energy_link_value)}J"
def on_deathlink(self, data: dict):
if self.rcon_client:
self.rcon_client.send_command(f"/ap-deathlink {data['source']}")
super(FactorioContext, self).on_deathlink(data)
def on_package(self, cmd: str, args: dict):
if cmd in {"Connected", "RoomUpdate"}:
# catch up sync anything that is already cleared.
if "checked_locations" in args and args["checked_locations"]:
self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for
item_name in args["checked_locations"]})
if cmd == "Connected" and self.energy_link_increment:
async_start(self.send_msgs([{
"cmd": "SetNotify", "keys": ["EnergyLink"]
}]))
elif cmd == "SetReply":
if args["key"] == "EnergyLink":
if self.energy_link_increment and args.get("last_deplete", -1) == self.last_deplete:
# it's our deplete request
gained = int(args["original_value"] - args["value"])
gained_text = Utils.format_SI_prefix(gained) + "J"
if gained:
logger.debug(f"EnergyLink: Received {gained_text}. "
f"{Utils.format_SI_prefix(args['value'])}J remaining.")
self.rcon_client.send_command(f"/ap-energylink {gained}")
def on_user_say(self, text: str) -> typing.Optional[str]:
# Mirror chat sent from the UI to the Factorio server.
self.print_to_game(f"{self.player_names[self.slot]}: {text}")
return text
async def chat_from_factorio(self, user: str, message: str) -> None:
if not self.bridge_chat_out:
return
# Pass through commands
if message.startswith("!"):
await self.send_msgs([{"cmd": "Say", "text": message}])
return
# Omit messages that contain local coordinates
if "[gps=" in message:
return
prefix = f"({user}) " if self.multiplayer else ""
await self.send_msgs([{"cmd": "Say", "text": f"{prefix}{message}"}])
def toggle_filter_item_sends(self) -> None:
self.filter_item_sends = not self.filter_item_sends
if self.filter_item_sends:
announcement = "Item sends are now filtered."
else:
announcement = "Item sends are no longer filtered."
logger.info(announcement)
self.print_to_game(announcement)
def toggle_bridge_chat_out(self) -> None:
self.bridge_chat_out = not self.bridge_chat_out
if self.bridge_chat_out:
announcement = "Chat is now bridged to Archipelago."
else:
announcement = "Chat is no longer bridged to Archipelago."
logger.info(announcement)
self.print_to_game(announcement)
def run_gui(self):
from kvui import GameManager
class FactorioManager(GameManager):
logging_pairs = [
("Client", "Archipelago"),
("FactorioServer", "Factorio Server Log"),
("FactorioWatcher", "Bridge Data Log"),
]
base_title = "Archipelago Factorio Client"
self.ui = FactorioManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
async def game_watcher(ctx: FactorioContext):
bridge_logger = logging.getLogger("FactorioWatcher")
next_bridge = time.perf_counter() + 1
try:
while not ctx.exit_event.is_set():
# TODO: restore on-demand refresh
if ctx.rcon_client and time.perf_counter() > next_bridge:
next_bridge = time.perf_counter() + 1
ctx.awaiting_bridge = False
data = json.loads(ctx.rcon_client.send_command("/ap-sync"))
if not ctx.auth:
pass # auth failed, wait for new attempt
elif data["slot_name"] != ctx.auth:
bridge_logger.warning(f"Connected World is not the expected one {data['slot_name']} != {ctx.auth}")
elif data["seed_name"] != ctx.seed_name:
bridge_logger.warning(
f"Connected Multiworld is not the expected one {data['seed_name']} != {ctx.seed_name}")
else:
data = data["info"]
research_data = data["research_done"]
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
victory = data["victory"]
await ctx.update_death_link(data["death_link"])
ctx.multiplayer = data.get("multiplayer", False)
if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
if ctx.locations_checked != research_data:
bridge_logger.debug(
f"New researches done: "
f"{[ctx.location_names[rid] for rid in research_data - ctx.locations_checked]}")
ctx.locations_checked = research_data
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
death_link_tick = data.get("death_link_tick", 0)
if death_link_tick != ctx.death_link_tick:
ctx.death_link_tick = death_link_tick
if "DeathLink" in ctx.tags:
async_start(ctx.send_death())
if ctx.energy_link_increment:
in_world_bridges = data["energy_bridges"]
if in_world_bridges:
in_world_energy = data["energy"]
if in_world_energy < (ctx.energy_link_increment * in_world_bridges):
# attempt to refill
ctx.last_deplete = time.time()
async_start(ctx.send_msgs([{
"cmd": "Set", "key": "EnergyLink", "operations":
[{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges},
{"operation": "max", "value": 0}],
"last_deplete": ctx.last_deplete
}]))
# Above Capacity - (len(Bridges) * ENERGY_INCREMENT)
elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \
ctx.energy_link_increment*in_world_bridges:
value = ctx.energy_link_increment * in_world_bridges
async_start(ctx.send_msgs([{
"cmd": "Set", "key": "EnergyLink", "operations":
[{"operation": "add", "value": value}]
}]))
ctx.rcon_client.send_command(
f"/ap-energylink -{value}")
logger.debug(f"EnergyLink: Sent {Utils.format_SI_prefix(value)}J")
await asyncio.sleep(0.1)
except Exception as e:
logging.exception(e)
logging.error("Aborted Factorio Server Bridge")
def stream_factorio_output(pipe, queue, process):
pipe.reconfigure(errors="replace")
def queuer():
while process.poll() is None:
text = pipe.readline().strip()
if text:
queue.put_nowait(text)
from threading import Thread
thread = Thread(target=queuer, name="Factorio Output Queue", daemon=True)
thread.start()
return thread
async def factorio_server_watcher(ctx: FactorioContext):
savegame_name = os.path.abspath(ctx.savegame_name)
if not os.path.exists(savegame_name):
logger.info(f"Creating savegame {savegame_name}")
subprocess.run((
executable, "--create", savegame_name, "--preset", "archipelago"
))
factorio_process = subprocess.Popen((executable, "--start-server", ctx.savegame_name,
*(str(elem) for elem in server_args)),
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
stdin=subprocess.DEVNULL,
encoding="utf-8")
factorio_server_logger.info("Started Factorio Server")
factorio_queue = Queue()
stream_factorio_output(factorio_process.stdout, factorio_queue, factorio_process)
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
try:
while not ctx.exit_event.is_set():
if factorio_process.poll() is not None:
factorio_server_logger.info("Factorio server has exited.")
ctx.exit_event.set()
while not factorio_queue.empty():
msg = factorio_queue.get()
factorio_queue.task_done()
if not ctx.rcon_client and "Starting RCON interface at IP ADDR:" in msg:
ctx.rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
if not ctx.server:
logger.info("Established bridge to Factorio Server. "
"Ready to connect to Archipelago via /connect")
check_stdin()
if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg:
ctx.awaiting_bridge = True
factorio_server_logger.debug(msg)
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command energy-link$", msg):
factorio_server_logger.debug(msg)
ctx.print_to_game(f"Energy Link: {ctx.energy_link_status}")
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-send-filter$", msg):
factorio_server_logger.debug(msg)
ctx.toggle_filter_item_sends()
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-chat$", msg):
factorio_server_logger.debug(msg)
ctx.toggle_bridge_chat_out()
else:
factorio_server_logger.info(msg)
match = re.match(r"^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d \[CHAT\] ([^:]+): (.*)$", msg)
if match:
await ctx.chat_from_factorio(match.group(1), match.group(2))
if ctx.rcon_client:
commands = {}
while ctx.send_index < len(ctx.items_received):
transfer_item: NetworkItem = ctx.items_received[ctx.send_index]
item_id = transfer_item.item
player_name = ctx.player_names[transfer_item.player]
if item_id not in Factorio.item_id_to_name:
factorio_server_logger.error(f"Cannot send unknown item ID: {item_id}")
else:
item_name = Factorio.item_id_to_name[item_id]
factorio_server_logger.info(f"Sending {item_name} to Nauvis from {player_name}.")
commands[ctx.send_index] = f'/ap-get-technology {item_name}\t{ctx.send_index}\t{player_name}'
ctx.send_index += 1
if commands:
ctx.rcon_client.send_commands(commands)
await asyncio.sleep(0.1)
except Exception as e:
logging.exception(e)
logging.error("Aborted Factorio Server Bridge")
ctx.exit_event.set()
finally:
if factorio_process.poll() is not None:
if ctx.rcon_client:
ctx.rcon_client.close()
ctx.rcon_client = None
return
sent_quit = False
if ctx.rcon_client:
# Attempt clean quit through RCON.
try:
ctx.rcon_client.send_command("/quit")
except factorio_rcon.RCONNetworkError:
pass
else:
sent_quit = True
ctx.rcon_client.close()
ctx.rcon_client = None
if not sent_quit:
# Attempt clean quit using SIGTERM. (Note that on Windows this kills the process instead.)
factorio_process.terminate()
try:
factorio_process.wait(10)
except subprocess.TimeoutExpired:
factorio_process.kill()
async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient):
info = json.loads(rcon_client.send_command("/ap-rcon-info"))
ctx.auth = info["slot_name"]
ctx.seed_name = info["seed_name"]
# 0.2.0 addition, not present earlier
death_link = bool(info.get("death_link", False))
ctx.energy_link_increment = info.get("energy_link", 0)
logger.debug(f"Energy Link Increment: {ctx.energy_link_increment}")
if ctx.energy_link_increment and ctx.ui:
ctx.ui.enable_energy_link()
await ctx.update_death_link(death_link)
async def factorio_spinup_server(ctx: FactorioContext) -> bool:
savegame_name = os.path.abspath("Archipelago.zip")
if not os.path.exists(savegame_name):
logger.info(f"Creating savegame {savegame_name}")
subprocess.run((
executable, "--create", savegame_name
))
factorio_process = subprocess.Popen(
(executable, "--start-server", savegame_name, *(str(elem) for elem in server_args)),
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
stdin=subprocess.DEVNULL,
encoding="utf-8")
factorio_server_logger.info("Started Information Exchange Factorio Server")
factorio_queue = Queue()
stream_factorio_output(factorio_process.stdout, factorio_queue, factorio_process)
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
rcon_client = None
try:
while not ctx.auth:
while not factorio_queue.empty():
msg = factorio_queue.get()
factorio_server_logger.info(msg)
if "Loading mod AP-" in msg and msg.endswith("(data.lua)"):
parts = msg.split()
ctx.mod_version = Utils.Version(*(int(number) for number in parts[-2].split(".")))
elif "Write data path: " in msg:
ctx.write_data_path = Utils.get_text_between(msg, "Write data path: ", " [")
if "AppData" in ctx.write_data_path:
logger.warning("It appears your mods are loaded from Appdata, "
"this can lead to problems with multiple Factorio instances. "
"If this is the case, you will get a file locked error running Factorio.")
if not rcon_client and "Starting RCON interface at IP ADDR:" in msg:
rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
if ctx.mod_version == ctx.__class__.mod_version:
raise Exception("No Archipelago mod was loaded. Aborting.")
await get_info(ctx, rcon_client)
await asyncio.sleep(0.01)
except Exception as e:
logger.exception(e, extra={"compact_gui": True})
msg = "Aborted Factorio Server Bridge"
logger.error(msg)
ctx.gui_error(msg, e)
ctx.exit_event.set()
else:
logger.info(
f"Got World Information from AP Mod {tuple(ctx.mod_version)} for seed {ctx.seed_name} in slot {ctx.auth}")
return True
finally:
factorio_process.terminate()
factorio_process.wait(5)
return False
async def main(args):
ctx = FactorioContext(args.connect, args.password)
ctx.filter_item_sends = initial_filter_item_sends
ctx.bridge_chat_out = initial_bridge_chat_out
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
factorio_server_task = asyncio.create_task(factorio_spinup_server(ctx), name="FactorioSpinupServer")
successful_launch = await factorio_server_task
if successful_launch:
factorio_server_task = asyncio.create_task(factorio_server_watcher(ctx), name="FactorioServer")
progression_watcher = asyncio.create_task(
game_watcher(ctx), name="FactorioProgressionWatcher")
await ctx.exit_event.wait()
ctx.server_address = None
await progression_watcher
await factorio_server_task
await ctx.shutdown()
class FactorioJSONtoTextParser(JSONtoTextParser):
def _handle_color(self, node: JSONMessagePart):
colors = node["color"].split(";")
for color in colors:
if color in self.color_codes:
node["text"] = f"[color=#{self.color_codes[color]}]{node['text']}[/color]"
return self._handle_text(node)
return self._handle_text(node)
if __name__ == '__main__':
parser = get_base_parser(description="Optional arguments to FactorioClient follow. "
"Remaining arguments get passed into bound Factorio instance."
"Refer to Factorio --help for those.")
parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio')
parser.add_argument('--rcon-password', help='Password to authenticate with RCON.')
parser.add_argument('--server-settings', help='Factorio server settings configuration file.')
args, rest = parser.parse_known_args()
colorama.init()
rcon_port = args.rcon_port
rcon_password = args.rcon_password if args.rcon_password else ''.join(
random.choice(string.ascii_letters) for x in range(32))
factorio_server_logger = logging.getLogger("FactorioServer")
options = Utils.get_options()
executable = options["factorio_options"]["executable"]
server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None)
if server_settings:
server_settings = os.path.abspath(server_settings)
if not isinstance(options["factorio_options"]["filter_item_sends"], bool):
logging.warning(f"Warning: Option filter_item_sends should be a bool.")
initial_filter_item_sends = bool(options["factorio_options"]["filter_item_sends"])
if not isinstance(options["factorio_options"]["bridge_chat_out"], bool):
logging.warning(f"Warning: Option bridge_chat_out should be a bool.")
initial_bridge_chat_out = bool(options["factorio_options"]["bridge_chat_out"])
if not os.path.exists(os.path.dirname(executable)):
raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.")
if os.path.isdir(executable): # user entered a path to a directory, let's find the executable therein
executable = os.path.join(executable, "factorio")
if not os.path.isfile(executable):
if os.path.isfile(executable + ".exe"):
executable = executable + ".exe"
else:
raise FileNotFoundError(f"Path {executable} is not an executable file.")
if server_settings and os.path.isfile(server_settings):
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, "--server-settings", server_settings, *rest)
else:
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
asyncio.run(main(args))
colorama.deinit()

57
Fill.py
View File

@@ -39,9 +39,8 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
"""
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
cleanup_required = False
swapped_items: typing.Counter[typing.Tuple[int, str, bool]] = Counter()
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
reachable_items: typing.Dict[int, typing.Deque[Item]] = {}
for item in item_pool:
reachable_items.setdefault(item.player, deque()).append(item)
@@ -51,10 +50,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
items_to_place = [items.pop()
for items in reachable_items.values() if items]
for item in items_to_place:
for p, pool_item in enumerate(item_pool):
if pool_item is item:
item_pool.pop(p)
break
item_pool.remove(item)
maximum_exploration_state = sweep_from_pool(
base_state, item_pool + unplaced_items)
@@ -88,28 +84,25 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
else:
# we filled all reachable spots.
if swap:
# try swapping this item with previously placed items in a safe way then in an unsafe way
swap_attempts = ((i, location, unsafe)
for unsafe in (False, True)
for i, location in enumerate(placements))
for (i, location, unsafe) in swap_attempts:
# try swapping this item with previously placed items
for (i, location) in enumerate(placements):
placed_item = location.item
# Unplaceable items can sometimes be swapped infinitely. Limit the
# number of times we will swap an individual item to prevent this
swap_count = swapped_items[placed_item.player, placed_item.name, unsafe]
swap_count = swapped_items[placed_item.player,
placed_item.name]
if swap_count > 1:
continue
location.item = None
placed_item.location = None
swap_state = sweep_from_pool(base_state, [placed_item] if unsafe else [])
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
# to clean that up later, so there is a chance generation fails.
swap_state = sweep_from_pool(base_state, [placed_item])
# swap_state assumes we can collect placed item before item_to_place
if (not single_player_placement or location.player == item_to_place.player) \
and location.can_fill(swap_state, item_to_place, perform_access_check):
# Verify placing this item won't reduce available locations, which would be a useless swap.
# Verify that placing this item won't reduce available locations, which could happen with rules
# that want to not have both items. Left in until removal is proven useful.
prev_state = swap_state.copy()
prev_loc_count = len(
world.get_reachable_locations(prev_state))
@@ -124,15 +117,13 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
spot_to_fill = placements.pop(i)
swap_count += 1
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
swapped_items[placed_item.player,
placed_item.name] = swap_count
reachable_items[placed_item.player].appendleft(
placed_item)
item_pool.append(placed_item)
# cleanup at the end to hopefully get better errors
cleanup_required = True
break
# Item can't be placed here, restore original item
@@ -153,16 +144,6 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
if on_place:
on_place(spot_to_fill)
if cleanup_required:
# validate all placements and remove invalid ones
state = sweep_from_pool(base_state, [])
for placement in placements:
if world.accessibility[placement.item.player] != "minimal" and not placement.can_reach(state):
placement.item.location = None
unplaced_items.append(placement.item)
placement.item = None
locations.append(placement)
if allow_excluded:
# check if partial fill is the result of excluded locations, in which case retry
excluded_locations = [
@@ -753,6 +734,8 @@ def distribute_planned(world: MultiWorld) -> None:
else: # not reachable with swept state
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
block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
@@ -838,12 +821,12 @@ def distribute_planned(world: MultiWorld) -> None:
if "early_locations" in locations:
locations.remove("early_locations")
for target_player in worlds:
locations += early_locations[target_player]
for player in worlds:
locations += early_locations[player]
if "non_early_locations" in locations:
locations.remove("non_early_locations")
for target_player in worlds:
locations += non_early_locations[target_player]
for player in worlds:
locations += non_early_locations[player]
block['locations'] = locations
@@ -895,6 +878,10 @@ def distribute_planned(world: MultiWorld) -> None:
for item_name in items:
item = world.worlds[player].create_item(item_name)
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 location.item_rule(item):
if location.can_fill(world.state, item, False):

View File

@@ -7,52 +7,55 @@ import random
import string
import urllib.parse
import urllib.request
from collections import ChainMap, Counter
from typing import Any, Callable, Dict, Tuple, Union
from collections import Counter, ChainMap
from typing import Dict, Tuple, Callable, Any, Union
import ModuleUpdate
ModuleUpdate.update()
import copy
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.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 Main import main as ERmain
from BaseClasses import seeddigits, get_seed, PlandoOptions
import Options
from worlds.alttp.Text import TextTable
from worlds.AutoWorld import AutoWorldRegister
from worlds.generic import PlandoConnection
import copy
def mystery_argparse():
options = get_settings()
defaults = options.generator
options = get_options()
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.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')
parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player',
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.")
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('--spoiler', type=int, default=defaults.spoiler)
parser.add_argument('--outputpath', default=options.general_options.output_path,
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('--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
parser.add_argument('--race', action='store_true', default=defaults.race)
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
parser.add_argument('--race', action='store_true', default=defaults["race"])
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('--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)')
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"')
parser.add_argument("--skip_prog_balancing", action="store_true",
help="Skip progression balancing step during generation.")
args = parser.parse_args()
if not os.path.isabs(args.weights_file_path):
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
@@ -69,16 +72,12 @@ def get_seed_name(random_source) -> str:
def main(args=None, callback=ERmain):
if not args:
args, options = mystery_argparse()
else:
options = get_settings()
seed = get_seed(args.seed)
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
random.seed(seed)
seed_name = get_seed_name(random)
if args.race:
logging.info("Race mode enabled. Using non-deterministic random source.")
random.seed() # reset to time-based random source
weights_cache: Dict[str, Tuple[Any, ...]] = {}
@@ -86,16 +85,16 @@ def main(args=None, callback=ERmain):
try:
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
except Exception as e:
raise ValueError(f"File {args.weights_file_path} is invalid. Please fix your yaml.") from e
logging.info(f"Weights: {args.weights_file_path} >> "
f"{get_choice('description', weights_cache[args.weights_file_path][-1], 'No description specified')}")
raise ValueError(f"File {args.weights_file_path} is destroyed. Please fix your yaml.") from e
print(f"Weights: {args.weights_file_path} >> "
f"{get_choice('description', weights_cache[args.weights_file_path][-1], 'No description specified')}")
if args.meta_file_path and os.path.exists(args.meta_file_path):
try:
meta_weights = read_weights_yamls(args.meta_file_path)[-1]
except Exception as e:
raise ValueError(f"File {args.meta_file_path} is invalid. Please fix your yaml.") from e
logging.info(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
print(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
del(meta_weights["meta_description"])
except Exception as e:
@@ -114,35 +113,35 @@ def main(args=None, callback=ERmain):
try:
weights_cache[fname] = read_weights_yamls(path)
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:
weights_cache = {key: value for key, value in sorted(weights_cache.items())}
for filename, yaml_data in weights_cache.items():
if filename not in {args.meta_file_path, args.weights_file_path}:
for yaml in yaml_data:
logging.info(f"P{player_id} Weights: {filename} >> "
f"{get_choice('description', yaml, 'No description specified')}")
print(f"P{player_id} Weights: {filename} >> "
f"{get_choice('description', yaml, 'No description specified')}")
player_files[player_id] = filename
player_id += 1
args.multi = max(player_id - 1, args.multi)
logging.info(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, "
f"{seed_name} Seed {seed} with plando: {args.plando}")
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: "
f"{args.plando}")
if not weights_cache:
raise Exception(f"No weights found. "
f"Provide a general weights file ({args.weights_file_path}) or individual player files. "
raise Exception(f"No weights found. Provide a general weights file ({args.weights_file_path}) or individual player files. "
f"A mix is also permitted.")
erargs = parse_arguments(['--multi', str(args.multi)])
erargs.seed = seed
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.race = args.race
erargs.outputname = seed_name
erargs.outputpath = args.outputpath
erargs.skip_prog_balancing = args.skip_prog_balancing
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
@@ -195,7 +194,7 @@ def main(args=None, callback=ERmain):
player += 1
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:
raise RuntimeError(f'No weights specified for player {player}')
@@ -374,7 +373,7 @@ def roll_linked_options(weights: dict) -> dict:
else:
logging.debug(f"linked option {option_set['name']} skipped.")
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
return weights
@@ -404,7 +403,7 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"])
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
return weights
@@ -450,11 +449,6 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
raise Exception(f"Option {option_key} has to be in a game's section, not on its own.")
ret.game = get_choice("game", weights)
if ret.game not in AutoWorldRegister.world_types:
picks = Utils.get_fuzzy_results(ret.game, AutoWorldRegister.world_types, limit=1)[0]
raise Exception(f"No world found to handle game {ret.game}. Did you mean '{picks[0]}' ({picks[1]}% sure)? "
f"Check your spelling or installation of that world.")
if ret.game not in weights:
raise Exception(f"No game options for selected game \"{ret.game}\" found.")
@@ -469,29 +463,32 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
for option_key, option in Options.common_options.items():
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
for option_key, option in world_type.option_definitions.items():
handle_option(ret, game_weights, option_key, option, plando_options)
for option_key, option in Options.per_game_common_options.items():
# skip setting this option if already set from common_options, defaulting to root option
if option_key not in world_type.option_definitions and \
(option_key not in Options.common_options or option_key in game_weights):
if ret.game in AutoWorldRegister.world_types:
for option_key, option in world_type.option_definitions.items():
handle_option(ret, game_weights, option_key, option, plando_options)
if PlandoOptions.items in plando_options:
ret.plando_items = game_weights.get("plando_items", [])
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
# bad hardcoded behavior to make this work for now
ret.plando_connections = []
if PlandoOptions.connections in plando_options:
options = game_weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)):
ret.plando_connections.append(PlandoConnection(
get_choice("entrance", placement),
get_choice("exit", placement),
get_choice("direction", placement)
))
elif ret.game == "A Link to the Past":
roll_alttp_settings(ret, game_weights, plando_options)
for option_key, option in Options.per_game_common_options.items():
# skip setting this option if already set from common_options, defaulting to root option
if option_key not in world_type.option_definitions and \
(option_key not in Options.common_options or option_key in game_weights):
handle_option(ret, game_weights, option_key, option, plando_options)
if PlandoOptions.items in plando_options:
ret.plando_items = game_weights.get("plando_items", [])
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
# bad hardcoded behavior to make this work for now
ret.plando_connections = []
if PlandoOptions.connections in plando_options:
options = game_weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)):
ret.plando_connections.append(PlandoConnection(
get_choice("entrance", placement),
get_choice("exit", placement),
get_choice("direction", placement)
))
elif ret.game == "A Link to the Past":
roll_alttp_settings(ret, game_weights, plando_options)
else:
raise Exception(f"Unsupported game {ret.game}")
return ret

View File

@@ -11,7 +11,6 @@ Scroll down to components= to add components to the launcher as well as setup.py
import argparse
import itertools
import logging
import multiprocessing
import shlex
import subprocess
@@ -22,7 +21,6 @@ from shutil import which
from typing import Sequence, Union, Optional
import Utils
import settings
from worlds.LauncherComponents import Component, components, Type, SuffixIdentifier, icon_paths
if __name__ == "__main__":
@@ -34,8 +32,7 @@ from Utils import is_frozen, user_path, local_path, init_logging, open_filename,
def open_host_yaml():
file = settings.get_settings().filename
assert file, "host.yaml missing"
file = user_path('host.yaml')
if is_linux:
exe = which('sensible-editor') or which('gedit') or \
which('xdg-open') or which('gnome-open') or which('kde-open')
@@ -50,22 +47,17 @@ def open_host_yaml():
def open_patch():
suffixes = []
for c in components:
if c.type == Type.CLIENT and \
isinstance(c.file_identifier, SuffixIdentifier) and \
(c.script_name is None or isfile(get_exe(c)[-1])):
suffixes += c.file_identifier.suffixes
if isfile(get_exe(c)[-1]):
suffixes += c.file_identifier.suffixes if c.type == Type.CLIENT and \
isinstance(c.file_identifier, SuffixIdentifier) else []
try:
filename = open_filename("Select patch", (("Patches", suffixes),))
filename = open_filename('Select patch', (('Patches', suffixes),))
except Exception as e:
messagebox("Error", str(e), error=True)
messagebox('Error', str(e), error=True)
else:
file, component = identify(filename)
file, _, component = identify(filename)
if file and component:
exe = get_exe(component)
if exe is None or not isfile(exe[-1]):
exe = get_exe("Launcher")
launch([*exe, file], component.cli)
launch([*get_exe(component), file], component.cli)
def generate_yamls():
@@ -91,11 +83,6 @@ def open_folder(folder_path):
webbrowser.open(folder_path)
def update_settings():
from settings import get_settings
get_settings().save()
components.extend([
# Functions
Component("Open host.yaml", func=open_host_yaml),
@@ -109,38 +96,36 @@ components.extend([
def identify(path: Union[None, str]):
if path is None:
return None, None
return None, None, None
for component in components:
if component.handles_file(path):
return path, component
elif path == component.display_name or path == component.script_name:
return None, component
return None, None
return path, component.script_name, component
return (None, None, None) if '/' in path or '\\' in path else (None, path, None)
def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
if isinstance(component, str):
name = component
component = None
if name.startswith("Archipelago"):
if name.startswith('Archipelago'):
name = name[11:]
if name.endswith(".exe"):
if name.endswith('.exe'):
name = name[:-4]
if name.endswith(".py"):
if name.endswith('.py'):
name = name[:-3]
if not name:
return None
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
break
if not component:
return None
if is_frozen():
suffix = ".exe" if is_windows else ""
return [local_path(f"{component.frozen_name}{suffix}")] if component.frozen_name else None
suffix = '.exe' if is_windows else ''
return [local_path(f'{component.frozen_name}{suffix}')]
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):
@@ -170,10 +155,10 @@ def run_gui():
container: ContainerLayout
grid: GridLayout
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
_tools = {c.display_name: c for c in components if c.type == Type.TOOL and isfile(get_exe(c)[-1])}
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT and isfile(get_exe(c)[-1])}
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER and isfile(get_exe(c)[-1])}
_funcs = {c.display_name: c for c in components if c.type == Type.FUNC}
def __init__(self, ctx=None):
self.title = self.base_title
@@ -214,7 +199,7 @@ def run_gui():
button_layout.add_widget(button)
for (tool, client) in itertools.zip_longest(itertools.chain(
self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()):
self._tools.items(), self._funcs.items(), self._adjusters.items()), self._clients.items()):
# column 1
if tool:
build_button(tool[1])
@@ -230,29 +215,14 @@ def run_gui():
@staticmethod
def component_action(button):
if button.component.func:
if button.component.type == Type.FUNC:
button.component.func()
else:
launch(get_exe(button.component), button.component.cli)
def _stop(self, *largs):
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
# Closing the window explicitly cleans it up.
self.root_window.close()
super()._stop(*largs)
Launcher().run()
def run_component(component: Component, *args):
if component.func:
component.func(*args)
elif component.script_name:
subprocess.run([*get_exe(component.script_name), *args])
else:
logging.warning(f"Component {component} does not appear to be executable.")
def main(args: Optional[Union[argparse.Namespace, dict]] = None):
if isinstance(args, argparse.Namespace):
args = {k: v for k, v in args._get_kwargs()}
@@ -260,40 +230,25 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
args = {}
if "Patch|Game|Component" in args:
file, component = identify(args["Patch|Game|Component"])
file, component, _ = identify(args["Patch|Game|Component"])
if file:
args['file'] = file
if component:
args['component'] = component
if not component:
logging.warning(f"Could not identify Component responsible for {args['Patch|Game|Component']}")
if args["update_settings"]:
update_settings()
if 'file' in args:
run_component(args["component"], args["file"], *args["args"])
subprocess.run([*get_exe(args['component']), args['file'], *args['args']])
elif 'component' in args:
run_component(args["component"], *args["args"])
elif not args["update_settings"]:
subprocess.run([*get_exe(args['component']), *args['args']])
else:
run_gui()
if __name__ == '__main__':
init_logging('Launcher')
Utils.freeze_support()
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
multiprocessing.freeze_support()
parser = argparse.ArgumentParser(description='Archipelago Launcher')
run_group = parser.add_argument_group("Run")
run_group.add_argument("--update_settings", action="store_true",
help="Update host.yaml and exit.")
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.")
parser.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.")
parser.add_argument('args', nargs="*", help="Arguments to pass to component.")
main(parser.parse_args())
from worlds.LauncherComponents import processes
for process in processes:
# we await all child processes to close before we tear down the process host
# this makes it feel like each one is its own program, as the Launcher is closed now
process.join()

View File

@@ -9,18 +9,15 @@ if __name__ == "__main__":
import asyncio
import base64
import binascii
import colorama
import io
import os
import re
import logging
import select
import shlex
import socket
import struct
import sys
import subprocess
import time
import typing
import urllib
import colorama
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
@@ -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.Tracker import LocationTracker, MagpieBridge
class GameboyException(Exception):
pass
@@ -95,7 +91,7 @@ class LAClientConstants:
# wLinkSendShopTarget = 0xDDFF
wRecvIndex = 0xDDFD # Two bytes
wRecvIndex = 0xDDFE # 0xDB58
wCheckAddress = 0xC0FF - 0x4
WRamCheckSize = 0x4
WRamSafetyValue = bytearray([0]*WRamCheckSize)
@@ -119,17 +115,17 @@ class RAGameboy():
assert (self.socket)
self.socket.setblocking(False)
async def send_command(self, command, timeout=1.0):
self.send(f'{command}\n')
response_str = await self.async_recv()
self.check_command_response(command, response_str)
def get_retroarch_version(self):
self.send(b'VERSION\n')
select.select([self.socket], [], [])
response_str, addr = self.socket.recvfrom(16)
return response_str.rstrip()
async def get_retroarch_version(self):
return await self.send_command("VERSION")
async def get_retroarch_status(self):
return await self.send_command("GET_STATUS")
def get_retroarch_status(self, timeout):
self.send(b'GET_STATUS\n')
select.select([self.socket], [], [], timeout)
response_str, addr = self.socket.recvfrom(1000, )
return response_str.rstrip()
def set_cache_limits(self, cache_start, cache_size):
self.cache_start = cache_start
@@ -145,8 +141,8 @@ class RAGameboy():
response, _ = self.socket.recvfrom(4096)
return response
async def async_recv(self, timeout=1.0):
response = await asyncio.wait_for(asyncio.get_event_loop().sock_recv(self.socket, 4096), timeout)
async def async_recv(self):
response = await asyncio.get_event_loop().sock_recv(self.socket, 4096)
return response
async def check_safe_gameplay(self, throw=True):
@@ -173,8 +169,6 @@ class RAGameboy():
raise InvalidEmulatorStateError()
return False
if not await check_wram():
if throw:
raise InvalidEmulatorStateError()
return False
return True
@@ -233,30 +227,20 @@ class RAGameboy():
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):
command = "READ_CORE_MEMORY"
self.send(f'{command} {hex(address)} {size}\n')
response = self.recv()
self.check_command_response(command, response)
splits = response.decode().split(" ", 2)
assert (splits[0] == command)
# 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()
# TODO: check response address, check hex behavior between RA and BH
return bytearray.fromhex(splits[2])
async def async_read_memory(self, address, size=1):
@@ -264,21 +248,14 @@ class RAGameboy():
self.send(f'{command} {hex(address)} {size}\n')
response = await self.async_recv()
self.check_command_response(command, response)
response = response[:-1]
splits = response.decode().split(" ", 2)
try:
response_addr = int(splits[1], 16)
except ValueError:
raise BadRetroArchResponse()
if response_addr != address:
raise BadRetroArchResponse()
assert (splits[0] == command)
# Ignore the address for now
ret = bytearray.fromhex(splits[2])
if len(ret) > size:
raise BadRetroArchResponse()
return ret
# TODO: transform to bytes
return bytearray.fromhex(splits[2])
def write_memory(self, address, bytes):
command = "WRITE_CORE_MEMORY"
@@ -286,7 +263,7 @@ class RAGameboy():
self.send(f'{command} {hex(address)} {" ".join(hex(b) for b in bytes)}')
select.select([self.socket], [], [])
response, _ = self.socket.recvfrom(4096)
self.check_command_response(command, response)
splits = response.decode().split(" ", 3)
assert (splits[0] == command)
@@ -304,9 +281,6 @@ class LinksAwakeningClient():
pending_deathlink = False
deathlink_debounce = True
recvd_checks = {}
retroarch_address = None
retroarch_port = None
gameboy = None
def msg(self, m):
logger.info(m)
@@ -314,48 +288,50 @@ class LinksAwakeningClient():
self.gameboy.send(s)
def __init__(self, retroarch_address="127.0.0.1", retroarch_port=55355):
self.retroarch_address = retroarch_address
self.retroarch_port = retroarch_port
pass
self.gameboy = RAGameboy(retroarch_address, retroarch_port)
stop_bizhawk_spam = False
async def wait_for_retroarch_connection(self):
if not self.stop_bizhawk_spam:
logger.info("Waiting on connection to Retroarch...")
self.stop_bizhawk_spam = True
self.gameboy = RAGameboy(self.retroarch_address, self.retroarch_port)
logger.info("Waiting on connection to Retroarch...")
while True:
try:
version = await self.gameboy.get_retroarch_version()
version = self.gameboy.get_retroarch_version()
NO_CONTENT = b"GET_STATUS CONTENTLESS"
status = NO_CONTENT
core_type = None
GAME_BOY = b"game_boy"
while status == NO_CONTENT or core_type != GAME_BOY:
status = await self.gameboy.get_retroarch_status()
if status.count(b" ") < 2:
await asyncio.sleep(1.0)
continue
GET_STATUS, PLAYING, info = status.split(b" ", 2)
if status.count(b",") < 2:
await asyncio.sleep(1.0)
continue
core_type, rom_name, self.game_crc = info.split(b",", 2)
if core_type != GAME_BOY:
logger.info(
f"Core type should be '{GAME_BOY}', found {core_type} instead - wrong type of ROM?")
await asyncio.sleep(1.0)
continue
self.stop_bizhawk_spam = False
logger.info(f"Connected to Retroarch {version.decode('ascii')} running {rom_name.decode('ascii')}")
try:
status = self.gameboy.get_retroarch_status(0.1)
if status.count(b" ") < 2:
await asyncio.sleep(1.0)
continue
GET_STATUS, PLAYING, info = status.split(b" ", 2)
if status.count(b",") < 2:
await asyncio.sleep(1.0)
continue
core_type, rom_name, self.game_crc = info.split(b",", 2)
if core_type != GAME_BOY:
logger.info(
f"Core type should be '{GAME_BOY}', found {core_type} instead - wrong type of ROM?")
await asyncio.sleep(1.0)
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
except (BlockingIOError, TimeoutError, ConnectionResetError):
except ConnectionResetError:
await asyncio.sleep(1.0)
pass
async def reset_auth(self):
auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode()
def reset_auth(self):
auth = binascii.hexlify(self.gameboy.read_memory(0x0134, 12)).decode()
if self.auth:
assert (auth == self.auth)
self.auth = auth
async def wait_and_init_tracker(self):
@@ -389,16 +365,14 @@ class LinksAwakeningClient():
item_id, from_player])
status |= 1
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, [next_index])
should_reset_auth = False
async def wait_for_game_ready(self):
logger.info("Waiting on game to be in valid state...")
while not await self.gameboy.check_safe_gameplay(throw=False):
if self.should_reset_auth:
self.should_reset_auth = False
raise GameboyException("Resetting due to wrong archipelago server")
logger.info("Game connection ready!")
pass
logger.info("Ready!")
last_index = 0
async def is_victory(self):
return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1
@@ -408,6 +382,11 @@ class LinksAwakeningClient():
await self.item_tracker.readItems()
await self.gps_tracker.read_location()
next_index = self.gameboy.read_memory(LAClientConstants.wRecvIndex)[0]
if next_index != self.last_index:
self.last_index = next_index
# logger.info(f"Got new index {next_index}")
current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth]
if self.deathlink_debounce and current_health != 0:
self.deathlink_debounce = False
@@ -425,7 +404,7 @@ class LinksAwakeningClient():
if await self.is_victory():
await win_cb()
recv_index = struct.unpack(">H", await self.gameboy.async_read_memory(LAClientConstants.wRecvIndex, 2))[0]
recv_index = (await self.gameboy.async_read_memory_safe(LAClientConstants.wRecvIndex))[0]
# Play back one at a time
if recv_index in self.recvd_checks:
@@ -459,16 +438,12 @@ class LinksAwakeningContext(CommonContext):
found_checks = []
last_resend = time.time()
magpie_enabled = False
magpie = None
magpie = MagpieBridge()
magpie_task = None
won = False
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None:
self.client = LinksAwakeningClient()
if magpie:
self.magpie_enabled = True
self.magpie = MagpieBridge()
super().__init__(server_address, password)
def run_gui(self) -> None:
@@ -487,17 +462,16 @@ class LinksAwakeningContext(CommonContext):
def build(self):
b = super().build()
if self.ctx.magpie_enabled:
button = Button(text="", size=(30, 30), size_hint_x=None,
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
image = Image(size=(16, 16), texture=magpie_logo())
button.add_widget(image)
button = Button(text="", size=(30, 30), size_hint_x=None,
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
image = Image(size=(16, 16), texture=magpie_logo())
button.add_widget(image)
def set_center(_, center):
image.center = center
button.bind(center=set_center)
def set_center(_, center):
image.center = center
button.bind(center=set_center)
self.connect_layout.add_widget(button)
self.connect_layout.add_widget(button)
return b
self.ui = LADXManager(self)
@@ -507,15 +481,6 @@ class LinksAwakeningContext(CommonContext):
message = [{"cmd": 'LocationChecks', "locations": self.found_checks}]
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
async def send_deathlink(self):
if self.ENABLE_DEATHLINK:
@@ -541,23 +506,13 @@ class LinksAwakeningContext(CommonContext):
def new_checks(self, item_ids, ladxr_ids):
self.found_checks += item_ids
create_task_log_exception(self.send_checks())
if self.magpie_enabled:
create_task_log_exception(self.magpie.send_new_checks(ladxr_ids))
create_task_log_exception(self.magpie.send_new_checks(ladxr_ids))
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
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
await self.get_username()
await self.send_connect()
def on_package(self, cmd: str, args: dict):
@@ -565,13 +520,9 @@ class LinksAwakeningContext(CommonContext):
self.game = self.slot_info[self.slot].game
# TODO - use watcher_event
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
async def sync(self):
sync_msg = [{'cmd': 'Sync'}]
await self.send_msgs(sync_msg)
item_id_lookup = get_locations_to_id()
async def run_game_loop(self):
@@ -586,33 +537,18 @@ class LinksAwakeningContext(CommonContext):
async def deathlink():
await self.send_deathlink()
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
await asyncio.sleep(0)
while True:
try:
# 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()
# 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.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()
self.client.reset_auth()
await self.client.wait_and_init_tracker()
while True:
@@ -622,62 +558,39 @@ class LinksAwakeningContext(CommonContext):
if self.last_resend + 5.0 < now:
self.last_resend = now
await self.send_checks()
if self.magpie_enabled:
try:
self.magpie.set_checks(self.client.tracker.all_checks)
await self.magpie.set_item_tracker(self.client.item_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)
self.magpie.set_checks(self.client.tracker.all_checks)
await self.magpie.set_item_tracker(self.client.item_tracker)
await self.magpie.send_gps(self.client.gps_tracker)
def run_game(romfile: str) -> None:
auto_start = typing.cast(typing.Union[bool, str],
Utils.get_options()["ladx_options"].get("rom_start", True))
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__))
except GameboyException:
time.sleep(1.0)
pass
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():
parser = get_base_parser(description="Link's Awakening Client.")
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('diff_file', default="", type=str, nargs="?",
help='Path to a .apladx Archipelago Binary Patch file')
args = parser.parse_args()
logger.info(args)
if args.diff_file:
import Patch
logger.info("patch file was supplied - creating rom...")
meta, rom_file = Patch.create_rom_file(args.diff_file)
if "server" in meta and not args.connect:
args.connect = meta["server"]
if "server" in meta:
args.url = meta["server"]
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)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
@@ -687,10 +600,6 @@ async def main():
ctx.run_gui()
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.shutdown()

View File

@@ -25,7 +25,7 @@ ModuleUpdate.update()
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, \
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"
@@ -43,49 +43,8 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
def _get_help_string(self, action):
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 main():
parser = argparse.ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
parser.add_argument('rom', nargs="?", default='AP_LttP.sfc', help='Path to an ALttP rom to adjust.')
@@ -93,8 +52,6 @@ def get_argparser() -> argparse.ArgumentParser:
help='Path to an ALttP Japan(1.0) rom to use as a base.')
parser.add_argument('--loglevel', default='info', const='info', nargs='?',
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='?',
choices=['normal', 'instant', 'double', 'triple', 'quadruple', 'half'],
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('--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('--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='?',
choices=['normal', 'hide_goal', 'hide_required', 'hide_both'],
help='''\
@@ -128,6 +85,9 @@ def get_argparser() -> argparse.ArgumentParser:
parser.add_argument('--ow_palettes', default='default',
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
'sick'])
# parser.add_argument('--link_palettes', default='default',
# choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
# 'sick'])
parser.add_argument('--shield_palettes', default='default',
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
'sick'])
@@ -147,23 +107,16 @@ def get_argparser() -> argparse.ArgumentParser:
Alternatively, can be a ALttP Rom patched with a Link
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='''\
Path to a sound effect to replace Link's "oof" sound.
Needs to be in a .brr format and have a length of no
more than 2673 bytes, created from a 16-bit signed PCM
.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.')
return parser
def main():
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
loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[
args.loglevel]
@@ -240,7 +193,7 @@ def adjustGUI():
from tkinter import Tk, LEFT, BOTTOM, TOP, \
StringVar, Frame, Label, X, Entry, Button, filedialog, messagebox, ttk
from argparse import Namespace
from Utils import __version__ as MWVersion
from Main import __version__ as MWVersion
adjustWindow = Tk()
adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion)
set_icon(adjustWindow)
@@ -575,6 +528,9 @@ class AttachTooltip(object):
def get_rom_frame(parent=None):
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)
baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
@@ -602,8 +558,33 @@ def get_rom_frame(parent=None):
return romFrame, romVar
def get_rom_options_frame(parent=None):
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.columnconfigure(0, weight=1)

View File

@@ -1,376 +0,0 @@
import asyncio
import hashlib
import json
import os
import multiprocessing
import subprocess
import zipfile
from asyncio import StreamReader, StreamWriter
import bsdiff4
from CommonClient import CommonContext, server_loop, gui_enabled, \
ClientCommandProcessor, logger, get_base_parser
import Utils
from NetUtils import ClientStatus
from worlds.mmbn3.Items import items_by_id
from worlds.mmbn3.Rom import get_base_rom_path
from worlds.mmbn3.Locations import all_locations, scoutable_locations
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_mmbn3.lua"
CONNECTION_REFUSED_STATUS = \
"Connection refused. Please start your emulator and make sure connector_mmbn3.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_mmbn3.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
CONNECTION_INCORRECT_ROM = "Supplied Base Rom does not match US GBA Blue Version. Please provide the correct ROM version"
script_version: int = 2
debugEnabled = False
locations_checked = []
items_sent = []
itemIndex = 1
CHECKSUM_BLUE = "6fe31df0144759b34ad666badaacc442"
class MMBN3CommandProcessor(ClientCommandProcessor):
def __init__(self, ctx):
super().__init__(ctx)
def _cmd_gba(self):
"""Check GBA Connection State"""
if isinstance(self.ctx, MMBN3Context):
logger.info(f"GBA Status: {self.ctx.gba_status}")
def _cmd_debug(self):
"""Toggle the Debug Text overlay in ROM"""
global debugEnabled
debugEnabled = not debugEnabled
logger.info("Debug Overlay Enabled" if debugEnabled else "Debug Overlay Disabled")
class MMBN3Context(CommonContext):
command_processor = MMBN3CommandProcessor
game = "MegaMan Battle Network 3"
items_handling = 0b001 # full local
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.gba_streams: (StreamReader, StreamWriter) = None
self.gba_sync_task = None
self.gba_status = CONNECTION_INITIAL_STATUS
self.awaiting_rom = False
self.location_table = {}
self.version_warning = False
self.auth_name = None
self.slot_data = dict()
self.patching_error = False
self.sent_hints = []
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(MMBN3Context, self).server_auth(password_requested)
if self.auth_name is None:
self.awaiting_rom = True
logger.info("No ROM detected, awaiting conection to Bizhawk to authenticate to the multiworld server")
return
logger.info("Attempting to decode from ROM... ")
self.awaiting_rom = False
self.auth = self.auth_name.decode("utf8").replace('\x00', '')
logger.info("Connecting as "+self.auth)
await self.send_connect(name=self.auth)
def run_gui(self):
from kvui import GameManager
class MMBN3Manager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago MegaMan Battle Network 3 Client"
self.ui = MMBN3Manager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
self.slot_data = args.get("slot_data", {})
print(self.slot_data)
class ItemInfo:
id = 0x00
sender = ""
type = ""
count = 1
itemName = "Unknown"
itemID = 0x00 # Item ID, Chip ID, etc.
subItemID = 0x00 # Code for chips, color for programs
itemIndex = 1
def __init__(self, id, sender, type):
self.id = id
self.sender = sender
self.type = type
def get_json(self):
json_data = {
"id": self.id,
"sender": self.sender,
"type": self.type,
"itemName": self.itemName,
"itemID": self.itemID,
"subItemID": self.subItemID,
"count": self.count,
"itemIndex": self.itemIndex
}
return json_data
def get_payload(ctx: MMBN3Context):
global debugEnabled
items_sent = []
for i, item in enumerate(ctx.items_received):
item_data = items_by_id[item.item]
new_item = ItemInfo(i, ctx.player_names[item.player], item_data.type)
new_item.itemIndex = i+1
new_item.itemName = item_data.itemName
new_item.type = item_data.type
new_item.itemID = item_data.itemID
new_item.subItemID = item_data.subItemID
new_item.count = item_data.count
items_sent.append(new_item)
return json.dumps({
"items": [item.get_json() for item in items_sent],
"debug": debugEnabled
})
async def parse_payload(payload: dict, ctx: MMBN3Context, force: bool):
# Game completion handling
if payload["gameComplete"] and not ctx.finished_game:
await ctx.send_msgs([{
"cmd": "StatusUpdate",
"status": ClientStatus.CLIENT_GOAL
}])
ctx.finished_game = True
# Locations handling
if ctx.location_table != payload["locations"]:
ctx.location_table = payload["locations"]
locs = [loc.id for loc in all_locations
if check_location_packet(loc, ctx.location_table)]
await ctx.send_msgs([{
"cmd": "LocationChecks",
"locations": locs
}])
# If trade hinting is enabled, send scout checks
if ctx.slot_data.get("trade_quest_hinting", 0) == 2:
trade_bits = [loc.id for loc in scoutable_locations
if check_location_scouted(loc, payload["locations"])]
scouted_locs = [loc for loc in trade_bits if loc not in ctx.sent_hints]
if len(scouted_locs) > 0:
ctx.sent_hints.extend(scouted_locs)
await ctx.send_msgs([{
"cmd": "LocationScouts",
"locations": scouted_locs,
"create_as_hint": 2
}])
def check_location_packet(location, memory):
if len(memory) == 0:
return False
# Our keys have to be strings to come through the JSON lua plugin so we have to turn our memory address into a string as well
location_key = hex(location.flag_byte)[2:]
byte = memory.get(location_key)
if byte is not None:
return byte & location.flag_mask
def check_location_scouted(location, memory):
if len(memory) == 0:
return False
location_key = hex(location.hint_flag)[2:]
byte = memory.get(location_key)
if byte is not None:
return byte & location.hint_flag_mask
async def gba_sync_task(ctx: MMBN3Context):
logger.info("Starting GBA connector. Use /gba for status information.")
if ctx.patching_error:
logger.error('Unable to Patch ROM. No ROM provided or ROM does not match US GBA Blue Version.')
while not ctx.exit_event.is_set():
error_status = None
if ctx.gba_streams:
(reader, writer) = ctx.gba_streams
msg = get_payload(ctx).encode()
writer.write(msg)
writer.write(b'\n')
try:
await asyncio.wait_for(writer.drain(), timeout=1.5)
try:
# Data will return a dict with up to four fields
# 1. str: player name (always)
# 2. int: script version (always)
# 3. dict[str, byte]: value of location's memory byte
# 4. bool: whether the game currently registers as complete
data = await asyncio.wait_for(reader.readline(), timeout=10)
data_decoded = json.loads(data.decode())
reported_version = data_decoded.get("scriptVersion", 0)
if reported_version >= script_version:
if ctx.game is not None and "locations" in data_decoded:
# Not just a keep alive ping, parse
asyncio.create_task((parse_payload(data_decoded, ctx, False)))
if not ctx.auth:
ctx.auth_name = bytes(data_decoded["playerName"])
if ctx.awaiting_rom:
logger.info("Awaiting data from ROM...")
await ctx.server_auth(False)
else:
if not ctx.version_warning:
logger.warning(f"Your Lua script is version {reported_version}, expected {script_version}."
"Please update to the latest version."
"Your connection to the Archipelago server will not be accepted.")
ctx.version_warning = True
except asyncio.TimeoutError:
logger.debug("Read Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.gba_streams = None
except ConnectionResetError:
logger.debug("Read failed due to Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.gba_streams = None
except TimeoutError:
logger.debug("Connection Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.gba_streams = None
except ConnectionResetError:
logger.debug("Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.gba_streams = None
if ctx.gba_status == CONNECTION_TENTATIVE_STATUS:
if not error_status:
logger.info("Successfully Connected to GBA")
ctx.gba_status = CONNECTION_CONNECTED_STATUS
else:
ctx.gba_status = f"Was tentatively connected but error occurred: {error_status}"
elif error_status:
ctx.gba_status = error_status
logger.info("Lost connection to GBA and attempting to reconnect. Use /gba for status updates")
else:
try:
logger.debug("Attempting to connect to GBA")
ctx.gba_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 28922), timeout=10)
ctx.gba_status = CONNECTION_TENTATIVE_STATUS
except TimeoutError:
logger.debug("Connection Timed Out, Trying Again")
ctx.gba_status = CONNECTION_TIMING_OUT_STATUS
continue
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.gba_status = CONNECTION_REFUSED_STATUS
continue
async def run_game(romfile):
options = Utils.get_options().get("mmbn3_options", None)
if options is None:
auto_start = True
else:
auto_start = options.get("rom_start", True)
if auto_start:
import webbrowser
webbrowser.open(romfile)
elif os.path.isfile(auto_start):
subprocess.Popen([auto_start, romfile],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
async def patch_and_run_game(apmmbn3_file):
base_name = os.path.splitext(apmmbn3_file)[0]
with zipfile.ZipFile(apmmbn3_file, 'r') as patch_archive:
try:
with patch_archive.open("delta.bsdiff4", 'r') as stream:
patch_data = stream.read()
except KeyError:
raise FileNotFoundError("Patch file missing from archive.")
rom_file = get_base_rom_path()
with open(rom_file, 'rb') as rom:
rom_bytes = rom.read()
patched_bytes = bsdiff4.patch(rom_bytes, patch_data)
patched_rom_file = base_name+".gba"
with open(patched_rom_file, 'wb') as patched_rom:
patched_rom.write(patched_bytes)
asyncio.create_task(run_game(patched_rom_file))
def confirm_checksum():
rom_file = get_base_rom_path()
if not os.path.exists(rom_file):
return False
with open(rom_file, 'rb') as rom:
rom_bytes = rom.read()
basemd5 = hashlib.md5()
basemd5.update(rom_bytes)
return CHECKSUM_BLUE == basemd5.hexdigest()
if __name__ == "__main__":
Utils.init_logging("MMBN3Client")
async def main():
multiprocessing.freeze_support()
parser = get_base_parser()
parser.add_argument("patch_file", default="", type=str, nargs="?",
help="Path to an APMMBN3 file")
args = parser.parse_args()
checksum_matches = confirm_checksum()
if checksum_matches:
if args.patch_file:
asyncio.create_task(patch_and_run_game(args.patch_file))
ctx = MMBN3Context(args.connect, args.password)
if not checksum_matches:
ctx.patching_error = True
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
ctx.gba_sync_task = asyncio.create_task(gba_sync_task(ctx), name="GBA Sync")
await ctx.exit_event.wait()
ctx.server_address = None
await ctx.shutdown()
if ctx.gba_sync_task:
await ctx.gba_sync_task
import colorama
colorama.init()
asyncio.run(main())
colorama.deinit()

81
Main.py
View File

@@ -7,24 +7,29 @@ import tempfile
import time
import zipfile
import zlib
from typing import Dict, List, Optional, Set, Tuple, Union
from typing import Dict, List, Optional, Set, Tuple
import worlds
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
from Options import StartInventoryPool
from settings import get_settings
from Utils import __version__, output_path, version_tuple
from Utils import __version__, get_options, output_path, version_tuple
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
__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):
if not baked_server_options:
baked_server_options = get_settings().server_options.as_dict()
assert isinstance(baked_server_options, dict)
baked_server_options = get_options()["server_options"]
if args.outputpath:
os.makedirs(args.outputpath, exist_ok=True)
output_path.cached_path = args.outputpath
@@ -133,27 +138,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 -= 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")
for player in world.player_ids:
exclusion_rules(world, player, world.exclude_locations[player].value)
world.priority_locations[player].value -= world.exclude_locations[player].value
for location_name in world.priority_locations[player].value:
try:
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
world.get_location(location_name, player).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")
# remove starting inventory from pool items.
@@ -165,8 +163,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player, items in depletion_pool.items():
player_world: AutoWorld.World = world.worlds[player]
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())
for i, item in enumerate(world.itempool):
if depletion_pool[item.player].get(item.name, 0):
@@ -186,7 +183,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
if remaining_items:
raise Exception(f"{world.get_player_name(player)}"
f" is trying to remove items from their pool that don't exist: {remaining_items}")
assert len(world.itempool) == len(new_items), "Item Pool amounts should not change."
world.itempool[:] = new_items
# temporary home for item links, should be moved out of Main
@@ -287,10 +283,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
AutoWorld.call_all(world, 'post_fill')
if world.players > 1 and not args.skip_prog_balancing:
if world.players > 1:
balance_multiworld_progression(world)
else:
logger.info("Progression balancing skipped.")
logger.info(f'Beginning output...')
@@ -315,6 +309,35 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
er_hint_data: Dict[int, Dict[int, str]] = {}
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():
import NetUtils
slot_data = {}
@@ -374,11 +397,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for game_world in world.worlds.values()
}
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
multidata = {
"slot_data": slot_data,
"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()},
"locations": locations_data,
"checks_in_area": checks_in_area,
@@ -400,7 +422,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
f.write(bytes([3])) # version of format
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 world.can_beat_game():
raise Exception("Game appears as unbeatable. Aborting.")
@@ -408,6 +430,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.warning("Location Accessibility requirements not fulfilled.")
# retrieve exceptions via .result() if they occurred.
multidata_task.result()
for i, future in enumerate(concurrent.futures.as_completed(output_file_futures), start=1):
if i % 10 == 0 or 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)
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"]
forge_version = args.forge or versions["forge"]
java_version = args.java or versions["java"]

View File

@@ -38,7 +38,7 @@ import NetUtils
import Utils
from Utils import version_tuple, restricted_loads, Version, async_start
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
SlotType, LocationStore
SlotType
min_client_version = Version(0, 1, 6)
colorama.init()
@@ -152,9 +152,7 @@ class Context:
"compatibility": int}
# team -> slot id -> list of clients authenticated to slot.
clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]]
locations: LocationStore # 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]
locations: typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
groups: typing.Dict[int, typing.Set[int]]
save_version = 2
stored_data: typing.Dict[str, object]
@@ -189,6 +187,8 @@ class Context:
self.player_name_lookup: typing.Dict[str, team_slot] = {}
self.connect_names = {} # names of slots clients can connect to
self.allow_releases = {}
# player location_id item_id target_player_id
self.locations = {}
self.host = host
self.port = port
self.server_password = server_password
@@ -284,7 +284,6 @@ class Context:
except websockets.ConnectionClosed:
logging.exception(f"Exception during send_msgs, could not send {msg}")
await self.disconnect(endpoint)
return False
else:
if self.log_network:
logging.info(f"Outgoing message: {msg}")
@@ -298,7 +297,6 @@ class Context:
except websockets.ConnectionClosed:
logging.exception("Exception during send_encoded_msgs")
await self.disconnect(endpoint)
return False
else:
if self.log_network:
logging.info(f"Outgoing message: {msg}")
@@ -313,7 +311,6 @@ class Context:
websockets.broadcast(sockets, msg)
except RuntimeError:
logging.exception("Exception during broadcast_send_encoded_msgs")
return False
else:
if self.log_network:
logging.info(f"Outgoing broadcast: {msg}")
@@ -416,7 +413,7 @@ class Context:
self.seed_name = decoded_obj["seed_name"]
self.random.seed(self.seed_name)
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']
for slot, data in self.slot_data.items():
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(
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) "
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})
ctx.notify_client(client, "Now that you are connected, "
"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):
"""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.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]:
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):
@@ -972,12 +977,13 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
slots.add(group_id)
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 \
in ctx.locations.find_item(slots, seeked_item_id):
found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
item_flags))
for finding_player, check_data in ctx.locations.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:
found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
item_flags))
return hints
@@ -1549,11 +1555,15 @@ class ClientMessageProcessor(CommonCommandProcessor):
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]:
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:
@@ -2118,15 +2128,13 @@ class ServerCommandProcessor(CommonCommandProcessor):
async def console(ctx: Context):
import sys
queue = asyncio.Queue()
worker = Utils.stream_input(sys.stdin, queue)
Utils.stream_input(sys.stdin, queue)
while not ctx.exit_event.is_set():
try:
# 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.
while queue.qsize() == 0:
await asyncio.sleep(0.05)
if not worker.is_alive():
return
input_text = await queue.get()
queue.task_done()
ctx.commandprocessor(input_text)
@@ -2137,7 +2145,7 @@ async def console(ctx: Context):
def parse_args() -> argparse.Namespace:
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('--host', default=defaults["host"])
parser.add_argument('--port', default=defaults["port"], type=int)
@@ -2246,15 +2254,12 @@ async def main(args: argparse.Namespace):
if not isinstance(e, ImportError):
logging.error(f"Failed to load tkinter ({e})")
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
import sys
sys.exit(1)
exit(1)
raise
if not data_filename:
logging.info("No file selected. Exiting.")
import sys
sys.exit(1)
exit(1)
try:
ctx.load(data_filename, args.use_embedded_options)

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
import typing
import enum
import warnings
from json import JSONEncoder, JSONDecoder
import websockets
@@ -344,85 +343,3 @@ class Hint(typing.NamedTuple):
@property
def local(self):
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

@@ -44,7 +44,7 @@ def adjustGUI():
StringVar, IntVar, Checkbutton, Frame, Label, X, Entry, Button, \
OptionMenu, filedialog, messagebox, ttk
from argparse import Namespace
from Utils import __version__ as MWVersion
from Main import __version__ as MWVersion
window = tk.Tk()
window.wm_title(f"Archipelago {MWVersion} OoT Adjuster")

View File

@@ -100,7 +100,7 @@ class OoTContext(CommonContext):
await super(OoTContext, self).server_auth(password_requested)
if not self.auth:
self.awaiting_rom = True
logger.info('Awaiting connection to EmuHawk to get player information')
logger.info('Awaiting connection to Bizhawk to get player information')
return
await self.send_connect()
@@ -296,6 +296,8 @@ async def patch_and_run_game(apz5_file):
comp_path = base_name + '.z64'
# Load vanilla ROM, patch file, compress ROM
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)
sub_file = None

View File

@@ -1,15 +1,13 @@
from __future__ import annotations
import abc
import logging
from copy import deepcopy
import math
import numbers
import random
import typing
from copy import deepcopy
from schema import And, Optional, Or, Schema
import random
from schema import Schema, And, Or, Optional
from Utils import get_fuzzy_results
if typing.TYPE_CHECKING:
@@ -771,7 +769,7 @@ class VerifyKeys(metaclass=FreezeValidKeys):
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] = {}
supports_weighting = False
@@ -789,14 +787,8 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
def get_option_name(self, value):
return ", ".join(f"{key}: {v}" for key, v in value.items())
def __getitem__(self, item: str) -> typing.Any:
return self.value.__getitem__(item)
def __iter__(self) -> typing.Iterator[str]:
return self.value.__iter__()
def __len__(self) -> int:
return self.value.__len__()
def __contains__(self, item):
return item in self.value
class ItemDict(OptionDict):
@@ -957,7 +949,6 @@ class DeathLink(Toggle):
class ItemLinks(OptionList):
"""Share part of your item pool with other players."""
display_name = "Item Links"
default = []
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_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
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart pkmn_rb.lua"
@@ -75,14 +72,13 @@ class GBContext(CommonContext):
self.items_handling = 0b001
self.sent_release = False
self.sent_collect = False
self.auto_hints = set()
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(GBContext, self).server_auth(password_requested)
if not self.auth:
self.awaiting_rom = True
logger.info('Awaiting connection to EmuHawk to get Player information')
logger.info('Awaiting connection to Bizhawk to get Player information')
return
await self.send_connect()
@@ -157,33 +153,6 @@ async def parse_locations(data: List, ctx: GBContext):
locations.append(loc_id)
elif flags[flag_type][location_bytes_bits[loc_id]['byte']] & 1 << location_bytes_bits[loc_id]['bit']:
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:
await ctx.send_msgs([
{"cmd": "StatusUpdate",

View File

@@ -45,12 +45,6 @@ Currently, the following games are supported:
* Adventure
* DLC Quest
* Noita
* Undertale
* Bumper Stickers
* Mega Man Battle Network 3: Blue Version
* Muse Dash
* DOOM 1993
* Terraria
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

View File

@@ -68,11 +68,12 @@ class SNIClientCommandProcessor(ClientCommandProcessor):
options = snes_options.split()
num_options = len(options)
if num_options > 0:
snes_device_number = int(options[0])
if num_options > 1:
snes_address = options[0]
snes_device_number = int(options[1])
elif num_options > 0:
snes_device_number = int(options[0])
self.ctx.snes_reconnect_address = None
if self.ctx.snes_connect_task:
@@ -314,7 +315,7 @@ def launch_sni() -> None:
f"please start it yourself if it is not running")
async def _snes_connect(ctx: SNIContext, address: str, retry: bool = True) -> WebSocketClientProtocol:
async def _snes_connect(ctx: SNIContext, address: str) -> WebSocketClientProtocol:
address = f"ws://{address}" if "://" not in address else address
snes_logger.info("Connecting to SNI at %s ..." % address)
seen_problems: typing.Set[str] = set()
@@ -335,8 +336,6 @@ async def _snes_connect(ctx: SNIContext, address: str, retry: bool = True) -> We
await asyncio.sleep(1)
else:
return snes_socket
if not retry:
break
class SNESRequest(typing.TypedDict):
@@ -564,16 +563,14 @@ async def snes_write(ctx: SNIContext, write_list: typing.List[typing.Tuple[int,
PutAddress_Request: SNESRequest = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'}
try:
for address, data in write_list:
while data:
# Divide the write into packets of 256 bytes.
PutAddress_Request['Operands'] = [hex(address)[2:], hex(min(len(data), 256))[2:]]
if ctx.snes_socket is not None:
await ctx.snes_socket.send(dumps(PutAddress_Request))
await ctx.snes_socket.send(data[:256])
address += 256
data = data[256:]
else:
snes_logger.warning(f"Could not send data to SNES: {data}")
PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]]
# REVIEW: above: `if snes_socket is None: return False`
# Does it need to be checked again?
if ctx.snes_socket is not None:
await ctx.snes_socket.send(dumps(PutAddress_Request))
await ctx.snes_socket.send(data)
else:
snes_logger.warning(f"Could not send data to SNES: {data}")
except ConnectionClosed:
return False
@@ -687,8 +684,6 @@ async def main() -> None:
logging.info(f"Wrote rom file to {romfile}")
if args.diff_file.endswith(".apsoe"):
import webbrowser
async_start(run_game(romfile))
await _snes_connect(SNIContext(args.snes, args.connect, args.password), args.snes, False)
webbrowser.open(f"http://www.evermizer.com/apclient/#server={meta['server']}")
logging.info("Starting Evermizer Client in your Browser...")
import time

File diff suppressed because it is too large Load Diff

View File

@@ -1,512 +0,0 @@
from __future__ import annotations
import os
import sys
import asyncio
import typing
import bsdiff4
import shutil
import Utils
from NetUtils import NetworkItem, ClientStatus
from worlds import undertale
from MultiServer import mark_raw
from CommonClient import CommonContext, server_loop, \
gui_enabled, ClientCommandProcessor, logger, get_base_parser
from Utils import async_start
class UndertaleCommandProcessor(ClientCommandProcessor):
def __init__(self, ctx):
super().__init__(ctx)
def _cmd_resync(self):
"""Manually trigger a resync."""
if isinstance(self.ctx, UndertaleContext):
self.output(f"Syncing items.")
self.ctx.syncing = True
def _cmd_patch(self):
"""Patch the game."""
if isinstance(self.ctx, UndertaleContext):
os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True)
self.ctx.patch_game()
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
def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None):
"""Patch the game automatically."""
if isinstance(self.ctx, UndertaleContext):
os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True)
tempInstall = steaminstall
if not os.path.isfile(os.path.join(tempInstall, "data.win")):
tempInstall = None
if tempInstall is None:
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
if not os.path.exists(tempInstall):
tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale"
elif not os.path.exists(tempInstall):
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
if not os.path.exists(tempInstall):
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")):
self.output("ERROR: Cannot find Undertale. Please rerun the command with the correct folder."
" command. \"/auto_patch (Steam directory)\".")
else:
for file_name in os.listdir(tempInstall):
if file_name != "steam_api.dll":
shutil.copy(os.path.join(tempInstall, file_name),
os.path.join(os.getcwd(), "Undertale", file_name))
self.ctx.patch_game()
self.output("Patching successful!")
def _cmd_online(self):
"""Makes you no longer able to see other Undertale players."""
if isinstance(self.ctx, UndertaleContext):
self.ctx.update_online_mode(not ("Online" in self.ctx.tags))
if "Online" in self.ctx.tags:
self.output(f"Now online.")
else:
self.output(f"Now offline.")
def _cmd_deathlink(self):
"""Toggles deathlink"""
if isinstance(self.ctx, UndertaleContext):
self.ctx.deathlink_status = not self.ctx.deathlink_status
if self.ctx.deathlink_status:
self.output(f"Deathlink enabled.")
else:
self.output(f"Deathlink disabled.")
class UndertaleContext(CommonContext):
tags = {"AP", "Online"}
game = "Undertale"
command_processor = UndertaleCommandProcessor
items_handling = 0b111
route = None
pieces_needed = None
completed_routes = None
completed_count = 0
save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE")
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.pieces_needed = 0
self.finished_game = False
self.game = "Undertale"
self.got_deathlink = False
self.syncing = False
self.deathlink_status = False
self.tem_armor = False
self.completed_count = 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):
with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "rb") as f:
patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff"))
with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "wb") as f:
f.write(patchedFile)
os.makedirs(name=os.path.join(os.getcwd(), "Undertale", "Custom Sprites"), exist_ok=True)
with open(os.path.expandvars(os.path.join(os.getcwd(), "Undertale", "Custom Sprites",
"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 "
"line other than this one.\n", "frisk"])
f.close()
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super().server_auth(password_requested)
await self.get_username()
await self.send_connect()
def clear_undertale_files(self):
path = self.save_game_folder
self.finished_game = False
for root, dirs, files in os.walk(path):
for file in files:
if "check.spot" == file or "scout" == file:
os.remove(os.path.join(root, file))
elif file.endswith((".item", ".victory", ".route", ".playerspot", ".mad",
".youDied", ".LV", ".mine", ".flag", ".hint")):
os.remove(os.path.join(root, file))
async def connect(self, address: typing.Optional[str] = None):
self.clear_undertale_files()
await super().connect(address)
async def disconnect(self, allow_autoreconnect: bool = False):
self.clear_undertale_files()
await super().disconnect(allow_autoreconnect)
async def connection_closed(self):
self.clear_undertale_files()
await super().connection_closed()
async def shutdown(self):
self.clear_undertale_files()
await super().shutdown()
def update_online_mode(self, online):
old_tags = self.tags.copy()
if online:
self.tags.add("Online")
else:
self.tags -= {"Online"}
if old_tags != self.tags and self.server and not self.server.socket.closed:
async_start(self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}]))
def on_package(self, cmd: str, args: dict):
if cmd == "Connected":
self.game = self.slot_info[self.slot].game
async_start(process_undertale_cmd(self, cmd, args))
def run_gui(self):
from kvui import GameManager
class UTManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Undertale Client"
self.ui = UTManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def on_deathlink(self, data: typing.Dict[str, typing.Any]):
self.got_deathlink = True
super().on_deathlink(data)
def to_room_name(place_name: str):
if place_name == "Old Home Exit":
return "room_ruinsexit"
elif place_name == "Snowdin Forest":
return "room_tundra1"
elif place_name == "Snowdin Town Exit":
return "room_fogroom"
elif place_name == "Waterfall":
return "room_water1"
elif place_name == "Waterfall Exit":
return "room_fire2"
elif place_name == "Hotland":
return "room_fire_prelab"
elif place_name == "Hotland Exit":
return "room_fire_precore"
elif place_name == "Core":
return "room_fire_core1"
async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
if cmd == "Connected":
if not os.path.exists(ctx.save_game_folder):
os.mkdir(ctx.save_game_folder)
ctx.route = args["slot_data"]["route"]
ctx.pieces_needed = args["slot_data"]["key_pieces"]
ctx.tem_armor = args["slot_data"]["temy_armor_include"]
await ctx.send_msgs([{"cmd": "Get", "keys": [str(ctx.slot)+" RoutesDone neutral",
str(ctx.slot)+" RoutesDone pacifist",
str(ctx.slot)+" RoutesDone genocide"]}])
await ctx.send_msgs([{"cmd": "SetNotify", "keys": [str(ctx.slot)+" RoutesDone neutral",
str(ctx.slot)+" RoutesDone pacifist",
str(ctx.slot)+" RoutesDone genocide"]}])
if args["slot_data"]["only_flakes"]:
with open(os.path.join(ctx.save_game_folder, "GenoNoChest.flag"), "w") as f:
f.close()
if not args["slot_data"]["key_hunt"]:
ctx.pieces_needed = 0
if args["slot_data"]["rando_love"]:
filename = f"LOVErando.LV"
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
f.close()
if args["slot_data"]["rando_stats"]:
filename = f"STATrando.LV"
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
f.close()
filename = f"{ctx.route}.route"
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
f.close()
filename = f"check.spot"
with open(os.path.join(ctx.save_game_folder, filename), "a") as f:
for ss in set(args["checked_locations"]):
f.write(str(ss-12000)+"\n")
f.close()
elif cmd == "LocationInfo":
for l in args["locations"]:
locationid = l.location
filename = f"{str(locationid-12000)}.hint"
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
toDraw = ""
for i in range(20):
if i < len(str(ctx.item_names[l.item])):
toDraw += str(ctx.item_names[l.item])[i]
else:
break
f.write(toDraw)
f.close()
elif cmd == "Retrieved":
if str(ctx.slot)+" RoutesDone neutral" in args["keys"]:
if args["keys"][str(ctx.slot)+" RoutesDone neutral"] is not None:
ctx.completed_routes["neutral"] = args["keys"][str(ctx.slot)+" RoutesDone neutral"]
if str(ctx.slot)+" RoutesDone genocide" in args["keys"]:
if args["keys"][str(ctx.slot)+" RoutesDone genocide"] is not None:
ctx.completed_routes["genocide"] = args["keys"][str(ctx.slot)+" RoutesDone genocide"]
if str(ctx.slot)+" RoutesDone pacifist" in args["keys"]:
if args["keys"][str(ctx.slot) + " RoutesDone pacifist"] is not None:
ctx.completed_routes["pacifist"] = args["keys"][str(ctx.slot)+" RoutesDone pacifist"]
elif cmd == "SetReply":
if args["value"] is not None:
if str(ctx.slot)+" RoutesDone pacifist" == args["key"]:
ctx.completed_routes["pacifist"] = args["value"]
elif str(ctx.slot)+" RoutesDone genocide" == args["key"]:
ctx.completed_routes["genocide"] = args["value"]
elif str(ctx.slot)+" RoutesDone neutral" == args["key"]:
ctx.completed_routes["neutral"] = args["value"]
elif cmd == "ReceivedItems":
start_index = args["index"]
if start_index == 0:
ctx.items_received = []
elif start_index != len(ctx.items_received):
sync_msg = [{"cmd": "Sync"}]
if ctx.locations_checked:
sync_msg.append({"cmd": "LocationChecks",
"locations": list(ctx.locations_checked)})
await ctx.send_msgs(sync_msg)
if start_index == len(ctx.items_received):
counter = -1
placedWeapon = 0
placedArmor = 0
for item in args["items"]:
id = NetworkItem(*item).location
while NetworkItem(*item).location < 0 and \
counter <= id:
id -= 1
if NetworkItem(*item).location < 0:
counter -= 1
filename = f"{str(id)}PLR{str(NetworkItem(*item).player)}.item"
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
if NetworkItem(*item).item == 77701:
if placedWeapon == 0:
f.write(str(77013-11000))
elif placedWeapon == 1:
f.write(str(77014-11000))
elif placedWeapon == 2:
f.write(str(77025-11000))
elif placedWeapon == 3:
f.write(str(77045-11000))
elif placedWeapon == 4:
f.write(str(77049-11000))
elif placedWeapon == 5:
f.write(str(77047-11000))
elif placedWeapon == 6:
if str(ctx.route) == "genocide" or str(ctx.route) == "all_routes":
f.write(str(77052-11000))
else:
f.write(str(77051-11000))
else:
f.write(str(77003-11000))
placedWeapon += 1
elif NetworkItem(*item).item == 77702:
if placedArmor == 0:
f.write(str(77012-11000))
elif placedArmor == 1:
f.write(str(77015-11000))
elif placedArmor == 2:
f.write(str(77024-11000))
elif placedArmor == 3:
f.write(str(77044-11000))
elif placedArmor == 4:
f.write(str(77048-11000))
elif placedArmor == 5:
if str(ctx.route) == "genocide":
f.write(str(77053-11000))
else:
f.write(str(77046-11000))
elif placedArmor == 6 and ((not str(ctx.route) == "genocide") or ctx.tem_armor):
if str(ctx.route) == "all_routes":
f.write(str(77053-11000))
elif str(ctx.route) == "genocide":
f.write(str(77064-11000))
else:
f.write(str(77050-11000))
elif placedArmor == 7 and ctx.tem_armor and not str(ctx.route) == "genocide":
f.write(str(77064-11000))
else:
f.write(str(77004-11000))
placedArmor += 1
else:
f.write(str(NetworkItem(*item).item-11000))
f.close()
ctx.items_received.append(NetworkItem(*item))
if [item.item for item in ctx.items_received].count(77000) >= ctx.pieces_needed > 0:
filename = f"{str(-99999)}PLR{str(0)}.item"
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
f.write(str(77787 - 11000))
f.close()
filename = f"{str(-99998)}PLR{str(0)}.item"
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
f.write(str(77789 - 11000))
f.close()
ctx.watcher_event.set()
elif cmd == "RoomUpdate":
if "checked_locations" in args:
filename = f"check.spot"
with open(os.path.join(ctx.save_game_folder, filename), "a") as f:
for ss in set(args["checked_locations"]):
f.write(str(ss-12000)+"\n")
f.close()
elif cmd == "Bounced":
tags = args.get("tags", [])
if "Online" in tags:
data = args.get("data", {})
if data["player"] != ctx.slot and data["player"] is not None:
filename = f"FRISK" + str(data["player"]) + ".playerspot"
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
f.write(str(data["x"]) + str(data["y"]) + str(data["room"]) + str(
data["spr"]) + str(data["frm"]))
f.close()
async def multi_watcher(ctx: UndertaleContext):
while not ctx.exit_event.is_set():
path = ctx.save_game_folder
for root, dirs, files in os.walk(path):
for file in files:
if "spots.mine" in file and "Online" in ctx.tags:
with open(os.path.join(root, file), "r") as mine:
this_x = mine.readline()
this_y = mine.readline()
this_room = mine.readline()
this_sprite = mine.readline()
this_frame = mine.readline()
mine.close()
message = [{"cmd": "Bounce", "tags": ["Online"],
"data": {"player": ctx.slot, "x": this_x, "y": this_y, "room": this_room,
"spr": this_sprite, "frm": this_frame}}]
await ctx.send_msgs(message)
await asyncio.sleep(0.1)
async def game_watcher(ctx: UndertaleContext):
while not ctx.exit_event.is_set():
await ctx.update_death_link(ctx.deathlink_status)
path = ctx.save_game_folder
if ctx.syncing:
for root, dirs, files in os.walk(path):
for file in files:
if ".item" in file:
os.remove(os.path.join(root, file))
sync_msg = [{"cmd": "Sync"}]
if ctx.locations_checked:
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
await ctx.send_msgs(sync_msg)
ctx.syncing = False
if ctx.got_deathlink:
ctx.got_deathlink = False
with open(os.path.join(ctx.save_game_folder, "WelcomeToTheDead.youDied"), "w") as f:
f.close()
sending = []
victory = False
found_routes = 0
for root, dirs, files in os.walk(path):
for file in files:
if "DontBeMad.mad" in file:
os.remove(os.path.join(root, file))
if "DeathLink" in ctx.tags:
await ctx.send_death()
if "scout" == file:
sending = []
try:
with open(os.path.join(root, file), "r") as f:
lines = f.readlines()
for l in lines:
if ctx.server_locations.__contains__(int(l)+12000):
sending = sending + [int(l.rstrip('\n'))+12000]
finally:
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": sending,
"create_as_hint": int(2)}])
os.remove(os.path.join(root, file))
if "check.spot" in file:
sending = []
try:
with open(os.path.join(root, file), "r") as f:
lines = f.readlines()
for l in lines:
sending = sending+[(int(l.rstrip('\n')))+12000]
finally:
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": sending}])
if "victory" in file and str(ctx.route) in file:
victory = True
if ".playerspot" in file and "Online" not in ctx.tags:
os.remove(os.path.join(root, file))
if "victory" in file:
if str(ctx.route) == "all_routes":
if "neutral" in file and ctx.completed_routes["neutral"] != 1:
await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone neutral",
"default": 0, "want_reply": True, "operations": [{"operation": "max",
"value": 1}]}])
elif "pacifist" in file and ctx.completed_routes["pacifist"] != 1:
await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone pacifist",
"default": 0, "want_reply": True, "operations": [{"operation": "max",
"value": 1}]}])
elif "genocide" in file and ctx.completed_routes["genocide"] != 1:
await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone genocide",
"default": 0, "want_reply": True, "operations": [{"operation": "max",
"value": 1}]}])
if str(ctx.route) == "all_routes":
found_routes += ctx.completed_routes["neutral"]
found_routes += ctx.completed_routes["pacifist"]
found_routes += ctx.completed_routes["genocide"]
if str(ctx.route) == "all_routes" and found_routes >= 3:
victory = True
ctx.locations_checked = sending
if (not ctx.finished_game) and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
await asyncio.sleep(0.1)
def main():
Utils.init_logging("UndertaleClient", exception_logger="Client")
async def _main():
ctx = UndertaleContext(None, None)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
asyncio.create_task(
game_watcher(ctx), name="UndertaleProgressionWatcher")
asyncio.create_task(
multi_watcher(ctx), name="UndertaleMultiplayerWatcher")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
await ctx.exit_event.wait()
await ctx.shutdown()
import colorama
colorama.init()
asyncio.run(_main())
colorama.deinit()
if __name__ == "__main__":
parser = get_base_parser(description="Undertale Client, for text interfacing.")
args = parser.parse_args()
main()

430
Utils.py
View File

@@ -13,11 +13,8 @@ import io
import collections
import importlib
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 yaml import load, load_all, dump, SafeLoader
try:
@@ -30,7 +27,6 @@ except ImportError:
if typing.TYPE_CHECKING:
import tkinter
import pathlib
from BaseClasses import Region
def tuplize_version(version: str) -> Version:
@@ -46,7 +42,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self)
__version__ = "0.4.3"
__version__ = "0.4.1"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -142,16 +138,13 @@ def user_path(*path: str) -> str:
user_path.cached_path = local_path()
else:
user_path.cached_path = home_path()
# populate home from local
if user_path.cached_path != local_path():
import filecmp
if not os.path.exists(user_path("manifest.json")) or \
not filecmp.cmp(local_path("manifest.json"), user_path("manifest.json"), shallow=True):
import shutil
for dn in ("Players", "data/sprites"):
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))
# populate home from local - TODO: upgrade feature
if user_path.cached_path != local_path() and not os.path.exists(user_path("host.yaml")):
import shutil
for dn in ("Players", "data/sprites"):
shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True)
for fn in ("manifest.json", "host.yaml"):
shutil.copy2(local_path(fn), user_path(fn))
return os.path.join(user_path.cached_path, *path)
@@ -217,13 +210,7 @@ def get_cert_none_ssl_context():
def get_public_ipv4() -> str:
import socket
import urllib.request
try:
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"
ip = socket.gethostbyname(socket.gethostname())
ctx = get_cert_none_ssl_context()
try:
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:
import socket
import urllib.request
try:
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"
ip = socket.gethostbyname(socket.gethostname())
ctx = get_cert_none_ssl_context()
try:
ip = urllib.request.urlopen("https://v6.ident.me", context=ctx, timeout=10).read().decode("utf8").strip()
@@ -257,15 +238,151 @@ def get_public_ipv6() -> str:
return ip
OptionsType = Settings # TODO: remove ~2 versions after 0.4.1
OptionsType = typing.Dict[str, typing.Dict[str, typing.Any]]
@cache_argsless
def get_default_options() -> Settings: # TODO: remove ~2 versions after 0.4.1
return Settings(None)
def get_default_options() -> OptionsType:
# 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"
},
"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):
@@ -333,27 +450,12 @@ def store_data_package_for_checksum(game: str, data: typing.Dict[str, Any]) -> N
except Exception as 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
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
def get_unique_identifier():
uuid = persistent_load().get("client", {}).get("uuid", None)
@@ -373,13 +475,11 @@ safe_builtins = frozenset((
class RestrictedUnpickler(pickle.Unpickler):
generic_properties_module: Optional[object]
def __init__(self, *args, **kwargs):
super(RestrictedUnpickler, self).__init__(*args, **kwargs)
self.options_module = importlib.import_module("Options")
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):
if module == "builtins" and name in safe_builtins:
@@ -389,8 +489,6 @@ class RestrictedUnpickler(pickle.Unpickler):
return getattr(self.net_utils_module, name)
# Options and Plando are unpickled by WebHost -> Generate
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)
# pep 8 specifies that modules should have "all-lowercase names" (options, not Options)
if module.lower().endswith("options"):
@@ -451,7 +549,6 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
root_logger.removeHandler(handler)
handler.close()
root_logger.setLevel(loglevel)
logging.getLogger("websockets").setLevel(loglevel) # make sure level is applied for websockets
if "a" not in write_mode:
name += f"_{datetime.datetime.now().strftime('%Y_%m_%d_%H_%M_%S')}"
file_handler = logging.FileHandler(
@@ -575,7 +672,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]:
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
@@ -586,12 +683,11 @@ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typin
kdialog = which("kdialog")
if kdialog:
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")
if zenity:
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, *selection)
return run(zenity, f"--title={title}", "--file-selection", *z_filters)
# fall back to tk
try:
@@ -602,47 +698,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}".')
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 = tkinter.Tk()
root.withdraw()
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)
return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes))
def messagebox(title: str, text: str, error: bool = False) -> None:
@@ -707,10 +765,10 @@ def read_snes_rom(stream: BinaryIO, strip_header: bool = True) -> bytearray:
return buffer
_faf_tasks: "Set[asyncio.Task[typing.Any]]" = set()
_faf_tasks: "Set[asyncio.Task[None]]" = set()
def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = None) -> None:
def async_start(co: Coroutine[typing.Any, typing.Any, bool], name: Optional[str] = None) -> None:
"""
Use this to start a task when you don't keep a reference to it or immediately await it,
to prevent early garbage collection. "fire-and-forget"
@@ -723,170 +781,6 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non
# ```
# This implementation follows the pattern given in that documentation.
task: asyncio.Task[typing.Any] = asyncio.create_task(co, name=name)
task = asyncio.create_task(co, name=name)
_faf_tasks.add(task)
task.add_done_callback(_faf_tasks.discard)
def deprecate(message: str):
if __debug__:
raise Exception(message)
import warnings
warnings.warn(message)
def _extend_freeze_support() -> None:
"""Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
# upstream issue: https://github.com/python/cpython/issues/76327
# code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26
import multiprocessing
import multiprocessing.spawn
def _freeze_support() -> None:
"""Minimal freeze_support. Only apply this if frozen."""
from subprocess import _args_from_interpreter_flags
# Prevent `spawn` from trying to read `__main__` in from the main script
multiprocessing.process.ORIGINAL_DIR = None
# Handle the first process that MP will create
if (
len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith((
'from multiprocessing.semaphore_tracker import main', # Py<3.8
'from multiprocessing.resource_tracker import main', # Py>=3.8
'from multiprocessing.forkserver import main'
)) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags())
):
exec(sys.argv[-1])
sys.exit()
# Handle the second process that MP will create
if multiprocessing.spawn.is_forking(sys.argv):
kwargs = {}
for arg in sys.argv[2:]:
name, value = arg.split('=')
if value == 'None':
kwargs[name] = None
else:
kwargs[name] = int(value)
multiprocessing.spawn.spawn_main(**kwargs)
sys.exit()
if not is_windows and is_frozen():
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support
def freeze_support() -> None:
"""This behaves like multiprocessing.freeze_support but also works on Non-Windows."""
import multiprocessing
_extend_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
import Utils
import settings
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")
if not os.path.exists(configpath): # fall back to config.yaml in home
configpath = os.path.abspath(Utils.user_path('config.yaml'))
def get_app():
from WebHostLib import register, cache, app as raw_app
from WebHostLib.models import db
register()
app = raw_app
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()
logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}")
cache.init_app(app)
db.bind(**app.config["PONY"])
db.generate_mapping(create_tables=True)
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:
for zfile in zf.infolist():
if not zfile.is_dir() and "/docs/" in zfile.filename:
zfile.filename = os.path.basename(zfile.filename)
zf.extract(zfile, target_path)
else:
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
@@ -115,11 +117,6 @@ if __name__ == "__main__":
multiprocessing.freeze_support()
multiprocessing.set_start_method('spawn')
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:
update_sprites_lttp()
except Exception as e:
@@ -136,5 +133,4 @@ if __name__ == "__main__":
if app.config["DEBUG"]:
app.run(debug=True, port=app.config["PORT"])
else:
from waitress import serve
serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"])

View File

@@ -49,11 +49,11 @@ app.config["PONY"] = {
'create_db': True
}
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["HOST_ADDRESS"] = ""
cache = Cache()
cache = Cache(app)
Compress(app)

View File

@@ -2,8 +2,7 @@ import json
import pickle
from uuid import UUID
from flask import request, session, url_for
from markupsafe import Markup
from flask import request, session, url_for, Markup
from pony.orm import commit
from WebHostLib import app

View File

@@ -3,6 +3,8 @@ from __future__ import annotations
import json
import logging
import multiprocessing
import os
import sys
import threading
import time
import typing
@@ -11,7 +13,55 @@ from datetime import timedelta, datetime
from pony.orm import db_session, select, commit
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):

View File

@@ -1,8 +1,7 @@
import zipfile
from typing import *
from flask import request, flash, redirect, url_for, render_template
from markupsafe import Markup
from flask import request, flash, redirect, url_for, render_template, Markup
from WebHostLib import app
@@ -24,8 +23,8 @@ def check():
if 'file' not in request.files:
flash('No file part')
else:
files = request.files.getlist('file')
options = get_yaml_data(files)
file = request.files['file']
options = get_yaml_data(file)
if isinstance(options, str):
flash(options)
else:
@@ -39,33 +38,30 @@ def mysterycheck():
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 = {}
for file in files:
# if user does not select file, browser also
# submit an empty part without filename
if file.filename == '':
return 'No selected file'
elif file.filename in options:
return f'Conflicting files named {file.filename} submitted'
elif file and allowed_file(file.filename):
if file.filename.endswith(".zip"):
# if user does not select file, browser also
# submit an empty part without filename
if file.filename == '':
return 'No selected file'
elif file and allowed_file(file.filename):
if file.filename.endswith(".zip"):
with zipfile.ZipFile(file, 'r') as zfile:
infolist = zfile.infolist()
with zipfile.ZipFile(file, 'r') as zfile:
infolist = zfile.infolist()
if any(file.filename.endswith(".archipelago") for file in infolist):
return Markup("Error: Your .zip file contains an .archipelago file. "
'Did you mean to <a href="/uploads">host a game</a>?')
if any(file.filename.endswith(".archipelago") for file in infolist):
return Markup("Error: Your .zip file contains an .archipelago file. "
'Did you mean to <a href="/uploads">host a game</a>?')
for file in infolist:
if file.filename.endswith(banned_zip_contents):
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \
"Your file was deleted."
elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")):
options[file.filename] = zfile.open(file, "r").read()
else:
options[file.filename] = file.read()
for file in infolist:
if file.filename.endswith(banned_zip_contents):
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \
"Your file was deleted."
elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")):
options[file.filename] = zfile.open(file, "r").read()
else:
options = {file.filename: file.read()}
if not options:
return "Did not find a .yaml file to process."
return options
@@ -95,7 +91,7 @@ def roll_options(options: Dict[str, Union[dict, str]],
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
plando_options=plando_options)
except Exception as e:
results[filename] = f"Failed to generate options in {filename}: {e}"
results[filename] = f"Failed to generate mystery in {filename}: {e}"
else:
results[filename] = True
return results, rolled_results

View File

@@ -11,7 +11,6 @@ import socket
import threading
import time
import typing
import sys
import websockets
from pony.orm import commit, db_session, select
@@ -19,8 +18,7 @@ from pony.orm import commit, db_session, select
import Utils
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
from Utils import restricted_loads, cache_argsless
from .locker import Locker
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless
from .models import Command, GameDataPackage, Room, db
@@ -165,22 +163,19 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
db.generate_mapping(check_tables=False)
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")
ctx = WebHostContext(static_server_data)
ctx.load(room_id)
ctx.init_save()
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
gc.collect() # free intermediate objects used during setup
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, ping_timeout=None,
ping_interval=None, ssl=ssl_context)
await ctx.server
except OSError: # likely port in use
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
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, ping_timeout=None,
ping_interval=None, ssl=ssl_context)
await ctx.server
port = 0
@@ -205,15 +200,16 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
await ctx.shutdown_task
logging.info("Shutting down")
from .autolauncher import Locker
with Locker(room_id):
try:
asyncio.run(main())
except (KeyboardInterrupt, SystemExit):
except KeyboardInterrupt:
with db_session:
room = Room.get(id=room_id)
# 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)
except Exception:
except:
with db_session:
room = Room.get(id=room_id)
room.last_port = -1

View File

@@ -64,8 +64,8 @@ def generate(race=False):
if 'file' not in request.files:
flash('No file part')
else:
files = request.files.getlist('file')
options = get_yaml_data(files)
file = request.files['file']
options = get_yaml_data(file)
if isinstance(options, str):
flash(options)
else:
@@ -106,7 +106,7 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
meta: Dict[str, Any] = {}
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
race = meta.setdefault("generator_options", {}).setdefault("race", False)
race = meta["generator_options"].setdefault("race", False)
def task():
target = tempfile.TemporaryDirectory()
@@ -123,14 +123,13 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
erargs = parse_arguments(['--multi', str(playercount)])
erargs.seed = seed
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
erargs.spoiler = meta["generator_options"].get("spoiler", 0)
erargs.spoiler = meta["generator_options"]["spoiler"]
erargs.race = race
erargs.outputname = seedname
erargs.outputpath = target.name
erargs.teams = 1
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
{"bosses", "items", "connections", "texts"}))
erargs.skip_prog_balancing = False
name_counter = Counter()
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
@app.route('/start-playing')
@cache.cached()
def start_playing():
return render_template(f"startPlaying.html")
@app.route('/weighted-settings')
@cache.cached()
def weighted_settings():
return render_template(f"weighted-settings.html")
# Player settings pages
@app.route('/games/<string:game>/player-settings')
@cache.cached()
def player_settings(game):
return render_template(f"player-settings.html", game=game, theme=get_world_theme(game))
# Game Info Pages
@app.route('/games/<string:game>/info/<string:lang>')
@cache.cached()
def game_info(game, lang):
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
# List of supported games
@app.route('/games')
@cache.cached()
def games():
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
@@ -69,25 +64,21 @@ def games():
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
@cache.cached()
def tutorial(game, file, lang):
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
@app.route('/tutorial/')
@cache.cached()
def tutorial_landing():
return render_template("tutorialLanding.html")
@app.route('/faq/<string:lang>/')
@cache.cached()
def faq(lang):
return render_template("faq.html", lang=lang)
@app.route('/glossary/<string:lang>/')
@cache.cached()
def terms(lang):
return render_template("glossary.html", lang=lang)
@@ -156,7 +147,7 @@ def host_room(room: UUID):
@app.route('/favicon.ico')
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')
@@ -176,7 +167,6 @@ def get_datapackage():
@app.route('/index')
@app.route('/sitemap')
@cache.cached()
def get_sitemap():
available_games: List[Dict[str, Union[str, bool]]] = []
for game, world in AutoWorldRegister.world_types.items():

View File

@@ -21,7 +21,7 @@ class Slot(db.Entity):
class Room(db.Entity):
id = PrimaryKey(UUID, default=uuid4)
last_activity = Required(datetime, default=lambda: datetime.utcnow(), index=True)
creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page
creation_time = Required(datetime, default=lambda: datetime.utcnow())
owner = Required(UUID, index=True)
commands = Set('Command')
seed = Required('Seed', index=True)
@@ -38,7 +38,7 @@ class Seed(db.Entity):
rooms = Set(Room)
multidata = Required(bytes, lazy=True)
owner = Required(UUID, index=True)
creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page
creation_time = Required(datetime, default=lambda: datetime.utcnow())
slots = Set(Slot)
spoiler = Optional(LongStr, lazy=True)
meta = Required(LongStr, default=lambda: "{\"race\": false}") # additional meta information/tags

View File

@@ -44,7 +44,7 @@ def create():
# Generate JSON files for player-settings pages
player_settings = {
"baseOptions": {
"description": f"Generated by https://archipelago.gg/ for {game_name}",
"description": "Generated by https://archipelago.gg/",
"game": game_name,
"name": "Player",
},

View File

@@ -1,9 +1,7 @@
flask>=2.2.3
pony>=0.7.17
pony>=0.7.16
waitress>=2.1.2
Flask-Caching>=2.0.2
Flask-Compress>=1.14
Flask-Limiter>=3.5.0
bokeh>=3.1.1; python_version <= '3.8'
bokeh>=3.2.2; python_version >= '3.9'
markupsafe>=2.1.3
Flask-Compress>=1.13
Flask-Limiter>=3.3.0
bokeh>=3.1.0

View File

@@ -148,7 +148,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, select));
randomButton.addEventListener('click', (event) => toggleRandomize(event, [select]));
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
select.disabled = true;
@@ -185,7 +185,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, range));
randomButton.addEventListener('click', (event) => toggleRandomize(event, [range]));
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
range.disabled = true;
@@ -269,7 +269,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(
event, specialRange, specialRangeSelect)
event, [specialRange, specialRangeSelect])
);
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
@@ -294,25 +294,23 @@ const buildOptionsTable = (settings, romOpts = false) => {
return table;
};
const toggleRandomize = (event, inputElement, optionalSelectElement = null) => {
const toggleRandomize = (event, inputElements) => {
const active = event.target.classList.contains('active');
const randomButton = event.target;
if (active) {
randomButton.classList.remove('active');
inputElement.disabled = undefined;
if (optionalSelectElement) {
optionalSelectElement.disabled = undefined;
for (const element of inputElements) {
element.disabled = undefined;
updateGameSetting(element);
}
} else {
randomButton.classList.add('active');
inputElement.disabled = true;
if (optionalSelectElement) {
optionalSelectElement.disabled = true;
for (const element of inputElements) {
element.disabled = true;
updateGameSetting(randomButton);
}
}
updateGameSetting(randomButton);
};
const updateBaseSetting = (event) => {
@@ -366,7 +364,6 @@ const generateGame = (raceMode = false) => {
weights: { player: settings },
presetData: { player: settings },
playerCount: 1,
spoiler: 3,
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;

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', () => {
const tables = $(".table").DataTable({
paging: false,
@@ -38,18 +27,7 @@ window.addEventListener('load', () => {
stateLoadCallback: function(settings) {
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: [
{
targets: 'last-activity',
name: 'lastActivity'
},
{
targets: 'hours',
render: function (data, type, row) {
@@ -62,7 +40,11 @@ window.addEventListener('load', () => {
if (data === "None")
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") {
target.find(".table").each(function (i, new_table) {
const new_trs = $(new_table).find("tbody>tr");
const footer_tr = $(new_table).find("tfoot>tr");
const old_table = tables.eq(i);
const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
old_table.clear();
if (footer_tr.length) {
$(old_table.table).find("tfoot").html(footer_tr);
}
old_table.rows.add(new_trs);
old_table.draw();
old_table.rows.add(new_trs).draw();
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
});

View File

@@ -160,7 +160,6 @@ const buildUI = (settingData) => {
weightedSettingsDiv.classList.add('invisible');
itemPoolDiv.classList.add('invisible');
hintsDiv.classList.add('invisible');
locationsDiv.classList.add('invisible');
expandButton.classList.remove('invisible');
});
@@ -169,7 +168,6 @@ const buildUI = (settingData) => {
weightedSettingsDiv.classList.remove('invisible');
itemPoolDiv.classList.remove('invisible');
hintsDiv.classList.remove('invisible');
locationsDiv.classList.remove('invisible');
expandButton.classList.add('invisible');
});
});
@@ -1136,8 +1134,8 @@ const validateSettings = () => {
return;
}
// Remove any disabled options
Object.keys(settings[game]).forEach((setting) => {
// Remove any disabled options
Object.keys(settings[game][setting]).forEach((option) => {
if (settings[game][setting][option] === 0) {
delete settings[game][setting][option];
@@ -1151,32 +1149,6 @@ const validateSettings = () => {
) {
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!';
}
// 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 (errorMessage) {
userMessage.innerText = errorMessage;
@@ -1232,7 +1199,6 @@ const generateGame = (raceMode = false) => {
weights: { player: JSON.stringify(settings) },
presetData: { player: JSON.stringify(settings) },
playerCount: 1,
spoiler: 3,
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;

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

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