diff --git a/.github/workflows/analyze-modified-files.yml b/.github/workflows/analyze-modified-files.yml
new file mode 100644
index 0000000000..ba2660809a
--- /dev/null
+++ b/.github/workflows/analyze-modified-files.yml
@@ -0,0 +1,80 @@
+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 }}
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 849e752305..a40084b9ab 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -38,12 +38,13 @@ jobs:
run: |
python -m pip install --upgrade pip
python setup.py build_exe --yes
- $NAME="$(ls build)".Split('.',2)[1]
+ $NAME="$(ls build | Select-String -Pattern 'exe')".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
@@ -65,10 +66,10 @@ jobs:
- name: Get a recent python
uses: actions/setup-python@v4
with:
- python-version: '3.9'
+ python-version: '3.11'
- name: Install build-time dependencies
run: |
- echo "PYTHON=python3.9" >> $GITHUB_ENV
+ echo "PYTHON=python3.11" >> $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
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
deleted file mode 100644
index c20d244ad9..0000000000
--- a/.github/workflows/lint.yml
+++ /dev/null
@@ -1,35 +0,0 @@
-# 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
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 42594721d0..cc68a88b76 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -44,10 +44,10 @@ jobs:
- name: Get a recent python
uses: actions/setup-python@v4
with:
- python-version: '3.9'
+ python-version: '3.11'
- name: Install build-time dependencies
run: |
- echo "PYTHON=python3.9" >> $GITHUB_ENV
+ echo "PYTHON=python3.11" >> $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
diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml
index 254d92dd6f..1a76a7f471 100644
--- a/.github/workflows/unittests.yml
+++ b/.github/workflows/unittests.yml
@@ -36,12 +36,13 @@ 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.10'} # current
+ - python: {version: '3.11'} # current
os: windows-latest
- - python: {version: '3.10'} # current
+ - python: {version: '3.11'} # current
os: macos-latest
steps:
@@ -53,8 +54,9 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
- pip install pytest pytest-subtests
+ pip install pytest pytest-subtests pytest-xdist
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
+ pytest -n auto
diff --git a/.gitignore b/.gitignore
index 5f8ad6b917..f4bcd35c32 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,15 +27,21 @@
*.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/
@@ -137,6 +143,7 @@ ipython_config.py
.venv*
env/
venv/
+/venv*/
ENV/
env.bak/
venv.bak/
@@ -167,6 +174,10 @@ dmypy.json
# Cython debug symbols
cython_debug/
+# Cython intermediates
+_speedups.cpp
+_speedups.html
+
# minecraft server stuff
jdk*/
minecraft*/
@@ -176,6 +187,9 @@ minecraft_versions.json
# pyenv
.python-version
+#undertale stuff
+/Undertale/
+
# OS General Files
.DS_Store
.AppleDouble
diff --git a/AdventureClient.py b/AdventureClient.py
index 06eea5215c..d2f4e734ac 100644
--- a/AdventureClient.py
+++ b/AdventureClient.py
@@ -25,11 +25,11 @@ from worlds.adventure.Offsets import static_item_element_size, connector_port_of
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = \
- "Connection timing out. Please restart your emulator, then restart adventure_connector.lua"
+ "Connection timing out. Please restart your emulator, then restart connector_adventure.lua"
CONNECTION_REFUSED_STATUS = \
- "Connection Refused. Please start your emulator and make sure adventure_connector.lua is running"
+ "Connection Refused. Please start your emulator and make sure connector_adventure.lua is running"
CONNECTION_RESET_STATUS = \
- "Connection was reset. Please restart your emulator, then restart adventure_connector.lua"
+ "Connection was reset. Please restart your emulator, then restart connector_adventure.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
@@ -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")
diff --git a/BaseClasses.py b/BaseClasses.py
index 7249138bfc..683cc11c2c 100644
--- a/BaseClasses.py
+++ b/BaseClasses.py
@@ -7,9 +7,11 @@ import random
import secrets
import typing # this can go away when Python 3.8 support is dropped
from argparse import Namespace
-from collections import OrderedDict, Counter, deque, ChainMap
+from collections import ChainMap, Counter, deque
+from collections.abc import Collection
from enum import IntEnum, IntFlag
-from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable, NamedTuple
+from typing import Any, Callable, Dict, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union, \
+ Type, ClassVar
import NetUtils
import Options
@@ -28,15 +30,15 @@ class Group(TypedDict, total=False):
link_replacement: bool
-class ThreadBarrierProxy():
+class ThreadBarrierProxy:
"""Passes through getattr while passthrough is True"""
- def __init__(self, obj: Any):
+ def __init__(self, obj: object) -> None:
self.passthrough = True
self.obj = obj
- def __getattr__(self, item):
+ def __getattr__(self, name: str) -> Any:
if self.passthrough:
- return getattr(self.obj, item)
+ return getattr(self.obj, name)
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.")
@@ -81,6 +83,7 @@ class MultiWorld():
random: random.Random
per_slot_randoms: Dict[int, random.Random]
+ """Deprecated. Please use `self.random` instead."""
class AttributeProxy():
def __init__(self, rule):
@@ -96,7 +99,6 @@ 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 = []
@@ -113,7 +115,6 @@ class MultiWorld():
self.dark_world_light_cone = False
self.rupoor_cost = 10
self.aga_randomness = True
- self.lock_aga_door_in_escape = False
self.save_and_quit_from_boss = True
self.custom = False
self.customitemarray = []
@@ -122,6 +123,7 @@ class MultiWorld():
self.early_items = {player: {} for player in self.player_ids}
self.local_early_items = {player: {} for player in self.player_ids}
self.indirect_connections = {}
+ self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
self.fix_trock_doors = self.AttributeProxy(
lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
self.fix_skullwoods_exit = self.AttributeProxy(
@@ -135,7 +137,6 @@ class MultiWorld():
def set_player_attr(attr, val):
self.__dict__.setdefault(attr, {})[player] = val
- set_player_attr('tech_tree_layout_prerequisites', {})
set_player_attr('_region_cache', {})
set_player_attr('shuffle', "vanilla")
set_player_attr('logic', "noglitches")
@@ -202,14 +203,7 @@ class MultiWorld():
self.player_types[new_id] = NetUtils.SlotType.group
self._region_cache[new_id] = {}
world_type = AutoWorld.AutoWorldRegister.world_types[game]
- 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] = world_type.create_group(self, new_id, players)
self.worlds[new_id].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id])
self.player_name[new_id] = name
@@ -232,24 +226,24 @@ class MultiWorld():
range(1, self.players + 1)}
def set_options(self, args: Namespace) -> None:
- for option_key in Options.common_options:
- setattr(self, option_key, getattr(args, option_key, ))
- for option_key in Options.per_game_common_options:
- setattr(self, option_key, getattr(args, option_key, {}))
-
for player in self.player_ids:
self.custom_data[player] = {}
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
- for option_key in world_type.option_definitions:
- setattr(self, option_key, getattr(args, option_key, {}))
-
self.worlds[player] = world_type(self, player)
+ self.worlds[player].random = self.per_slot_randoms[player]
+ for option_key in world_type.options_dataclass.type_hints:
+ option_values = getattr(args, option_key, {})
+ setattr(self, option_key, option_values)
+ # TODO - remove this loop once all worlds use options dataclasses
+ options_dataclass: typing.Type[Options.PerGameCommonOptions] = self.worlds[player].options_dataclass
+ self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player]
+ for option_key in options_dataclass.type_hints})
def set_item_links(self):
item_links = {}
replacement_prio = [False, True, None]
for player in self.player_ids:
- for item_link in self.item_links[player].value:
+ for item_link in self.worlds[player].options.item_links.value:
if item_link["name"] in item_links:
if item_links[item_link["name"]]["game"] != self.game[player]:
raise Exception(f"Cannot ItemLink across games. Link: {item_link['name']}")
@@ -304,14 +298,6 @@ class MultiWorld():
group["non_local_items"] = item_link["non_local_items"]
group["link_replacement"] = replacement_prio[item_link["link_replacement"]]
- # intended for unittests
- def set_default_common_options(self):
- for option_key, option in Options.common_options.items():
- setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids})
- for option_key, option in Options.per_game_common_options.items():
- setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids})
- self.state = CollectionState(self)
-
def secure(self):
self.random = ThreadBarrierProxy(secrets.SystemRandom())
self.is_race = True
@@ -363,7 +349,7 @@ class MultiWorld():
for r_location in region.locations:
self._location_cache[r_location.name, player] = r_location
- def get_regions(self, player=None):
+ def get_regions(self, player: Optional[int] = None) -> Collection[Region]:
return self.regions if player is None else self._region_cache[player].values()
def get_region(self, regionname: str, player: int) -> Region:
@@ -387,12 +373,6 @@ 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:
@@ -445,7 +425,6 @@ class MultiWorld():
self.state.collect(item, True)
def push_item(self, location: Location, item: Item, collect: bool = True):
- assert location.can_fill(self.state, item, False), f"Cannot place {item} into {location}."
location.item = item
item.location = location
if collect:
@@ -493,8 +472,10 @@ class MultiWorld():
def get_unfilled_locations_for_players(self, location_names: List[str], players: Iterable[int]):
for player in players:
if not location_names:
- location_names = [location.name for location in self.get_unfilled_locations(player)]
- for location_name in 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 = self._location_cache.get((location_name, player), None)
if location is not None and location.item is None:
yield location
@@ -743,9 +724,11 @@ class CollectionState():
return self.prog_items[item, player] >= count
def has_all(self, items: Set[str], player: int) -> bool:
+ """Returns True if each item name of items is in state at least once."""
return all(self.prog_items[item, player] for item in items)
def has_any(self, items: Set[str], player: int) -> bool:
+ """Returns True if at least one item name of items is in state at least once."""
return any(self.prog_items[item, player] for item in items)
def count(self, item: str, player: int) -> int:
@@ -794,56 +777,6 @@ 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 __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
@@ -882,41 +815,92 @@ class Entrance:
return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
-class Dungeon(object):
- def __init__(self, name: str, regions: List[Region], big_key: Item, small_keys: List[Item],
- dungeon_items: List[Item], player: int):
+class Region:
+ name: str
+ _hint_text: str
+ player: int
+ multiworld: Optional[MultiWorld]
+ entrances: List[Entrance]
+ exits: List[Entrance]
+ locations: List[Location]
+ entrance_type: ClassVar[Type[Entrance]] = Entrance
+
+ def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None):
self.name = name
- self.regions = regions
- self.big_key = big_key
- self.small_keys = small_keys
- self.dungeon_items = dungeon_items
- self.bosses = dict()
+ self.entrances = []
+ self.exits = []
+ self.locations = []
+ self.multiworld = multiworld
+ self._hint_text = hint
self.player = player
- self.multiworld = None
+
+ def can_reach(self, state: CollectionState) -> bool:
+ if state.stale[self.player]:
+ state.update_reachable_regions(self.player)
+ return self in state.reachable_regions[self.player]
@property
- def boss(self) -> Optional[Boss]:
- return self.bosses.get(None, None)
+ def hint_text(self) -> str:
+ return self._hint_text if self._hint_text else self.name
- @boss.setter
- def boss(self, value: Optional[Boss]):
- self.bosses[None] = value
+ 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)
- @property
- def keys(self) -> List[Item]:
- return self.small_keys + ([self.big_key] if self.big_key else [])
+ 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.
- @property
- def all_items(self) -> List[Item]:
- return self.dungeon_items + self.keys
+ :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 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 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.
- def __eq__(self, other: Dungeon) -> bool:
- if not other:
- return False
- return self.name == other.name and self.player == other.player
+ :param connecting_region: Region object to connect to path is `self -> exiting_region`
+ :param name: name of the connection being created
+ :param rule: callable to determine access of this connection to go from self to the exiting_region"""
+ exit_ = self.create_exit(name if name else f"{self.name} -> {connecting_region.name}")
+ if rule:
+ exit_.access_rule = rule
+ exit_.connect(connecting_region)
+
+ def create_exit(self, name: str) -> Entrance:
+ """
+ Creates and returns an Entrance object as an exit of this region.
+
+ :param name: name of the Entrance being created
+ """
+ exit_ = self.entrance_type(self.player, name, self)
+ self.exits.append(exit_)
+ return exit_
+
+ def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]],
+ rules: Dict[str, Callable[[CollectionState], bool]] = None) -> None:
+ """
+ Connects current region to regions in exit dictionary. Passed region names must exist first.
+
+ :param exits: exits from the region. format is {"connecting_region": "exit_name"}. if a non dict is provided,
+ created entrances will be named "self.name -> connecting_region"
+ :param rules: rules for the exits from this region. format is {"connecting_region", rule}
+ """
+ if not isinstance(exits, Dict):
+ exits = dict.fromkeys(exits)
+ for connecting_region, name in exits.items():
+ self.connect(self.multiworld.get_region(connecting_region, self.player),
+ name,
+ rules[connecting_region] if rules and connecting_region in rules else None)
def __repr__(self):
return self.__str__()
@@ -925,20 +909,6 @@ class Dungeon(object):
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
@@ -1071,15 +1041,19 @@ class Item:
def flags(self) -> int:
return self.classification.as_flag()
- def __eq__(self, other):
+ def __eq__(self, other: object) -> bool:
+ if not isinstance(other, Item):
+ return NotImplemented
return self.name == other.name and self.player == other.player
- def __lt__(self, other: Item) -> bool:
+ def __lt__(self, other: object) -> bool:
+ if not isinstance(other, Item):
+ return NotImplemented
if other.player != self.player:
return other.player < self.player
return self.name < other.name
- def __hash__(self):
+ def __hash__(self) -> int:
return hash((self.name, self.player))
def __repr__(self) -> str:
@@ -1091,33 +1065,44 @@ class Item:
return f"{self.name} (Player {self.player})"
-class Spoiler():
- multiworld: MultiWorld
- unreachables: Set[Location]
+class EntranceInfo(TypedDict, total=False):
+ player: int
+ entrance: str
+ exit: str
+ direction: str
- def __init__(self, world):
- self.multiworld = world
+
+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
self.hashes = {}
- self.entrances = OrderedDict()
+ self.entrances = {}
self.playthrough = {}
self.unreachables = set()
self.paths = {}
- def set_entrance(self, entrance: str, exit_: str, direction: str, player: int):
+ def set_entrance(self, entrance: str, exit_: str, direction: str, player: int) -> None:
if self.multiworld.players == 1:
- self.entrances[(entrance, direction, player)] = OrderedDict(
- [('entrance', entrance), ('exit', exit_), ('direction', direction)])
+ self.entrances[(entrance, direction, player)] = \
+ {"entrance": entrance, "exit": exit_, "direction": direction}
else:
- self.entrances[(entrance, direction, player)] = OrderedDict(
- [('player', player), ('entrance', entrance), ('exit', exit_), ('direction', direction)])
+ self.entrances[(entrance, direction, player)] = \
+ {"player": player, "entrance": entrance, "exit": exit_, "direction": direction}
- def create_playthrough(self, create_paths: bool = True):
+ def create_playthrough(self, create_paths: bool = True) -> None:
"""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 = [None]
+ state_cache: List[Optional[CollectionState]] = [None]
collection_spheres: List[Set[Location]] = []
state = CollectionState(multiworld)
sphere_candidates = set(prog_locations)
@@ -1226,17 +1211,17 @@ class Spoiler():
for item in removed_precollected:
multiworld.push_precollected(item)
- def create_paths(self, state: CollectionState, collection_spheres: List[Set[Location]]):
+ def create_paths(self, state: CollectionState, collection_spheres: List[Set[Location]]) -> None:
from itertools import zip_longest
multiworld = self.multiworld
- def flist_to_iter(node):
- while node:
- value, node = node
- yield value
+ 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 get_path(state, region):
- reversed_path_as_flist = state.path.get(region, (region, None))
+ 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))
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)
@@ -1262,14 +1247,11 @@ 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):
- def write_option(option_key: str, option_obj: type(Options.Option)):
- res = getattr(self.multiworld, option_key)[player]
+ def to_file(self, filename: str) -> None:
+ def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None:
+ res = getattr(self.multiworld.worlds[player].options, option_key)
display_name = getattr(option_obj, "display_name", option_key)
- try:
- outfile.write(f'{display_name + ":":33}{res.current_option_name}\n')
- except:
- raise Exception
+ outfile.write(f"{display_name + ':':33}{res.current_option_name}\n")
with open(filename, 'w', encoding="utf-8-sig") as outfile:
outfile.write(
@@ -1285,8 +1267,7 @@ class Spoiler():
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
outfile.write('Game: %s\n' % self.multiworld.game[player])
- options = ChainMap(Options.per_game_common_options, self.multiworld.worlds[player].option_definitions)
- for f_option, option in options.items():
+ for f_option, option in self.multiworld.worlds[player].options_dataclass.type_hints.items():
write_option(f_option, option)
AutoWorld.call_single(self.multiworld, "write_spoiler_header", player, outfile)
@@ -1302,15 +1283,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(
- [' %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()]))
+ [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()]))
if self.unreachables:
outfile.write('\n\nUnreachable Items:\n\n')
outfile.write(
@@ -1371,23 +1352,21 @@ class PlandoOptions(IntFlag):
@classmethod
def _handle_part(cls, part: str, base: PlandoOptions) -> PlandoOptions:
try:
- part = cls[part]
+ return base | cls[part]
except Exception as e:
raise KeyError(f"{part} is not a recognized name for a plando module. "
- f"Known options: {', '.join(flag.name for flag in cls)}") from e
- else:
- return base | part
+ f"Known options: {', '.join(str(flag.name) for flag in cls)}") from e
def __str__(self) -> str:
if self.value:
- return ", ".join(flag.name for flag in PlandoOptions if self.value & flag.value)
+ return ", ".join(str(flag.name) for flag in PlandoOptions if self.value & flag.value)
return "None"
seeddigits = 20
-def get_seed(seed=None) -> int:
+def get_seed(seed: Optional[int] = None) -> int:
if seed is None:
random.seed(None)
return random.randint(0, pow(10, seeddigits) - 1)
diff --git a/BizHawkClient.py b/BizHawkClient.py
new file mode 100644
index 0000000000..86c8e5197e
--- /dev/null
+++ b/BizHawkClient.py
@@ -0,0 +1,9 @@
+from __future__ import annotations
+
+import ModuleUpdate
+ModuleUpdate.update()
+
+from worlds._bizhawk.context import launch
+
+if __name__ == "__main__":
+ launch()
diff --git a/CommonClient.py b/CommonClient.py
index 4892f69f06..154b61b1d5 100644
--- a/CommonClient.py
+++ b/CommonClient.py
@@ -1,4 +1,6 @@
from __future__ import annotations
+
+import copy
import logging
import asyncio
import urllib.parse
@@ -23,6 +25,7 @@ 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
@@ -33,6 +36,12 @@ 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
@@ -157,6 +166,7 @@ class CommonContext:
disconnected_intentionally: bool = False
server: typing.Optional[Endpoint] = None
server_version: Version = Version(0, 0, 0)
+ generator_version: Version = Version(0, 0, 0)
current_energy_link_value: typing.Optional[int] = None # to display in UI, gets set by server
last_death_link: float = time.time() # last send/received death link on AP layer
@@ -166,6 +176,7 @@ class CommonContext:
server_address: typing.Optional[str]
password: typing.Optional[str]
hint_cost: typing.Optional[int]
+ hint_points: typing.Optional[int]
player_names: typing.Dict[int, str]
finished_game: bool
@@ -182,6 +193,10 @@ 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
@@ -217,6 +232,9 @@ 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
@@ -226,6 +244,7 @@ class CommonContext:
self.watcher_event = asyncio.Event()
self.jsontotextparser = JSONtoTextParser(self)
+ self.rawjsontotextparser = RawJSONtoTextParser(self)
self.update_data_package(network_data_package)
# execution
@@ -259,6 +278,7 @@ class CommonContext:
self.items_received = []
self.locations_info = {}
self.server_version = Version(0, 0, 0)
+ self.generator_version = Version(0, 0, 0)
self.server = None
self.server_task = None
self.hint_cost = None
@@ -360,10 +380,13 @@ class CommonContext:
def on_print_json(self, args: dict):
if self.ui:
- self.ui.print_json(args["data"])
- else:
- text = self.jsontotextparser(args["data"])
- logger.info(text)
+ # send copy to UI
+ self.ui.print_json(copy.deepcopy(args["data"]))
+
+ logging.getLogger("FileLog").info(self.rawjsontotextparser(copy.deepcopy(args["data"])),
+ extra={"NoStream": True})
+ logging.getLogger("StreamLog").info(self.jsontotextparser(copy.deepcopy(args["data"])),
+ extra={"NoFile": True})
def on_package(self, cmd: str, args: dict):
"""For custom package handling in subclasses."""
@@ -457,6 +480,21 @@ 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:
@@ -586,7 +624,8 @@ 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)
+ socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None,
+ ssl=get_ssl_context() if address.startswith("wss://") else None)
if ctx.ui is not None:
ctx.ui.update_address_bar(server_url.netloc)
ctx.server = Endpoint(socket)
@@ -601,6 +640,7 @@ 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"
@@ -645,11 +685,16 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
logger.info('Room Information:')
logger.info('--------------------------------')
version = args["version"]
- ctx.server_version = tuple(version)
- version = ".".join(str(item) for item in version)
+ ctx.server_version = Version(*version)
- logger.info(f'Server protocol version: {version}')
- logger.info("Server protocol tags: " + ", ".join(args["tags"]))
+ if "generator_version" in args:
+ ctx.generator_version = Version(*args["generator_version"])
+ logger.info(f'Server protocol version: {ctx.server_version.as_simple_string()}, '
+ f'generator version: {ctx.generator_version.as_simple_string()}, '
+ f'tags: {", ".join(args["tags"])}')
+ else:
+ logger.info(f'Server protocol version: {ctx.server_version.as_simple_string()}, '
+ f'tags: {", ".join(args["tags"])}')
if args['password']:
logger.info('Password required')
ctx.update_permissions(args.get("permissions", {}))
@@ -711,6 +756,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.slot = args["slot"]
# int keys get lost in JSON transfer
ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
+ ctx.hint_points = args.get("hint_points", 0)
ctx.consume_players_package(args["players"])
msgs = []
if ctx.locations_checked:
@@ -719,6 +765,11 @@ 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:
@@ -782,8 +833,13 @@ 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":
- if args["key"] == "EnergyLink":
+ ctx.stored_data[args["key"]] = args["value"]
+ if args["key"].startswith("EnergyLink"):
ctx.current_energy_link_value = args["value"]
if ctx.ui:
ctx.ui.set_new_energy_link_value()
@@ -823,10 +879,9 @@ def get_base_parser(description: typing.Optional[str] = None):
return parser
-if __name__ == '__main__':
- # Text Mode to use !hint and such with games that have no text entry
-
+def run_as_textclient():
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
@@ -841,12 +896,11 @@ if __name__ == '__main__':
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
@@ -859,7 +913,6 @@ if __name__ == '__main__':
await ctx.exit_event.wait()
await ctx.shutdown()
-
import colorama
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
@@ -879,3 +932,7 @@ if __name__ == '__main__':
asyncio.run(main(args))
colorama.deinit()
+
+
+if __name__ == '__main__':
+ run_as_textclient()
diff --git a/FF1Client.py b/FF1Client.py
index 83c2484682..b7c58e2061 100644
--- a/FF1Client.py
+++ b/FF1Client.py
@@ -13,9 +13,9 @@ from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandP
SYSTEM_MESSAGE_ID = 0
-CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart ff1_connector.lua"
-CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure ff1_connector.lua is running"
-CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart ff1_connector.lua"
+CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_ff1.lua"
+CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure connector_ff1.lua is running"
+CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_ff1.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
@@ -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 bizhawk"""
+ """Toggle displaying messages in EmuHawk"""
global DISPLAY_MSGS
DISPLAY_MSGS = not DISPLAY_MSGS
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
diff --git a/FactorioClient.py b/FactorioClient.py
index 9c294c1016..070ca50326 100644
--- a/FactorioClient.py
+++ b/FactorioClient.py
@@ -1,553 +1,12 @@
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()
-import factorio_rcon
-import colorama
-import asyncio
-from queue import Queue
+from worlds.factorio.Client import check_stdin, launch
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()
-
-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()
+ launch()
diff --git a/Fill.py b/Fill.py
index 6fa5ecb00d..600d18ef2a 100644
--- a/Fill.py
+++ b/Fill.py
@@ -5,6 +5,8 @@ import typing
from collections import Counter, deque
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
+from Options import Accessibility
+
from worlds.AutoWorld import call_all
from worlds.generic.Rules import add_item_rule
@@ -39,8 +41,9 @@ 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]] = Counter()
+ swapped_items: typing.Counter[typing.Tuple[int, str, bool]] = Counter()
reachable_items: typing.Dict[int, typing.Deque[Item]] = {}
for item in item_pool:
reachable_items.setdefault(item.player, deque()).append(item)
@@ -50,7 +53,10 @@ 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:
- item_pool.remove(item)
+ for p, pool_item in enumerate(item_pool):
+ if pool_item is item:
+ item_pool.pop(p)
+ break
maximum_exploration_state = sweep_from_pool(
base_state, item_pool + unplaced_items)
@@ -66,7 +72,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
spot_to_fill: typing.Optional[Location] = None
# if minimal accessibility, only check whether location is reachable if game not beatable
- if world.accessibility[item_to_place.player] == 'minimal':
+ if world.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
perform_access_check = not world.has_beaten_game(maximum_exploration_state,
item_to_place.player) \
if single_player_placement else not has_beaten_game
@@ -84,25 +90,28 @@ 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
- for (i, location) in enumerate(placements):
+ # 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:
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]
+ swap_count = swapped_items[placed_item.player, placed_item.name, unsafe]
if swap_count > 1:
continue
location.item = None
placed_item.location = None
- swap_state = sweep_from_pool(base_state, [placed_item])
- # swap_state assumes we can collect placed item before item_to_place
+ 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.
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 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.
+ # Verify placing this item won't reduce available locations, which would be a useless swap.
prev_state = swap_state.copy()
prev_loc_count = len(
world.get_reachable_locations(prev_state))
@@ -117,13 +126,15 @@ 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] = swap_count
+ swapped_items[placed_item.player, placed_item.name, unsafe] = 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
@@ -144,6 +155,16 @@ 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 = [
@@ -246,7 +267,7 @@ def fast_fill(world: MultiWorld,
def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]):
maximum_exploration_state = sweep_from_pool(state, pool)
- minimal_players = {player for player in world.player_ids if world.accessibility[player] == "minimal"}
+ minimal_players = {player for player in world.player_ids if world.worlds[player].options.accessibility == "minimal"}
unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and
not location.can_reach(maximum_exploration_state)]
for location in unreachable_locations:
@@ -269,7 +290,7 @@ def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locat
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
if unreachable_locations:
def forbid_important_item_rule(item: Item):
- return not ((item.classification & 0b0011) and world.accessibility[item.player] != 'minimal')
+ return not ((item.classification & 0b0011) and world.worlds[item.player].options.accessibility != 'minimal')
for location in unreachable_locations:
add_item_rule(location, forbid_important_item_rule)
@@ -512,9 +533,9 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
# If other players are below the threshold value, swap progression in this sphere into earlier spheres,
# which gives more locations available by this sphere.
balanceable_players: typing.Dict[int, float] = {
- player: world.progression_balancing[player] / 100
+ player: world.worlds[player].options.progression_balancing / 100
for player in world.player_ids
- if world.progression_balancing[player] > 0
+ if world.worlds[player].options.progression_balancing > 0
}
if not balanceable_players:
logging.info('Skipping multiworld progression balancing.')
@@ -734,8 +755,6 @@ 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]
@@ -821,12 +840,12 @@ def distribute_planned(world: MultiWorld) -> None:
if "early_locations" in locations:
locations.remove("early_locations")
- for player in worlds:
- locations += early_locations[player]
+ for target_player in worlds:
+ locations += early_locations[target_player]
if "non_early_locations" in locations:
locations.remove("non_early_locations")
- for player in worlds:
- locations += non_early_locations[player]
+ for target_player in worlds:
+ locations += non_early_locations[target_player]
block['locations'] = locations
@@ -878,10 +897,6 @@ 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):
diff --git a/Generate.py b/Generate.py
index afb34f11c6..08fe2b9083 100644
--- a/Generate.py
+++ b/Generate.py
@@ -7,55 +7,52 @@ import random
import string
import urllib.parse
import urllib.request
-from collections import Counter, ChainMap
-from typing import Dict, Tuple, Callable, Any, Union
+from collections import ChainMap, Counter
+from typing import Any, Callable, Dict, Tuple, Union
import ModuleUpdate
ModuleUpdate.update()
+import copy
import Utils
-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 BaseClasses import seeddigits, get_seed, PlandoOptions
+from Main import main as ERmain
+from settings import get_settings
+from Utils import parse_yamls, version_tuple, __version__, tuplize_version, user_path
+from worlds.alttp import Options as LttPOptions
+from worlds.alttp.EntranceRandomizer import parse_arguments
from worlds.alttp.Text import TextTable
from worlds.AutoWorld import AutoWorldRegister
-import copy
-
-
-
+from worlds.generic import PlandoConnection
def mystery_argparse():
- 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)
+ options = get_settings()
+ defaults = options.generator
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=resolve_path(defaults["player_files_path"], user_path),
+ parser.add_argument('--player_files_path', default=defaults.player_files_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=resolve_path(options["general_options"]["output_path"], user_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=options.general_options.output_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)
@@ -72,12 +69,16 @@ 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, ...]] = {}
@@ -85,16 +86,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 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')}")
+ 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')}")
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 destroyed. Please fix your yaml.") from e
- print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
+ 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)}")
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:
@@ -113,35 +114,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 destroyed. Please fix your yaml.") from e
+ raise ValueError(f"File {fname} is invalid. 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:
- print(f"P{player_id} Weights: {filename} >> "
- f"{get_choice('description', yaml, 'No description specified')}")
+ logging.info(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)
- print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: "
- f"{args.plando}")
+ logging.info(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, "
+ f"{seed_name} Seed {seed} with plando: {args.plando}")
if not weights_cache:
- raise Exception(f"No weights found. Provide a general weights file ({args.weights_file_path}) or individual player files. "
+ raise Exception(f"No weights found. "
+ f"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
-
- Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
+ erargs.skip_prog_balancing = args.skip_prog_balancing
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
@@ -156,7 +157,8 @@ def main(args=None, callback=ERmain):
for yaml in weights_cache[path]:
if category_name is None:
for category in yaml:
- if category in AutoWorldRegister.world_types and key in Options.common_options:
+ if category in AutoWorldRegister.world_types and \
+ key in Options.CommonOptions.type_hints:
yaml[category][key] = option
elif category_name not in yaml:
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
@@ -194,7 +196,7 @@ def main(args=None, callback=ERmain):
player += 1
except Exception as e:
- raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
+ raise ValueError(f"File {path} is invalid. Please fix your yaml.") from e
else:
raise RuntimeError(f'No weights specified for player {player}')
@@ -339,7 +341,7 @@ def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
return get_choice(option_key, category_dict)
if game in AutoWorldRegister.world_types:
game_world = AutoWorldRegister.world_types[game]
- options = ChainMap(game_world.option_definitions, Options.per_game_common_options)
+ options = game_world.options_dataclass.type_hints
if option_key in options:
if options[option_key].supports_weighting:
return get_choice(option_key, category_dict)
@@ -373,7 +375,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 destroyed. "
+ raise ValueError(f"Linked option {option_set['name']} is invalid. "
f"Please fix your linked option.") from e
return weights
@@ -403,7 +405,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 destroyed. "
+ raise ValueError(f"Your trigger number {i + 1} is invalid. "
f"Please fix your triggers.") from e
return weights
@@ -444,11 +446,16 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
f"which is not enabled.")
ret = argparse.Namespace()
- for option_key in Options.per_game_common_options:
- if option_key in weights and option_key not in Options.common_options:
+ for option_key in Options.PerGameCommonOptions.type_hints:
+ if option_key in weights and option_key not in Options.CommonOptions.type_hints:
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.")
@@ -460,35 +467,27 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
game_weights = weights[ret.game]
ret.name = get_choice('name', weights)
- for option_key, option in Options.common_options.items():
+ for option_key, option in Options.CommonOptions.type_hints.items():
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
- 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)
- 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}")
+ for option_key, option in world_type.options_dataclass.type_hints.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)
return ret
diff --git a/KH2Client.py b/KH2Client.py
index 5223d8a111..1134932dc2 100644
--- a/KH2Client.py
+++ b/KH2Client.py
@@ -53,79 +53,8 @@ class KH2Context(CommonContext):
self.collectible_override_flags_address = 0
self.collectible_offsets = {}
self.sending = []
- # flag for if the player has gotten their starting inventory from the server
- self.hasStartingInvo = False
# list used to keep track of locations+items player has. Used for disoneccting
- self.kh2seedsave = {"checked_locations": {"0": []},
- "starting_inventory": self.hasStartingInvo,
-
- # Character: [back of invo, front of invo]
- "SoraInvo": [0x25CC, 0x2546],
- "DonaldInvo": [0x2678, 0x2658],
- "GoofyInvo": [0x278E, 0x276C],
- "AmountInvo": {
- "ServerItems": {
- "Ability": {},
- "Amount": {},
- "Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0, "Aerial Dodge": 0,
- "Glide": 0},
- "Bitmask": [],
- "Weapon": {"Sora": [], "Donald": [], "Goofy": []},
- "Equipment": [],
- "Magic": {},
- "StatIncrease": {},
- "Boost": {},
- },
- "LocalItems": {
- "Ability": {},
- "Amount": {},
- "Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
- "Aerial Dodge": 0, "Glide": 0},
- "Bitmask": [],
- "Weapon": {"Sora": [], "Donald": [], "Goofy": []},
- "Equipment": [],
- "Magic": {},
- "StatIncrease": {},
- "Boost": {},
- }},
- # 1,3,255 are in this list in case the player gets locations in those "worlds" and I need to still have them checked
- "worldIdChecks": {
- "1": [], # world of darkness (story cutscenes)
- "2": [],
- "3": [], # destiny island doesn't have checks to ima put tt checks here
- "4": [],
- "5": [],
- "6": [],
- "7": [],
- "8": [],
- "9": [],
- "10": [],
- "11": [],
- # atlantica isn't a supported world. if you go in atlantica it will check dc
- "12": [],
- "13": [],
- "14": [],
- "15": [],
- # world map, but you only go to the world map while on the way to goa so checking hb
- "16": [],
- "17": [],
- "18": [],
- "255": [], # starting screen
- },
- "Levels": {
- "SoraLevel": 0,
- "ValorLevel": 0,
- "WisdomLevel": 0,
- "LimitLevel": 0,
- "MasterLevel": 0,
- "FinalLevel": 0,
- },
- "SoldEquipment": [],
- "SoldBoosts": {"Power Boost": 0,
- "Magic Boost": 0,
- "Defense Boost": 0,
- "AP Boost": 0}
- }
+ self.kh2seedsave = None
self.slotDataProgressionNames = {}
self.kh2seedname = None
self.kh2slotdata = None
@@ -202,14 +131,13 @@ class KH2Context(CommonContext):
self.boost_set = set(CheckDupingItems["Boosts"])
self.stat_increase_set = set(CheckDupingItems["Stat Increases"])
-
self.AbilityQuantityDict = {item: self.item_name_to_data[item].quantity for item in self.all_abilities}
# Growth:[level 1,level 4,slot]
- self.growth_values_dict = {"High Jump": [0x05E, 0x061, 0x25CE],
- "Quick Run": [0x62, 0x65, 0x25D0],
- "Dodge Roll": [0x234, 0x237, 0x25D2],
- "Aerial Dodge": [0x066, 0x069, 0x25D4],
- "Glide": [0x6A, 0x6D, 0x25D6]}
+ self.growth_values_dict = {"High Jump": [0x05E, 0x061, 0x25DA],
+ "Quick Run": [0x62, 0x65, 0x25DC],
+ "Dodge Roll": [0x234, 0x237, 0x25DE],
+ "Aerial Dodge": [0x066, 0x069, 0x25E0],
+ "Glide": [0x6A, 0x6D, 0x25E2]}
self.boost_to_anchor_dict = {
"Power Boost": 0x24F9,
"Magic Boost": 0x24FA,
@@ -269,19 +197,66 @@ class KH2Context(CommonContext):
if not os.path.exists(self.game_communication_path):
os.makedirs(self.game_communication_path)
if not os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"):
+ self.kh2seedsave = {"itemIndex": -1,
+ # back of soras invo is 0x25E2. Growth should be moved there
+ # Character: [back of invo, front of invo]
+ "SoraInvo": [0x25D8, 0x2546],
+ "DonaldInvo": [0x26F4, 0x2658],
+ "GoofyInvo": [0x280A, 0x276C],
+ "AmountInvo": {
+ "ServerItems": {
+ "Ability": {},
+ "Amount": {},
+ "Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
+ "Aerial Dodge": 0,
+ "Glide": 0},
+ "Bitmask": [],
+ "Weapon": {"Sora": [], "Donald": [], "Goofy": []},
+ "Equipment": [],
+ "Magic": {},
+ "StatIncrease": {},
+ "Boost": {},
+ },
+ "LocalItems": {
+ "Ability": {},
+ "Amount": {},
+ "Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
+ "Aerial Dodge": 0, "Glide": 0},
+ "Bitmask": [],
+ "Weapon": {"Sora": [], "Donald": [], "Goofy": []},
+ "Equipment": [],
+ "Magic": {},
+ "StatIncrease": {},
+ "Boost": {},
+ }},
+ # 1,3,255 are in this list in case the player gets locations in those "worlds" and I need to still have them checked
+ "LocationsChecked": [],
+ "Levels": {
+ "SoraLevel": 0,
+ "ValorLevel": 0,
+ "WisdomLevel": 0,
+ "LimitLevel": 0,
+ "MasterLevel": 0,
+ "FinalLevel": 0,
+ },
+ "SoldEquipment": [],
+ "SoldBoosts": {"Power Boost": 0,
+ "Magic Boost": 0,
+ "Defense Boost": 0,
+ "AP Boost": 0}
+ }
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
'wt') as f:
pass
+ self.locations_checked = set()
elif os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"):
with open(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json", 'r') as f:
self.kh2seedsave = json.load(f)
+ self.locations_checked = set(self.kh2seedsave["LocationsChecked"])
+ self.serverconneced = True
if cmd in {"Connected"}:
- for player in args['players']:
- if str(player.slot) not in self.kh2seedsave["checked_locations"]:
- self.kh2seedsave["checked_locations"].update({str(player.slot): []})
self.kh2slotdata = args['slot_data']
- self.serverconneced = True
self.kh2LocalItems = {int(location): item for location, item in self.kh2slotdata["LocalItems"].items()}
try:
self.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX")
@@ -296,21 +271,29 @@ class KH2Context(CommonContext):
if cmd in {"ReceivedItems"}:
start_index = args["index"]
- if start_index != len(self.items_received):
+ if start_index == 0:
+ # resetting everything that were sent from the server
+ self.kh2seedsave["SoraInvo"][0] = 0x25D8
+ self.kh2seedsave["DonaldInvo"][0] = 0x26F4
+ self.kh2seedsave["GoofyInvo"][0] = 0x280A
+ self.kh2seedsave["itemIndex"] = - 1
+ self.kh2seedsave["AmountInvo"]["ServerItems"] = {
+ "Ability": {},
+ "Amount": {},
+ "Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
+ "Aerial Dodge": 0,
+ "Glide": 0},
+ "Bitmask": [],
+ "Weapon": {"Sora": [], "Donald": [], "Goofy": []},
+ "Equipment": [],
+ "Magic": {},
+ "StatIncrease": {},
+ "Boost": {},
+ }
+ if start_index > self.kh2seedsave["itemIndex"]:
+ self.kh2seedsave["itemIndex"] = start_index
for item in args['items']:
- # starting invo from server
- if item.location in {-2}:
- if not self.kh2seedsave["starting_inventory"]:
- asyncio.create_task(self.give_item(item.item))
- # if location is not already given or is !getitem
- elif item.location not in self.kh2seedsave["checked_locations"][str(item.player)] \
- or item.location in {-1}:
- asyncio.create_task(self.give_item(item.item))
- if item.location not in self.kh2seedsave["checked_locations"][str(item.player)] \
- and item.location not in {-1, -2}:
- self.kh2seedsave["checked_locations"][str(item.player)].append(item.location)
- if not self.kh2seedsave["starting_inventory"]:
- self.kh2seedsave["starting_inventory"] = True
+ asyncio.create_task(self.give_item(item.item))
if cmd in {"RoomUpdate"}:
if "checked_locations" in args:
@@ -326,12 +309,12 @@ class KH2Context(CommonContext):
if currentworldint in self.worldid:
curworldid = self.worldid[currentworldint]
for location, data in curworldid.items():
- if location not in self.locations_checked \
+ locationId = kh2_loc_name_to_id[location]
+ if locationId not in self.locations_checked \
and (int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
"big") & 0x1 << data.bitIndex) > 0:
- self.locations_checked.add(location)
- self.sending = self.sending + [(int(kh2_loc_name_to_id[location]))]
+ self.sending = self.sending + [(int(locationId))]
except Exception as e:
logger.info("Line 285")
if self.kh2connected:
@@ -344,12 +327,12 @@ class KH2Context(CommonContext):
for location, data in SoraLevels.items():
currentLevel = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x24FF, 1), "big")
- if location not in self.locations_checked \
+ locationId = kh2_loc_name_to_id[location]
+ if locationId not in self.locations_checked \
and currentLevel >= data.bitIndex:
if self.kh2seedsave["Levels"]["SoraLevel"] < currentLevel:
self.kh2seedsave["Levels"]["SoraLevel"] = currentLevel
- self.locations_checked.add(location)
- self.sending = self.sending + [(int(kh2_loc_name_to_id[location]))]
+ self.sending = self.sending + [(int(locationId))]
formDict = {
0: ["ValorLevel", ValorLevels], 1: ["WisdomLevel", WisdomLevels], 2: ["LimitLevel", LimitLevels],
3: ["MasterLevel", MasterLevels], 4: ["FinalLevel", FinalLevels]}
@@ -357,12 +340,12 @@ class KH2Context(CommonContext):
for location, data in formDict[i][1].items():
formlevel = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1), "big")
- if location not in self.locations_checked \
+ locationId = kh2_loc_name_to_id[location]
+ if locationId not in self.locations_checked \
and formlevel >= data.bitIndex:
if formlevel > self.kh2seedsave["Levels"][formDict[i][0]]:
self.kh2seedsave["Levels"][formDict[i][0]] = formlevel
- self.locations_checked.add(location)
- self.sending = self.sending + [(int(kh2_loc_name_to_id[location]))]
+ self.sending = self.sending + [(int(locationId))]
except Exception as e:
logger.info("Line 312")
if self.kh2connected:
@@ -373,18 +356,20 @@ class KH2Context(CommonContext):
async def checkSlots(self):
try:
for location, data in weaponSlots.items():
- if location not in self.locations_checked:
+ locationId = kh2_loc_name_to_id[location]
+ if locationId not in self.locations_checked:
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
"big") > 0:
- self.locations_checked.add(location)
- self.sending = self.sending + [(int(kh2_loc_name_to_id[location]))]
+ self.sending = self.sending + [(int(locationId))]
for location, data in formSlots.items():
- if location not in self.locations_checked:
+ locationId = kh2_loc_name_to_id[location]
+ if locationId not in self.locations_checked:
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
"big") & 0x1 << data.bitIndex > 0:
- self.locations_checked.add(location)
- self.sending = self.sending + [(int(kh2_loc_name_to_id[location]))]
+ # self.locations_checked
+ self.sending = self.sending + [(int(locationId))]
+
except Exception as e:
if self.kh2connected:
logger.info("Line 333")
@@ -394,8 +379,7 @@ class KH2Context(CommonContext):
async def verifyChests(self):
try:
- currentworld = str(int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x0714DB8, 1), "big"))
- for location in self.kh2seedsave["worldIdChecks"][currentworld]:
+ for location in self.locations_checked:
locationName = self.lookup_id_to_Location[location]
if locationName in self.chest_set:
if locationName in self.location_name_to_worlddata.keys():
@@ -428,24 +412,6 @@ class KH2Context(CommonContext):
self.kh2.write_bytes(self.kh2.base_address + self.Save + anchor,
(self.kh2seedsave["Levels"][leveltype]).to_bytes(1, 'big'), 1)
- def verifyLocation(self, location):
- locationData = self.location_name_to_worlddata[location]
- locationName = self.lookup_id_to_Location[location]
- isChecked = True
-
- if locationName not in levels_locations:
- if (int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained, 1),
- "big") & 0x1 << locationData.bitIndex) == 0:
- isChecked = False
- elif locationName in SoraLevels:
- if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x24FF, 1),
- "big") < locationData.bitIndex:
- isChecked = False
- elif int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained, 1),
- "big") < locationData.bitIndex:
- isChecked = False
- return isChecked
-
async def give_item(self, item, ItemType="ServerItems"):
try:
itemname = self.lookup_id_to_item[item]
@@ -679,7 +645,21 @@ class KH2Context(CommonContext):
current = self.kh2.read_short(self.kh2.base_address + self.Save + slot)
ability = current & 0x0FFF
if ability | 0x8000 != (0x8000 + itemData.memaddr):
- self.kh2.write_short(self.kh2.base_address + self.Save + slot, itemData.memaddr)
+ if current - 0x8000 > 0:
+ self.kh2.write_short(self.kh2.base_address + self.Save + slot, (0x8000 + itemData.memaddr))
+ else:
+ self.kh2.write_short(self.kh2.base_address + self.Save + slot, itemData.memaddr)
+ # removes the duped ability if client gave faster than the game.
+ for charInvo in {"SoraInvo", "DonaldInvo", "GoofyInvo"}:
+ if self.kh2.read_short(self.kh2.base_address + self.Save + self.kh2seedsave[charInvo][1]) != 0 and \
+ self.kh2seedsave[charInvo][1] + 2 < self.kh2seedsave[charInvo][0]:
+ self.kh2.write_short(self.kh2.base_address + self.Save + self.kh2seedsave[charInvo][1], 0)
+ # remove the dummy level 1 growths if they are in these invo slots.
+ for inventorySlot in {0x25CE, 0x25D0, 0x25D2, 0x25D4, 0x25D6, 0x25D8}:
+ current = self.kh2.read_short(self.kh2.base_address + self.Save + inventorySlot)
+ ability = current & 0x0FFF
+ if 0x05E <= ability <= 0x06D:
+ self.kh2.write_short(self.kh2.base_address + self.Save + inventorySlot, 0)
for itemName in self.master_growth:
growthLevel = self.kh2seedsave["AmountInvo"]["ServerItems"]["Growth"][itemName] \
@@ -707,6 +687,10 @@ class KH2Context(CommonContext):
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), "big")
if (int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") & 0x1 << itemData.bitmask) == 0:
+ # when getting a form anti points should be reset to 0 but bit-shift doesn't trigger the game.
+ if itemName in {"Valor Form", "Wisdom Form", "Limit Form", "Master Form", "Final Form"}:
+ self.kh2.write_bytes(self.kh2.base_address + self.Save + 0x3410,
+ (0).to_bytes(1, 'big'), 1)
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(itemMemory | 0x01 << itemData.bitmask).to_bytes(1, 'big'), 1)
@@ -753,10 +737,13 @@ class KH2Context(CommonContext):
if itemName in server_stat:
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["StatIncrease"][itemName]
+ # 0x130293 is Crit_1's location id for touching the computer
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != amountOfItems \
and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Slot1 + 0x1B2, 1),
- "big") >= 5:
+ "big") >= 5 and int.from_bytes(
+ self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x23DF, 1),
+ "big") > 0:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
amountOfItems.to_bytes(1, 'big'), 1)
@@ -777,7 +764,8 @@ class KH2Context(CommonContext):
if itemName == "AP Boost":
amountOfUsedBoosts -= 50
totalBoosts = (amountOfBoostsInInvo + amountOfUsedBoosts)
- if totalBoosts <= amountOfItems - self.kh2seedsave["SoldBoosts"][itemName] and amountOfBoostsInInvo < 255:
+ if totalBoosts <= amountOfItems - self.kh2seedsave["SoldBoosts"][
+ itemName] and amountOfBoostsInInvo < 255:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(amountOfBoostsInInvo + 1).to_bytes(1, 'big'), 1)
@@ -859,9 +847,9 @@ async def kh2_watcher(ctx: KH2Context):
location_ids = []
location_ids = [location for location in message[0]["locations"] if location not in location_ids]
for location in location_ids:
- currentWorld = int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + 0x0714DB8, 1), "big")
- if location not in ctx.kh2seedsave["worldIdChecks"][str(currentWorld)]:
- ctx.kh2seedsave["worldIdChecks"][str(currentWorld)].append(location)
+ if location not in ctx.locations_checked:
+ ctx.locations_checked.add(location)
+ ctx.kh2seedsave["LocationsChecked"].append(location)
if location in ctx.kh2LocalItems:
item = ctx.kh2slotdata["LocalItems"][str(location)]
await asyncio.create_task(ctx.give_item(item, "LocalItems"))
diff --git a/Launcher.py b/Launcher.py
index be40987e32..9e184bf108 100644
--- a/Launcher.py
+++ b/Launcher.py
@@ -11,14 +11,19 @@ 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
import sys
+import webbrowser
from os.path import isfile
from shutil import which
from typing import Sequence, Union, Optional
-from worlds.LauncherComponents import Component, components, Type, SuffixIdentifier
+import Utils
+import settings
+from worlds.LauncherComponents import Component, components, Type, SuffixIdentifier, icon_paths
if __name__ == "__main__":
import ModuleUpdate
@@ -29,7 +34,8 @@ from Utils import is_frozen, user_path, local_path, init_logging, open_filename,
def open_host_yaml():
- file = user_path('host.yaml')
+ file = settings.get_settings().filename
+ assert file, "host.yaml missing"
if is_linux:
exe = which('sensible-editor') or which('gedit') or \
which('xdg-open') or which('gnome-open') or which('kde-open')
@@ -38,79 +44,103 @@ def open_host_yaml():
exe = which("open")
subprocess.Popen([exe, file])
else:
- import webbrowser
webbrowser.open(file)
def open_patch():
suffixes = []
for c in components:
- if isfile(get_exe(c)[-1]):
- suffixes += c.file_identifier.suffixes if c.type == Type.CLIENT and \
- isinstance(c.file_identifier, SuffixIdentifier) else []
+ 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
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:
- launch([*get_exe(component), file], component.cli)
+ exe = get_exe(component)
+ if exe is None or not isfile(exe[-1]):
+ exe = get_exe("Launcher")
+
+ launch([*exe, file], component.cli)
+
+
+def generate_yamls():
+ from Options import generate_yaml_templates
+
+ target = Utils.user_path("Players", "Templates")
+ generate_yaml_templates(target, False)
+ open_folder(target)
def browse_files():
- file = user_path()
+ open_folder(user_path())
+
+
+def open_folder(folder_path):
if is_linux:
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
- subprocess.Popen([exe, file])
+ subprocess.Popen([exe, folder_path])
elif is_macos:
exe = which("open")
- subprocess.Popen([exe, file])
+ subprocess.Popen([exe, folder_path])
else:
- import webbrowser
- webbrowser.open(file)
+ 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),
- Component('Open Patch', func=open_patch),
- Component('Browse Files', func=browse_files),
+ Component("Open host.yaml", func=open_host_yaml),
+ Component("Open Patch", func=open_patch),
+ Component("Generate Template Settings", func=generate_yamls),
+ Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
+ Component("18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
+ Component("Browse Files", func=browse_files),
])
def identify(path: Union[None, str]):
if path is None:
- return None, None, None
+ return None, None
for component in components:
if component.handles_file(path):
- return path, component.script_name, component
- return (None, None, None) if '/' in path or '\\' in path else (None, path, None)
+ return path, component
+ elif path == component.display_name or path == component.script_name:
+ return None, component
+ return None, 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}')]
+ suffix = ".exe" if is_windows else ""
+ return [local_path(f"{component.frozen_name}{suffix}")] if component.frozen_name else None
else:
- return [sys.executable, local_path(f'{component.script_name}.py')]
+ return [sys.executable, local_path(f"{component.script_name}.py")] if component.script_name else None
def launch(exe, in_terminal=False):
@@ -132,16 +162,18 @@ def launch(exe, in_terminal=False):
def run_gui():
from kvui import App, ContainerLayout, GridLayout, Button, Label
+ from kivy.uix.image import AsyncImage
+ from kivy.uix.relativelayout import RelativeLayout
class Launcher(App):
base_title: str = "Archipelago Launcher"
container: ContainerLayout
grid: GridLayout
- _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}
+ _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}
def __init__(self, ctx=None):
self.title = self.base_title
@@ -153,24 +185,44 @@ def run_gui():
self.container = ContainerLayout()
self.grid = GridLayout(cols=2)
self.container.add_widget(self.grid)
-
+ self.grid.add_widget(Label(text="General"))
+ self.grid.add_widget(Label(text="Clients"))
button_layout = self.grid # make buttons fill the window
+
+ def build_button(component: Component):
+ """
+ Builds a button widget for a given component.
+
+ Args:
+ component (Component): The component associated with the button.
+
+ Returns:
+ None. The button is added to the parent grid layout.
+
+ """
+ button = Button(text=component.display_name)
+ button.component = component
+ button.bind(on_release=self.component_action)
+ if component.icon != "icon":
+ image = AsyncImage(source=icon_paths[component.icon],
+ size=(38, 38), size_hint=(None, 1), pos=(5, 0))
+ box_layout = RelativeLayout()
+ box_layout.add_widget(button)
+ box_layout.add_widget(image)
+ button_layout.add_widget(box_layout)
+ else:
+ button_layout.add_widget(button)
+
for (tool, client) in itertools.zip_longest(itertools.chain(
- self._tools.items(), self._funcs.items(), self._adjusters.items()), self._clients.items()):
+ self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()):
# column 1
if tool:
- button = Button(text=tool[0])
- button.component = tool[1]
- button.bind(on_release=self.component_action)
- button_layout.add_widget(button)
+ build_button(tool[1])
else:
button_layout.add_widget(Label())
# column 2
if client:
- button = Button(text=client[0])
- button.component = client[1]
- button.bind(on_press=self.component_action)
- button_layout.add_widget(button)
+ build_button(client[1])
else:
button_layout.add_widget(Label())
@@ -178,14 +230,29 @@ def run_gui():
@staticmethod
def component_action(button):
- if button.component.type == Type.FUNC:
+ if button.component.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()}
@@ -193,24 +260,40 @@ 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:
- subprocess.run([*get_exe(args['component']), args['file'], *args['args']])
+ run_component(args["component"], args["file"], *args["args"])
elif 'component' in args:
- subprocess.run([*get_exe(args['component']), *args['args']])
- else:
+ run_component(args["component"], *args["args"])
+ elif not args["update_settings"]:
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
parser = argparse.ArgumentParser(description='Archipelago Launcher')
- 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.")
+ 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.")
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()
diff --git a/LinksAwakeningClient.py b/LinksAwakeningClient.py
index e0557e4af4..f3fc9d2cdb 100644
--- a/LinksAwakeningClient.py
+++ b/LinksAwakeningClient.py
@@ -9,15 +9,18 @@ if __name__ == "__main__":
import asyncio
import base64
import binascii
+import colorama
import io
-import logging
+import os
+import re
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,
@@ -30,6 +33,7 @@ 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
@@ -91,7 +95,7 @@ class LAClientConstants:
# wLinkSendShopTarget = 0xDDFF
- wRecvIndex = 0xDDFE # 0xDB58
+ wRecvIndex = 0xDDFD # Two bytes
wCheckAddress = 0xC0FF - 0x4
WRamCheckSize = 0x4
WRamSafetyValue = bytearray([0]*WRamCheckSize)
@@ -115,17 +119,17 @@ class RAGameboy():
assert (self.socket)
self.socket.setblocking(False)
- def get_retroarch_version(self):
- self.send(b'VERSION\n')
- select.select([self.socket], [], [])
- response_str, addr = self.socket.recvfrom(16)
+ 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)
return response_str.rstrip()
- 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()
+ 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 set_cache_limits(self, cache_start, cache_size):
self.cache_start = cache_start
@@ -141,8 +145,8 @@ class RAGameboy():
response, _ = self.socket.recvfrom(4096)
return response
- async def async_recv(self):
- response = await asyncio.get_event_loop().sock_recv(self.socket, 4096)
+ async def async_recv(self, timeout=1.0):
+ response = await asyncio.wait_for(asyncio.get_event_loop().sock_recv(self.socket, 4096), timeout)
return response
async def check_safe_gameplay(self, throw=True):
@@ -169,6 +173,8 @@ class RAGameboy():
raise InvalidEmulatorStateError()
return False
if not await check_wram():
+ if throw:
+ raise InvalidEmulatorStateError()
return False
return True
@@ -227,20 +233,30 @@ 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
-
- # TODO: transform to bytes
- if splits[2][:2] == "-1" or splits[0] != "READ_CORE_MEMORY":
+ if splits[2][:2] == "-1":
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):
@@ -248,14 +264,21 @@ 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()
- assert (splits[0] == command)
- # Ignore the address for now
+ if response_addr != address:
+ raise BadRetroArchResponse()
- # TODO: transform to bytes
- return bytearray.fromhex(splits[2])
+ ret = bytearray.fromhex(splits[2])
+ if len(ret) > size:
+ raise BadRetroArchResponse()
+ return ret
def write_memory(self, address, bytes):
command = "WRITE_CORE_MEMORY"
@@ -263,7 +286,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)
@@ -281,6 +304,9 @@ 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)
@@ -288,50 +314,48 @@ class LinksAwakeningClient():
self.gameboy.send(s)
def __init__(self, retroarch_address="127.0.0.1", retroarch_port=55355):
- self.gameboy = RAGameboy(retroarch_address, retroarch_port)
+ self.retroarch_address = retroarch_address
+ self.retroarch_port = retroarch_port
+ pass
+ stop_bizhawk_spam = False
async def wait_for_retroarch_connection(self):
- logger.info("Waiting on connection to Retroarch...")
+ 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)
+
while True:
try:
- version = self.gameboy.get_retroarch_version()
+ version = await 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:
- 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)
+ 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')}")
return
- except ConnectionResetError:
+ except (BlockingIOError, TimeoutError, ConnectionResetError):
await asyncio.sleep(1.0)
pass
- def reset_auth(self):
- auth = binascii.hexlify(self.gameboy.read_memory(0x0134, 12)).decode()
-
- if self.auth:
- assert (auth == self.auth)
-
+ async def reset_auth(self):
+ auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode()
self.auth = auth
async def wait_and_init_tracker(self):
@@ -365,14 +389,16 @@ class LinksAwakeningClient():
item_id, from_player])
status |= 1
status = self.gameboy.write_memory(LAClientConstants.wLinkStatusBits, [status])
- self.gameboy.write_memory(LAClientConstants.wRecvIndex, [next_index])
+ self.gameboy.write_memory(LAClientConstants.wRecvIndex, struct.pack(">H", 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):
- pass
- logger.info("Ready!")
- last_index = 0
+ if self.should_reset_auth:
+ self.should_reset_auth = False
+ raise GameboyException("Resetting due to wrong archipelago server")
+ logger.info("Game connection ready!")
async def is_victory(self):
return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1
@@ -382,11 +408,6 @@ 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
@@ -404,7 +425,7 @@ class LinksAwakeningClient():
if await self.is_victory():
await win_cb()
- recv_index = (await self.gameboy.async_read_memory_safe(LAClientConstants.wRecvIndex))[0]
+ recv_index = struct.unpack(">H", await self.gameboy.async_read_memory(LAClientConstants.wRecvIndex, 2))[0]
# Play back one at a time
if recv_index in self.recvd_checks:
@@ -438,12 +459,16 @@ class LinksAwakeningContext(CommonContext):
found_checks = []
last_resend = time.time()
- magpie = MagpieBridge()
+ magpie_enabled = False
+ magpie = None
magpie_task = None
won = False
- def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None:
+ def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
self.client = LinksAwakeningClient()
+ if magpie:
+ self.magpie_enabled = True
+ self.magpie = MagpieBridge()
super().__init__(server_address, password)
def run_gui(self) -> None:
@@ -462,16 +487,17 @@ class LinksAwakeningContext(CommonContext):
def build(self):
b = super().build()
- 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)
+ 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)
- 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)
@@ -481,6 +507,15 @@ 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:
@@ -506,13 +541,23 @@ class LinksAwakeningContext(CommonContext):
def new_checks(self, item_ids, ladxr_ids):
self.found_checks += item_ids
create_task_log_exception(self.send_checks())
- create_task_log_exception(self.magpie.send_new_checks(ladxr_ids))
+ if self.magpie_enabled:
+ 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):
@@ -520,9 +565,13 @@ 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"], args["index"]):
+ for index, item in enumerate(args["items"], start=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):
@@ -537,18 +586,33 @@ class LinksAwakeningContext(CommonContext):
async def deathlink():
await self.send_deathlink()
- self.magpie_task = asyncio.create_task(self.magpie.serve())
-
+ if self.magpie_enabled:
+ 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
- logger.info("(Re)Starting game loop")
+ if not self.client.stop_bizhawk_spam:
+ 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()
- self.client.reset_auth()
+ 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()
+
await self.client.wait_and_init_tracker()
while True:
@@ -558,39 +622,62 @@ class LinksAwakeningContext(CommonContext):
if self.last_resend + 5.0 < now:
self.last_resend = now
await self.send_checks()
- 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)
+ 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)
- except GameboyException:
- time.sleep(1.0)
- pass
+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__))
+ 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:
- args.url = meta["server"]
+ if "server" in meta and not args.connect:
+ args.connect = 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)
+ ctx = LinksAwakeningContext(args.connect, args.password, args.magpie)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
@@ -600,6 +687,10 @@ 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()
diff --git a/LttPAdjuster.py b/LttPAdjuster.py
index 205a76813a..9c5bd10244 100644
--- a/LttPAdjuster.py
+++ b/LttPAdjuster.py
@@ -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, tkinter_center_window, init_logging
+ get_adjuster_settings, get_adjuster_settings_no_defaults, tkinter_center_window, init_logging
GAME_ALTTP = "A Link to the Past"
@@ -43,8 +43,49 @@ 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):
-def main():
+ _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:
parser = argparse.ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
parser.add_argument('rom', nargs="?", default='AP_LttP.sfc', help='Path to an ALttP rom to adjust.')
@@ -52,6 +93,8 @@ def main():
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='''\
@@ -61,7 +104,7 @@ def main():
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('--disablemusic', help='Disables game music.', action='store_true')
+ parser.add_argument('--music', default=True, help='Enables/Disables game music.', action=BooleanOptionalActionWithDisable)
parser.add_argument('--triforcehud', default='hide_goal', const='hide_goal', nargs='?',
choices=['normal', 'hide_goal', 'hide_required', 'hide_both'],
help='''\
@@ -85,9 +128,6 @@ def main():
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'])
@@ -107,10 +147,23 @@ def main():
Alternatively, can be a ALttP Rom patched with a Link
sprite that will be extracted.
''')
- parser.add_argument('--names', default='', type=str)
+ 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('--update_sprites', action='store_true', help='Update Sprite Database, then exit.')
- args = parser.parse_args()
- args.music = not args.disablemusic
+ return parser
+
+
+def main():
+ parser = get_argparser()
+ args = parser.parse_args(namespace=get_adjuster_settings_no_defaults(GAME_ALTTP))
+
# set up logger
loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[
args.loglevel]
@@ -126,6 +179,13 @@ def main():
if args.sprite is not None and not os.path.isfile(args.sprite) and not Sprite.get_sprite_from_name(args.sprite):
input('Could not find link sprite sheet at given location. \nPress Enter to exit.')
sys.exit(1)
+ if args.oof is not None and not os.path.isfile(args.oof):
+ input('Could not find oof sound effect at given location. \nPress Enter to exit.')
+ sys.exit(1)
+ if args.oof is not None and os.path.getsize(args.oof) > 2673:
+ input('"oof" sound effect cannot exceed 2673 bytes. \nPress Enter to exit.')
+ sys.exit(1)
+
args, path = adjust(args=args)
if isinstance(args.sprite, Sprite):
@@ -165,7 +225,7 @@ def adjust(args):
world = getattr(args, "world")
apply_rom_settings(rom, args.heartbeep, args.heartcolor, args.quickswap, args.menuspeed, args.music,
- args.sprite, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world,
+ args.sprite, args.oof, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world,
deathlink=args.deathlink, allowcollect=args.allowcollect)
path = output_path(f'{os.path.basename(args.rom)[:-4]}_adjusted.sfc')
rom.write_to_file(path)
@@ -180,7 +240,7 @@ def adjustGUI():
from tkinter import Tk, LEFT, BOTTOM, TOP, \
StringVar, Frame, Label, X, Entry, Button, filedialog, messagebox, ttk
from argparse import Namespace
- from Main import __version__ as MWVersion
+ from Utils import __version__ as MWVersion
adjustWindow = Tk()
adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion)
set_icon(adjustWindow)
@@ -227,6 +287,7 @@ def adjustGUI():
guiargs.sprite = rom_vars.sprite
if rom_vars.sprite_pool:
guiargs.world = AdjusterWorld(rom_vars.sprite_pool)
+ guiargs.oof = rom_vars.oof
try:
guiargs, path = adjust(args=guiargs)
@@ -265,6 +326,7 @@ def adjustGUI():
else:
guiargs.sprite = rom_vars.sprite
guiargs.sprite_pool = rom_vars.sprite_pool
+ guiargs.oof = rom_vars.oof
persistent_store("adjuster", GAME_ALTTP, guiargs)
messagebox.showinfo(title="Success", message="Settings saved to persistent storage")
@@ -481,11 +543,38 @@ class BackgroundTaskProgressNullWindow(BackgroundTask):
self.stop()
+class AttachTooltip(object):
+
+ def __init__(self, parent, text):
+ self._parent = parent
+ self._text = text
+ self._window = None
+ parent.bind('
".join(location for location in locations)
- pre_message += f"]"
- formatted_message = pre_message + message + "[/ref]"
-
- return formatted_message
-
-
-def request_available_missions(ctx: SC2Context):
- if ctx.mission_req_table:
- message = "Available Missions: "
-
- # Initialize mission unlock table
- unlocks = initialize_blank_mission_dict(ctx.mission_req_table)
-
- missions = calc_available_missions(ctx, unlocks)
- message += \
- ", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}"
- f"[{ctx.mission_req_table[mission].id}]"
- for mission in missions)
-
- if ctx.ui:
- ctx.ui.log_panels['All'].on_message_markup(message)
- ctx.ui.log_panels['Starcraft2'].on_message_markup(message)
- else:
- sc2_logger.info(message)
- else:
- sc2_logger.warning("No mission table found, you are likely not connected to a server.")
-
-
-def calc_available_missions(ctx: SC2Context, unlocks=None):
- available_missions = []
- missions_complete = 0
-
- # Get number of missions completed
- for loc in ctx.checked_locations:
- if loc % victory_modulo == 0:
- missions_complete += 1
-
- for name in ctx.mission_req_table:
- # Go through the required missions for each mission and fill up unlock table used later for hover-over tooltips
- if unlocks:
- for unlock in ctx.mission_req_table[name].required_world:
- unlocks[list(ctx.mission_req_table)[unlock - 1]].append(name)
-
- if mission_reqs_completed(ctx, name, missions_complete):
- available_missions.append(name)
-
- return available_missions
-
-
-def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete: int):
- """Returns a bool signifying if the mission has all requirements complete and can be done
-
- Arguments:
- ctx -- instance of SC2Context
- locations_to_check -- the mission string name to check
- missions_complete -- an int of how many missions have been completed
- mission_path -- a list of missions that have already been checked
-"""
- if len(ctx.mission_req_table[mission_name].required_world) >= 1:
- # A check for when the requirements are being or'd
- or_success = False
-
- # Loop through required missions
- for req_mission in ctx.mission_req_table[mission_name].required_world:
- req_success = True
-
- # Check if required mission has been completed
- if not (ctx.mission_req_table[list(ctx.mission_req_table)[req_mission - 1]].id *
- victory_modulo + SC2WOL_LOC_ID_OFFSET) in ctx.checked_locations:
- if not ctx.mission_req_table[mission_name].or_requirements:
- return False
- else:
- req_success = False
-
- # Grid-specific logic (to avoid long path checks and infinite recursion)
- if ctx.mission_order in (3, 4):
- if req_success:
- return True
- else:
- if req_mission is ctx.mission_req_table[mission_name].required_world[-1]:
- return False
- else:
- continue
-
- # Recursively check required mission to see if it's requirements are met, in case !collect has been done
- # Skipping recursive check on Grid settings to speed up checks and avoid infinite recursion
- if not mission_reqs_completed(ctx, list(ctx.mission_req_table)[req_mission - 1], missions_complete):
- if not ctx.mission_req_table[mission_name].or_requirements:
- return False
- else:
- req_success = False
-
- # If requirement check succeeded mark or as satisfied
- if ctx.mission_req_table[mission_name].or_requirements and req_success:
- or_success = True
-
- if ctx.mission_req_table[mission_name].or_requirements:
- # Return false if or requirements not met
- if not or_success:
- return False
-
- # Check number of missions
- if missions_complete >= ctx.mission_req_table[mission_name].number:
- return True
- else:
- return False
- else:
- return True
-
-
-def initialize_blank_mission_dict(location_table):
- unlocks = {}
-
- for mission in list(location_table):
- unlocks[mission] = []
-
- return unlocks
-
-
-def check_game_install_path() -> bool:
- # First thing: go to the default location for ExecuteInfo.
- # An exception for Windows is included because it's very difficult to find ~\Documents if the user moved it.
- if is_windows:
- # The next five lines of utterly inscrutable code are brought to you by copy-paste from Stack Overflow.
- # https://stackoverflow.com/questions/6227590/finding-the-users-my-documents-path/30924555#
- import ctypes.wintypes
- CSIDL_PERSONAL = 5 # My Documents
- SHGFP_TYPE_CURRENT = 0 # Get current, not default value
-
- buf = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH)
- ctypes.windll.shell32.SHGetFolderPathW(None, CSIDL_PERSONAL, None, SHGFP_TYPE_CURRENT, buf)
- documentspath = buf.value
- einfo = str(documentspath / Path("StarCraft II\\ExecuteInfo.txt"))
- else:
- einfo = str(sc2.paths.get_home() / Path(sc2.paths.USERPATH[sc2.paths.PF]))
-
- # Check if the file exists.
- if os.path.isfile(einfo):
-
- # Open the file and read it, picking out the latest executable's path.
- with open(einfo) as f:
- content = f.read()
- if content:
- try:
- base = re.search(r" = (.*)Versions", content).group(1)
- except AttributeError:
- sc2_logger.warning(f"Found {einfo}, but it was empty. Run SC2 through the Blizzard launcher, then "
- f"try again.")
- return False
- if os.path.exists(base):
- executable = sc2.paths.latest_executeble(Path(base).expanduser() / "Versions")
-
- # Finally, check the path for an actual executable.
- # If we find one, great. Set up the SC2PATH.
- if os.path.isfile(executable):
- sc2_logger.info(f"Found an SC2 install at {base}!")
- sc2_logger.debug(f"Latest executable at {executable}.")
- os.environ["SC2PATH"] = base
- sc2_logger.debug(f"SC2PATH set to {base}.")
- return True
- else:
- sc2_logger.warning(f"We may have found an SC2 install at {base}, but couldn't find {executable}.")
- else:
- sc2_logger.warning(f"{einfo} pointed to {base}, but we could not find an SC2 install there.")
- else:
- sc2_logger.warning(f"Couldn't find {einfo}. Run SC2 through the Blizzard launcher, then try again. "
- f"If that fails, please run /set_path with your SC2 install directory.")
- return False
-
-
-def is_mod_installed_correctly() -> bool:
- """Searches for all required files."""
- if "SC2PATH" not in os.environ:
- check_game_install_path()
-
- mapdir = os.environ['SC2PATH'] / Path('Maps/ArchipelagoCampaign')
- modfile = os.environ["SC2PATH"] / Path("Mods/Archipelago.SC2Mod")
- wol_required_maps = [
- "ap_thanson01.SC2Map", "ap_thanson02.SC2Map", "ap_thanson03a.SC2Map", "ap_thanson03b.SC2Map",
- "ap_thorner01.SC2Map", "ap_thorner02.SC2Map", "ap_thorner03.SC2Map", "ap_thorner04.SC2Map", "ap_thorner05s.SC2Map",
- "ap_traynor01.SC2Map", "ap_traynor02.SC2Map", "ap_traynor03.SC2Map",
- "ap_ttosh01.SC2Map", "ap_ttosh02.SC2Map", "ap_ttosh03a.SC2Map", "ap_ttosh03b.SC2Map",
- "ap_ttychus01.SC2Map", "ap_ttychus02.SC2Map", "ap_ttychus03.SC2Map", "ap_ttychus04.SC2Map", "ap_ttychus05.SC2Map",
- "ap_tvalerian01.SC2Map", "ap_tvalerian02a.SC2Map", "ap_tvalerian02b.SC2Map", "ap_tvalerian03.SC2Map",
- "ap_tzeratul01.SC2Map", "ap_tzeratul02.SC2Map", "ap_tzeratul03.SC2Map", "ap_tzeratul04.SC2Map"
- ]
- needs_files = False
-
- # Check for maps.
- missing_maps = []
- for mapfile in wol_required_maps:
- if not os.path.isfile(mapdir / mapfile):
- missing_maps.append(mapfile)
- if len(missing_maps) >= 19:
- sc2_logger.warning(f"All map files missing from {mapdir}.")
- needs_files = True
- elif len(missing_maps) > 0:
- for map in missing_maps:
- sc2_logger.debug(f"Missing {map} from {mapdir}.")
- sc2_logger.warning(f"Missing {len(missing_maps)} map files.")
- needs_files = True
- else: # Must be no maps missing
- sc2_logger.info(f"All maps found in {mapdir}.")
-
- # Check for mods.
- if os.path.isfile(modfile):
- sc2_logger.info(f"Archipelago mod found at {modfile}.")
- else:
- sc2_logger.warning(f"Archipelago mod could not be found at {modfile}.")
- needs_files = True
-
- # Final verdict.
- if needs_files:
- sc2_logger.warning(f"Required files are missing. Run /download_data to acquire them.")
- return False
- else:
- return True
-
-
-class DllDirectory:
- # Credit to Black Sliver for this code.
- # More info: https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-setdlldirectoryw
- _old: typing.Optional[str] = None
- _new: typing.Optional[str] = None
-
- def __init__(self, new: typing.Optional[str]):
- self._new = new
-
- def __enter__(self):
- old = self.get()
- if self.set(self._new):
- self._old = old
-
- def __exit__(self, *args):
- if self._old is not None:
- self.set(self._old)
-
- @staticmethod
- def get() -> typing.Optional[str]:
- if sys.platform == "win32":
- n = ctypes.windll.kernel32.GetDllDirectoryW(0, None)
- buf = ctypes.create_unicode_buffer(n)
- ctypes.windll.kernel32.GetDllDirectoryW(n, buf)
- return buf.value
- # NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific
- return None
-
- @staticmethod
- def set(s: typing.Optional[str]) -> bool:
- if sys.platform == "win32":
- return ctypes.windll.kernel32.SetDllDirectoryW(s) != 0
- # NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific
- return False
-
-
-def download_latest_release_zip(owner: str, repo: str, current_version: str = None, force_download=False) -> (str, str):
- """Downloads the latest release of a GitHub repo to the current directory as a .zip file."""
- import requests
-
- headers = {"Accept": 'application/vnd.github.v3+json'}
- url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
-
- r1 = requests.get(url, headers=headers)
- if r1.status_code == 200:
- latest_version = r1.json()["tag_name"]
- sc2_logger.info(f"Latest version: {latest_version}.")
- else:
- sc2_logger.warning(f"Status code: {r1.status_code}")
- sc2_logger.warning(f"Failed to reach GitHub. Could not find download link.")
- sc2_logger.warning(f"text: {r1.text}")
- return "", current_version
-
- if (force_download is False) and (current_version == latest_version):
- sc2_logger.info("Latest version already installed.")
- return "", current_version
-
- sc2_logger.info(f"Attempting to download version {latest_version} of {repo}.")
- download_url = r1.json()["assets"][0]["browser_download_url"]
-
- r2 = requests.get(download_url, headers=headers)
- if r2.status_code == 200 and zipfile.is_zipfile(io.BytesIO(r2.content)):
- with open(f"{repo}.zip", "wb") as fh:
- fh.write(r2.content)
- sc2_logger.info(f"Successfully downloaded {repo}.zip.")
- return f"{repo}.zip", latest_version
- else:
- sc2_logger.warning(f"Status code: {r2.status_code}")
- sc2_logger.warning("Download failed.")
- sc2_logger.warning(f"text: {r2.text}")
- return "", current_version
-
-
-def is_mod_update_available(owner: str, repo: str, current_version: str) -> bool:
- import requests
-
- headers = {"Accept": 'application/vnd.github.v3+json'}
- url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
-
- r1 = requests.get(url, headers=headers)
- if r1.status_code == 200:
- latest_version = r1.json()["tag_name"]
- if current_version != latest_version:
- return True
- else:
- return False
-
- else:
- sc2_logger.warning(f"Failed to reach GitHub while checking for updates.")
- sc2_logger.warning(f"Status code: {r1.status_code}")
- sc2_logger.warning(f"text: {r1.text}")
- return False
-
-
-if __name__ == '__main__':
- colorama.init()
- asyncio.run(main())
- colorama.deinit()
+ Utils.init_logging("Starcraft2Client", exception_logger="Client")
+ launch()
diff --git a/UndertaleClient.py b/UndertaleClient.py
new file mode 100644
index 0000000000..62fbe128bd
--- /dev/null
+++ b/UndertaleClient.py
@@ -0,0 +1,512 @@
+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()
diff --git a/Utils.py b/Utils.py
index 60b3904ff6..5fb037a173 100644
--- a/Utils.py
+++ b/Utils.py
@@ -13,8 +13,11 @@ import io
import collections
import importlib
import logging
-from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
+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:
@@ -27,6 +30,7 @@ except ImportError:
if typing.TYPE_CHECKING:
import tkinter
import pathlib
+ from BaseClasses import Region
def tuplize_version(version: str) -> Version:
@@ -38,8 +42,11 @@ class Version(typing.NamedTuple):
minor: int
build: int
+ def as_simple_string(self) -> str:
+ return ".".join(str(item) for item in self)
-__version__ = "0.4.0"
+
+__version__ = "0.4.3"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -135,13 +142,16 @@ def user_path(*path: str) -> str:
user_path.cached_path = local_path()
else:
user_path.cached_path = home_path()
- # 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))
+ # 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))
return os.path.join(user_path.cached_path, *path)
@@ -207,7 +217,13 @@ def get_cert_none_ssl_context():
def get_public_ipv4() -> str:
import socket
import urllib.request
- ip = socket.gethostbyname(socket.gethostname())
+ 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"
+
ctx = get_cert_none_ssl_context()
try:
ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx, timeout=10).read().decode("utf8").strip()
@@ -225,7 +241,13 @@ def get_public_ipv4() -> str:
def get_public_ipv6() -> str:
import socket
import urllib.request
- ip = socket.gethostbyname(socket.gethostname())
+ 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"
+
ctx = get_cert_none_ssl_context()
try:
ip = urllib.request.urlopen("https://v6.ident.me", context=ctx, timeout=10).read().decode("utf8").strip()
@@ -235,151 +257,15 @@ def get_public_ipv6() -> str:
return ip
-OptionsType = typing.Dict[str, typing.Dict[str, typing.Any]]
+OptionsType = Settings # TODO: remove ~2 versions after 0.4.1
@cache_argsless
-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
+def get_default_options() -> Settings: # TODO: remove ~2 versions after 0.4.1
+ return Settings(None)
-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.")
+get_options = get_settings # TODO: add a warning ~2 versions after 0.4.1 and remove once all games are ported
def persistent_store(category: str, key: typing.Any, value: typing.Any):
@@ -447,12 +333,27 @@ 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)
@@ -472,11 +373,13 @@ 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 = importlib.import_module("worlds.generic")
+ self.generic_properties_module = None
def find_class(self, module, name):
if module == "builtins" and name in safe_builtins:
@@ -486,6 +389,8 @@ 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"):
@@ -505,6 +410,15 @@ def restricted_loads(s):
return RestrictedUnpickler(io.BytesIO(s)).load()
+class ByValue:
+ """
+ Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent.
+ See https://github.com/python/cpython/pull/26658 for why this exists.
+ """
+ def __reduce_ex__(self, prot):
+ return self.__class__, (self._value_, )
+
+
class KeyedDefaultDict(collections.defaultdict):
"""defaultdict variant that uses the missing key as argument to default_factory"""
default_factory: typing.Callable[[typing.Any], typing.Any]
@@ -537,6 +451,7 @@ 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(
@@ -544,11 +459,21 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
write_mode,
encoding="utf-8-sig")
file_handler.setFormatter(logging.Formatter(log_format))
+
+ class Filter(logging.Filter):
+ def __init__(self, filter_name, condition):
+ super().__init__(filter_name)
+ self.condition = condition
+
+ def filter(self, record: logging.LogRecord) -> bool:
+ return self.condition(record)
+
+ file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False)))
root_logger.addHandler(file_handler)
if sys.stdout:
- root_logger.addHandler(
- logging.StreamHandler(sys.stdout)
- )
+ stream_handler = logging.StreamHandler(sys.stdout)
+ stream_handler.addFilter(Filter("NoFile", lambda record: not getattr(record, "NoStream", False)))
+ root_logger.addHandler(stream_handler)
# Relay unhandled exceptions to logger.
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
@@ -660,7 +585,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]]]) \
+def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[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
@@ -671,11 +596,12 @@ 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", ".", k_filters)
+ return run(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters)
zenity = which("zenity")
if zenity:
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
- return run(zenity, f"--title={title}", "--file-selection", *z_filters)
+ selection = (f"--filename={suggest}",) if suggest else ()
+ return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
# fall back to tk
try:
@@ -686,9 +612,47 @@ 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:
- root = tkinter.Tk()
+ 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.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes))
+ return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
+ initialfile=suggest or None)
+
+
+def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
+ def run(*args: str):
+ return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
+
+ if is_linux:
+ # prefer native dialog
+ from shutil import which
+ kdialog = which("kdialog")
+ if kdialog:
+ return run(kdialog, f"--title={title}", "--getexistingdirectory",
+ os.path.abspath(suggest) if suggest else ".")
+ zenity = which("zenity")
+ if zenity:
+ z_filters = ("--directory",)
+ selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else ()
+ return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
+
+ # fall back to tk
+ try:
+ import tkinter
+ import tkinter.filedialog
+ except Exception as e:
+ logging.error('Could not load tkinter, which is likely not installed. '
+ f'This attempt was made because open_filename was used for "{title}".')
+ raise e
+ else:
+ try:
+ root = tkinter.Tk()
+ except tkinter.TclError:
+ return None # GUI not available. None is the same as a user clicking "cancel"
+ root.withdraw()
+ return tkinter.filedialog.askdirectory(title=title, mustexist=True, initialdir=suggest or None)
def messagebox(title: str, text: str, error: bool = False) -> None:
@@ -716,6 +680,11 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
if zenity:
return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
+ elif is_windows:
+ import ctypes
+ style = 0x10 if error else 0x0
+ return ctypes.windll.user32.MessageBoxW(0, text, title, style)
+
# fall back to tk
try:
import tkinter
@@ -753,10 +722,10 @@ def read_snes_rom(stream: BinaryIO, strip_header: bool = True) -> bytearray:
return buffer
-_faf_tasks: "Set[asyncio.Task[None]]" = set()
+_faf_tasks: "Set[asyncio.Task[typing.Any]]" = set()
-def async_start(co: Coroutine[typing.Any, typing.Any, bool], name: Optional[str] = None) -> None:
+def async_start(co: Coroutine[None, None, typing.Any], 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"
@@ -769,6 +738,170 @@ def async_start(co: Coroutine[typing.Any, typing.Any, bool], name: Optional[str]
# ```
# This implementation follows the pattern given in that documentation.
- task = asyncio.create_task(co, name=name)
+ task: asyncio.Task[typing.Any] = 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\" <
| Finder | +Receiver | +Item | +Location | +Entrance | +Found | +
|---|---|---|---|---|---|
| {{ long_player_names[team, hint.finding_player] }} | +{{ long_player_names[team, hint.receiving_player] }} | +{{ hint.item|item_name }} | +{{ hint.location|location_name }} | +{% if hint.entrance %}{{ hint.entrance }}{% else %}Vanilla{% endif %} | +{% if hint.found %}✔{% endif %} | +
+
{{ world.__doc__ | default("No description provided.", true) }}
Game Page
{% if world.web.tutorials %}
diff --git a/WebHostLib/templates/viewSeed.html b/WebHostLib/templates/viewSeed.html
index e252fb06a2..a8478c95c3 100644
--- a/WebHostLib/templates/viewSeed.html
+++ b/WebHostLib/templates/viewSeed.html
@@ -34,7 +34,7 @@
{% endif %}