Merge branch 'ArchipelagoMW:main' into main

This commit is contained in:
Adrian Priestley
2025-04-19 10:04:10 -02:30
committed by GitHub
541 changed files with 35427 additions and 8540 deletions

View File

@@ -2,6 +2,7 @@
"include": [ "include": [
"../BizHawkClient.py", "../BizHawkClient.py",
"../Patch.py", "../Patch.py",
"../test/param.py",
"../test/general/test_groups.py", "../test/general/test_groups.py",
"../test/general/test_helpers.py", "../test/general/test_helpers.py",
"../test/general/test_memory.py", "../test/general/test_memory.py",

View File

@@ -65,7 +65,7 @@ jobs:
continue-on-error: false continue-on-error: false
if: env.diff != '' && matrix.task == 'flake8' if: env.diff != '' && matrix.task == 'flake8'
run: | run: |
flake8 --count --select=E9,F63,F7,F82 --show-source --statistics ${{ env.diff }} flake8 --count --select=E9,F63,F7,F82 --ignore F824 --show-source --statistics ${{ env.diff }}
- name: "flake8: Lint modified files" - name: "flake8: Lint modified files"
continue-on-error: true continue-on-error: true

View File

@@ -99,8 +99,8 @@ jobs:
if-no-files-found: error if-no-files-found: error
retention-days: 7 # keep for 7 days, should be enough retention-days: 7 # keep for 7 days, should be enough
build-ubuntu2004: build-ubuntu2204:
runs-on: ubuntu-20.04 runs-on: ubuntu-22.04
steps: steps:
# - copy code below to release.yml - # - copy code below to release.yml -
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -132,7 +132,7 @@ jobs:
# charset-normalizer was somehow incomplete in the github runner # charset-normalizer was somehow incomplete in the github runner
"${{ env.PYTHON }}" -m venv venv "${{ env.PYTHON }}" -m venv venv
source venv/bin/activate source venv/bin/activate
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer "${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer
python setup.py build_exe --yes bdist_appimage --yes python setup.py build_exe --yes bdist_appimage --yes
echo -e "setup.py build output:\n `ls build`" echo -e "setup.py build output:\n `ls build`"
echo -e "setup.py dist output:\n `ls dist`" echo -e "setup.py dist output:\n `ls dist`"

View File

@@ -11,7 +11,7 @@ on:
- '**.hh?' - '**.hh?'
- '**.hpp' - '**.hpp'
- '**.hxx' - '**.hxx'
- '**.CMakeLists' - '**/CMakeLists.txt'
- '.github/workflows/ctest.yml' - '.github/workflows/ctest.yml'
pull_request: pull_request:
paths: paths:
@@ -21,7 +21,7 @@ on:
- '**.hh?' - '**.hh?'
- '**.hpp' - '**.hpp'
- '**.hxx' - '**.hxx'
- '**.CMakeLists' - '**/CMakeLists.txt'
- '.github/workflows/ctest.yml' - '.github/workflows/ctest.yml'
jobs: jobs:
@@ -36,9 +36,9 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: ilammy/msvc-dev-cmd@v1 - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
if: startsWith(matrix.os,'windows') if: startsWith(matrix.os,'windows')
- uses: Bacondish2023/setup-googletest@v1 - uses: Bacondish2023/setup-googletest@49065d1f7a6d21f6134864dd65980fe5dbe06c73
with: with:
build-type: 'Release' build-type: 'Release'
- name: Build tests - name: Build tests

View File

@@ -29,8 +29,8 @@ jobs:
# build-release-windows: # this is done by hand because of signing # build-release-windows: # this is done by hand because of signing
# build-release-macos: # LF volunteer # build-release-macos: # LF volunteer
build-release-ubuntu2004: build-release-ubuntu2204:
runs-on: ubuntu-20.04 runs-on: ubuntu-22.04
steps: steps:
- name: Set env - name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
@@ -64,7 +64,7 @@ jobs:
# charset-normalizer was somehow incomplete in the github runner # charset-normalizer was somehow incomplete in the github runner
"${{ env.PYTHON }}" -m venv venv "${{ env.PYTHON }}" -m venv venv
source venv/bin/activate source venv/bin/activate
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer "${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer
python setup.py build_exe --yes bdist_appimage --yes python setup.py build_exe --yes bdist_appimage --yes
echo -e "setup.py build output:\n `ls build`" echo -e "setup.py build output:\n `ls build`"
echo -e "setup.py dist output:\n `ls dist`" echo -e "setup.py dist output:\n `ls dist`"

2
.gitignore vendored
View File

@@ -4,11 +4,13 @@
*_Spoiler.txt *_Spoiler.txt
*.bmbp *.bmbp
*.apbp *.apbp
*.apcivvi
*.apl2ac *.apl2ac
*.apm3 *.apm3
*.apmc *.apmc
*.apz5 *.apz5
*.aptloz *.aptloz
*.aptww
*.apemerald *.apemerald
*.pyc *.pyc
*.pyd *.pyd

View File

@@ -511,7 +511,7 @@ if __name__ == '__main__':
import colorama import colorama
colorama.init() colorama.just_fix_windows_console()
asyncio.run(main()) asyncio.run(main())
colorama.deinit() colorama.deinit()

View File

@@ -616,7 +616,7 @@ class MultiWorld():
locations: Set[Location] = set() locations: Set[Location] = set()
events: Set[Location] = set() events: Set[Location] = set()
for location in self.get_filled_locations(): for location in self.get_filled_locations():
if type(location.item.code) is int: if type(location.item.code) is int and type(location.address) is int:
locations.add(location) locations.add(location)
else: else:
events.add(location) events.add(location)
@@ -869,21 +869,40 @@ class CollectionState():
def has(self, item: str, player: int, count: int = 1) -> bool: def has(self, item: str, player: int, count: int = 1) -> bool:
return self.prog_items[player][item] >= count return self.prog_items[player][item] >= count
# for loops are specifically used in all/any/count methods, instead of all()/any()/sum(), to avoid the overhead of
# creating and iterating generator instances. In `return all(player_prog_items[item] for item in items)`, the
# argument to all() would be a new generator instance, for example.
def has_all(self, items: Iterable[str], player: int) -> bool: def has_all(self, items: Iterable[str], player: int) -> bool:
"""Returns True if each item name of items is in state at least once.""" """Returns True if each item name of items is in state at least once."""
return all(self.prog_items[player][item] for item in items) player_prog_items = self.prog_items[player]
for item in items:
if not player_prog_items[item]:
return False
return True
def has_any(self, items: Iterable[str], player: int) -> bool: def has_any(self, items: Iterable[str], player: int) -> bool:
"""Returns True if at least one item name of items is in state at least once.""" """Returns True if at least one item name of items is in state at least once."""
return any(self.prog_items[player][item] for item in items) player_prog_items = self.prog_items[player]
for item in items:
if player_prog_items[item]:
return True
return False
def has_all_counts(self, item_counts: Mapping[str, int], player: int) -> bool: def has_all_counts(self, item_counts: Mapping[str, int], player: int) -> bool:
"""Returns True if each item name is in the state at least as many times as specified.""" """Returns True if each item name is in the state at least as many times as specified."""
return all(self.prog_items[player][item] >= count for item, count in item_counts.items()) player_prog_items = self.prog_items[player]
for item, count in item_counts.items():
if player_prog_items[item] < count:
return False
return True
def has_any_count(self, item_counts: Mapping[str, int], player: int) -> bool: def has_any_count(self, item_counts: Mapping[str, int], player: int) -> bool:
"""Returns True if at least one item name is in the state at least as many times as specified.""" """Returns True if at least one item name is in the state at least as many times as specified."""
return any(self.prog_items[player][item] >= count for item, count in item_counts.items()) player_prog_items = self.prog_items[player]
for item, count in item_counts.items():
if player_prog_items[item] >= count:
return True
return False
def count(self, item: str, player: int) -> int: def count(self, item: str, player: int) -> int:
return self.prog_items[player][item] return self.prog_items[player][item]
@@ -911,11 +930,20 @@ class CollectionState():
def count_from_list(self, items: Iterable[str], player: int) -> int: def count_from_list(self, items: Iterable[str], player: int) -> int:
"""Returns the cumulative count of items from a list present in state.""" """Returns the cumulative count of items from a list present in state."""
return sum(self.prog_items[player][item_name] for item_name in items) player_prog_items = self.prog_items[player]
total = 0
for item_name in items:
total += player_prog_items[item_name]
return total
def count_from_list_unique(self, items: Iterable[str], player: int) -> int: def count_from_list_unique(self, items: Iterable[str], player: int) -> int:
"""Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item.""" """Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item."""
return sum(self.prog_items[player][item_name] > 0 for item_name in items) player_prog_items = self.prog_items[player]
total = 0
for item_name in items:
if player_prog_items[item_name] > 0:
total += 1
return total
# item name group related # item name group related
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool: def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
@@ -994,9 +1022,6 @@ class Entrance:
connected_region: Optional[Region] = None connected_region: Optional[Region] = None
randomization_group: int randomization_group: int
randomization_type: EntranceType randomization_type: EntranceType
# LttP specific, TODO: should make a LttPEntrance
addresses = None
target = None
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None, def __init__(self, player: int, name: str = "", parent: Optional[Region] = None,
randomization_group: int = 0, randomization_type: EntranceType = EntranceType.ONE_WAY) -> None: randomization_group: int = 0, randomization_type: EntranceType = EntranceType.ONE_WAY) -> None:
@@ -1015,10 +1040,8 @@ class Entrance:
return False return False
def connect(self, region: Region, addresses: Any = None, target: Any = None) -> None: def connect(self, region: Region) -> None:
self.connected_region = region self.connected_region = region
self.target = target
self.addresses = addresses
region.entrances.append(self) region.entrances.append(self)
def is_valid_source_transition(self, er_state: "ERPlacementState") -> bool: def is_valid_source_transition(self, er_state: "ERPlacementState") -> bool:
@@ -1078,6 +1101,9 @@ class Region:
def __len__(self) -> int: def __len__(self) -> int:
return self._list.__len__() return self._list.__len__()
def __iter__(self):
return iter(self._list)
# This seems to not be needed, but that's a bit suspicious. # This seems to not be needed, but that's a bit suspicious.
# def __del__(self): # def __del__(self):
# self.clear() # self.clear()
@@ -1282,9 +1308,6 @@ class Location:
multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})' return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
def __hash__(self):
return hash((self.name, self.player))
def __lt__(self, other: Location): def __lt__(self, other: Location):
return (self.player, self.name) < (other.player, other.name) return (self.player, self.name) < (other.player, other.name)
@@ -1388,6 +1411,10 @@ class Item:
def flags(self) -> int: def flags(self) -> int:
return self.classification.as_flag() return self.classification.as_flag()
@property
def is_event(self) -> bool:
return self.code is None
def __eq__(self, other: object) -> bool: def __eq__(self, other: object) -> bool:
if not isinstance(other, Item): if not isinstance(other, Item):
return NotImplemented return NotImplemented

View File

@@ -413,7 +413,8 @@ class CommonContext:
await self.server.socket.close() await self.server.socket.close()
if self.server_task is not None: if self.server_task is not None:
await self.server_task await self.server_task
self.ui.update_hints() if self.ui:
self.ui.update_hints()
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None: async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
""" `msgs` JSON serializable """ """ `msgs` JSON serializable """
@@ -624,9 +625,6 @@ class CommonContext:
def consume_network_data_package(self, data_package: dict): def consume_network_data_package(self, data_package: dict):
self.update_data_package(data_package) self.update_data_package(data_package)
current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {})
current_cache.update(data_package["games"])
Utils.persistent_store("datapackage", "games", current_cache)
logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}") logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}")
for game, game_data in data_package["games"].items(): for game, game_data in data_package["games"].items():
Utils.store_data_package_for_checksum(game, game_data) Utils.store_data_package_for_checksum(game, game_data)
@@ -709,8 +707,16 @@ class CommonContext:
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True}) logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1]) self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
def make_gui(self) -> typing.Type["kvui.GameManager"]: def make_gui(self) -> "type[kvui.GameManager]":
"""To return the Kivy App class needed for run_gui so it can be overridden before being built""" """
To return the Kivy `App` class needed for `run_gui` so it can be overridden before being built
Common changes are changing `base_title` to update the window title of the client and
updating `logging_pairs` to automatically make new tabs that can be filled with their respective logger.
ex. `logging_pairs.append(("Foo", "Bar"))`
will add a "Bar" tab which follows the logger returned from `logging.getLogger("Foo")`
"""
from kvui import GameManager from kvui import GameManager
class TextManager(GameManager): class TextManager(GameManager):
@@ -899,6 +905,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.disconnected_intentionally = True ctx.disconnected_intentionally = True
ctx.event_invalid_game() ctx.event_invalid_game()
elif 'IncompatibleVersion' in errors: elif 'IncompatibleVersion' in errors:
ctx.disconnected_intentionally = True
raise Exception('Server reported your client version as incompatible. ' raise Exception('Server reported your client version as incompatible. '
'This probably means you have to update.') 'This probably means you have to update.')
elif 'InvalidItemsHandling' in errors: elif 'InvalidItemsHandling' in errors:
@@ -1087,7 +1094,7 @@ def run_as_textclient(*args):
if password_requested and not self.password: if password_requested and not self.password:
await super(TextContext, self).server_auth(password_requested) await super(TextContext, self).server_auth(password_requested)
await self.get_username() await self.get_username()
await self.send_connect() await self.send_connect(game="")
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
if cmd == "Connected": if cmd == "Connected":
@@ -1119,7 +1126,7 @@ def run_as_textclient(*args):
args = handle_url_arg(args, parser=parser) args = handle_url_arg(args, parser=parser)
# use colorama to display colored text highlighting on windows # use colorama to display colored text highlighting on windows
colorama.init() colorama.just_fix_windows_console()
asyncio.run(main(args)) asyncio.run(main(args))
colorama.deinit() colorama.deinit()

View File

@@ -261,7 +261,7 @@ if __name__ == '__main__':
parser = get_base_parser() parser = get_base_parser()
args = parser.parse_args() args = parser.parse_args()
colorama.init() colorama.just_fix_windows_console()
asyncio.run(main(args)) asyncio.run(main(args))
colorama.deinit() colorama.deinit()

21
Fill.py
View File

@@ -75,9 +75,11 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
items_to_place.append(reachable_items[next_player].pop()) items_to_place.append(reachable_items[next_player].pop())
for item in items_to_place: for item in items_to_place:
for p, pool_item in enumerate(item_pool): # The items added into `reachable_items` are placed starting from the end of each deque in
# `reachable_items`, so the items being placed are more likely to be found towards the end of `item_pool`.
for p, pool_item in enumerate(reversed(item_pool), start=1):
if pool_item is item: if pool_item is item:
item_pool.pop(p) del item_pool[-p]
break break
maximum_exploration_state = sweep_from_pool( maximum_exploration_state = sweep_from_pool(
@@ -348,10 +350,10 @@ def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, lo
if (location.item is not None and location.item.advancement and location.address is not None and not if (location.item is not None and location.item.advancement and location.address is not None and not
location.locked and location.item.player not in minimal_players): location.locked and location.item.player not in minimal_players):
pool.append(location.item) pool.append(location.item)
state.remove(location.item)
location.item = None location.item = None
if location in state.advancements: if location in state.advancements:
state.advancements.remove(location) state.advancements.remove(location)
state.remove(location.item)
locations.append(location) locations.append(location)
if pool and locations: if pool and locations:
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY) locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
@@ -500,13 +502,15 @@ def distribute_items_restrictive(multiworld: MultiWorld,
if prioritylocations: if prioritylocations:
# "priority fill" # "priority fill"
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool, maximum_exploration_state = sweep_from_pool(multiworld.state)
fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool,
single_player_placement=single_player, swap=False, on_place=mark_for_locking, single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority", one_item_per_player=True, allow_partial=True) name="Priority", one_item_per_player=True, allow_partial=True)
if prioritylocations: if prioritylocations:
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization # retry with one_item_per_player off because some priority fills can fail to fill with that optimization
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool, maximum_exploration_state = sweep_from_pool(multiworld.state)
fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool,
single_player_placement=single_player, swap=False, on_place=mark_for_locking, single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority Retry", one_item_per_player=False) name="Priority Retry", one_item_per_player=False)
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool) accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
@@ -514,14 +518,15 @@ def distribute_items_restrictive(multiworld: MultiWorld,
if progitempool: if progitempool:
# "advancement/progression fill" # "advancement/progression fill"
maximum_exploration_state = sweep_from_pool(multiworld.state)
if panic_method == "swap": if panic_method == "swap":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=True, fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=True,
name="Progression", single_player_placement=single_player) name="Progression", single_player_placement=single_player)
elif panic_method == "raise": elif panic_method == "raise":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False, fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False,
name="Progression", single_player_placement=single_player) name="Progression", single_player_placement=single_player)
elif panic_method == "start_inventory": elif panic_method == "start_inventory":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False, fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False,
allow_partial=True, name="Progression", single_player_placement=single_player) allow_partial=True, name="Progression", single_player_placement=single_player)
if progitempool: if progitempool:
for item in progitempool: for item in progitempool:

View File

@@ -54,12 +54,22 @@ def mystery_argparse():
parser.add_argument("--skip_output", action="store_true", parser.add_argument("--skip_output", action="store_true",
help="Skips generation assertion and output stages and skips multidata and spoiler output. " help="Skips generation assertion and output stages and skips multidata and spoiler output. "
"Intended for debugging and testing purposes.") "Intended for debugging and testing purposes.")
parser.add_argument("--spoiler_only", action="store_true",
help="Skips generation assertion and multidata, outputting only a spoiler log. "
"Intended for debugging and testing purposes.")
args = parser.parse_args() args = parser.parse_args()
if args.skip_output and args.spoiler_only:
parser.error("Cannot mix --skip_output and --spoiler_only")
elif args.spoiler == 0 and args.spoiler_only:
parser.error("Cannot use --spoiler_only when --spoiler=0. Use --skip_output or set --spoiler to a different value")
if not os.path.isabs(args.weights_file_path): if not os.path.isabs(args.weights_file_path):
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path) args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
if not os.path.isabs(args.meta_file_path): if not os.path.isabs(args.meta_file_path):
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path) args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando) args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
return args return args
@@ -108,6 +118,8 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
raise Exception("Cannot mix --sameoptions with --meta") raise Exception("Cannot mix --sameoptions with --meta")
else: else:
meta_weights = None meta_weights = None
player_id = 1 player_id = 1
player_files = {} player_files = {}
for file in os.scandir(args.player_files_path): for file in os.scandir(args.player_files_path):
@@ -164,6 +176,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
erargs.outputpath = args.outputpath erargs.outputpath = args.outputpath
erargs.skip_prog_balancing = args.skip_prog_balancing erargs.skip_prog_balancing = args.skip_prog_balancing
erargs.skip_output = args.skip_output erargs.skip_output = args.skip_output
erargs.spoiler_only = args.spoiler_only
erargs.name = {} erargs.name = {}
erargs.csv_output = args.csv_output erargs.csv_output = args.csv_output
@@ -279,22 +292,30 @@ def get_choice(option, root, value=None) -> Any:
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.") raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
class SafeDict(dict): class SafeFormatter(string.Formatter):
def __missing__(self, key): def get_value(self, key, args, kwargs):
return '{' + key + '}' if isinstance(key, int):
if key < len(args):
return args[key]
else:
return "{" + str(key) + "}"
else:
return kwargs.get(key, "{" + key + "}")
def handle_name(name: str, player: int, name_counter: Counter): def handle_name(name: str, player: int, name_counter: Counter):
name_counter[name.lower()] += 1 name_counter[name.lower()] += 1
number = name_counter[name.lower()] number = name_counter[name.lower()]
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")]) new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number,
NUMBER=(number if number > 1 else ''), new_name = SafeFormatter().vformat(new_name, (), {"number": number,
player=player, "NUMBER": (number if number > 1 else ''),
PLAYER=(player if player > 1 else ''))) "player": player,
"PLAYER": (player if player > 1 else '')})
# Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace. # Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace.
# Could cause issues for some clients that cannot handle the additional whitespace. # Could cause issues for some clients that cannot handle the additional whitespace.
new_name = new_name.strip()[:16].strip() new_name = new_name.strip()[:16].strip()
if new_name == "Archipelago": if new_name == "Archipelago":
raise Exception(f"You cannot name yourself \"{new_name}\"") raise Exception(f"You cannot name yourself \"{new_name}\"")
return new_name return new_name

View File

@@ -1,5 +1,5 @@
""" """
Archipelago launcher for bundled app. Archipelago Launcher
* if run with APBP as argument, launch corresponding client. * if run with APBP as argument, launch corresponding client.
* if run with executable as argument, run it passing argv[2:] as arguments * if run with executable as argument, run it passing argv[2:] as arguments
@@ -8,9 +8,7 @@ Archipelago launcher for bundled app.
Scroll down to components= to add components to the launcher as well as setup.py Scroll down to components= to add components to the launcher as well as setup.py
""" """
import argparse import argparse
import itertools
import logging import logging
import multiprocessing import multiprocessing
import shlex import shlex
@@ -20,10 +18,11 @@ import urllib.parse
import webbrowser import webbrowser
from os.path import isfile from os.path import isfile
from shutil import which from shutil import which
from typing import Callable, Optional, Sequence, Tuple, Union from typing import Callable, Optional, Sequence, Tuple, Union, Any
if __name__ == "__main__": if __name__ == "__main__":
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
import settings import settings
@@ -105,7 +104,8 @@ components.extend([
Component("Generate Template Options", func=generate_yamls), Component("Generate Template Options", func=generate_yamls),
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")), Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")),
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")), Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
Component("Unrated/18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")), Component("Unrated/18+ Discord Server", icon="discord",
func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
Component("Browse Files", func=browse_files), Component("Browse Files", func=browse_files),
]) ])
@@ -114,7 +114,7 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
url = urllib.parse.urlparse(path) url = urllib.parse.urlparse(path)
queries = urllib.parse.parse_qs(url.query) queries = urllib.parse.parse_qs(url.query)
launch_args = (path, *launch_args) launch_args = (path, *launch_args)
client_component = None client_component = []
text_client_component = None text_client_component = None
if "game" in queries: if "game" in queries:
game = queries["game"][0] game = queries["game"][0]
@@ -122,49 +122,40 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
game = "Archipelago" game = "Archipelago"
for component in components: for component in components:
if component.supports_uri and component.game_name == game: if component.supports_uri and component.game_name == game:
client_component = component client_component.append(component)
elif component.display_name == "Text Client": elif component.display_name == "Text Client":
text_client_component = component text_client_component = component
if client_component is None: from kvui import MDButton, MDButtonText
from kivymd.uix.dialog import MDDialog, MDDialogHeadlineText, MDDialogContentContainer, MDDialogSupportingText
from kivymd.uix.divider import MDDivider
if not client_component:
run_component(text_client_component, *launch_args) run_component(text_client_component, *launch_args)
return return
else:
popup_text = MDDialogSupportingText(text="Select client to open and connect with.")
component_buttons = [MDDivider()]
for component in [text_client_component, *client_component]:
component_buttons.append(MDButton(
MDButtonText(text=component.display_name),
on_release=lambda *args, comp=component: run_component(comp, *launch_args),
style="text"
))
component_buttons.append(MDDivider())
from kvui import App, Button, BoxLayout, Label, Window MDDialog(
# Headline
MDDialogHeadlineText(text="Connect to Multiworld"),
# Text
popup_text,
# Content
MDDialogContentContainer(
*component_buttons,
orientation="vertical"
),
class Popup(App): ).open()
def __init__(self):
self.title = "Connect to Multiworld"
self.icon = r"data/icon.png"
super().__init__()
def build(self):
layout = BoxLayout(orientation="vertical")
layout.add_widget(Label(text="Select client to open and connect with."))
button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4))
text_client_button = Button(
text=text_client_component.display_name,
on_release=lambda *args: run_component(text_client_component, *launch_args)
)
button_row.add_widget(text_client_button)
game_client_button = Button(
text=client_component.display_name,
on_release=lambda *args: run_component(client_component, *launch_args)
)
button_row.add_widget(game_client_button)
layout.add_widget(button_row)
return layout
def _stop(self, *largs):
# see run_gui Launcher _stop comment for details
self.root_window.close()
super()._stop(*largs)
Popup().run()
def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]: def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
@@ -220,100 +211,171 @@ def launch(exe, in_terminal=False):
subprocess.Popen(exe) subprocess.Popen(exe)
def create_shortcut(button: Any, component: Component) -> None:
from pyshortcuts import make_shortcut
script = sys.argv[0]
wkdir = Utils.local_path()
script = f"{script} \"{component.display_name}\""
make_shortcut(script, name=f"Archipelago {component.display_name}", icon=local_path("data", "icon.ico"),
startmenu=False, terminal=False, working_dir=wkdir)
button.menu.dismiss()
refresh_components: Optional[Callable[[], None]] = None refresh_components: Optional[Callable[[], None]] = None
def run_gui(): def run_gui(path: str, args: Any) -> None:
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget, ApAsyncImage from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox)
from kivy.properties import ObjectProperty
from kivy.core.window import Window from kivy.core.window import Window
from kivy.uix.relativelayout import RelativeLayout from kivy.metrics import dp
from kivymd.uix.button import MDIconButton
from kivymd.uix.card import MDCard
from kivymd.uix.menu import MDDropdownMenu
from kivymd.uix.snackbar import MDSnackbar, MDSnackbarText
class Launcher(App): from kivy.lang.builder import Builder
class LauncherCard(MDCard):
component: Component | None
image: str
context_button: MDIconButton = ObjectProperty(None)
def __init__(self, *args, component: Component | None = None, image_path: str = "", **kwargs):
self.component = component
self.image = image_path
super().__init__(args, kwargs)
class Launcher(ThemedApp):
base_title: str = "Archipelago Launcher" base_title: str = "Archipelago Launcher"
container: ContainerLayout top_screen: MDFloatLayout = ObjectProperty(None)
grid: GridLayout navigation: MDGridLayout = ObjectProperty(None)
_tool_layout: Optional[ScrollBox] = None grid: MDGridLayout = ObjectProperty(None)
_client_layout: Optional[ScrollBox] = None button_layout: ScrollBox = ObjectProperty(None)
cards: list[LauncherCard]
current_filter: Sequence[str | Type] | None
def __init__(self, ctx=None): def __init__(self, ctx=None, path=None, args=None):
self.title = self.base_title + " " + Utils.__version__ self.title = self.base_title + " " + Utils.__version__
self.ctx = ctx self.ctx = ctx
self.icon = r"data/icon.png" self.icon = r"data/icon.png"
self.favorites = []
self.launch_uri = path
self.launch_args = args
self.cards = []
self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
persistent = Utils.persistent_load()
if "launcher" in persistent:
if "favorites" in persistent["launcher"]:
self.favorites.extend(persistent["launcher"]["favorites"])
if "filter" in persistent["launcher"]:
if persistent["launcher"]["filter"]:
filters = []
for filter in persistent["launcher"]["filter"].split(", "):
if filter == "favorites":
filters.append(filter)
else:
filters.append(Type[filter])
self.current_filter = filters
super().__init__() super().__init__()
def _refresh_components(self) -> None: def set_favorite(self, caller):
if caller.component.display_name in self.favorites:
self.favorites.remove(caller.component.display_name)
caller.icon = "star-outline"
else:
self.favorites.append(caller.component.display_name)
caller.icon = "star"
def build_button(component: Component) -> Widget: def build_card(self, component: Component) -> LauncherCard:
"""
Builds a card widget for a given component.
:param component: The component associated with the button.
:return: The created Card Widget.
""" """
Builds a button widget for a given component. button_card = LauncherCard(component=component,
image_path=icon_paths[component.icon])
Args: def open_menu(caller):
component (Component): The component associated with the button. caller.menu.open()
Returns: menu_items = [
None. The button is added to the parent grid layout. {
"text": "Add shortcut on desktop",
"leading_icon": "laptop",
"on_release": lambda: create_shortcut(button_card.context_button, component)
}
]
button_card.context_button.menu = MDDropdownMenu(caller=button_card.context_button, items=menu_items)
button_card.context_button.bind(on_release=open_menu)
""" return button_card
button = Button(text=component.display_name, size_hint_y=None, height=40)
button.component = component def _refresh_components(self, type_filter: Sequence[str | Type] | None = None) -> None:
button.bind(on_release=self.component_action) if not type_filter:
if component.icon != "icon": type_filter = [Type.CLIENT, Type.ADJUSTER, Type.TOOL, Type.MISC]
image = ApAsyncImage(source=icon_paths[component.icon], favorites = "favorites" in type_filter
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
box_layout = RelativeLayout(size_hint_y=None, height=40)
box_layout.add_widget(button)
box_layout.add_widget(image)
return box_layout
return button
# clear before repopulating # clear before repopulating
assert self._tool_layout and self._client_layout, "must call `build` first" assert self.button_layout, "must call `build` first"
tool_children = reversed(self._tool_layout.layout.children) tool_children = reversed(self.button_layout.layout.children)
for child in tool_children: for child in tool_children:
self._tool_layout.layout.remove_widget(child) self.button_layout.layout.remove_widget(child)
client_children = reversed(self._client_layout.layout.children)
for child in client_children:
self._client_layout.layout.remove_widget(child)
_tools = {c.display_name: c for c in components if c.type == Type.TOOL} cards = [card for card in self.cards if card.component.type in type_filter
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT} or favorites and card.component.display_name in self.favorites]
_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}
for (tool, client) in itertools.zip_longest(itertools.chain( self.current_filter = type_filter
_tools.items(), _miscs.items(), _adjusters.items()
), _clients.items()): for card in cards:
# column 1 self.button_layout.layout.add_widget(card)
if tool:
self._tool_layout.layout.add_widget(build_button(tool[1])) top = self.button_layout.children[0].y + self.button_layout.children[0].height \
# column 2 - self.button_layout.height
if client: scroll_percent = self.button_layout.convert_distance_to_scroll(0, top)
self._client_layout.layout.add_widget(build_button(client[1])) self.button_layout.scroll_y = max(0, min(1, scroll_percent[1]))
def filter_clients(self, caller):
self._refresh_components(caller.type)
def build(self): def build(self):
self.container = ContainerLayout() self.top_screen = Builder.load_file(Utils.local_path("data/launcher.kv"))
self.grid = GridLayout(cols=2) self.grid = self.top_screen.ids.grid
self.container.add_widget(self.grid) self.navigation = self.top_screen.ids.navigation
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40)) self.button_layout = self.top_screen.ids.button_layout
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40)) self.set_colors()
self._tool_layout = ScrollBox() self.top_screen.md_bg_color = self.theme_cls.backgroundColor
self._tool_layout.layout.orientation = "vertical"
self.grid.add_widget(self._tool_layout)
self._client_layout = ScrollBox()
self._client_layout.layout.orientation = "vertical"
self.grid.add_widget(self._client_layout)
self._refresh_components()
global refresh_components global refresh_components
refresh_components = self._refresh_components refresh_components = self._refresh_components
Window.bind(on_drop_file=self._on_drop_file) Window.bind(on_drop_file=self._on_drop_file)
return self.container for component in components:
self.cards.append(self.build_card(component))
self._refresh_components(self.current_filter)
# Uncomment to re-enable the Kivy console/live editor
# Ctrl-E to enable it, make sure numlock/capslock is disabled
# from kivy.modules.console import create_console
# create_console(Window, self.top_screen)
return self.top_screen
def on_start(self):
if self.launch_uri:
handle_uri(self.launch_uri, self.launch_args)
self.launch_uri = None
self.launch_args = None
@staticmethod @staticmethod
def component_action(button): def component_action(button):
MDSnackbar(MDSnackbarText(text="Opening in a new window..."), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
if button.component.func: if button.component.func:
button.component.func() button.component.func()
else: else:
@@ -333,7 +395,13 @@ def run_gui():
self.root_window.close() self.root_window.close()
super()._stop(*largs) super()._stop(*largs)
Launcher().run() def on_stop(self):
Utils.persistent_store("launcher", "favorites", self.favorites)
Utils.persistent_store("launcher", "filter", ", ".join(filter.name if isinstance(filter, Type) else filter
for filter in self.current_filter))
super().on_stop()
Launcher(path=path, args=args).run()
# avoiding Launcher reference leak # avoiding Launcher reference leak
# and don't try to do something with widgets after window closed # and don't try to do something with widgets after window closed
@@ -360,16 +428,14 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
path = args.get("Patch|Game|Component|url", None) path = args.get("Patch|Game|Component|url", None)
if path is not None: if path is not None:
if path.startswith("archipelago://"): if not path.startswith("archipelago://"):
handle_uri(path, args.get("args", ())) file, component = identify(path)
return if file:
file, component = identify(path) args['file'] = file
if file: if component:
args['file'] = file args['component'] = component
if component: if not component:
args['component'] = component logging.warning(f"Could not identify Component responsible for {path}")
if not component:
logging.warning(f"Could not identify Component responsible for {path}")
if args["update_settings"]: if args["update_settings"]:
update_settings() update_settings()
@@ -378,7 +444,7 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
elif "component" in args: elif "component" in args:
run_component(args["component"], *args["args"]) run_component(args["component"], *args["args"])
elif not args["update_settings"]: elif not args["update_settings"]:
run_gui() run_gui(path, args.get("args", ()))
if __name__ == '__main__': if __name__ == '__main__':
@@ -400,6 +466,7 @@ if __name__ == '__main__':
main(parser.parse_args()) main(parser.parse_args())
from worlds.LauncherComponents import processes from worlds.LauncherComponents import processes
for process in processes: for process in processes:
# we await all child processes to close before we tear down the process host # 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 # this makes it feel like each one is its own program, as the Launcher is closed now

View File

@@ -26,8 +26,10 @@ import typing
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger, from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
server_loop) server_loop)
from NetUtils import ClientStatus from NetUtils import ClientStatus
from worlds.ladx import LinksAwakeningWorld
from worlds.ladx.Common import BASE_ID as LABaseID from worlds.ladx.Common import BASE_ID as LABaseID
from worlds.ladx.GpsTracker import GpsTracker from worlds.ladx.GpsTracker import GpsTracker
from worlds.ladx.TrackerConsts import storage_key
from worlds.ladx.ItemTracker import ItemTracker from worlds.ladx.ItemTracker import ItemTracker
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
from worlds.ladx.Locations import get_locations_to_id, meta_to_name from worlds.ladx.Locations import get_locations_to_id, meta_to_name
@@ -100,19 +102,23 @@ class LAClientConstants:
WRamCheckSize = 0x4 WRamCheckSize = 0x4
WRamSafetyValue = bytearray([0]*WRamCheckSize) WRamSafetyValue = bytearray([0]*WRamCheckSize)
wRamStart = 0xC000
hRamStart = 0xFF80
hRamSize = 0x80
MinGameplayValue = 0x06 MinGameplayValue = 0x06
MaxGameplayValue = 0x1A MaxGameplayValue = 0x1A
VictoryGameplayAndSub = 0x0102 VictoryGameplayAndSub = 0x0102
class RAGameboy(): class RAGameboy():
cache = [] cache = []
cache_start = 0
cache_size = 0
last_cache_read = None last_cache_read = None
socket = None socket = None
def __init__(self, address, port) -> None: def __init__(self, address, port) -> None:
self.cache_start = LAClientConstants.wRamStart
self.cache_size = LAClientConstants.hRamStart + LAClientConstants.hRamSize - LAClientConstants.wRamStart
self.address = address self.address = address
self.port = port self.port = port
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
@@ -131,9 +137,14 @@ class RAGameboy():
async def get_retroarch_status(self): async def get_retroarch_status(self):
return await self.send_command("GET_STATUS") return await self.send_command("GET_STATUS")
def set_cache_limits(self, cache_start, cache_size): def set_checks_range(self, checks_start, checks_size):
self.cache_start = cache_start self.checks_start = checks_start
self.cache_size = cache_size self.checks_size = checks_size
def set_location_range(self, location_start, location_size, critical_addresses):
self.location_start = location_start
self.location_size = location_size
self.critical_location_addresses = critical_addresses
def send(self, b): def send(self, b):
if type(b) is str: if type(b) is str:
@@ -188,21 +199,57 @@ class RAGameboy():
if not await self.check_safe_gameplay(): if not await self.check_safe_gameplay():
return return
cache = [] attempts = 0
remaining_size = self.cache_size while True:
while remaining_size: # RA doesn't let us do an atomic read of a large enough block of RAM
block = await self.async_read_memory(self.cache_start + len(cache), remaining_size) # Some bytes can't change in between reading location_block and hram_block
remaining_size -= len(block) location_block = await self.read_memory_block(self.location_start, self.location_size)
cache += block hram_block = await self.read_memory_block(LAClientConstants.hRamStart, LAClientConstants.hRamSize)
verification_block = await self.read_memory_block(self.location_start, self.location_size)
valid = True
for address in self.critical_location_addresses:
if location_block[address - self.location_start] != verification_block[address - self.location_start]:
valid = False
if valid:
break
attempts += 1
# Shouldn't really happen, but keep it from choking
if attempts > 5:
return
checks_block = await self.read_memory_block(self.checks_start, self.checks_size)
if not await self.check_safe_gameplay(): if not await self.check_safe_gameplay():
return return
self.cache = cache self.cache = bytearray(self.cache_size)
start = self.checks_start - self.cache_start
self.cache[start:start + len(checks_block)] = checks_block
start = self.location_start - self.cache_start
self.cache[start:start + len(location_block)] = location_block
start = LAClientConstants.hRamStart - self.cache_start
self.cache[start:start + len(hram_block)] = hram_block
self.last_cache_read = time.time() self.last_cache_read = time.time()
async def read_memory_block(self, address: int, size: int):
block = bytearray()
remaining_size = size
while remaining_size:
chunk = await self.async_read_memory(address + len(block), remaining_size)
remaining_size -= len(chunk)
block += chunk
return block
async def read_memory_cache(self, addresses): async def read_memory_cache(self, addresses):
# TODO: can we just update once per frame?
if not self.last_cache_read or self.last_cache_read + 0.1 < time.time(): if not self.last_cache_read or self.last_cache_read + 0.1 < time.time():
await self.update_cache() await self.update_cache()
if not self.cache: if not self.cache:
@@ -359,11 +406,12 @@ class LinksAwakeningClient():
auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode() auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode()
self.auth = auth self.auth = auth
async def wait_and_init_tracker(self): async def wait_and_init_tracker(self, magpie: MagpieBridge):
await self.wait_for_game_ready() await self.wait_for_game_ready()
self.tracker = LocationTracker(self.gameboy) self.tracker = LocationTracker(self.gameboy)
self.item_tracker = ItemTracker(self.gameboy) self.item_tracker = ItemTracker(self.gameboy)
self.gps_tracker = GpsTracker(self.gameboy) self.gps_tracker = GpsTracker(self.gameboy)
magpie.gps_tracker = self.gps_tracker
async def recved_item_from_ap(self, item_id, from_player, next_index): async def recved_item_from_ap(self, item_id, from_player, next_index):
# Don't allow getting an item until you've got your first check # Don't allow getting an item until you've got your first check
@@ -405,9 +453,11 @@ class LinksAwakeningClient():
return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1 return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1
async def main_tick(self, item_get_cb, win_cb, deathlink_cb): async def main_tick(self, item_get_cb, win_cb, deathlink_cb):
await self.gameboy.update_cache()
await self.tracker.readChecks(item_get_cb) await self.tracker.readChecks(item_get_cb)
await self.item_tracker.readItems() await self.item_tracker.readItems()
await self.gps_tracker.read_location() await self.gps_tracker.read_location()
await self.gps_tracker.read_entrances()
current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth] current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth]
if self.deathlink_debounce and current_health != 0: if self.deathlink_debounce and current_health != 0:
@@ -457,7 +507,7 @@ class LinksAwakeningContext(CommonContext):
la_task = None la_task = None
client = None client = None
# TODO: does this need to re-read on reset? # TODO: does this need to re-read on reset?
found_checks = [] found_checks = set()
last_resend = time.time() last_resend = time.time()
magpie_enabled = False magpie_enabled = False
@@ -465,6 +515,10 @@ class LinksAwakeningContext(CommonContext):
magpie_task = None magpie_task = None
won = False won = False
@property
def slot_storage_key(self):
return f"{self.slot_info[self.slot].name}_{storage_key}"
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None: def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
self.client = LinksAwakeningClient() self.client = LinksAwakeningClient()
self.slot_data = {} self.slot_data = {}
@@ -476,9 +530,7 @@ class LinksAwakeningContext(CommonContext):
def run_gui(self) -> None: def run_gui(self) -> None:
import webbrowser import webbrowser
import kvui from kvui import GameManager, ImageButton
from kvui import Button, GameManager
from kivy.uix.image import Image
class LADXManager(GameManager): class LADXManager(GameManager):
logging_pairs = [ logging_pairs = [
@@ -491,23 +543,25 @@ class LinksAwakeningContext(CommonContext):
b = super().build() b = super().build()
if self.ctx.magpie_enabled: if self.ctx.magpie_enabled:
button = Button(text="", size=(30, 30), size_hint_x=None, button = ImageButton(texture=magpie_logo(), fit_mode="cover", image_size=(32, 32), size_hint_x=None,
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1')) 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)
self.connect_layout.add_widget(button) self.connect_layout.add_widget(button)
return b return b
self.ui = LADXManager(self) self.ui = LADXManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
async def send_checks(self): async def send_new_entrances(self, entrances: typing.Dict[str, str]):
message = [{"cmd": 'LocationChecks', "locations": self.found_checks}] # Store the entrances we find on the server for future sessions
message = [{
"cmd": "Set",
"key": self.slot_storage_key,
"default": {},
"want_reply": False,
"operations": [{"operation": "update", "value": entrances}],
}]
await self.send_msgs(message) await self.send_msgs(message)
had_invalid_slot_data = None had_invalid_slot_data = None
@@ -537,13 +591,19 @@ class LinksAwakeningContext(CommonContext):
await self.send_msgs(message) await self.send_msgs(message)
self.won = True self.won = True
async def request_found_entrances(self):
await self.send_msgs([{"cmd": "Get", "keys": [self.slot_storage_key]}])
# Ask for updates so that players can co-op entrances in a seed
await self.send_msgs([{"cmd": "SetNotify", "keys": [self.slot_storage_key]}])
async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None: async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
if self.ENABLE_DEATHLINK: if self.ENABLE_DEATHLINK:
self.client.pending_deathlink = True self.client.pending_deathlink = True
def new_checks(self, item_ids, ladxr_ids): def new_checks(self, item_ids, ladxr_ids):
self.found_checks += item_ids self.found_checks.update(item_ids)
create_task_log_exception(self.send_checks()) create_task_log_exception(self.check_locations(self.found_checks))
if self.magpie_enabled: if self.magpie_enabled:
create_task_log_exception(self.magpie.send_new_checks(ladxr_ids)) create_task_log_exception(self.magpie.send_new_checks(ladxr_ids))
@@ -571,12 +631,24 @@ class LinksAwakeningContext(CommonContext):
if cmd == "Connected": if cmd == "Connected":
self.game = self.slot_info[self.slot].game self.game = self.slot_info[self.slot].game
self.slot_data = args.get("slot_data", {}) self.slot_data = args.get("slot_data", {})
# This is sent to magpie over local websocket to make its own connection
self.slot_data.update({
"server_address": self.server_address,
"slot_name": self.player_names[self.slot],
"password": self.password,
})
# TODO - use watcher_event # TODO - use watcher_event
if cmd == "ReceivedItems": if cmd == "ReceivedItems":
for index, item in enumerate(args["items"], start=args["index"]): for index, item in enumerate(args["items"], start=args["index"]):
self.client.recvd_checks[index] = item self.client.recvd_checks[index] = item
if cmd == "Retrieved" and self.magpie_enabled and self.slot_storage_key in args["keys"]:
self.client.gps_tracker.receive_found_entrances(args["keys"][self.slot_storage_key])
if cmd == "SetReply" and self.magpie_enabled and args["key"] == self.slot_storage_key:
self.client.gps_tracker.receive_found_entrances(args["value"])
async def sync(self): async def sync(self):
sync_msg = [{'cmd': 'Sync'}] sync_msg = [{'cmd': 'Sync'}]
await self.send_msgs(sync_msg) await self.send_msgs(sync_msg)
@@ -589,6 +661,12 @@ class LinksAwakeningContext(CommonContext):
checkMetadataTable[check.id])] for check in ladxr_checks] checkMetadataTable[check.id])] for check in ladxr_checks]
self.new_checks(checks, [check.id for check in ladxr_checks]) self.new_checks(checks, [check.id for check in ladxr_checks])
for check in ladxr_checks:
if check.value and check.linkedItem:
linkedItem = check.linkedItem
if 'condition' not in linkedItem or linkedItem['condition'](self.slot_data):
self.client.item_tracker.setExtraItem(check.linkedItem['item'], check.linkedItem['qty'])
async def victory(): async def victory():
await self.send_victory() await self.send_victory()
@@ -622,21 +700,38 @@ class LinksAwakeningContext(CommonContext):
if not self.client.recvd_checks: if not self.client.recvd_checks:
await self.sync() await self.sync()
await self.client.wait_and_init_tracker() await self.client.wait_and_init_tracker(self.magpie)
min_tick_duration = 0.1
last_tick = time.time()
while True: while True:
await self.client.main_tick(on_item_get, victory, deathlink) await self.client.main_tick(on_item_get, victory, deathlink)
await asyncio.sleep(0.1)
now = time.time() now = time.time()
tick_duration = now - last_tick
sleep_duration = max(min_tick_duration - tick_duration, 0)
await asyncio.sleep(sleep_duration)
last_tick = now
if self.last_resend + 5.0 < now: if self.last_resend + 5.0 < now:
self.last_resend = now self.last_resend = now
await self.send_checks() await self.check_locations(self.found_checks)
if self.magpie_enabled: if self.magpie_enabled:
try: try:
self.magpie.set_checks(self.client.tracker.all_checks) self.magpie.set_checks(self.client.tracker.all_checks)
await self.magpie.set_item_tracker(self.client.item_tracker) await self.magpie.set_item_tracker(self.client.item_tracker)
await self.magpie.send_gps(self.client.gps_tracker) if self.slot_data and "slot_data" in self.magpie.features and not self.magpie.has_sent_slot_data:
self.magpie.slot_data = self.slot_data self.magpie.slot_data = self.slot_data
await self.magpie.send_slot_data()
if self.client.gps_tracker.needs_found_entrances:
await self.request_found_entrances()
self.client.gps_tracker.needs_found_entrances = False
new_entrances = await self.magpie.send_gps(self.client.gps_tracker)
if new_entrances:
await self.send_new_entrances(new_entrances)
except Exception: except Exception:
# Don't let magpie errors take out the client # Don't let magpie errors take out the client
pass pass
@@ -647,8 +742,8 @@ class LinksAwakeningContext(CommonContext):
await asyncio.sleep(1.0) await asyncio.sleep(1.0)
def run_game(romfile: str) -> None: def run_game(romfile: str) -> None:
auto_start = typing.cast(typing.Union[bool, str], auto_start = LinksAwakeningWorld.settings.rom_start
Utils.get_options()["ladx_options"].get("rom_start", True))
if auto_start is True: if auto_start is True:
import webbrowser import webbrowser
webbrowser.open(romfile) webbrowser.open(romfile)
@@ -705,6 +800,6 @@ async def main():
await ctx.shutdown() await ctx.shutdown()
if __name__ == '__main__': if __name__ == '__main__':
colorama.init() colorama.just_fix_windows_console()
asyncio.run(main()) asyncio.run(main())
colorama.deinit() colorama.deinit()

View File

@@ -33,10 +33,15 @@ WINDOW_MIN_HEIGHT = 525
WINDOW_MIN_WIDTH = 425 WINDOW_MIN_WIDTH = 425
class AdjusterWorld(object): class AdjusterWorld(object):
class AdjusterSubWorld(object):
def __init__(self, random):
self.random = random
def __init__(self, sprite_pool): def __init__(self, sprite_pool):
import random import random
self.sprite_pool = {1: sprite_pool} self.sprite_pool = {1: sprite_pool}
self.per_slot_randoms = {1: random} self.per_slot_randoms = {1: random}
self.worlds = {1: self.AdjusterSubWorld(random)}
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter): class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):

View File

@@ -370,7 +370,7 @@ if __name__ == "__main__":
import colorama import colorama
colorama.init() colorama.just_fix_windows_console()
asyncio.run(main()) asyncio.run(main())
colorama.deinit() colorama.deinit()

31
Main.py
View File

@@ -56,32 +56,18 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:") logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types) longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
max_item = 0
max_location = 0
for cls in AutoWorld.AutoWorldRegister.world_types.values():
if cls.item_id_to_name:
max_item = max(max_item, max(cls.item_id_to_name))
max_location = max(max_location, max(cls.location_id_to_name))
item_digits = len(str(max_item))
location_digits = len(str(max_location))
item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values()))) item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values()))) location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
del max_item, max_location
for name, cls in AutoWorld.AutoWorldRegister.world_types.items(): for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
if not cls.hidden and len(cls.item_names) > 0: if not cls.hidden and len(cls.item_names) > 0:
logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} " logger.info(f" {name:{longest_name}}: Items: {len(cls.item_names):{item_count}} | "
f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - " f"Locations: {len(cls.location_names):{location_count}}")
f"{max(cls.item_id_to_name):{item_digits}}) | "
f"{len(cls.location_names):{location_count}} "
f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - "
f"{max(cls.location_id_to_name):{location_digits}})")
del item_digits, location_digits, item_count, location_count del item_count, location_count
# This assertion method should not be necessary to run if we are not outputting any multidata. # This assertion method should not be necessary to run if we are not outputting any multidata.
if not args.skip_output: if not args.skip_output and not args.spoiler_only:
AutoWorld.call_stage(multiworld, "assert_generate") AutoWorld.call_stage(multiworld, "assert_generate")
AutoWorld.call_all(multiworld, "generate_early") AutoWorld.call_all(multiworld, "generate_early")
@@ -224,6 +210,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.info(f'Beginning output...') logger.info(f'Beginning output...')
outfilebase = 'AP_' + multiworld.seed_name outfilebase = 'AP_' + multiworld.seed_name
if args.spoiler_only:
if args.spoiler > 1:
logger.info('Calculating playthrough.')
multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2)
multiworld.spoiler.to_file(output_path('%s_Spoiler.txt' % outfilebase))
logger.info('Done. Skipped multidata modification. Total time: %s', time.perf_counter() - start)
return multiworld
output = tempfile.TemporaryDirectory() output = tempfile.TemporaryDirectory()
with output as temp_dir: with output as temp_dir:
output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__ output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__

View File

@@ -28,9 +28,11 @@ ModuleUpdate.update()
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
import ssl import ssl
from NetUtils import ServerConnection
import websockets
import colorama import colorama
import websockets
from websockets.extensions.permessage_deflate import PerMessageDeflate
try: try:
# ponyorm is a requirement for webhost, not default server, so may not be importable # ponyorm is a requirement for webhost, not default server, so may not be importable
from pony.orm.dbapiprovider import OperationalError from pony.orm.dbapiprovider import OperationalError
@@ -44,8 +46,9 @@ from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, Networ
SlotType, LocationStore, Hint, HintStatus SlotType, LocationStore, Hint, HintStatus
from BaseClasses import ItemClassification from BaseClasses import ItemClassification
min_client_version = Version(0, 1, 6)
colorama.init() min_client_version = Version(0, 5, 0)
colorama.just_fix_windows_console()
def remove_from_list(container, value): def remove_from_list(container, value):
@@ -64,9 +67,13 @@ def pop_from_container(container, value):
return container return container
def update_dict(dictionary, entries): def update_container_unique(container, entries):
dictionary.update(entries) if isinstance(container, list):
return dictionary existing_container_as_set = set(container)
container.extend([entry for entry in entries if entry not in existing_container_as_set])
else:
container.update(entries)
return container
def queue_gc(): def queue_gc():
@@ -107,7 +114,7 @@ modify_functions = {
# lists/dicts: # lists/dicts:
"remove": remove_from_list, "remove": remove_from_list,
"pop": pop_from_container, "pop": pop_from_container,
"update": update_dict, "update": update_container_unique,
} }
@@ -119,13 +126,14 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
class Client(Endpoint): class Client(Endpoint):
version = Version(0, 0, 0) version = Version(0, 0, 0)
tags: typing.List[str] = [] tags: typing.List[str]
remote_items: bool remote_items: bool
remote_start_inventory: bool remote_start_inventory: bool
no_items: bool no_items: bool
no_locations: bool no_locations: bool
no_text: bool
def __init__(self, socket: websockets.WebSocketServerProtocol, ctx: Context): def __init__(self, socket: "ServerConnection", ctx: Context) -> None:
super().__init__(socket) super().__init__(socket)
self.auth = False self.auth = False
self.team = None self.team = None
@@ -175,6 +183,7 @@ class Context:
"compatibility": int} "compatibility": int}
# team -> slot id -> list of clients authenticated to slot. # team -> slot id -> list of clients authenticated to slot.
clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]] clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]]
endpoints: list[Client]
locations: LocationStore # typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]] locations: LocationStore # typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]] location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]]
hints_used: typing.Dict[typing.Tuple[int, int], int] hints_used: typing.Dict[typing.Tuple[int, int], int]
@@ -364,18 +373,28 @@ class Context:
return True return True
def broadcast_all(self, msgs: typing.List[dict]): def broadcast_all(self, msgs: typing.List[dict]):
msgs = self.dumper(msgs) msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs)
endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth) data = self.dumper(msgs)
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs)) endpoints = (
endpoint
for endpoint in self.endpoints
if endpoint.auth and not (msg_is_text and endpoint.no_text)
)
async_start(self.broadcast_send_encoded_msgs(endpoints, data))
def broadcast_text_all(self, text: str, additional_arguments: dict = {}): def broadcast_text_all(self, text: str, additional_arguments: dict = {}):
self.logger.info("Notice (all): %s" % text) self.logger.info("Notice (all): %s" % text)
self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}]) self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
def broadcast_team(self, team: int, msgs: typing.List[dict]): def broadcast_team(self, team: int, msgs: typing.List[dict]):
msgs = self.dumper(msgs) msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs)
endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values())) data = self.dumper(msgs)
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs)) endpoints = (
endpoint
for endpoint in itertools.chain.from_iterable(self.clients[team].values())
if not (msg_is_text and endpoint.no_text)
)
async_start(self.broadcast_send_encoded_msgs(endpoints, data))
def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]): def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
msgs = self.dumper(msgs) msgs = self.dumper(msgs)
@@ -389,13 +408,13 @@ class Context:
await on_client_disconnected(self, endpoint) await on_client_disconnected(self, endpoint)
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}): def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
if not client.auth: if not client.auth or client.no_text:
return return
self.logger.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text)) self.logger.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}])) async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}]))
def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}): def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}):
if not client.auth: if not client.auth or client.no_text:
return return
async_start(self.send_msgs(client, async_start(self.send_msgs(client,
[{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments} [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}
@@ -760,7 +779,7 @@ class Context:
self.on_new_hint(team, slot) self.on_new_hint(team, slot)
for slot, hint_data in concerns.items(): for slot, hint_data in concerns.items():
if recipients is None or slot in recipients: if recipients is None or slot in recipients:
clients = self.clients[team].get(slot) clients = filter(lambda c: not c.no_text, self.clients[team].get(slot, []))
if not clients: if not clients:
continue continue
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player != slot)] client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player != slot)]
@@ -769,7 +788,7 @@ class Context:
def get_hint(self, team: int, finding_player: int, seeked_location: int) -> typing.Optional[Hint]: def get_hint(self, team: int, finding_player: int, seeked_location: int) -> typing.Optional[Hint]:
for hint in self.hints[team, finding_player]: for hint in self.hints[team, finding_player]:
if hint.location == seeked_location: if hint.location == seeked_location and hint.finding_player == finding_player:
return hint return hint
return None return None
@@ -819,7 +838,7 @@ def update_aliases(ctx: Context, team: int):
async_start(ctx.send_encoded_msgs(client, cmd)) async_start(ctx.send_encoded_msgs(client, cmd))
async def server(websocket, path: str = "/", ctx: Context = None): async def server(websocket: "ServerConnection", path: str = "/", ctx: Context = None) -> None:
client = Client(websocket, ctx) client = Client(websocket, ctx)
ctx.endpoints.append(client) ctx.endpoints.append(client)
@@ -910,6 +929,10 @@ async def on_client_joined(ctx: Context, client: Client):
"If your client supports it, " "If your client supports it, "
"you may have additional local commands you can list with /help.", "you may have additional local commands you can list with /help.",
{"type": "Tutorial"}) {"type": "Tutorial"})
if not any(isinstance(extension, PerMessageDeflate) for extension in client.socket.extensions):
ctx.notify_client(client, "Warning: your client does not support compressed websocket connections! "
"It may stop working in the future. If you are a player, please report this to the "
"client's developer.")
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc) ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
@@ -1117,7 +1140,7 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item] seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item]
for finding_player, location_id, item_id, receiving_player, item_flags \ for finding_player, location_id, item_id, receiving_player, item_flags \
in ctx.locations.find_item(slots, seeked_item_id): in ctx.locations.find_item(slots, seeked_item_id):
prev_hint = ctx.get_hint(team, slot, location_id) prev_hint = ctx.get_hint(team, finding_player, location_id)
if prev_hint: if prev_hint:
hints.append(prev_hint) hints.append(prev_hint)
else: else:
@@ -1803,7 +1826,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
ctx.clients[team][slot].append(client) ctx.clients[team][slot].append(client)
client.version = args['version'] client.version = args['version']
client.tags = args['tags'] client.tags = args['tags']
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags client.no_locations = "TextOnly" in client.tags or "Tracker" in client.tags
# set NoText for old PopTracker clients that predate the tag to save traffic
client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1))
connected_packet = { connected_packet = {
"cmd": "Connected", "cmd": "Connected",
"team": client.team, "slot": client.slot, "team": client.team, "slot": client.slot,
@@ -1876,6 +1901,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
client.tags = args["tags"] client.tags = args["tags"]
if set(old_tags) != set(client.tags): if set(old_tags) != set(client.tags):
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
client.no_text = "NoText" in client.tags or (
"PopTracker" in client.tags and client.version < (0, 5, 1)
)
ctx.broadcast_text_all( ctx.broadcast_text_all(
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags " f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags "
f"from {old_tags} to {client.tags}.", f"from {old_tags} to {client.tags}.",
@@ -2014,7 +2042,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
value = func(value, operation["value"]) value = func(value, operation["value"])
ctx.stored_data[args["key"]] = args["value"] = value ctx.stored_data[args["key"]] = args["value"] = value
targets = set(ctx.stored_data_notification_clients[args["key"]]) targets = set(ctx.stored_data_notification_clients[args["key"]])
if args.get("want_reply", True): if args.get("want_reply", False):
targets.add(client) targets.add(client)
if targets: if targets:
ctx.broadcast(targets, [args]) ctx.broadcast(targets, [args])

View File

@@ -5,17 +5,18 @@ import enum
import warnings import warnings
from json import JSONEncoder, JSONDecoder from json import JSONEncoder, JSONDecoder
import websockets if typing.TYPE_CHECKING:
from websockets import WebSocketServerProtocol as ServerConnection
from Utils import ByValue, Version from Utils import ByValue, Version
class HintStatus(ByValue, enum.IntEnum): class HintStatus(ByValue, enum.IntEnum):
HINT_FOUND = 0 HINT_UNSPECIFIED = 0
HINT_UNSPECIFIED = 1
HINT_NO_PRIORITY = 10 HINT_NO_PRIORITY = 10
HINT_AVOID = 20 HINT_AVOID = 20
HINT_PRIORITY = 30 HINT_PRIORITY = 30
HINT_FOUND = 40
class JSONMessagePart(typing.TypedDict, total=False): class JSONMessagePart(typing.TypedDict, total=False):
@@ -151,7 +152,7 @@ decode = JSONDecoder(object_hook=_object_hook).decode
class Endpoint: class Endpoint:
socket: websockets.WebSocketServerProtocol socket: "ServerConnection"
def __init__(self, socket): def __init__(self, socket):
self.socket = socket self.socket = socket

View File

@@ -346,7 +346,7 @@ if __name__ == '__main__':
import colorama import colorama
colorama.init() colorama.just_fix_windows_console()
asyncio.run(main()) asyncio.run(main())
colorama.deinit() colorama.deinit()

View File

@@ -1579,10 +1579,11 @@ def dump_player_options(multiworld: MultiWorld) -> None:
player_output = { player_output = {
"Game": multiworld.game[player], "Game": multiworld.game[player],
"Name": multiworld.get_player_name(player), "Name": multiworld.get_player_name(player),
"ID": player,
} }
output.append(player_output) output.append(player_output)
for option_key, option in world.options_dataclass.type_hints.items(): for option_key, option in world.options_dataclass.type_hints.items():
if issubclass(Removed, option): if option.visibility == Visibility.none:
continue continue
display_name = getattr(option, "display_name", option_key) display_name = getattr(option, "display_name", option_key)
player_output[display_name] = getattr(world.options, option_key).current_option_name player_output[display_name] = getattr(world.options, option_key).current_option_name
@@ -1591,7 +1592,7 @@ def dump_player_options(multiworld: MultiWorld) -> None:
game_option_names.append(display_name) game_option_names.append(display_name)
with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file: with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file:
fields = ["Game", "Name", *all_option_names] fields = ["ID", "Game", "Name", *all_option_names]
writer = DictWriter(file, fields) writer = DictWriter(file, fields)
writer.writeheader() writer.writeheader()
writer.writerows(output) writer.writerows(output)

View File

@@ -80,6 +80,8 @@ Currently, the following games are supported:
* Saving Princess * Saving Princess
* Castlevania: Circle of the Moon * Castlevania: Circle of the Moon
* Inscryption * Inscryption
* Civilization VI
* The Legend of Zelda: The Wind Waker
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled

View File

@@ -735,6 +735,6 @@ async def main() -> None:
if __name__ == '__main__': if __name__ == '__main__':
colorama.init() colorama.just_fix_windows_console()
asyncio.run(main()) asyncio.run(main())
colorama.deinit() colorama.deinit()

View File

@@ -500,7 +500,7 @@ def main():
import colorama import colorama
colorama.init() colorama.just_fix_windows_console()
asyncio.run(_main()) asyncio.run(_main())
colorama.deinit() colorama.deinit()

View File

@@ -47,7 +47,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self) return ".".join(str(item) for item in self)
__version__ = "0.6.0" __version__ = "0.6.2"
version_tuple = tuplize_version(__version__) version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux") is_linux = sys.platform.startswith("linux")
@@ -443,7 +443,8 @@ class RestrictedUnpickler(pickle.Unpickler):
else: else:
mod = importlib.import_module(module) mod = importlib.import_module(module)
obj = getattr(mod, name) obj = getattr(mod, name)
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection)): if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection,
self.options_module.PlandoText)):
return obj return obj
# Forbid everything else. # Forbid everything else.
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden") raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")

View File

@@ -214,17 +214,11 @@ class WargrooveContext(CommonContext):
def run_gui(self): def run_gui(self):
"""Import kivy UI system and start running it as self.ui_task.""" """Import kivy UI system and start running it as self.ui_task."""
from kvui import GameManager, HoverBehavior, ServerToolTip from kvui import GameManager, HoverBehavior, ServerToolTip
from kivy.uix.tabbedpanel import TabbedPanelItem from kivymd.uix.tab import MDTabsItem, MDTabsItemText
from kivy.lang import Builder from kivy.lang import Builder
from kivy.uix.button import Button
from kivy.uix.togglebutton import ToggleButton from kivy.uix.togglebutton import ToggleButton
from kivy.uix.boxlayout import BoxLayout from kivy.uix.boxlayout import BoxLayout
from kivy.uix.gridlayout import GridLayout
from kivy.uix.image import AsyncImage, Image
from kivy.uix.stacklayout import StackLayout
from kivy.uix.label import Label from kivy.uix.label import Label
from kivy.properties import ColorProperty
from kivy.uix.image import Image
import pkgutil import pkgutil
class TrackerLayout(BoxLayout): class TrackerLayout(BoxLayout):
@@ -446,6 +440,6 @@ if __name__ == '__main__':
parser = get_base_parser(description="Wargroove Client, for text interfacing.") parser = get_base_parser(description="Wargroove Client, for text interfacing.")
args, rest = parser.parse_known_args() args, rest = parser.parse_known_args()
colorama.init() colorama.just_fix_windows_console()
asyncio.run(main(args)) asyncio.run(main(args))
colorama.deinit() colorama.deinit()

View File

@@ -28,6 +28,6 @@ def get_seeds():
response.append({ response.append({
"seed_id": seed.id, "seed_id": seed.id,
"creation_time": seed.creation_time, "creation_time": seed.creation_time,
"players": get_players(seed.slots), "players": get_players(seed),
}) })
return jsonify(response) return jsonify(response)

View File

@@ -9,7 +9,7 @@ from threading import Event, Thread
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
from pony.orm import db_session, select, commit from pony.orm import db_session, select, commit, PrimaryKey
from Utils import restricted_loads from Utils import restricted_loads
from .locker import Locker, AlreadyRunningException from .locker import Locker, AlreadyRunningException
@@ -36,12 +36,21 @@ def handle_generation_failure(result: BaseException):
logging.exception(e) logging.exception(e)
def _mp_gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None) -> PrimaryKey | None:
from setproctitle import setproctitle
setproctitle(f"Generator ({sid})")
res = gen_game(gen_options, meta=meta, owner=owner, sid=sid)
setproctitle(f"Generator (idle)")
return res
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation): def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
try: try:
meta = json.loads(generation.meta) meta = json.loads(generation.meta)
options = restricted_loads(generation.options) options = restricted_loads(generation.options)
logging.info(f"Generating {generation.id} for {len(options)} players") logging.info(f"Generating {generation.id} for {len(options)} players")
pool.apply_async(gen_game, (options,), pool.apply_async(_mp_gen_game, (options,),
{"meta": meta, {"meta": meta,
"sid": generation.id, "sid": generation.id,
"owner": generation.owner}, "owner": generation.owner},
@@ -55,6 +64,10 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
def init_generator(config: dict[str, Any]) -> None: def init_generator(config: dict[str, Any]) -> None:
from setproctitle import setproctitle
setproctitle("Generator (idle)")
try: try:
import resource import resource
except ModuleNotFoundError: except ModuleNotFoundError:

View File

@@ -117,6 +117,7 @@ class WebHostContext(Context):
self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load
self.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})} self.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})}
self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})} self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})}
missing_checksum = False
for game in list(multidata.get("datapackage", {})): for game in list(multidata.get("datapackage", {})):
game_data = multidata["datapackage"][game] game_data = multidata["datapackage"][game]
@@ -132,11 +133,13 @@ class WebHostContext(Context):
continue continue
else: else:
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}") self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
else:
missing_checksum = True # Game rolled on old AP and will load data package from multidata
self.gamespackage[game] = static_gamespackage.get(game, {}) self.gamespackage[game] = static_gamespackage.get(game, {})
self.item_name_groups[game] = static_item_name_groups.get(game, {}) self.item_name_groups[game] = static_item_name_groups.get(game, {})
self.location_name_groups[game] = static_location_name_groups.get(game, {}) self.location_name_groups[game] = static_location_name_groups.get(game, {})
if not game_data_packages: if not game_data_packages and not missing_checksum:
# all static -> use the static dicts directly # all static -> use the static dicts directly
self.gamespackage = static_gamespackage self.gamespackage = static_gamespackage
self.item_name_groups = static_item_name_groups self.item_name_groups = static_item_name_groups
@@ -224,6 +227,9 @@ def set_up_logging(room_id) -> logging.Logger:
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict, def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str], cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue): host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
from setproctitle import setproctitle
setproctitle(name)
Utils.init_logging(name) Utils.init_logging(name)
try: try:
import resource import resource
@@ -244,8 +250,23 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
raise Exception("Worlds system should not be loaded in the custom server.") raise Exception("Worlds system should not be loaded in the custom server.")
import gc import gc
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
del cert_file, cert_key_file, ponyconfig if not cert_file:
def get_ssl_context():
return None
else:
load_date = None
ssl_context = load_server_cert(cert_file, cert_key_file)
def get_ssl_context():
nonlocal load_date, ssl_context
today = datetime.date.today()
if load_date != today:
ssl_context = load_server_cert(cert_file, cert_key_file)
load_date = today
return ssl_context
del ponyconfig
gc.collect() # free intermediate objects used during setup gc.collect() # free intermediate objects used during setup
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
@@ -260,12 +281,12 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
assert ctx.server is None assert ctx.server is None
try: try:
ctx.server = websockets.serve( ctx.server = websockets.serve(
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context) functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=get_ssl_context())
await ctx.server await ctx.server
except OSError: # likely port in use except OSError: # likely port in use
ctx.server = websockets.serve( ctx.server = websockets.serve(
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context) functools.partial(server, ctx=ctx), ctx.host, 0, ssl=get_ssl_context())
await ctx.server await ctx.server
port = 0 port = 0

View File

@@ -135,6 +135,7 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
{"bosses", "items", "connections", "texts"})) {"bosses", "items", "connections", "texts"}))
erargs.skip_prog_balancing = False erargs.skip_prog_balancing = False
erargs.skip_output = False erargs.skip_output = False
erargs.spoiler_only = False
erargs.csv_output = False erargs.csv_output = False
name_counter = Counter() name_counter = Counter()

View File

@@ -35,6 +35,12 @@ def start_playing():
@app.route('/games/<string:game>/info/<string:lang>') @app.route('/games/<string:game>/info/<string:lang>')
@cache.cached() @cache.cached()
def game_info(game, lang): def game_info(game, lang):
try:
world = AutoWorldRegister.world_types[game]
if lang not in world.web.game_info_languages:
raise KeyError("Sorry, this game's info page is not available in that language yet.")
except KeyError:
return abort(404)
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game)) return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
@@ -52,6 +58,12 @@ def games():
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>') @app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
@cache.cached() @cache.cached()
def tutorial(game, file, lang): def tutorial(game, file, lang):
try:
world = AutoWorldRegister.world_types[game]
if lang not in [tut.link.split("/")[1] for tut in world.web.tutorials]:
raise KeyError("Sorry, the tutorial is not available in that language yet.")
except KeyError:
return abort(404)
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game)) return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))

View File

@@ -6,7 +6,7 @@ from typing import Dict, Union
from docutils.core import publish_parts from docutils.core import publish_parts
import yaml import yaml
from flask import redirect, render_template, request, Response from flask import redirect, render_template, request, Response, abort
import Options import Options
from Utils import local_path from Utils import local_path
@@ -142,7 +142,10 @@ def weighted_options_old():
@app.route("/games/<string:game>/weighted-options") @app.route("/games/<string:game>/weighted-options")
@cache.cached() @cache.cached()
def weighted_options(game: str): def weighted_options(game: str):
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True) try:
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
except KeyError:
return abort(404)
@app.route("/games/<string:game>/generate-weighted-yaml", methods=["POST"]) @app.route("/games/<string:game>/generate-weighted-yaml", methods=["POST"])
@@ -197,7 +200,10 @@ def generate_weighted_yaml(game: str):
@app.route("/games/<string:game>/player-options") @app.route("/games/<string:game>/player-options")
@cache.cached() @cache.cached()
def player_options(game: str): def player_options(game: str):
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False) try:
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
except KeyError:
return abort(404)
# YAML generator for player-options # YAML generator for player-options

View File

@@ -1,11 +1,12 @@
flask>=3.0.3 flask>=3.1.0
werkzeug>=3.0.6 werkzeug>=3.1.3
pony>=0.7.19 pony>=0.7.19
waitress>=3.0.0 waitress>=3.0.2
Flask-Caching>=2.3.0 Flask-Caching>=2.3.0
Flask-Compress>=1.15 Flask-Compress>=1.17
Flask-Limiter>=3.8.0 Flask-Limiter>=3.12
bokeh>=3.5.2 bokeh>=3.6.3
markupsafe>=2.1.5 markupsafe>=3.0.2
Markdown>=3.7 Markdown>=3.7
mdx-breakless-lists>=1.0.1 mdx-breakless-lists>=1.0.1
setproctitle>=1.3.5

View File

@@ -22,7 +22,7 @@ players to rely upon each other to complete their game.
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
players to randomize any of the supported games, and send items between them. This allows players of different players to randomize any of the supported games, and send items between them. This allows players of different
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworld. games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworlds.
Here is a list of our [Supported Games](https://archipelago.gg/games). Here is a list of our [Supported Games](https://archipelago.gg/games).
## Can I generate a single-player game with Archipelago? ## Can I generate a single-player game with Archipelago?

View File

@@ -23,7 +23,6 @@ window.addEventListener('load', () => {
showdown.setOption('strikethrough', true); showdown.setOption('strikethrough', true);
showdown.setOption('literalMidWordUnderscores', true); showdown.setOption('literalMidWordUnderscores', true);
gameInfo.innerHTML += (new showdown.Converter()).makeHtml(results); gameInfo.innerHTML += (new showdown.Converter()).makeHtml(results);
adjustHeaderWidth();
// Reset the id of all header divs to something nicer // Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) { for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
@@ -42,10 +41,5 @@ window.addEventListener('load', () => {
scrollTarget?.scrollIntoView(); scrollTarget?.scrollIntoView();
} }
}); });
}).catch((error) => {
console.error(error);
gameInfo.innerHTML =
`<h2>This page is out of logic!</h2>
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
}); });
}); });

View File

@@ -6,6 +6,4 @@ window.addEventListener('load', () => {
document.getElementById('file-input').addEventListener('change', () => { document.getElementById('file-input').addEventListener('change', () => {
document.getElementById('host-game-form').submit(); document.getElementById('host-game-form').submit();
}); });
adjustFooterHeight();
}); });

View File

@@ -1,47 +0,0 @@
const adjustFooterHeight = () => {
// If there is no footer on this page, do nothing
const footer = document.getElementById('island-footer');
if (!footer) { return; }
// If the body is taller than the window, also do nothing
if (document.body.offsetHeight > window.innerHeight) {
footer.style.marginTop = '0';
return;
}
// Add a margin-top to the footer to position it at the bottom of the screen
const sibling = footer.previousElementSibling;
const margin = (window.innerHeight - sibling.offsetTop - sibling.offsetHeight - footer.offsetHeight);
if (margin < 1) {
footer.style.marginTop = '0';
return;
}
footer.style.marginTop = `${margin}px`;
};
const adjustHeaderWidth = () => {
// If there is no header, do nothing
const header = document.getElementById('base-header');
if (!header) { return; }
const tempDiv = document.createElement('div');
tempDiv.style.width = '100px';
tempDiv.style.height = '100px';
tempDiv.style.overflow = 'scroll';
tempDiv.style.position = 'absolute';
tempDiv.style.top = '-500px';
document.body.appendChild(tempDiv);
const scrollbarWidth = tempDiv.offsetWidth - tempDiv.clientWidth;
document.body.removeChild(tempDiv);
const documentRoot = document.compatMode === 'BackCompat' ? document.body : document.documentElement;
const margin = (documentRoot.scrollHeight > documentRoot.clientHeight) ? 0-scrollbarWidth : 0;
document.getElementById('base-header-right').style.marginRight = `${margin}px`;
};
window.addEventListener('load', () => {
window.addEventListener('resize', adjustFooterHeight);
window.addEventListener('resize', adjustHeaderWidth);
adjustFooterHeight();
adjustHeaderWidth();
});

View File

@@ -25,7 +25,6 @@ window.addEventListener('load', () => {
showdown.setOption('literalMidWordUnderscores', true); showdown.setOption('literalMidWordUnderscores', true);
showdown.setOption('disableForced4SpacesIndentedSublists', true); showdown.setOption('disableForced4SpacesIndentedSublists', true);
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results); tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
adjustHeaderWidth();
const title = document.querySelector('h1') const title = document.querySelector('h1')
if (title) { if (title) {
@@ -49,10 +48,5 @@ window.addEventListener('load', () => {
scrollTarget?.scrollIntoView(); scrollTarget?.scrollIntoView();
} }
}); });
}).catch((error) => {
console.error(error);
tutorialWrapper.innerHTML =
`<h2>This page is out of logic!</h2>
<h3>Click <a href="${window.location.origin}/tutorial">here</a> to return to safety.</h3>`;
}); });
}); });

View File

@@ -36,6 +36,13 @@ html{
body{ body{
margin: 0; margin: 0;
display: flex;
flex-direction: column;
min-height: calc(100vh - 110px);
}
main {
flex-grow: 1;
} }
a{ a{

View File

@@ -75,6 +75,27 @@
#inventory-table img.acquired.green{ /*32CD32*/ #inventory-table img.acquired.green{ /*32CD32*/
filter: hue-rotate(84deg) saturate(10) brightness(0.7); filter: hue-rotate(84deg) saturate(10) brightness(0.7);
} }
#inventory-table img.acquired.hotpink{ /*FF69B4*/
filter: sepia(100%) hue-rotate(300deg) saturate(10);
}
#inventory-table img.acquired.lightsalmon{ /*FFA07A*/
filter: sepia(100%) hue-rotate(347deg) saturate(10);
}
#inventory-table img.acquired.crimson{ /*DB143B*/
filter: sepia(100%) hue-rotate(318deg) saturate(10) brightness(0.86);
}
#inventory-table span{
color: #B4B4A0;
font-size: 40px;
max-width: 40px;
max-height: 40px;
filter: grayscale(100%) contrast(75%) brightness(30%);
}
#inventory-table span.acquired{
filter: none;
}
#inventory-table div.image-stack{ #inventory-table div.image-stack{
display: grid; display: grid;

View File

@@ -1,5 +1,6 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% import "macros.html" as macros %} {% import "macros.html" as macros %}
{% set show_footer = True %}
{% block head %} {% block head %}
<title>Page Not Found (404)</title> <title>Page Not Found (404)</title>
@@ -13,5 +14,4 @@
The page you're looking for doesn&apos;t exist.<br /> The page you're looking for doesn&apos;t exist.<br />
<a href="/">Click here to return to safety.</a> <a href="/">Click here to return to safety.</a>
</div> </div>
{% include 'islandFooter.html' %}
{% endblock %} {% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% set show_footer = True %}
{% block head %} {% block head %}
<title>Upload Multidata</title> <title>Upload Multidata</title>
@@ -27,6 +28,4 @@
</div> </div>
</div> </div>
</div> </div>
{% include 'islandFooter.html' %}
{% endblock %} {% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% set show_footer = True %}
{% block head %} {% block head %}
<title>Archipelago</title> <title>Archipelago</title>
@@ -57,5 +58,4 @@
</div> </div>
</div> </div>
</div> </div>
{% include 'islandFooter.html' %}
{% endblock %} {% endblock %}

View File

@@ -5,26 +5,29 @@
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tooltip.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tooltip.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/cookieNotice.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/cookieNotice.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/globalStyles.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/globalStyles.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/styleController.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/cookieNotice.js") }}"></script> <script type="application/ecmascript" src="{{ url_for('static', filename="assets/cookieNotice.js") }}"></script>
{% block head %} {% block head %}
<title>Archipelago</title> <title>Archipelago</title>
{% endblock %} {% endblock %}
</head> </head>
<body> <body>
<main>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div>
{% for message in messages | unique %}
<div class="user-message">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% with messages = get_flashed_messages() %} {% block body %}
{% if messages %} {% endblock %}
<div> </main>
{% for message in messages | unique %}
<div class="user-message">{{ message }}</div> {% if show_footer %}
{% endfor %} {% include "islandFooter.html" %}
</div>
{% endif %} {% endif %}
{% endwith %}
{% block body %}
{% endblock %}
</body> </body>
</html> </html>

View File

@@ -213,7 +213,7 @@
{% endmacro %} {% endmacro %}
{% macro RandomizeButton(option_name, option) %} {% macro RandomizeButton(option_name, option) %}
<div class="randomize-button" data-tooltip="Toggle randomization for this option!"> <div class="randomize-button" data-tooltip="Pick a random value for this option.">
<label for="random-{{ option_name }}"> <label for="random-{{ option_name }}">
<input type="checkbox" id="random-{{ option_name }}" name="random-{{ option_name }}" class="randomize-checkbox" data-option-name="{{ option_name }}" {{ "checked" if option.default == "random" }} /> <input type="checkbox" id="random-{{ option_name }}" name="random-{{ option_name }}" class="randomize-checkbox" data-option-name="{{ option_name }}" {{ "checked" if option.default == "random" }} />
🎲 🎲

View File

@@ -1,5 +1,6 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% import "macros.html" as macros %} {% import "macros.html" as macros %}
{% set show_footer = True %}
{% block head %} {% block head %}
<title>Generation failed, please retry.</title> <title>Generation failed, please retry.</title>
@@ -15,5 +16,4 @@
{{ seed_error }} {{ seed_error }}
</div> </div>
</div> </div>
{% include 'islandFooter.html' %}
{% endblock %} {% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% set show_footer = True %}
{% block head %} {% block head %}
<title>Start Playing</title> <title>Start Playing</title>
@@ -26,6 +27,4 @@
</p> </p>
</div> </div>
</div> </div>
{% include 'islandFooter.html' %}
{% endblock %} {% endblock %}

View File

@@ -99,6 +99,52 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% if 'PrismBreak' in options or 'LockKeyAmadeus' in options or 'GateKeep' in options %}
<div class="table-row">
{% if 'PrismBreak' in options %}
<div class="C1">
<div class="image-stack">
<div class="stack-front">
<div class="stack-top-left">
<img src="{{ icons['Laser Access'] }}" class="hotpink {{ 'acquired' if 'Laser Access A' in acquired_items }}" title="Laser Access A" />
</div>
<div class="stack-top-right">
<img src="{{ icons['Laser Access'] }}" class="lightsalmon {{ 'acquired' if 'Laser Access I' in acquired_items }}" title="Laser Access I" />
</div>
<div class="stack-bottum-left">
<img src="{{ icons['Laser Access'] }}" class="crimson {{ 'acquired' if 'Laser Access M' in acquired_items }}" title="Laser Access M" />
</div>
</div>
</div>
</div>
{% endif %}
{% if 'LockKeyAmadeus' in options %}
<div class="C2">
<div class="image-stack">
<div class="stack-front">
<div class="stack-top-left">
<img src="{{ icons['Lab Glasses'] }}" class="{{ 'acquired' if 'Lab Access Genza' in acquired_items }}" title="Lab Access Genza" />
</div>
<div class="stack-top-right">
<img src="{{ icons['Eye Orb'] }}" class="{{ 'acquired' if 'Lab Access Dynamo' in acquired_items }}" title="Lab Access Dynamo" />
</div>
<div class="stack-bottum-left">
<img src="{{ icons['Lab Coat'] }}" class="{{ 'acquired' if 'Lab Access Research' in acquired_items }}" title="Lab Access Research" />
</div>
<div class="stack-bottum-right">
<img src="{{ icons['Demon'] }}" class="{{ 'acquired' if 'Lab Access Experiment' in acquired_items }}" title="Lab Access Experiment" />
</div>
</div>
</div>
</div>
{% endif %}
{% if 'GateKeep' in options %}
<div class="C3">
<span class="{{ 'acquired' if 'Drawbridge Key' in acquired_items }}" title="Drawbridge Key">&#10070;</span>
</div>
{% endif %}
</div>
{% endif %}
</div> </div>
<table id="location-table"> <table id="location-table">

View File

@@ -29,7 +29,8 @@
<div id="user-content-wrapper" class="markdown"> <div id="user-content-wrapper" class="markdown">
<div id="user-content" class="grass-island"> <div id="user-content" class="grass-island">
<h1>User Content</h1> <h1>User Content</h1>
Below is a list of all the content you have generated on this site. Rooms and seeds are listed separately. Below is a list of all the content you have generated on this site. Rooms and seeds are listed separately.<br/>
Sessions can be saved or synced across devices using the <a href="{{url_for('show_session')}}">Sessions Page.</a>
<h2>Your Rooms</h2> <h2>Your Rooms</h2>
{% if rooms %} {% if rooms %}

View File

@@ -1,5 +1,6 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% import "macros.html" as macros %} {% import "macros.html" as macros %}
{% set show_footer = True %}
{% block head %} {% block head %}
<title>View Seed {{ seed.id|suuid }}</title> <title>View Seed {{ seed.id|suuid }}</title>
@@ -50,5 +51,4 @@
</table> </table>
</div> </div>
</div> </div>
{% include 'islandFooter.html' %}
{% endblock %} {% endblock %}

View File

@@ -1,9 +1,12 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% import "macros.html" as macros %} {% import "macros.html" as macros %}
{% set show_footer = True %}
{% block head %} {% block head %}
<title>Generation in Progress</title> <title>Generation in Progress</title>
<meta http-equiv="refresh" content="1"> <noscript>
<meta http-equiv="refresh" content="1">
</noscript>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/waitSeed.css") }}"/> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/waitSeed.css") }}"/>
{% endblock %} {% endblock %}
@@ -15,5 +18,34 @@
Waiting for game to generate, this page auto-refreshes to check. Waiting for game to generate, this page auto-refreshes to check.
</div> </div>
</div> </div>
{% include 'islandFooter.html' %} <script>
const waitSeedDiv = document.getElementById("wait-seed");
async function checkStatus() {
try {
const response = await fetch("{{ url_for('api.wait_seed_api', seed=seed_id) }}");
if (response.status !== 202) {
// Seed is ready; reload page to load seed page.
location.reload();
return;
}
const data = await response.json();
waitSeedDiv.innerHTML = `
<h1>Generation in Progress</h1>
<p>${data.text}</p>
`;
setTimeout(checkStatus, 1000); // Continue polling.
} catch (error) {
waitSeedDiv.innerHTML = `
<h1>Progress Unknown</h1>
<p>${error.message}<br />(Last checked: ${new Date().toLocaleTimeString()})</p>
`;
setTimeout(checkStatus, 1000);
}
}
setTimeout(checkStatus, 1000);
</script>
{% endblock %} {% endblock %}

View File

@@ -100,7 +100,7 @@
{% else %} {% else %}
<div class="unsupported-option"> <div class="unsupported-option">
This option is not supported. Please edit your .yaml file manually. This option cannot be modified here. Please edit your .yaml file manually.
</div> </div>
{% endif %} {% endif %}

View File

@@ -1071,6 +1071,11 @@ if "Timespinner" in network_data_package["games"]:
"Plasma Orb": "https://timespinnerwiki.com/mediawiki/images/4/44/Plasma_Orb.png", "Plasma Orb": "https://timespinnerwiki.com/mediawiki/images/4/44/Plasma_Orb.png",
"Kobo": "https://timespinnerwiki.com/mediawiki/images/c/c6/Familiar_Kobo.png", "Kobo": "https://timespinnerwiki.com/mediawiki/images/c/c6/Familiar_Kobo.png",
"Merchant Crow": "https://timespinnerwiki.com/mediawiki/images/4/4e/Familiar_Crow.png", "Merchant Crow": "https://timespinnerwiki.com/mediawiki/images/4/4e/Familiar_Crow.png",
"Laser Access": "https://timespinnerwiki.com/mediawiki/images/9/99/Historical_Documents.png",
"Lab Glasses": "https://timespinnerwiki.com/mediawiki/images/4/4a/Lab_Glasses.png",
"Eye Orb": "https://timespinnerwiki.com/mediawiki/images/a/a4/Eye_Orb.png",
"Lab Coat": "https://timespinnerwiki.com/mediawiki/images/5/51/Lab_Coat.png",
"Demon": "https://timespinnerwiki.com/mediawiki/images/f/f8/Familiar_Demon.png",
} }
timespinner_location_ids = { timespinner_location_ids = {
@@ -1118,6 +1123,9 @@ if "Timespinner" in network_data_package["games"]:
timespinner_location_ids["Ancient Pyramid"] += [ timespinner_location_ids["Ancient Pyramid"] += [
1337237, 1337238, 1337239, 1337237, 1337238, 1337239,
1337240, 1337241, 1337242, 1337243, 1337244, 1337245] 1337240, 1337241, 1337242, 1337243, 1337244, 1337245]
if (slot_data["PyramidStart"]):
timespinner_location_ids["Ancient Pyramid"] += [
1337233, 1337234, 1337235]
display_data = {} display_data = {}

View File

@@ -386,7 +386,7 @@ if __name__ == '__main__':
parser.add_argument('diff_file', default="", type=str, nargs="?", parser.add_argument('diff_file', default="", type=str, nargs="?",
help='Path to a Archipelago Binary Patch file') help='Path to a Archipelago Binary Patch file')
args = parser.parse_args() args = parser.parse_args()
colorama.init() colorama.just_fix_windows_console()
asyncio.run(main(args)) asyncio.run(main(args))
colorama.deinit() colorama.deinit()

View File

@@ -14,23 +14,60 @@
salmon: "FA8072" # typically trap item salmon: "FA8072" # typically trap item
white: "FFFFFF" # not used, if you want to change the generic text color change color in Label white: "FFFFFF" # not used, if you want to change the generic text color change color in Label
orange: "FF7700" # Used for command echo orange: "FF7700" # Used for command echo
<Label>: # KivyMD theming parameters
color: "FFFFFF" theme_style: "Dark" # Light/Dark
<TabbedPanel>: primary_palette: "Lightsteelblue" # Many options
tab_width: root.width / app.tab_count dynamic_scheme_name: "VIBRANT"
dynamic_scheme_contrast: 0.0
<MDLabel>:
color: self.theme_cls.primaryColor
<BaseButton>:
ripple_color: app.theme_cls.primaryColor
ripple_duration_in_fast: 0.2
<MDTabsItemBase>:
ripple_color: app.theme_cls.primaryColor
ripple_duration_in_fast: 0.2
<TooltipLabel>: <TooltipLabel>:
text_size: self.width, None adaptive_height: True
size_hint_y: None theme_font_size: "Custom"
height: self.texture_size[1] font_size: "20dp"
font_size: dp(20)
markup: True markup: True
halign: "left"
<SelectableLabel>: <SelectableLabel>:
size_hint: 1, None
theme_text_color: "Custom"
text_color: 1, 1, 1, 1
canvas.before: canvas.before:
Color: Color:
rgba: (.0, 0.9, .1, .3) if self.selected else (0.2, 0.2, 0.2, 1) rgba: (self.theme_cls.primaryColor[0], self.theme_cls.primaryColor[1], self.theme_cls.primaryColor[2], .3) if self.selected else self.theme_cls.surfaceContainerLowestColor
Rectangle: Rectangle:
size: self.size size: self.size
pos: self.pos pos: self.pos
<MarkupDropdownItem>
orientation: "vertical"
MDLabel:
text: root.text
valign: "center"
padding_x: "12dp"
shorten: True
shorten_from: "right"
theme_text_color: "Custom"
markup: True
text_color:
app.theme_cls.onSurfaceVariantColor \
if not root.text_color else \
root.text_color
MDDivider:
md_bg_color:
( \
app.theme_cls.outlineVariantColor \
if not root.divider_color \
else root.divider_color \
) \
if root.divider else \
(0, 0, 0, 0)
<UILog>: <UILog>:
messages: 1000 # amount of messages stored in client logs. messages: 1000 # amount of messages stored in client logs.
cols: 1 cols: 1
@@ -49,7 +86,7 @@
<HintLabel>: <HintLabel>:
canvas.before: canvas.before:
Color: Color:
rgba: (.0, 0.9, .1, .3) if self.selected else (0.2, 0.2, 0.2, 1) if self.striped else (0.18, 0.18, 0.18, 1) rgba: (.0, 0.9, .1, .3) if self.selected else self.theme_cls.surfaceContainerHighColor if self.striped else self.theme_cls.surfaceContainerLowColor
Rectangle: Rectangle:
size: self.size size: self.size
pos: self.pos pos: self.pos
@@ -126,9 +163,12 @@
<ToolTip>: <ToolTip>:
size: self.texture_size size: self.texture_size
size_hint: None, None size_hint: None, None
theme_font_size: "Custom"
font_size: dp(18) font_size: dp(18)
pos_hint: {'center_y': 0.5, 'center_x': 0.5} pos_hint: {'center_y': 0.5, 'center_x': 0.5}
halign: "left" halign: "left"
theme_text_color: "Custom"
text_color: (1, 1, 1, 1)
canvas.before: canvas.before:
Color: Color:
rgba: 0.2, 0.2, 0.2, 1 rgba: 0.2, 0.2, 0.2, 1
@@ -147,8 +187,38 @@
rectangle: self.x-2, self.y-2, self.width+4, self.height+4 rectangle: self.x-2, self.y-2, self.width+4, self.height+4
<ServerToolTip>: <ServerToolTip>:
pos_hint: {'center_y': 0.5, 'center_x': 0.5} pos_hint: {'center_y': 0.5, 'center_x': 0.5}
<AutocompleteHintInput> <AutocompleteHintInput>:
size_hint_y: None size_hint_y: None
height: dp(30) height: "30dp"
multiline: False multiline: False
write_tab: False write_tab: False
pos_hint: {"center_x": 0.5, "center_y": 0.5}
<ConnectBarTextInput>:
height: "30dp"
multiline: False
write_tab: False
role: "medium"
size_hint_y: None
pos_hint: {"center_x": 0.5, "center_y": 0.5}
<CommandPromptTextInput>:
size_hint_y: None
height: "30dp"
multiline: False
write_tab: False
pos_hint: {"center_x": 0.5, "center_y": 0.5}
<MessageBoxLabel>:
theme_text_color: "Custom"
text_color: 1, 1, 1, 1
<ScrollBox>:
layout: layout
bar_width: "12dp"
scroll_wheel_distance: 40
do_scroll_x: False
scroll_type: ['bars', 'content']
MDBoxLayout:
id: layout
orientation: "vertical"
spacing: 10
size_hint_y: None
height: self.minimum_height

145
data/launcher.kv Normal file
View File

@@ -0,0 +1,145 @@
<LauncherCard>:
id: main
style: "filled"
padding: "4dp"
size_hint: 1, None
height: "75dp"
context_button: context
focus_behavior: False
MDRelativeLayout:
ApAsyncImage:
source: main.image
size: (48, 48)
size_hint: None, None
pos_hint: {"center_x": 0.1, "center_y": 0.5}
MDLabel:
text: main.component.display_name
pos_hint:{"center_x": 0.5, "center_y": 0.75 if main.component.description else 0.65}
halign: "center"
font_style: "Title"
role: "medium"
theme_text_color: "Custom"
text_color: app.theme_cls.primaryColor
MDLabel:
text: main.component.description
pos_hint: {"center_x": 0.5, "center_y": 0.35}
halign: "center"
role: "small"
theme_text_color: "Custom"
text_color: app.theme_cls.primaryColor
MDIconButton:
component: main.component
icon: "star" if self.component.display_name in app.favorites else "star-outline"
style: "standard"
pos_hint:{"center_x": 0.85, "center_y": 0.8}
theme_text_color: "Custom"
text_color: app.theme_cls.primaryColor
detect_visible: False
on_release: app.set_favorite(self)
MDIconButton:
id: context
icon: "menu"
style: "standard"
pos_hint:{"center_x": 0.95, "center_y": 0.8}
theme_text_color: "Custom"
text_color: app.theme_cls.primaryColor
detect_visible: False
MDButton:
pos_hint:{"center_x": 0.9, "center_y": 0.25}
size_hint_y: None
height: "25dp"
component: main.component
on_release: app.component_action(self)
detect_visible: False
MDButtonText:
text: "Open"
#:import Type worlds.LauncherComponents.Type
MDFloatLayout:
id: top_screen
MDGridLayout:
id: grid
cols: 2
spacing: "5dp"
padding: "10dp"
MDGridLayout:
id: navigation
cols: 1
size_hint_x: 0.25
MDButton:
id: all
style: "text"
type: (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
on_release: app.filter_clients(self)
MDButtonIcon:
icon: "asterisk"
MDButtonText:
text: "All"
MDButton:
id: client
style: "text"
type: (Type.CLIENT, )
on_release: app.filter_clients(self)
MDButtonIcon:
icon: "controller"
MDButtonText:
text: "Client"
MDButton:
id: Tool
style: "text"
type: (Type.TOOL, )
on_release: app.filter_clients(self)
MDButtonIcon:
icon: "desktop-classic"
MDButtonText:
text: "Tool"
MDButton:
id: adjuster
style: "text"
type: (Type.ADJUSTER, )
on_release: app.filter_clients(self)
MDButtonIcon:
icon: "wrench"
MDButtonText:
text: "Adjuster"
MDButton:
id: misc
style: "text"
type: (Type.MISC, )
on_release: app.filter_clients(self)
MDButtonIcon:
icon: "dots-horizontal-circle-outline"
MDButtonText:
text: "Misc"
MDButton:
id: favorites
style: "text"
type: ("favorites", )
on_release: app.filter_clients(self)
MDButtonIcon:
icon: "star"
MDButtonText:
text: "Favorites"
MDNavigationDrawerDivider:
ScrollBox:
id: button_layout

View File

@@ -45,6 +45,9 @@
# ChecksFinder # ChecksFinder
/worlds/checksfinder/ @SunCatMC /worlds/checksfinder/ @SunCatMC
# Civilization VI
/worlds/civ6/ @hesto2
# Clique # Clique
/worlds/clique/ @ThePhar /worlds/clique/ @ThePhar
@@ -211,6 +214,9 @@
# Wargroove # Wargroove
/worlds/wargroove/ @FlySniper /worlds/wargroove/ @FlySniper
# The Wind Waker
/worlds/tww/ @tanjo3
# The Witness # The Witness
/worlds/witness/ @NewSoupVi @blastron /worlds/witness/ @NewSoupVi @blastron

View File

@@ -1,5 +1,8 @@
# Adding Games # Adding Games
Like all contributions to Archipelago, New Game implementations should follow the [Contributing](/docs/contributing.md)
guide.
Adding a new game to Archipelago has two major parts: Adding a new game to Archipelago has two major parts:
* Game Modification to communicate with Archipelago server (hereafter referred to as "client") * Game Modification to communicate with Archipelago server (hereafter referred to as "client")
@@ -13,30 +16,51 @@ it will not be detailed here.
The client is an intermediary program between the game and the Archipelago server. This can either be a direct The client is an intermediary program between the game and the Archipelago server. This can either be a direct
modification to the game, an external program, or both. This can be implemented in nearly any modern language, but it modification to the game, an external program, or both. This can be implemented in nearly any modern language, but it
must fulfill a few requirements in order to function as expected. The specific requirements the game client must follow must fulfill a few requirements in order to function as expected. Libraries for most modern languages and the spec for
to behave as expected are: various packets can be found in the [network protocol](/docs/network%20protocol.md) API reference document.
### Hard Requirements
In order for the game client to behave as expected, it must be able to perform these functions:
* Handle both secure and unsecure websocket connections * Handle both secure and unsecure websocket connections
* Detect and react when a location has been "checked" by the player by sending a network packet to the server * Reconnect if the connection is unstable and lost while playing
* Receive and parse network packets when the player receives an item from the server, and reward it to the player on
demand
* **Any** of your items can be received any number of times, up to and far surpassing those that the game might
normally expect from features such as starting inventory, item link replacement, or item cheating
* Players and the admin can cheat items to the player at any time with a server command, and these items may not have
a player or location attributed to them
* Be able to change the port for saved connection info * Be able to change the port for saved connection info
* Rooms hosted on the website attempt to reserve their port, but since there are a limited number of ports, this * Rooms hosted on the website attempt to reserve their port, but since there are a limited number of ports, this
privilege can be lost, requiring the room to be moved to a new port privilege can be lost, requiring the room to be moved to a new port
* Reconnect if the connection is unstable and lost while playing
* Keep an index for items received in order to resync. The ItemsReceived Packets are a single list with guaranteed
order.
* Receive items that were sent to the player while they were not connected to the server
* The player being able to complete checks while offline and sending them when reconnecting is a good bonus, but not
strictly required
* Send a status update packet alerting the server that the player has completed their goal * Send a status update packet alerting the server that the player has completed their goal
Libraries for most modern languages and the spec for various packets can be found in the Regarding items and locations, the game client must be able to handle these tasks:
[network protocol](/docs/network%20protocol.md) API reference document.
#### Location Handling
Send a network packet to the server when it detects a location has been "checked" by the player in-game.
* If actions were taken in game that would usually trigger a location check, and those actions can only ever be taken
once, but the client was not connected when they happened: The client must send those location checks on connection
so that they are not permanently lost, e.g. by reading flags in the game state or save file.
#### Item Handling
Receive and parse network packets from the server when the player receives an item.
* It must reward items to the player on demand, as items can come from other players at any time.
* It must be able to reward copies of an item, up to and beyond the number the game normally expects. This may happen
due to features such as starting inventory, item link replacement, admin commands, or item cheating. **Any** of
your items can be received **any** number of times.
* Admins and players may use server commands to create items without a player or location attributed to them. The
client must be able to handle these items.
* It must keep an index for items received in order to resync. The ItemsReceived Packets are a single list with a
guaranteed order.
* It must be able to receive items that were sent to the player while they were not connected to the server.
### Encouraged Features
These are "nice to have" features for a client, but they are not strictly required. It is encouraged to add them
if possible.
* If your client appears in the Archipelago Launcher, you may define an icon for it that differentiates it from
other clients. The icon size is 48x48 pixels, but smaller or larger images will scale to that size.
## World ## World
@@ -44,35 +68,94 @@ The world is your game integration for the Archipelago generator, webhost, and m
information necessary for creating the items and locations to be randomized, the logic for item placement, the information necessary for creating the items and locations to be randomized, the logic for item placement, the
datapackage information so other game clients can recognize your game data, and documentation. Your world must be datapackage information so other game clients can recognize your game data, and documentation. Your world must be
written as a Python package to be loaded by Archipelago. This is currently done by creating a fork of the Archipelago written as a Python package to be loaded by Archipelago. This is currently done by creating a fork of the Archipelago
repository and creating a new world package in `/worlds/`. A bare minimum world implementation must satisfy the repository and creating a new world package in `/worlds/`.
following requirements:
* A folder within `/worlds/` that contains an `__init__.py` The base World class can be found in [AutoWorld](/worlds/AutoWorld.py). Methods available for your world to call
* A `World` subclass where you create your world and define all of its rules during generation can be found in [BaseClasses](/BaseClasses.py) and [Fill](/Fill.py). Some examples and documentation
* A unique game name regarding the API can be found in the [world api doc](/docs/world%20api.md). Before publishing, make sure to also
* For webhost documentation and behaviors, a `WebWorld` subclass that must be instantiated in the `World` class check out [world maintainer.md](/docs/world%20maintainer.md).
definition
* The game_info doc must follow the format `{language_code}_{game_name}.md` ### Hard Requirements
A bare minimum world implementation must satisfy the following requirements:
* It has a folder with the name of your game (or an abbreviation) under `/worlds/`
* The `/worlds/{game}` folder contains an `__init__.py`
* Any subfolders within `/worlds/{game}` that contain `*.py` files also contain an `__init__.py` for frozen build
packaging
* The game folder has at least one game_info doc named with follow the format `{language_code}_{game_name}.md`
* The game folder has at least one setup doc
* There must be a `World` subclass in your game folder (typically in `/worlds/{game}/__init__.py`) where you create
your world and define all of its rules and features
Within the `World` subclass you should also have:
* A [unique game name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L260)
* An [instance](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L295) of a `WebWorld`
subclass for webhost documentation and behaviors
* In your `WebWorld`, if you wrote a game_info doc in more than one language, override the list of
[game info languages](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L210) with the
ones you include.
* In your `WebWorld`, override the list of
[tutorials](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L213) with each tutorial
or setup doc you included in the game folder.
* A mapping for items and locations defining their names and ids for clients to be able to identify them. These are * A mapping for items and locations defining their names and ids for clients to be able to identify them. These are
`item_name_to_id` and `location_name_to_id`, respectively. `item_name_to_id` and `location_name_to_id`, respectively.
* Create an item when `create_item` is called both by your code and externally * An implementation of `create_item` that can create an item when called by either your code or by another process
* An `options_dataclass` defining the options players have available to them within Archipelago
* A `Region` for your player with the name "Menu" to start from * At least one `Region` for your player to start from (i.e. the Origin Region)
* Create a non-zero number of locations and add them to your regions * The default name of this region is "Menu" but you may configure a different name with
* Create a non-zero number of items **equal** to the number of locations and add them to the multiworld itempool [origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L298-L299)
* All items submitted to the multiworld itempool must not be manually placed by the World. If you need to place specific * A non-zero number of locations, added to your regions
items, there are multiple ways to do so, but they should not be added to the multiworld itempool. * A non-zero number of items **equal** to the number of locations, added to the multiworld itempool
* In rare cases, there may be 0-location-0-item games, but this is extremely atypical.
* A set
[completion condition](https://github.com/ArchipelagoMW/Archipelago/blob/main/BaseClasses.py#L77) (aka "goal") for
the player.
* Use your player as the index (`multiworld.completion_condition[player]`) for your world's completion goal.
Notable caveats: ### Encouraged Features
* The "Menu" region will always be considered the "start" for the player
* The "Menu" region is *always* considered accessible; i.e. the player is expected to always be able to return to the These are "nice to have" features for a world, but they are not strictly required. It is encouraged to add them
if possible.
* An implementation of
[get_filler_item_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L473)
* By default, this function chooses any item name from `item_name_to_id`, so you want to limit it to only the true
filler items.
* An `options_dataclass` defining the options players have available to them
* This should be accompanied by a type hint for `options` with the same class name
* A [bug report page](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L220)
* A list of [option groups](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L226)
for better organization on the webhost
* A dictionary of [options presets](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L223)
for player convenience
* A dictionary of [item name groups](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L273)
for player convenience
* A dictionary of
[location name groups](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L276)
for player convenience
* Other games may also benefit from your name group dictionaries for hints, features, etc.
### Discouraged or Prohibited Behavior
These are behaviors or implementations that are known to cause various issues. Some of these points have notable
workarounds or preferred methods which should be used instead:
* All items submitted to the multiworld itempool must not be manually placed by the World.
* If you need to place specific items, there are multiple ways to do so, but they should not be added to the
multiworld itempool.
* It is not allowed to use `eval` for most reasons, chiefly due to security concerns.
* It is discouraged to use PyYAML (i.e. `yaml.load`) directly due to security concerns.
* When possible, use `Utils.parse_yaml` instead, as this defaults to the safe loader and the faster C parser.
* When submitting regions or items to the multiworld (`multiworld.regions` and `multiworld.itempool` respectively),
do **not** use `=` as this will overwrite all elements for all games in the seed.
* Instead, use `append`, `extend`, or `+=`.
### Notable Caveats
* The Origin Region will always be considered the "start" for the player
* The Origin Region is *always* considered accessible; i.e. the player is expected to always be able to return to the
start of the game from anywhere start of the game from anywhere
* When submitting regions or items to the multiworld (multiworld.regions and multiworld.itempool respectively), use
`append`, `extend`, or `+=`. **Do not use `=`**
* Regions are simply containers for locations that share similar access rules. They do not have to map to * Regions are simply containers for locations that share similar access rules. They do not have to map to
concrete, physical areas within your game and can be more abstract like tech trees or a questline. concrete, physical areas within your game and can be more abstract like tech trees or a questline.
The base World class can be found in [AutoWorld](/worlds/AutoWorld.py). Methods available for your world to call during
generation can be found in [BaseClasses](/BaseClasses.py) and [Fill](/Fill.py). Some examples and documentation
regarding the API can be found in the [world api doc](/docs/world%20api.md).
Before publishing, make sure to also check out [world maintainer.md](/docs/world%20maintainer.md).

View File

@@ -66,3 +66,22 @@ The reason entrance access rules using `location.can_reach` and `entrance.can_re
We recognize it can feel like a trap since it will not alert you when you are missing an indirect condition, and that some games have very complex access rules. We recognize it can feel like a trap since it will not alert you when you are missing an indirect condition, and that some games have very complex access rules.
As of [PR #3682 (Core: Region handling customization)](https://github.com/ArchipelagoMW/Archipelago/pull/3682) being merged, it is possible for a world to opt out of indirect conditions entirely, instead using the system of checking each entrance whenever a region has been reached, although this does come with a performance cost. As of [PR #3682 (Core: Region handling customization)](https://github.com/ArchipelagoMW/Archipelago/pull/3682) being merged, it is possible for a world to opt out of indirect conditions entirely, instead using the system of checking each entrance whenever a region has been reached, although this does come with a performance cost.
Opting out of using indirect conditions should only be used by games that *really* need it. For most games, it should be reasonable to know all entrance &rarr; region dependencies, making indirect conditions preferred because they are much faster. Opting out of using indirect conditions should only be used by games that *really* need it. For most games, it should be reasonable to know all entrance &rarr; region dependencies, making indirect conditions preferred because they are much faster.
---
### I uploaded the generated output of my world to the webhost and webhost is erroring on corrupted multidata
The error `Could not load multidata. File may be corrupted or incompatible.` occurs when uploading a locally generated
file where there is an issue with the multidata contained within it. It may come with a description like
`(No module named 'worlds.myworld')` or `(global 'worlds.myworld.names.ItemNames' is forbidden)`
Pickling is a way to compress python objects such that they can be decompressed and be used to rebuild the
python objects. This means that if one of your custom class instances ends up in the multidata, the server would not
be able to load that custom class to decompress the data, which can fail either because the custom class is unknown
(because it cannot load your world module) or the class it's attempting to import to decompress is deemed unsafe.
Common situations where this can happen include:
* Using Option instances directly in slot_data. Ex: using `options.option_name` instead of `options.option_name.value`.
Also, consider using the `options.as_dict("option_name", "option_two")` helper.
* Using enums as Location/Item names in the datapackage. When building out `location_name_to_id` and `item_name_to_id`,
make sure that you are not using your enum class for either the names or ids in these mappings.

View File

@@ -47,6 +47,9 @@ Packets are simple JSON lists in which any number of ordered network commands ca
An object can contain the "class" key, which will tell the content data type, such as "Version" in the following example. An object can contain the "class" key, which will tell the content data type, such as "Version" in the following example.
Websocket connections should support per-message compression. Uncompressed connections are deprecated and may stop
working in the future.
Example: Example:
```javascript ```javascript
[{"cmd": "RoomInfo", "version": {"major": 0, "minor": 1, "build": 3, "class": "Version"}, "tags": ["WebHost"], ... }] [{"cmd": "RoomInfo", "version": {"major": 0, "minor": 1, "build": 3, "class": "Version"}, "tags": ["WebHost"], ... }]
@@ -360,11 +363,11 @@ An enumeration containing the possible hint states.
```python ```python
import enum import enum
class HintStatus(enum.IntEnum): class HintStatus(enum.IntEnum):
HINT_FOUND = 0 # The location has been collected. Status cannot be changed once found. HINT_UNSPECIFIED = 0 # The receiving player has not specified any status
HINT_UNSPECIFIED = 1 # The receiving player has not specified any status
HINT_NO_PRIORITY = 10 # The receiving player has specified that the item is unneeded HINT_NO_PRIORITY = 10 # The receiving player has specified that the item is unneeded
HINT_AVOID = 20 # The receiving player has specified that the item is detrimental HINT_AVOID = 20 # The receiving player has specified that the item is detrimental
HINT_PRIORITY = 30 # The receiving player has specified that the item is needed HINT_PRIORITY = 30 # The receiving player has specified that the item is needed
HINT_FOUND = 40 # The location has been collected. Status cannot be changed once found.
``` ```
- Hints for items with `ItemClassification.trap` default to `HINT_AVOID`. - Hints for items with `ItemClassification.trap` default to `HINT_AVOID`.
- Hints created with `LocationScouts`, `!hint_location`, or similar (hinting a location) default to `HINT_UNSPECIFIED`. - Hints created with `LocationScouts`, `!hint_location`, or similar (hinting a location) default to `HINT_UNSPECIFIED`.
@@ -467,7 +470,7 @@ The following operations can be applied to a datastorage key
| right_shift | Applies a bitwise right-shift to the current value of the key by `value`. | | right_shift | Applies a bitwise right-shift to the current value of the key by `value`. |
| remove | List only: removes the first instance of `value` found in the list. | | remove | List only: removes the first instance of `value` found in the list. |
| pop | List or Dict: for lists it will remove the index of the `value` given. for dicts it removes the element with the specified key of `value`. | | pop | List or Dict: for lists it will remove the index of the `value` given. for dicts it removes the element with the specified key of `value`. |
| update | Dict only: Updates the dictionary with the specified elements given in `value` creating new keys, or updating old ones if they previously existed. | | update | List or Dict: Adds the elements of `value` to the container if they weren't already present. In the case of a Dict, already present keys will have their corresponding values updated. |
### SetNotify ### SetNotify
Used to register your current session for receiving all [SetReply](#SetReply) packages of certain keys to allow your client to keep track of changes. Used to register your current session for receiving all [SetReply](#SetReply) packages of certain keys to allow your client to keep track of changes.
@@ -530,9 +533,9 @@ In JSON this may look like:
{"item": 3, "location": 3, "player": 3, "flags": 0} {"item": 3, "location": 3, "player": 3, "flags": 0}
] ]
``` ```
`item` is the item id of the item. Item ids are only supported in the range of [-2<sup>53</sup>, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use. `item` is the item id of the item. Item ids are only supported in the range of [-2<sup>53</sup> + 1, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
`location` is the location id of the item inside the world. Location ids are only supported in the range of [-2<sup>53</sup>, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use. `location` is the location id of the item inside the world. Location ids are only supported in the range of [-2<sup>53</sup> + 1, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
`player` is the player slot of the world the item is located in, except when inside an [LocationInfo](#LocationInfo) Packet then it will be the slot of the player to receive the item `player` is the player slot of the world the item is located in, except when inside an [LocationInfo](#LocationInfo) Packet then it will be the slot of the player to receive the item
@@ -745,6 +748,7 @@ Tags are represented as a list of strings, the common client tags follow:
| HintGame | Indicates the client is a hint game, made to send hints instead of locations. Special join/leave message,¹ `game` is optional.² | | HintGame | Indicates the client is a hint game, made to send hints instead of locations. Special join/leave message,¹ `game` is optional.² |
| Tracker | Indicates the client is a tracker, made to track instead of sending locations. Special join/leave message,¹ `game` is optional.² | | Tracker | Indicates the client is a tracker, made to track instead of sending locations. Special join/leave message,¹ `game` is optional.² |
| TextOnly | Indicates the client is a basic client, made to chat instead of sending locations. Special join/leave message,¹ `game` is optional.² | | TextOnly | Indicates the client is a basic client, made to chat instead of sending locations. Special join/leave message,¹ `game` is optional.² |
| NoText | Indicates the client does not want to receive text messages, improving performance if not needed. |
¹: When connecting or disconnecting, the chat message shows e.g. "tracking".\ ¹: When connecting or disconnecting, the chat message shows e.g. "tracking".\
²: Allows `game` to be empty or null in [Connect](#connect). Game and version validation will then be skipped. ²: Allows `game` to be empty or null in [Connect](#connect). Game and version validation will then be skipped.
@@ -752,8 +756,8 @@ Tags are represented as a list of strings, the common client tags follow:
### DeathLink ### DeathLink
A special kind of Bounce packet that can be supported by any AP game. It targets the tag "DeathLink" and carries the following data: A special kind of Bounce packet that can be supported by any AP game. It targets the tag "DeathLink" and carries the following data:
| Name | Type | Notes | | Name | Type | Notes |
|--------|-------|--------------------------------------------------------------------------------------------------------------------------------------------------------| |--------|-------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| time | float | Unix Time Stamp of time of death. | | time | float | Unix Time Stamp of time of death. |
| cause | str | Optional. Text to explain the cause of death. When provided, or checked, this should contain the player name, ex. "Berserker was run over by a train." | | cause | str | Optional. Text to explain the cause of death. When provided, or checked, if the string is non-empty, it should contain the player name, ex. "Berserker was run over by a train." |
| source | str | Name of the player who first died. Can be a slot name, but can also be a name from within a multiplayer game. | | source | str | Name of the player who first died. Can be a slot name, but can also be a name from within a multiplayer game. |

View File

@@ -73,15 +73,47 @@ When tests are run, this class will create a multiworld with a single player hav
generic tests, as well as the new custom test. Each test method definition will create its own separate solo multiworld generic tests, as well as the new custom test. Each test method definition will create its own separate solo multiworld
that will be cleaned up after. If you don't want to run the generic tests on a base, `run_default_tests` can be that will be cleaned up after. If you don't want to run the generic tests on a base, `run_default_tests` can be
overridden. For more information on what methods are available to your class, check the overridden. For more information on what methods are available to your class, check the
[WorldTestBase definition](/test/bases.py#L104). [WorldTestBase definition](/test/bases.py#L106).
#### Alternatives to WorldTestBase #### Alternatives to WorldTestBase
Unit tests can also be created using [TestBase](/test/bases.py#L14) or Unit tests can also be created using [TestBase](/test/bases.py#L16) or
[unittest.TestCase](https://docs.python.org/3/library/unittest.html#unittest.TestCase) depending on your use case. These [unittest.TestCase](https://docs.python.org/3/library/unittest.html#unittest.TestCase) depending on your use case. These
may be useful for generating a multiworld under very specific constraints without using the generic world setup, or for may be useful for generating a multiworld under very specific constraints without using the generic world setup, or for
testing portions of your code that can be tested without relying on a multiworld to be created first. testing portions of your code that can be tested without relying on a multiworld to be created first.
#### Parametrization
When defining a test that needs to cover a range of inputs it is useful to parameterize (to run the same test
for multiple inputs) the base test. Some important things to consider when attempting to parametrize your test are:
* [Subtests](https://docs.python.org/3/library/unittest.html#distinguishing-test-iterations-using-subtests)
can be used to have parametrized assertions that show up similar to individual tests but without the overhead
of needing to instantiate multiple tests; however, subtests can not be multithreaded and do not have individual
timing data, so they are not suitable for slow tests.
* Archipelago's tests are test-runner-agnostic. That means tests are not allowed to use e.g. `@pytest.mark.parametrize`.
Instead, we define our own parametrization helpers in [test.param](/test/param.py).
* Classes inheriting from `WorldTestBase`, including those created by the helpers in `test.param`, will run all
base tests by default, make sure the produced tests actually do what you aim for and do not waste a lot of
extra CPU time. Consider using `TestBase` or `unittest.TestCase` directly
or setting `WorldTestBase.run_default_tests` to False.
#### Performance Considerations
Archipelago is big enough that the runtime of unittests can have an impact on productivity.
Individual tests should take less than a second, so they can be properly multithreaded.
Ideally, thorough tests are directed at actual code/functionality. Do not just create and/or fill a ton of individual
Multiworlds that spend most of the test time outside what you actually want to test.
Consider generating/validating "random" games as part of your APWorld release workflow rather than having that be part
of continuous integration, and add minimal reproducers to the "normal" tests for problems that were found.
You can use [@unittest.skipIf](https://docs.python.org/3/library/unittest.html#unittest.skipIf) with an environment
variable to keep all the benefits of the test framework while not running the marked tests by default.
## Running Tests ## Running Tests
#### Using Pycharm #### Using Pycharm
@@ -100,3 +132,11 @@ next to the run and debug buttons.
#### Running Tests without Pycharm #### Running Tests without Pycharm
Run `pip install pytest pytest-subtests`, then use your IDE to run tests or run `pytest` from the source folder. Run `pip install pytest pytest-subtests`, then use your IDE to run tests or run `pytest` from the source folder.
#### Running Tests Multithreaded
pytest can run multiple test runners in parallel with the pytest-xdist extension.
Install with `pip install pytest-xdist`.
Run with `pytest -n12` to spawn 12 process that each run 1/12th of the tests.

View File

@@ -291,7 +291,7 @@ like entrance randomization in logic.
Regions have a list called `exits`, containing `Entrance` objects representing transitions to other regions. Regions have a list called `exits`, containing `Entrance` objects representing transitions to other regions.
There must be one special region (Called "Menu" by default, but configurable using [origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L295-L296)), There must be one special region (Called "Menu" by default, but configurable using [origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L298-L299)),
from which the logic unfolds. AP assumes that a player will always be able to return to this starting region by resetting the game ("Save and quit"). from which the logic unfolds. AP assumes that a player will always be able to return to this starting region by resetting the game ("Save and quit").
### Entrances ### Entrances
@@ -331,7 +331,7 @@ Even doing `state.can_reach_location` or `state.can_reach_entrance` is problemat
You can use `multiworld.register_indirect_condition(region, entrance)` to explicitly tell the generator that, when a given region becomes accessible, it is necessary to re-check a specific entrance. You can use `multiworld.register_indirect_condition(region, entrance)` to explicitly tell the generator that, when a given region becomes accessible, it is necessary to re-check a specific entrance.
You **must** use `multiworld.register_indirect_condition` if you perform this kind of `can_reach` from an entrance access rule, unless you have a **very** good technical understanding of the relevant code and can reason why it will never lead to problems in your case. You **must** use `multiworld.register_indirect_condition` if you perform this kind of `can_reach` from an entrance access rule, unless you have a **very** good technical understanding of the relevant code and can reason why it will never lead to problems in your case.
Alternatively, you can set [world.explicit_indirect_conditions = False](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L298-L301), Alternatively, you can set [world.explicit_indirect_conditions = False](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L301-L304),
avoiding the need for indirect conditions at the expense of performance. avoiding the need for indirect conditions at the expense of performance.
### Item Rules ### Item Rules
@@ -562,17 +562,13 @@ from .items import is_progression # this is just a dummy
def create_item(self, item: str) -> MyGameItem: def create_item(self, item: str) -> MyGameItem:
# this is called when AP wants to create an item by name (for plando) or when you call it from your own code # this is called when AP wants to create an item by name (for plando) or when you call it from your own code
classification = ItemClassification.progression if is_progression(item) else classification = ItemClassification.progression if is_progression(item) else ItemClassification.filler
ItemClassification.filler return MyGameItem(item, classification, self.item_name_to_id[item], self.player)
return MyGameItem(item, classification, self.item_name_to_id[item],
self.player)
def create_event(self, event: str) -> MyGameItem: def create_event(self, event: str) -> MyGameItem:
# while we are at it, we can also add a helper to create events # while we are at it, we can also add a helper to create events
return MyGameItem(event, True, None, self.player) return MyGameItem(event, ItemClassification.progression, None, self.player)
``` ```
#### create_items #### create_items
@@ -610,8 +606,8 @@ from .items import get_item_type
def set_rules(self) -> None: def set_rules(self) -> None:
# For some worlds this step can be omitted if either a Logic mixin # For some worlds this step can be omitted if either a Logic mixin
# (see below) is used, it's easier to apply the rules from data during # (see below) is used or it's easier to apply the rules from data during
# location generation or everything is in generate_basic # location generation
# set a simple rule for an region # set a simple rule for an region
set_rule(self.multiworld.get_entrance("Boss Door", self.player), set_rule(self.multiworld.get_entrance("Boss Door", self.player),

View File

@@ -50,13 +50,15 @@ class EntranceLookup:
_random: random.Random _random: random.Random
_expands_graph_cache: dict[Entrance, bool] _expands_graph_cache: dict[Entrance, bool]
_coupled: bool _coupled: bool
_usable_exits: set[Entrance]
def __init__(self, rng: random.Random, coupled: bool): def __init__(self, rng: random.Random, coupled: bool, usable_exits: set[Entrance]):
self.dead_ends = EntranceLookup.GroupLookup() self.dead_ends = EntranceLookup.GroupLookup()
self.others = EntranceLookup.GroupLookup() self.others = EntranceLookup.GroupLookup()
self._random = rng self._random = rng
self._expands_graph_cache = {} self._expands_graph_cache = {}
self._coupled = coupled self._coupled = coupled
self._usable_exits = usable_exits
def _can_expand_graph(self, entrance: Entrance) -> bool: def _can_expand_graph(self, entrance: Entrance) -> bool:
""" """
@@ -95,7 +97,8 @@ class EntranceLookup:
# randomizable exits which are not reverse of the incoming entrance. # randomizable exits which are not reverse of the incoming entrance.
# uncoupled mode is an exception because in this case going back in the door you just came in could # uncoupled mode is an exception because in this case going back in the door you just came in could
# actually lead somewhere new # actually lead somewhere new
if not exit_.connected_region and (not self._coupled or exit_.name != entrance.name): if (not exit_.connected_region and (not self._coupled or exit_.name != entrance.name)
and exit_ in self._usable_exits):
self._expands_graph_cache[entrance] = True self._expands_graph_cache[entrance] = True
return True return True
elif exit_.connected_region and exit_.connected_region not in visited: elif exit_.connected_region and exit_.connected_region not in visited:
@@ -157,17 +160,16 @@ class ERPlacementState:
def placed_regions(self) -> set[Region]: def placed_regions(self) -> set[Region]:
return self.collection_state.reachable_regions[self.world.player] return self.collection_state.reachable_regions[self.world.player]
def find_placeable_exits(self, check_validity: bool) -> list[Entrance]: def find_placeable_exits(self, check_validity: bool, usable_exits: list[Entrance]) -> list[Entrance]:
if check_validity: if check_validity:
blocked_connections = self.collection_state.blocked_connections[self.world.player] blocked_connections = self.collection_state.blocked_connections[self.world.player]
blocked_connections = sorted(blocked_connections, key=lambda x: x.name) placeable_randomized_exits = [ex for ex in usable_exits
placeable_randomized_exits = [connection for connection in blocked_connections if not ex.connected_region
if not connection.connected_region and ex in blocked_connections
and connection.is_valid_source_transition(self)] and ex.is_valid_source_transition(self)]
else: else:
# this is on a beaten minimal attempt, so any exit anywhere is fair game # this is on a beaten minimal attempt, so any exit anywhere is fair game
placeable_randomized_exits = [ex for region in self.world.multiworld.get_regions(self.world.player) placeable_randomized_exits = [ex for ex in usable_exits if not ex.connected_region]
for ex in region.exits if not ex.connected_region]
self.world.random.shuffle(placeable_randomized_exits) self.world.random.shuffle(placeable_randomized_exits)
return placeable_randomized_exits return placeable_randomized_exits
@@ -181,7 +183,8 @@ class ERPlacementState:
self.placements.append(source_exit) self.placements.append(source_exit)
self.pairings.append((source_exit.name, target_entrance.name)) self.pairings.append((source_exit.name, target_entrance.name))
def test_speculative_connection(self, source_exit: Entrance, target_entrance: Entrance) -> bool: def test_speculative_connection(self, source_exit: Entrance, target_entrance: Entrance,
usable_exits: set[Entrance]) -> bool:
copied_state = self.collection_state.copy() copied_state = self.collection_state.copy()
# simulated connection. A real connection is unsafe because the region graph is shallow-copied and would # simulated connection. A real connection is unsafe because the region graph is shallow-copied and would
# propagate back to the real multiworld. # propagate back to the real multiworld.
@@ -198,6 +201,9 @@ class ERPlacementState:
# ignore the source exit, and, if coupled, the reverse exit. They're not actually new # ignore the source exit, and, if coupled, the reverse exit. They're not actually new
if _exit.name == source_exit.name or (self.coupled and _exit.name == target_entrance.name): if _exit.name == source_exit.name or (self.coupled and _exit.name == target_entrance.name):
continue continue
# make sure we are only paying attention to usable exits
if _exit not in usable_exits:
continue
# technically this should be is_valid_source_transition, but that may rely on side effects from # technically this should be is_valid_source_transition, but that may rely on side effects from
# on_connect, which have not happened here (because we didn't do a real connection, and if we did, we would # on_connect, which have not happened here (because we didn't do a real connection, and if we did, we would
# not want them to persist). can_reach is a close enough approximation most of the time. # not want them to persist). can_reach is a close enough approximation most of the time.
@@ -262,14 +268,19 @@ def bake_target_group_lookup(world: World, get_target_groups: Callable[[int], li
return { group: get_target_groups(group) for group in unique_groups } return { group: get_target_groups(group) for group in unique_groups }
def disconnect_entrance_for_randomization(entrance: Entrance, target_group: int | None = None) -> None: def disconnect_entrance_for_randomization(entrance: Entrance, target_group: int | None = None,
one_way_target_name: str | None = None) -> None:
""" """
Given an entrance in a "vanilla" region graph, splits that entrance to prepare it for randomization Given an entrance in a "vanilla" region graph, splits that entrance to prepare it for randomization
in randomize_entrances. This should be done after setting the type and group of the entrance. in randomize_entrances. This should be done after setting the type and group of the entrance. Because it attempts
to meet strict entrance naming requirements for coupled mode, this function may produce unintuitive results when
called only on a single entrance; it produces eventually-correct outputs only after calling it on all entrances.
:param entrance: The entrance which will be disconnected in preparation for randomization. :param entrance: The entrance which will be disconnected in preparation for randomization.
:param target_group: The group to assign to the created ER target. If not specified, the group from :param target_group: The group to assign to the created ER target. If not specified, the group from
the original entrance will be copied. the original entrance will be copied.
:param one_way_target_name: The name of the created ER target if `entrance` is one-way. This argument
is required for one-way entrances and is ignored otherwise.
""" """
child_region = entrance.connected_region child_region = entrance.connected_region
parent_region = entrance.parent_region parent_region = entrance.parent_region
@@ -284,8 +295,11 @@ def disconnect_entrance_for_randomization(entrance: Entrance, target_group: int
# targets in the child region will be created when the other direction edge is disconnected # targets in the child region will be created when the other direction edge is disconnected
target = parent_region.create_er_target(entrance.name) target = parent_region.create_er_target(entrance.name)
else: else:
# for 1-ways, the child region needs a target and coupling/naming is not a concern # for 1-ways, the child region needs a target. naming is not a concern for coupling so we
target = child_region.create_er_target(child_region.name) # allow it to be user provided (and require it, to prevent an unhelpful assumed name in pairings)
if not one_way_target_name:
raise ValueError("Cannot disconnect a one-way entrance without a target name specified")
target = child_region.create_er_target(one_way_target_name)
target.randomization_type = entrance.randomization_type target.randomization_type = entrance.randomization_type
target.randomization_group = target_group or entrance.randomization_group target.randomization_group = target_group or entrance.randomization_group
@@ -322,10 +336,28 @@ def randomize_entrances(
start_time = time.perf_counter() start_time = time.perf_counter()
er_state = ERPlacementState(world, coupled) er_state = ERPlacementState(world, coupled)
entrance_lookup = EntranceLookup(world.random, coupled)
# similar to fill, skip validity checks on entrances if the game is beatable on minimal accessibility # similar to fill, skip validity checks on entrances if the game is beatable on minimal accessibility
perform_validity_check = True perform_validity_check = True
if not er_targets:
er_targets = sorted([entrance for region in world.multiworld.get_regions(world.player)
for entrance in region.entrances if not entrance.parent_region], key=lambda x: x.name)
if not exits:
exits = sorted([ex for region in world.multiworld.get_regions(world.player)
for ex in region.exits if not ex.connected_region], key=lambda x: x.name)
if len(er_targets) != len(exits):
raise EntranceRandomizationError(f"Unable to randomize entrances due to a mismatched count of "
f"entrances ({len(er_targets)}) and exits ({len(exits)}.")
# used when membership checks are needed on the exit list, e.g. speculative sweep
exits_set = set(exits)
entrance_lookup = EntranceLookup(world.random, coupled, exits_set)
for entrance in er_targets:
entrance_lookup.add(entrance)
# place the menu region and connected start region(s)
er_state.collection_state.update_reachable_regions(world.player)
def do_placement(source_exit: Entrance, target_entrance: Entrance) -> None: def do_placement(source_exit: Entrance, target_entrance: Entrance) -> None:
placed_exits, removed_entrances = er_state.connect(source_exit, target_entrance) placed_exits, removed_entrances = er_state.connect(source_exit, target_entrance)
# remove the placed targets from consideration # remove the placed targets from consideration
@@ -337,9 +369,37 @@ def randomize_entrances(
if on_connect: if on_connect:
on_connect(er_state, placed_exits) on_connect(er_state, placed_exits)
def needs_speculative_sweep(dead_end: bool, require_new_exits: bool, placeable_exits: list[Entrance]) -> bool:
# speculative sweep is expensive. We currently only do it as a last resort, if we might cap off the graph
# entirely
if len(placeable_exits) > 1:
return False
# in certain stages of randomization we either expect or don't care if the search space shrinks.
# we should never speculative sweep here.
if dead_end or not require_new_exits or not perform_validity_check:
return False
# edge case - if all dead ends have pre-placed progression or indirect connections, they are pulled forward
# into the non dead end stage. In this case, and only this case, it's possible that the last connection may
# actually be placeable in stage 1. We need to skip speculative sweep in this case because we expect the graph
# to get capped off.
# check to see if we are proposing the last placement
if not coupled:
# in uncoupled, this check is easy as there will only be one target.
is_last_placement = len(entrance_lookup) == 1
else:
# a bit harder, there may be 1 or 2 targets depending on if the exit to place is one way or two way.
# if it is two way, we can safely assume that one of the targets is the logical pair of the exit.
desired_target_count = 2 if placeable_exits[0].randomization_type == EntranceType.TWO_WAY else 1
is_last_placement = len(entrance_lookup) == desired_target_count
# if it's not the last placement, we need a sweep
return not is_last_placement
def find_pairing(dead_end: bool, require_new_exits: bool) -> bool: def find_pairing(dead_end: bool, require_new_exits: bool) -> bool:
nonlocal perform_validity_check nonlocal perform_validity_check
placeable_exits = er_state.find_placeable_exits(perform_validity_check) placeable_exits = er_state.find_placeable_exits(perform_validity_check, exits)
for source_exit in placeable_exits: for source_exit in placeable_exits:
target_groups = target_group_lookup[source_exit.randomization_group] target_groups = target_group_lookup[source_exit.randomization_group]
for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order): for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order):
@@ -350,12 +410,10 @@ def randomize_entrances(
# very last exit and check whatever exits we open up are functionally accessible. # very last exit and check whatever exits we open up are functionally accessible.
# this requirement can be ignored on a beaten minimal, islands are no issue there. # this requirement can be ignored on a beaten minimal, islands are no issue there.
exit_requirement_satisfied = (not perform_validity_check or not require_new_exits exit_requirement_satisfied = (not perform_validity_check or not require_new_exits
or target_entrance.connected_region not in er_state.placed_regions) or target_entrance.connected_region not in er_state.placed_regions)
needs_speculative_sweep = (not dead_end and require_new_exits and perform_validity_check
and len(placeable_exits) == 1)
if exit_requirement_satisfied and source_exit.can_connect_to(target_entrance, dead_end, er_state): if exit_requirement_satisfied and source_exit.can_connect_to(target_entrance, dead_end, er_state):
if (needs_speculative_sweep if (needs_speculative_sweep(dead_end, require_new_exits, placeable_exits)
and not er_state.test_speculative_connection(source_exit, target_entrance)): and not er_state.test_speculative_connection(source_exit, target_entrance, exits_set)):
continue continue
do_placement(source_exit, target_entrance) do_placement(source_exit, target_entrance)
return True return True
@@ -378,13 +436,14 @@ def randomize_entrances(
and world.multiworld.has_beaten_game(er_state.collection_state, world.player): and world.multiworld.has_beaten_game(er_state.collection_state, world.player):
# ensure that we have enough locations to place our progression # ensure that we have enough locations to place our progression
accessible_location_count = 0 accessible_location_count = 0
prog_item_count = sum(er_state.collection_state.prog_items[world.player].values()) prog_item_count = len([item for item in world.multiworld.itempool if item.advancement and item.player == world.player])
# short-circuit location checking in this case # short-circuit location checking in this case
if prog_item_count == 0: if prog_item_count == 0:
return True return True
for region in er_state.placed_regions: for region in er_state.placed_regions:
for loc in region.locations: for loc in region.locations:
if loc.can_reach(er_state.collection_state): if not loc.item and loc.can_reach(er_state.collection_state):
# don't count locations with preplaced items
accessible_location_count += 1 accessible_location_count += 1
if accessible_location_count >= prog_item_count: if accessible_location_count >= prog_item_count:
perform_validity_check = False perform_validity_check = False
@@ -406,21 +465,6 @@ def randomize_entrances(
f"All unplaced entrances: {unplaced_entrances}\n" f"All unplaced entrances: {unplaced_entrances}\n"
f"All unplaced exits: {unplaced_exits}") f"All unplaced exits: {unplaced_exits}")
if not er_targets:
er_targets = sorted([entrance for region in world.multiworld.get_regions(world.player)
for entrance in region.entrances if not entrance.parent_region], key=lambda x: x.name)
if not exits:
exits = sorted([ex for region in world.multiworld.get_regions(world.player)
for ex in region.exits if not ex.connected_region], key=lambda x: x.name)
if len(er_targets) != len(exits):
raise EntranceRandomizationError(f"Unable to randomize entrances due to a mismatched count of "
f"entrances ({len(er_targets)}) and exits ({len(exits)}.")
for entrance in er_targets:
entrance_lookup.add(entrance)
# place the menu region and connected start region(s)
er_state.collection_state.update_reachable_regions(world.player)
# stage 1 - try to place all the non-dead-end entrances # stage 1 - try to place all the non-dead-end entrances
while entrance_lookup.others: while entrance_lookup.others:
if not find_pairing(dead_end=False, require_new_exits=True): if not find_pairing(dead_end=False, require_new_exits=True):

View File

@@ -221,6 +221,11 @@ Root: HKCR; Subkey: "{#MyAppName}ygo06patch"; ValueData: "Ar
Root: HKCR; Subkey: "{#MyAppName}ygo06patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}ygo06patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ygo06patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}ygo06patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apcivvi"; ValueData: "{#MyAppName}apcivvipatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}apcivvipatch"; ValueData: "Archipelago Civilization 6 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}apcivvipatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}apcivvipatch\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: "";

639
kvui.py
View File

@@ -26,13 +26,16 @@ import Utils
if Utils.is_frozen(): if Utils.is_frozen():
os.environ["KIVY_DATA_DIR"] = Utils.local_path("data") os.environ["KIVY_DATA_DIR"] = Utils.local_path("data")
import platformdirs
os.environ["KIVY_HOME"] = os.path.join(platformdirs.user_config_dir("Archipelago", False), "kivy")
os.makedirs(os.environ["KIVY_HOME"], exist_ok=True)
from kivy.config import Config from kivy.config import Config
Config.set("input", "mouse", "mouse,disable_multitouch") Config.set("input", "mouse", "mouse,disable_multitouch")
Config.set("kivy", "exit_on_escape", "0") Config.set("kivy", "exit_on_escape", "0")
Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers
from kivymd.uix.divider import MDDivider
from kivy.app import App
from kivy.core.window import Window from kivy.core.window import Window
from kivy.core.clipboard import Clipboard from kivy.core.clipboard import Clipboard
from kivy.core.text.markup import MarkupLabel from kivy.core.text.markup import MarkupLabel
@@ -40,32 +43,34 @@ from kivy.core.image import ImageLoader, ImageLoaderBase, ImageData
from kivy.base import ExceptionHandler, ExceptionManager from kivy.base import ExceptionHandler, ExceptionManager
from kivy.clock import Clock from kivy.clock import Clock
from kivy.factory import Factory from kivy.factory import Factory
from kivy.properties import BooleanProperty, ObjectProperty, NumericProperty from kivy.properties import BooleanProperty, ObjectProperty, NumericProperty, StringProperty
from kivy.metrics import dp from kivy.metrics import dp, sp
from kivy.effects.scroll import ScrollEffect
from kivy.uix.widget import Widget from kivy.uix.widget import Widget
from kivy.uix.button import Button
from kivy.uix.gridlayout import GridLayout
from kivy.uix.layout import Layout from kivy.uix.layout import Layout
from kivy.uix.textinput import TextInput
from kivy.uix.scrollview import ScrollView
from kivy.uix.recycleview import RecycleView
from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.floatlayout import FloatLayout
from kivy.uix.label import Label
from kivy.uix.progressbar import ProgressBar
from kivy.uix.dropdown import DropDown
from kivy.utils import escape_markup from kivy.utils import escape_markup
from kivy.lang import Builder from kivy.lang import Builder
from kivy.uix.recycleview.views import RecycleDataViewBehavior from kivy.uix.recycleview.views import RecycleDataViewBehavior
from kivy.uix.behaviors import FocusBehavior from kivy.uix.behaviors import FocusBehavior, ToggleButtonBehavior
from kivy.uix.recycleboxlayout import RecycleBoxLayout from kivy.uix.recycleboxlayout import RecycleBoxLayout
from kivy.uix.recycleview.layout import LayoutSelectionBehavior from kivy.uix.recycleview.layout import LayoutSelectionBehavior
from kivy.animation import Animation from kivy.animation import Animation
from kivy.uix.popup import Popup from kivy.uix.popup import Popup
from kivy.uix.dropdown import DropDown
from kivy.uix.image import AsyncImage from kivy.uix.image import AsyncImage
from kivymd.app import MDApp
from kivymd.uix.gridlayout import MDGridLayout
from kivymd.uix.floatlayout import MDFloatLayout
from kivymd.uix.boxlayout import MDBoxLayout
from kivymd.uix.tab.tab import MDTabsSecondary, MDTabsItem, MDTabsItemText, MDTabsCarousel
from kivymd.uix.menu import MDDropdownMenu
from kivymd.uix.menu.menu import MDDropdownTextItem
from kivymd.uix.dropdownitem import MDDropDownItem, MDDropDownItemText
from kivymd.uix.button import MDButton, MDButtonText, MDButtonIcon, MDIconButton
from kivymd.uix.label import MDLabel, MDIcon
from kivymd.uix.recycleview import MDRecycleView
from kivymd.uix.textfield.textfield import MDTextField
from kivymd.uix.progressindicator import MDLinearProgressIndicator
from kivymd.uix.scrollview import MDScrollView
from kivymd.uix.tooltip import MDTooltip, MDTooltipPlain
fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25) fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25)
@@ -82,6 +87,111 @@ else:
remove_between_brackets = re.compile(r"\[.*?]") remove_between_brackets = re.compile(r"\[.*?]")
class ThemedApp(MDApp):
def set_colors(self):
text_colors = KivyJSONtoTextParser.TextColors()
self.theme_cls.theme_style = text_colors.theme_style
self.theme_cls.primary_palette = text_colors.primary_palette
self.theme_cls.dynamic_scheme_name = text_colors.dynamic_scheme_name
self.theme_cls.dynamic_scheme_contrast = text_colors.dynamic_scheme_contrast
class ImageIcon(MDButtonIcon, AsyncImage):
def __init__(self, *args, **kwargs):
super().__init__(args, kwargs)
self.image = ApAsyncImage(**kwargs)
self.add_widget(self.image)
def add_widget(self, widget, index=0, canvas=None):
return super(MDIcon, self).add_widget(widget)
class ImageButton(MDIconButton):
def __init__(self, **kwargs):
image_args = dict()
for kwarg in ("fit_mode", "image_size", "color", "source", "texture"):
val = kwargs.pop(kwarg, "None")
if val != "None":
image_args[kwarg.replace("image_", "")] = val
super().__init__()
self.image = ApAsyncImage(**image_args)
def set_center(button, center):
self.image.center_x = self.center_x
self.image.center_y = self.center_y
self.bind(center=set_center)
self.add_widget(self.image)
def add_widget(self, widget, index=0, canvas=None):
return super(MDIcon, self).add_widget(widget)
class ScrollBox(MDScrollView):
layout: MDBoxLayout = ObjectProperty(None)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# thanks kivymd
class ToggleButton(MDButton, ToggleButtonBehavior):
def __init__(self, *args, **kwargs):
super(ToggleButton, self).__init__(*args, **kwargs)
self.bind(state=self._update_bg)
def _update_bg(self, _, state: str):
if self.disabled:
return
if self.theme_bg_color == "Primary":
self.theme_bg_color = "Custom"
if state == "down":
self.md_bg_color = self.theme_cls.primaryColor
for child in self.children:
if child.theme_text_color == "Primary":
child.theme_text_color = "Custom"
if child.theme_icon_color == "Primary":
child.theme_icon_color = "Custom"
child.text_color = self.theme_cls.onPrimaryColor
child.icon_color = self.theme_cls.onPrimaryColor
else:
self.md_bg_color = self.theme_cls.surfaceContainerLowestColor
for child in self.children:
if child.theme_text_color == "Primary":
child.theme_text_color = "Custom"
if child.theme_icon_color == "Primary":
child.theme_icon_color = "Custom"
child.text_color = self.theme_cls.primaryColor
child.icon_color = self.theme_cls.primaryColor
# thanks kivymd
class ResizableTextField(MDTextField):
"""
Resizable MDTextField that manually overrides the builtin sizing.
Note that in order to use this, the sizing must be specified from within a .kv rule.
"""
def __init__(self, *args, **kwargs):
# cursed rules override
rules = Builder.match(self)
textfield = next((rule for rule in rules if rule.name == f"<MDTextField>"), None)
if textfield:
subclasses = rules[rules.index(textfield) + 1:]
for subclass in subclasses:
height_rule = subclass.properties.get("height", None)
if height_rule:
height_rule.ignore_prev = True
super().__init__(args, kwargs)
def on_release(self: MDButton, *args):
super(MDButton, self).on_release(args)
self.on_leave()
MDButton.on_release = on_release
# I was surprised to find this didn't already exist in kivy :( # I was surprised to find this didn't already exist in kivy :(
class HoverBehavior(object): class HoverBehavior(object):
"""originally from https://stackoverflow.com/a/605348110""" """originally from https://stackoverflow.com/a/605348110"""
@@ -121,7 +231,7 @@ class HoverBehavior(object):
Factory.register("HoverBehavior", HoverBehavior) Factory.register("HoverBehavior", HoverBehavior)
class ToolTip(Label): class ToolTip(MDTooltipPlain):
pass pass
@@ -129,49 +239,30 @@ class ServerToolTip(ToolTip):
pass pass
class ScrollBox(ScrollView): class HovererableLabel(HoverBehavior, MDLabel):
layout: BoxLayout
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.layout = BoxLayout(size_hint_y=None)
self.layout.bind(minimum_height=self.layout.setter("height"))
self.add_widget(self.layout)
self.effect_cls = ScrollEffect
self.bar_width = dp(12)
self.scroll_type = ["content", "bars"]
class HovererableLabel(HoverBehavior, Label):
pass pass
class TooltipLabel(HovererableLabel): class TooltipLabel(HovererableLabel, MDTooltip):
tooltip = None tooltip_display_delay = 0.1
def create_tooltip(self, text, x, y): def create_tooltip(self, text, x, y):
text = text.replace("<br>", "\n").replace("&amp;", "&").replace("&bl;", "[").replace("&br;", "]") text = text.replace("<br>", "\n").replace("&amp;", "&").replace("&bl;", "[").replace("&br;", "]")
if self.tooltip:
# update
self.tooltip.children[0].text = text
else:
self.tooltip = FloatLayout()
tooltip_label = ToolTip(text=text)
self.tooltip.add_widget(tooltip_label)
fade_in_animation.start(self.tooltip)
App.get_running_app().root.add_widget(self.tooltip)
# handle left-side boundary to not render off-screen
x = max(x, 3 + self.tooltip.children[0].texture_size[0] / 2)
# position float layout # position float layout
self.tooltip.x = x - self.tooltip.width / 2 center_x, center_y = self.to_window(self.center_x, self.center_y)
self.tooltip.y = y - self.tooltip.height / 2 + 48 self.shift_y = y - center_y
shift_x = center_x - x
if shift_x > 0:
self.shift_left = shift_x
else:
self.shift_right = shift_x
def remove_tooltip(self): if self._tooltip:
if self.tooltip: # update
App.get_running_app().root.remove_widget(self.tooltip) self._tooltip.text = text
self.tooltip = None else:
self._tooltip = ToolTip(text=text, pos_hint={})
self.display_tooltip()
def on_mouse_pos(self, window, pos): def on_mouse_pos(self, window, pos):
if not self.get_root_window(): if not self.get_root_window():
@@ -198,26 +289,30 @@ class TooltipLabel(HovererableLabel):
def on_leave(self): def on_leave(self):
self.remove_tooltip() self.remove_tooltip()
self._tooltip = None
class ServerLabel(HovererableLabel): class ServerLabel(HoverBehavior, MDTooltip, MDBoxLayout):
tooltip_display_delay = 0.1
text: str = StringProperty("Server:")
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(HovererableLabel, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.layout = FloatLayout() self.add_widget(MDIcon(icon="information", font_size=sp(15)))
self.popuplabel = ServerToolTip(text="Test") self.add_widget(TooltipLabel(text=self.text, pos_hint={"center_x": 0.5, "center_y": 0.5},
self.layout.add_widget(self.popuplabel) font_size=sp(15)))
self._tooltip = ServerToolTip(text="Test")
def on_enter(self): def on_enter(self):
self.popuplabel.text = self.get_text() self._tooltip.text = self.get_text()
App.get_running_app().root.add_widget(self.layout) self.display_tooltip()
fade_in_animation.start(self.layout)
def on_leave(self): def on_leave(self):
App.get_running_app().root.remove_widget(self.layout) self.animation_tooltip_dismiss()
@property @property
def ctx(self) -> context_type: def ctx(self) -> context_type:
return App.get_running_app().ctx return MDApp.get_running_app().ctx
def get_text(self): def get_text(self):
if self.ctx.server: if self.ctx.server:
@@ -258,11 +353,11 @@ class ServerLabel(HovererableLabel):
return "No current server connection. \nPlease connect to an Archipelago server." return "No current server connection. \nPlease connect to an Archipelago server."
class MainLayout(GridLayout): class MainLayout(MDGridLayout):
pass pass
class ContainerLayout(FloatLayout): class ContainerLayout(MDFloatLayout):
pass pass
@@ -282,6 +377,11 @@ class SelectableLabel(RecycleDataViewBehavior, TooltipLabel):
return super(SelectableLabel, self).refresh_view_attrs( return super(SelectableLabel, self).refresh_view_attrs(
rv, index, data) rv, index, data)
def on_size(self, instance_label, size: list) -> None:
super().on_size(instance_label, size)
if self.parent:
self.width = self.parent.width
def on_touch_down(self, touch): def on_touch_down(self, touch):
""" Add selection on touch down """ """ Add selection on touch down """
if super(SelectableLabel, self).on_touch_down(touch): if super(SelectableLabel, self).on_touch_down(touch):
@@ -292,10 +392,10 @@ class SelectableLabel(RecycleDataViewBehavior, TooltipLabel):
else: else:
# Not a fan of the following few lines, but they work. # Not a fan of the following few lines, but they work.
temp = MarkupLabel(text=self.text).markup temp = MarkupLabel(text=self.text).markup
text = "".join(part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]"))) text = "".join(part for part in temp if not part.startswith("["))
cmdinput = App.get_running_app().textinput cmdinput = MDApp.get_running_app().textinput
if not cmdinput.text: if not cmdinput.text:
input_text = get_input_text_from_response(text, App.get_running_app().last_autofillable_command) input_text = get_input_text_from_response(text, MDApp.get_running_app().last_autofillable_command)
if input_text is not None: if input_text is not None:
cmdinput.text = input_text cmdinput.text = input_text
@@ -306,32 +406,118 @@ class SelectableLabel(RecycleDataViewBehavior, TooltipLabel):
""" Respond to the selection of items in the view. """ """ Respond to the selection of items in the view. """
self.selected = is_selected self.selected = is_selected
class AutocompleteHintInput(TextInput): class MarkupDropdownTextItem(MDDropdownTextItem):
def __init__(self):
super().__init__()
for child in self.children:
if child.__class__ == MDLabel:
child.markup = True
# Currently, this only lets us do markup on text that does not have any icons
# Create new TextItems as needed
class MarkupDropdown(MDDropdownMenu):
def on_items(self, instance, value: list) -> None:
"""
The method sets the class that will be used to create the menu item.
"""
items = []
viewclass = "MarkupDropdownTextItem"
for data in value:
if "viewclass" not in data:
if (
"leading_icon" not in data
and "trailing_icon" not in data
and "trailing_text" not in data
):
viewclass = "MarkupDropdownTextItem"
elif (
"leading_icon" in data
and "trailing_icon" not in data
and "trailing_text" not in data
):
viewclass = "MDDropdownLeadingIconItem"
elif (
"leading_icon" not in data
and "trailing_icon" in data
and "trailing_text" not in data
):
viewclass = "MDDropdownTrailingIconItem"
elif (
"leading_icon" not in data
and "trailing_icon" in data
and "trailing_text" in data
):
viewclass = "MDDropdownTrailingIconTextItem"
elif (
"leading_icon" in data
and "trailing_icon" in data
and "trailing_text" in data
):
viewclass = "MDDropdownLeadingTrailingIconTextItem"
elif (
"leading_icon" in data
and "trailing_icon" in data
and "trailing_text" not in data
):
viewclass = "MDDropdownLeadingTrailingIconItem"
elif (
"leading_icon" not in data
and "trailing_icon" not in data
and "trailing_text" in data
):
viewclass = "MDDropdownTrailingTextItem"
elif (
"leading_icon" in data
and "trailing_icon" not in data
and "trailing_text" in data
):
viewclass = "MDDropdownLeadingIconTrailingTextItem"
data["viewclass"] = viewclass
if "height" not in data:
data["height"] = dp(48)
items.append(data)
self._items = items
# Update items in view
if hasattr(self, "menu"):
self.menu.data = self._items
class AutocompleteHintInput(ResizableTextField):
min_chars = NumericProperty(3) min_chars = NumericProperty(3)
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.dropdown = DropDown() self.dropdown = MarkupDropdown(caller=self, position="bottom", border_margin=dp(2), width=self.width)
self.dropdown.bind(on_select=lambda instance, x: setattr(self, 'text', x))
self.bind(on_text_validate=self.on_message) self.bind(on_text_validate=self.on_message)
self.bind(width=lambda instance, x: setattr(self.dropdown, "width", x))
def on_message(self, instance): def on_message(self, instance):
App.get_running_app().commandprocessor("!hint "+instance.text) MDApp.get_running_app().commandprocessor("!hint "+instance.text)
def on_text(self, instance, value): def on_text(self, instance, value):
if len(value) >= self.min_chars: if len(value) >= self.min_chars:
self.dropdown.clear_widgets() self.dropdown.items.clear()
ctx: context_type = App.get_running_app().ctx ctx: context_type = MDApp.get_running_app().ctx
if not ctx.game: if not ctx.game:
return return
item_names = ctx.item_names._game_store[ctx.game].values() item_names = ctx.item_names._game_store[ctx.game].values()
def on_press(button: Button): def on_press(text):
split_text = MarkupLabel(text=button.text).markup split_text = MarkupLabel(text=text).markup
return self.dropdown.select("".join(text_frag for text_frag in split_text self.set_text(self, "".join(text_frag for text_frag in split_text
if not text_frag.startswith("["))) if not text_frag.startswith("[")))
self.dropdown.dismiss()
self.focus = True
lowered = value.lower() lowered = value.lower()
for item_name in item_names: for item_name in item_names:
try: try:
@@ -341,20 +527,29 @@ class AutocompleteHintInput(TextInput):
else: else:
text = escape_markup(item_name) text = escape_markup(item_name)
text = text[:index] + "[b]" + text[index:index+len(value)]+"[/b]"+text[index+len(value):] text = text[:index] + "[b]" + text[index:index+len(value)]+"[/b]"+text[index+len(value):]
btn = Button(text=text, size_hint_y=None, height=dp(30), markup=True) self.dropdown.items.append({
btn.bind(on_release=on_press) "text": text,
self.dropdown.add_widget(btn) "on_release": lambda txt=text: on_press(txt),
if not self.dropdown.attach_to: "markup": True
self.dropdown.open(self) })
if not self.dropdown.parent:
self.dropdown.open()
else: else:
self.dropdown.dismiss() self.dropdown.dismiss()
class HintLabel(RecycleDataViewBehavior, BoxLayout): status_icons = {
HintStatus.HINT_NO_PRIORITY: "information",
HintStatus.HINT_PRIORITY: "exclamation-thick",
HintStatus.HINT_AVOID: "alert"
}
class HintLabel(RecycleDataViewBehavior, MDBoxLayout):
selected = BooleanProperty(False) selected = BooleanProperty(False)
striped = BooleanProperty(False) striped = BooleanProperty(False)
index = None index = None
dropdown: DropDown dropdown: MDDropdownMenu
def __init__(self): def __init__(self):
super(HintLabel, self).__init__() super(HintLabel, self).__init__()
@@ -365,29 +560,28 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
self.entrance_text = "" self.entrance_text = ""
self.status_text = "" self.status_text = ""
self.hint = {} self.hint = {}
for child in self.children:
child.bind(texture_size=self.set_height)
ctx = MDApp.get_running_app().ctx
menu_items = []
ctx = App.get_running_app().ctx for status in (HintStatus.HINT_NO_PRIORITY, HintStatus.HINT_PRIORITY, HintStatus.HINT_AVOID):
self.dropdown = DropDown() name = status_names[status]
status_button = MDDropDownItem(MDDropDownItemText(text=name), size_hint_y=None, height=dp(50))
status_button.status = status
menu_items.append({
"text": name,
"leading_icon": status_icons[status],
"on_release": lambda x=status: select(self, x)
})
def set_value(button): self.dropdown = MDDropdownMenu(caller=self.ids["status"], items=menu_items)
self.dropdown.select(button.status)
def select(instance, data): def select(instance, data):
ctx.update_hint(self.hint["location"], ctx.update_hint(self.hint["location"],
self.hint["finding_player"], self.hint["finding_player"],
data) data)
for status in (HintStatus.HINT_NO_PRIORITY, HintStatus.HINT_PRIORITY, HintStatus.HINT_AVOID): self.dropdown.bind(on_release=self.dropdown.dismiss)
name = status_names[status]
status_button = Button(text=name, size_hint_y=None, height=dp(50))
status_button.status = status
status_button.bind(on_release=set_value)
self.dropdown.add_widget(status_button)
self.dropdown.bind(on_select=select)
def set_height(self, instance, value): def set_height(self, instance, value):
self.height = max([child.texture_size[1] for child in self.children]) self.height = max([child.texture_size[1] for child in self.children])
@@ -402,7 +596,6 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
self.entrance_text = data["entrance"]["text"] self.entrance_text = data["entrance"]["text"]
self.status_text = data["status"]["text"] self.status_text = data["status"]["text"]
self.hint = data["status"]["hint"] self.hint = data["status"]["hint"]
self.height = self.minimum_height
return super(HintLabel, self).refresh_view_attrs(rv, index, data) return super(HintLabel, self).refresh_view_attrs(rv, index, data)
def on_touch_down(self, touch): def on_touch_down(self, touch):
@@ -415,10 +608,10 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
if status_label.collide_point(*touch.pos): if status_label.collide_point(*touch.pos):
if self.hint["status"] == HintStatus.HINT_FOUND: if self.hint["status"] == HintStatus.HINT_FOUND:
return return
ctx = App.get_running_app().ctx ctx = MDApp.get_running_app().ctx
if ctx.slot_concerns_self(self.hint["receiving_player"]): # If this player owns this hint if ctx.slot_concerns_self(self.hint["receiving_player"]): # If this player owns this hint
# open a dropdown # open a dropdown
self.dropdown.open(self.ids["status"]) self.dropdown.open()
elif self.selected: elif self.selected:
self.parent.clear_selection() self.parent.clear_selection()
else: else:
@@ -427,8 +620,7 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
if self.entrance_text != "Vanilla" if self.entrance_text != "Vanilla"
else "", ". (", self.status_text.lower(), ")")) else "", ". (", self.status_text.lower(), ")"))
temp = MarkupLabel(text).markup temp = MarkupLabel(text).markup
text = "".join( text = "".join(part for part in temp if not part.startswith("["))
part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]")))
Clipboard.copy(escape_markup(text).replace("&amp;", "&").replace("&bl;", "[").replace("&br;", "]")) Clipboard.copy(escape_markup(text).replace("&amp;", "&").replace("&bl;", "[").replace("&br;", "]"))
return self.parent.select_with_touch(self.index, touch) return self.parent.select_with_touch(self.index, touch)
else: else:
@@ -440,15 +632,18 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
if child.collide_point(*touch.pos): if child.collide_point(*touch.pos):
key = child.sort_key key = child.sort_key
if key == "status": if key == "status":
parent.hint_sorter = lambda element: element["status"]["hint"]["status"] parent.hint_sorter = lambda element: status_sort_weights[element["status"]["hint"]["status"]]
else: parent.hint_sorter = lambda element: remove_between_brackets.sub("", element[key]["text"]).lower() else:
parent.hint_sorter = lambda element: (
remove_between_brackets.sub("", element[key]["text"]).lower()
)
if key == parent.sort_key: if key == parent.sort_key:
# second click reverses order # second click reverses order
parent.reversed = not parent.reversed parent.reversed = not parent.reversed
else: else:
parent.sort_key = key parent.sort_key = key
parent.reversed = False parent.reversed = False
App.get_running_app().update_hints() MDApp.get_running_app().update_hints()
def apply_selection(self, rv, index, is_selected): def apply_selection(self, rv, index, is_selected):
""" Respond to the selection of items in the view. """ """ Respond to the selection of items in the view. """
@@ -456,7 +651,7 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
self.selected = is_selected self.selected = is_selected
class ConnectBarTextInput(TextInput): class ConnectBarTextInput(ResizableTextField):
def insert_text(self, substring, from_undo=False): def insert_text(self, substring, from_undo=False):
s = substring.replace("\n", "").replace("\r", "") s = substring.replace("\n", "").replace("\r", "")
return super(ConnectBarTextInput, self).insert_text(s, from_undo=from_undo) return super(ConnectBarTextInput, self).insert_text(s, from_undo=from_undo)
@@ -466,14 +661,14 @@ def is_command_input(string: str) -> bool:
return len(string) > 0 and string[0] in "/!" return len(string) > 0 and string[0] in "/!"
class CommandPromptTextInput(TextInput): class CommandPromptTextInput(ResizableTextField):
MAXIMUM_HISTORY_MESSAGES = 50 MAXIMUM_HISTORY_MESSAGES = 50
def __init__(self, **kwargs) -> None: def __init__(self, **kwargs) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self._command_history_index = -1 self._command_history_index = -1
self._command_history: typing.Deque[str] = deque(maxlen=CommandPromptTextInput.MAXIMUM_HISTORY_MESSAGES) self._command_history: typing.Deque[str] = deque(maxlen=CommandPromptTextInput.MAXIMUM_HISTORY_MESSAGES)
def update_history(self, new_entry: str) -> None: def update_history(self, new_entry: str) -> None:
self._command_history_index = -1 self._command_history_index = -1
if is_command_input(new_entry): if is_command_input(new_entry):
@@ -500,7 +695,7 @@ class CommandPromptTextInput(TextInput):
self._change_to_history_text_if_available(self._command_history_index - 1) self._change_to_history_text_if_available(self._command_history_index - 1)
return True return True
return super().keyboard_on_key_down(window, keycode, text, modifiers) return super().keyboard_on_key_down(window, keycode, text, modifiers)
def _change_to_history_text_if_available(self, new_index: int) -> None: def _change_to_history_text_if_available(self, new_index: int) -> None:
if new_index < -1: if new_index < -1:
return return
@@ -514,32 +709,96 @@ class CommandPromptTextInput(TextInput):
class MessageBox(Popup): class MessageBox(Popup):
class MessageBoxLabel(Label): class MessageBoxLabel(MDLabel):
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self._label.refresh() self._label.refresh()
self.size = self._label.texture.size
if self.width + 50 > Window.width:
self.text_size[0] = Window.width - 50
self._label.refresh()
self.size = self._label.texture.size
def __init__(self, title, text, error=False, **kwargs): def __init__(self, title, text, error=False, **kwargs):
label = MessageBox.MessageBoxLabel(text=text) label = MessageBox.MessageBoxLabel(text=text)
separator_color = [217 / 255, 129 / 255, 122 / 255, 1.] if error else [47 / 255., 167 / 255., 212 / 255, 1.] separator_color = [217 / 255, 129 / 255, 122 / 255, 1.] if error else [47 / 255., 167 / 255., 212 / 255, 1.]
super().__init__(title=title, content=label, size_hint=(None, None), width=max(100, int(label.width) + 40), super().__init__(title=title, content=label, size_hint=(0.5, None), width=max(100, int(label.width) + 40),
separator_color=separator_color, **kwargs) separator_color=separator_color, **kwargs)
self.height += max(0, label.height - 18) self.height += max(0, label.height - 18)
class GameManager(App): class ClientTabs(MDTabsSecondary):
carousel: MDTabsCarousel
lock_swiping = True
def __init__(self, *args, **kwargs):
self.carousel = MDTabsCarousel(lock_swiping=True, anim_move_duration=0.2)
super().__init__(*args, MDDivider(size_hint_y=None, height=dp(1)), self.carousel, **kwargs)
self.size_hint_y = 1
def _check_panel_height(self, *args):
self.ids.tab_scroll.height = dp(38)
def update_indicator(
self, x: float = 0.0, w: float = 0.0, instance: MDTabsItem = None
) -> None:
def update_indicator(*args):
indicator_pos = (0, 0)
indicator_size = (0, 0)
item_text_object = self._get_tab_item_text_icon_object()
if item_text_object:
indicator_pos = (
instance.x + dp(12),
self.indicator.pos[1]
if not self._tabs_carousel
else self._tabs_carousel.height,
)
indicator_size = (
instance.width - dp(24),
self.indicator_height,
)
Animation(
pos=indicator_pos,
size=indicator_size,
d=0 if not self.indicator_anim else self.indicator_duration,
t=self.indicator_transition,
).start(self.indicator)
if not instance:
self.indicator.pos = (x, self.indicator.pos[1])
self.indicator.size = (w, self.indicator_height)
else:
Clock.schedule_once(update_indicator)
def remove_tab(self, tab, content=None):
if content is None:
content = tab.content
self.ids.container.remove_widget(tab)
self.carousel.remove_widget(content)
self.on_size(self, self.size)
class CommandButton(MDButton, MDTooltip):
def __init__(self, *args, manager: "GameManager", **kwargs):
super().__init__(*args, **kwargs)
self.manager = manager
self._tooltip = ToolTip(text="Test")
def on_enter(self):
self._tooltip.text = self.manager.commandprocessor.get_help_text()
self._tooltip.font_size = dp(20 - (len(self._tooltip.text) // 400)) # mostly guessing on the numbers here
self.display_tooltip()
def on_leave(self):
self.animation_tooltip_dismiss()
class GameManager(ThemedApp):
logging_pairs = [ logging_pairs = [
("Client", "Archipelago"), ("Client", "Archipelago"),
] ]
base_title: str = "Archipelago Client" base_title: str = "Archipelago Client"
last_autofillable_command: str last_autofillable_command: str
main_area_container: GridLayout main_area_container: MDGridLayout
""" subclasses can add more columns beside the tabs """ """ subclasses can add more columns beside the tabs """
def __init__(self, ctx: context_type): def __init__(self, ctx: context_type):
@@ -574,45 +833,58 @@ class GameManager(App):
return max(1, len(self.tabs.tab_list)) return max(1, len(self.tabs.tab_list))
return 1 return 1
def on_start(self):
def on_start(*args):
self.root.md_bg_color = self.theme_cls.backgroundColor
super().on_start()
Clock.schedule_once(on_start)
def build(self) -> Layout: def build(self) -> Layout:
self.set_colors()
self.container = ContainerLayout() self.container = ContainerLayout()
self.grid = MainLayout() self.grid = MainLayout()
self.grid.cols = 1 self.grid.cols = 1
self.connect_layout = BoxLayout(orientation="horizontal", size_hint_y=None, height=dp(30)) self.connect_layout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(40),
spacing=5, padding=(5, 10))
# top part # top part
server_label = ServerLabel() server_label = ServerLabel(width=dp(75))
self.connect_layout.add_widget(server_label) self.connect_layout.add_widget(server_label)
self.server_connect_bar = ConnectBarTextInput(text=self.ctx.suggested_address or "archipelago.gg:", self.server_connect_bar = ConnectBarTextInput(text=self.ctx.suggested_address or "archipelago.gg:",
size_hint_y=None, pos_hint={"center_x": 0.5, "center_y": 0.5})
height=dp(30), multiline=False, write_tab=False)
def connect_bar_validate(sender): def connect_bar_validate(sender):
if not self.ctx.server: if not self.ctx.server:
self.connect_button_action(sender) self.connect_button_action(sender)
self.server_connect_bar.height = dp(30)
self.server_connect_bar.bind(on_text_validate=connect_bar_validate) self.server_connect_bar.bind(on_text_validate=connect_bar_validate)
self.connect_layout.add_widget(self.server_connect_bar) self.connect_layout.add_widget(self.server_connect_bar)
self.server_connect_button = Button(text="Connect", size=(dp(100), dp(30)), size_hint_y=None, size_hint_x=None) self.server_connect_button = MDButton(MDButtonText(text="Connect"), style="filled", size=(dp(100), dp(70)),
size_hint_x=None, size_hint_y=None, radius=5, pos_hint={"center_y": 0.55})
self.server_connect_button.bind(on_press=self.connect_button_action) self.server_connect_button.bind(on_press=self.connect_button_action)
self.server_connect_button.height = self.server_connect_bar.height
self.connect_layout.add_widget(self.server_connect_button) self.connect_layout.add_widget(self.server_connect_button)
self.grid.add_widget(self.connect_layout) self.grid.add_widget(self.connect_layout)
self.progressbar = ProgressBar(size_hint_y=None, height=3) self.progressbar = MDLinearProgressIndicator(size_hint_y=None, height=3)
self.grid.add_widget(self.progressbar) self.grid.add_widget(self.progressbar)
# middle part # middle part
self.tabs = TabbedPanel(size_hint_y=1) self.tabs = ClientTabs(pos_hint={"center_x": 0.5, "center_y": 0.5})
self.tabs.default_tab_text = "All" self.tabs.add_widget(MDTabsItem(MDTabsItemText(text="All" if len(self.logging_pairs) > 1 else "Archipelago")))
self.log_panels["All"] = self.tabs.default_tab_content = UILog(*(logging.getLogger(logger_name) self.log_panels["All"] = self.tabs.default_tab_content = UILog(*(logging.getLogger(logger_name)
for logger_name, name in for logger_name, name in
self.logging_pairs)) self.logging_pairs))
self.tabs.carousel.add_widget(self.tabs.default_tab_content)
for logger_name, display_name in self.logging_pairs: for logger_name, display_name in self.logging_pairs:
bridge_logger = logging.getLogger(logger_name) bridge_logger = logging.getLogger(logger_name)
panel = TabbedPanelItem(text=display_name) self.log_panels[display_name] = UILog(bridge_logger)
self.log_panels[display_name] = panel.content = UILog(bridge_logger)
if len(self.logging_pairs) > 1: if len(self.logging_pairs) > 1:
panel = MDTabsItem(MDTabsItemText(text=display_name))
panel.content = self.log_panels[display_name]
# show Archipelago tab if other logging is present # show Archipelago tab if other logging is present
self.tabs.carousel.add_widget(panel.content)
self.tabs.add_widget(panel) self.tabs.add_widget(panel)
hint_panel = self.add_client_tab("Hints", HintLayout()) hint_panel = self.add_client_tab("Hints", HintLayout())
@@ -620,21 +892,21 @@ class GameManager(App):
self.log_panels["Hints"] = hint_panel.content self.log_panels["Hints"] = hint_panel.content
hint_panel.content.add_widget(self.hint_log) hint_panel.content.add_widget(self.hint_log)
if len(self.logging_pairs) == 1: self.main_area_container = MDGridLayout(size_hint_y=1, rows=1)
self.tabs.default_tab_text = "Archipelago"
self.main_area_container = GridLayout(size_hint_y=1, rows=1)
self.main_area_container.add_widget(self.tabs) self.main_area_container.add_widget(self.tabs)
self.grid.add_widget(self.main_area_container) self.grid.add_widget(self.main_area_container)
# bottom part # bottom part
bottom_layout = BoxLayout(orientation="horizontal", size_hint_y=None, height=dp(30)) bottom_layout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(40), spacing=5, padding=(5, 10))
info_button = Button(size=(dp(100), dp(30)), text="Command:", size_hint_x=None) info_button = CommandButton(MDButtonText(text="Command:", halign="left"), manager=self, radius=5,
style="filled", size=(dp(100), dp(70)), size_hint_x=None, size_hint_y=None,
pos_hint={"center_y": 0.575})
info_button.bind(on_release=self.command_button_action) info_button.bind(on_release=self.command_button_action)
bottom_layout.add_widget(info_button) bottom_layout.add_widget(info_button)
self.textinput = CommandPromptTextInput(size_hint_y=None, height=dp(30), multiline=False, write_tab=False) self.textinput = CommandPromptTextInput(size_hint_y=None, height=dp(30), multiline=False, write_tab=False)
self.textinput.bind(on_text_validate=self.on_message) self.textinput.bind(on_text_validate=self.on_message)
info_button.height = self.textinput.height
self.textinput.text_validate_unfocus = False self.textinput.text_validate_unfocus = False
bottom_layout.add_widget(self.textinput) bottom_layout.add_widget(self.textinput)
self.grid.add_widget(bottom_layout) self.grid.add_widget(bottom_layout)
@@ -650,29 +922,43 @@ class GameManager(App):
self.server_connect_bar.focus = True self.server_connect_bar.focus = True
self.server_connect_bar.select_text(port_start if port_start > 0 else host_start, len(s)) self.server_connect_bar.select_text(port_start if port_start > 0 else host_start, len(s))
# Uncomment to enable the kivy live editor console
# Press Ctrl-E (with numlock/capslock) disabled to open
# from kivy.core.window import Window
# from kivy.modules import console
# console.create_console(Window, self.container)
return self.container return self.container
def add_client_tab(self, title: str, content: Widget) -> Widget: def add_client_tab(self, title: str, content: Widget, index: int = -1) -> Widget:
"""Adds a new tab to the client window with a given title, and provides a given Widget as its content. """Adds a new tab to the client window with a given title, and provides a given Widget as its content.
Returns the new tab widget, with the provided content being placed on the tab as content.""" Returns the new tab widget, with the provided content being placed on the tab as content."""
new_tab = TabbedPanelItem(text=title) new_tab = MDTabsItem(MDTabsItemText(text=title))
new_tab.content = content new_tab.content = content
self.tabs.add_widget(new_tab) if -1 < index <= len(self.tabs.carousel.slides):
new_tab.bind(on_release=self.tabs.set_active_item)
new_tab._tabs = self.tabs
self.tabs.ids.container.add_widget(new_tab, index=index)
self.tabs.carousel.add_widget(new_tab.content, index=len(self.tabs.carousel.slides) - index)
else:
self.tabs.add_widget(new_tab)
self.tabs.carousel.add_widget(new_tab.content)
return new_tab return new_tab
def update_texts(self, dt): def update_texts(self, dt):
if hasattr(self.tabs.content.children[0], "fix_heights"): for slide in self.tabs.carousel.slides:
self.tabs.content.children[0].fix_heights() # TODO: remove this when Kivy fixes this upstream if hasattr(slide, "fix_heights"):
slide.fix_heights() # TODO: remove this when Kivy fixes this upstream
if self.ctx.server: if self.ctx.server:
self.title = self.base_title + " " + Utils.__version__ + \ self.title = self.base_title + " " + Utils.__version__ + \
f" | Connected to: {self.ctx.server_address} " \ f" | Connected to: {self.ctx.server_address} " \
f"{'.'.join(str(e) for e in self.ctx.server_version)}" f"{'.'.join(str(e) for e in self.ctx.server_version)}"
self.server_connect_button.text = "Disconnect" self.server_connect_button._button_text.text = "Disconnect"
self.server_connect_bar.readonly = True self.server_connect_bar.readonly = True
self.progressbar.max = len(self.ctx.checked_locations) + len(self.ctx.missing_locations) self.progressbar.max = len(self.ctx.checked_locations) + len(self.ctx.missing_locations)
self.progressbar.value = len(self.ctx.checked_locations) self.progressbar.value = len(self.ctx.checked_locations)
else: else:
self.server_connect_button.text = "Connect" self.server_connect_button._button_text.text = "Connect"
self.server_connect_bar.readonly = False self.server_connect_bar.readonly = False
self.title = self.base_title + " " + Utils.__version__ self.title = self.base_title + " " + Utils.__version__
self.progressbar.value = 0 self.progressbar.value = 0
@@ -735,8 +1021,8 @@ class GameManager(App):
def enable_energy_link(self): def enable_energy_link(self):
if not hasattr(self, "energy_link_label"): if not hasattr(self, "energy_link_label"):
self.energy_link_label = Label(text="Energy Link: Standby", self.energy_link_label = MDLabel(text="Energy Link: Standby",
size_hint_x=None, width=150) size_hint_x=None, width=150, halign="center")
self.connect_layout.add_widget(self.energy_link_label) self.connect_layout.add_widget(self.energy_link_label)
def set_new_energy_link_value(self): def set_new_energy_link_value(self):
@@ -772,8 +1058,9 @@ class LogtoUI(logging.Handler):
self.on_log(self.format(record)) self.on_log(self.format(record))
class UILog(RecycleView): class UILog(MDRecycleView):
messages: typing.ClassVar[int] # comes from kv file messages: typing.ClassVar[int] # comes from kv file
adaptive_height = True
def __init__(self, *loggers_to_handle, **kwargs): def __init__(self, *loggers_to_handle, **kwargs):
super(UILog, self).__init__(**kwargs) super(UILog, self).__init__(**kwargs)
@@ -800,17 +1087,24 @@ class UILog(RecycleView):
element.height = element.texture_size[1] element.height = element.texture_size[1]
class HintLayout(BoxLayout): class HintLayout(MDBoxLayout):
orientation = "vertical" orientation = "vertical"
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
boxlayout = BoxLayout(orientation="horizontal", size_hint_y=None, height=dp(30)) boxlayout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(40))
boxlayout.add_widget(Label(text="New Hint:", size_hint_x=None, size_hint_y=None, height=dp(30))) boxlayout.add_widget(MDLabel(text="New Hint:", size_hint_x=None, size_hint_y=None,
height=dp(40), width=dp(75), halign="center", valign="center"))
boxlayout.add_widget(AutocompleteHintInput()) boxlayout.add_widget(AutocompleteHintInput())
self.add_widget(boxlayout) self.add_widget(boxlayout)
def fix_heights(self):
for child in self.children:
fix_func = getattr(child, "fix_heights", None)
if fix_func:
fix_func()
status_names: typing.Dict[HintStatus, str] = { status_names: typing.Dict[HintStatus, str] = {
HintStatus.HINT_FOUND: "Found", HintStatus.HINT_FOUND: "Found",
HintStatus.HINT_UNSPECIFIED: "Unspecified", HintStatus.HINT_UNSPECIFIED: "Unspecified",
@@ -825,10 +1119,15 @@ status_colors: typing.Dict[HintStatus, str] = {
HintStatus.HINT_AVOID: "salmon", HintStatus.HINT_AVOID: "salmon",
HintStatus.HINT_PRIORITY: "plum", HintStatus.HINT_PRIORITY: "plum",
} }
status_sort_weights: dict[HintStatus, int] = {
HintStatus.HINT_FOUND: 0,
HintStatus.HINT_UNSPECIFIED: 1,
HintStatus.HINT_NO_PRIORITY: 2,
HintStatus.HINT_AVOID: 3,
HintStatus.HINT_PRIORITY: 4,
}
class HintLog(MDRecycleView):
class HintLog(RecycleView):
header = { header = {
"receiving": {"text": "[u]Receiving Player[/u]"}, "receiving": {"text": "[u]Receiving Player[/u]"},
"item": {"text": "[u]Item[/u]"}, "item": {"text": "[u]Item[/u]"},
@@ -839,7 +1138,7 @@ class HintLog(RecycleView):
"hint": {"receiving_player": -1, "location": -1, "finding_player": -1, "status": ""}}, "hint": {"receiving_player": -1, "location": -1, "finding_player": -1, "status": ""}},
"striped": True, "striped": True,
} }
data: list[typing.Any]
sort_key: str = "" sort_key: str = ""
reversed: bool = True reversed: bool = True
@@ -852,7 +1151,7 @@ class HintLog(RecycleView):
if not hints: # Fix the scrolling looking visually wrong in some edge cases if not hints: # Fix the scrolling looking visually wrong in some edge cases
self.scroll_y = 1.0 self.scroll_y = 1.0
data = [] data = []
ctx = App.get_running_app().ctx ctx = MDApp.get_running_app().ctx
for hint in hints: for hint in hints:
if not hint.get("status"): # Allows connecting to old servers if not hint.get("status"): # Allows connecting to old servers
hint["status"] = HintStatus.HINT_FOUND if hint["found"] else HintStatus.HINT_UNSPECIFIED hint["status"] = HintStatus.HINT_FOUND if hint["found"] else HintStatus.HINT_UNSPECIFIED
@@ -902,6 +1201,7 @@ class HintLog(RecycleView):
class ApAsyncImage(AsyncImage): class ApAsyncImage(AsyncImage):
def is_uri(self, filename: str) -> bool: def is_uri(self, filename: str) -> bool:
if filename.startswith("ap:"): if filename.startswith("ap:"):
return True return True
@@ -916,7 +1216,8 @@ class ImageLoaderPkgutil(ImageLoaderBase):
data = pkgutil.get_data(module, path) data = pkgutil.get_data(module, path)
return self._bytes_to_data(data) return self._bytes_to_data(data)
def _bytes_to_data(self, data: typing.Union[bytes, bytearray]) -> typing.List[ImageData]: @staticmethod
def _bytes_to_data(data: typing.Union[bytes, bytearray]) -> typing.List[ImageData]:
loader = next(loader for loader in ImageLoader.loaders if loader.can_load_memory()) loader = next(loader for loader in ImageLoader.loaders if loader.can_load_memory())
return loader.load(loader, io.BytesIO(data)) return loader.load(loader, io.BytesIO(data))
@@ -946,7 +1247,23 @@ class E(ExceptionHandler):
class KivyJSONtoTextParser(JSONtoTextParser): class KivyJSONtoTextParser(JSONtoTextParser):
# dummy class to absorb kvlang definitions # dummy class to absorb kvlang definitions
class TextColors(Widget): class TextColors(Widget):
pass white: str = StringProperty("FFFFFF")
black: str = StringProperty("000000")
red: str = StringProperty("EE0000")
green: str = StringProperty("00FF7F")
yellow: str = StringProperty("FAFAD2")
blue: str = StringProperty("6495ED")
magenta: str = StringProperty("EE00EE")
cyan: str = StringProperty("00EEEE")
slateblue: str = StringProperty("6D8BE8")
plum: str = StringProperty("AF99EF")
salmon: str = StringProperty("FA8072")
orange: str = StringProperty("FF7700")
# KivyMD parameters
theme_style: str = StringProperty("Dark")
primary_palette: str = StringProperty("Lightsteelblue")
dynamic_scheme_name: str = StringProperty("VIBRANT")
dynamic_scheme_contrast: int = NumericProperty(0)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
# we grab the color definitions from the .kv file, then overwrite the JSONtoTextParser default entries # we grab the color definitions from the .kv file, then overwrite the JSONtoTextParser default entries

View File

@@ -1,14 +1,17 @@
colorama>=0.4.6 colorama>=0.4.6
websockets>=13.0.1,<14 websockets>=13.0.1,<14
PyYAML>=6.0.2 PyYAML>=6.0.2
jellyfish>=1.1.0 jellyfish>=1.1.3
jinja2>=3.1.4 jinja2>=3.1.6
schema>=0.7.7 schema>=0.7.7
kivy>=2.3.0 kivy>=2.3.1
bsdiff4>=1.2.4 bsdiff4>=1.2.6
platformdirs>=4.2.2 platformdirs>=4.3.6
certifi>=2024.12.14 certifi>=2025.1.31
cython>=3.0.11 cython>=3.0.12
cymem>=2.0.8 cymem>=2.0.11
orjson>=3.10.7 orjson>=3.10.15
typing_extensions>=4.12.2 typing_extensions>=4.12.2
pyshortcuts>=1.9.1
kivymd @ git+https://github.com/kivymd/KivyMD@5ff9d0d
kivymd>=2.0.1.dev0

View File

@@ -109,7 +109,7 @@ class Group:
def get_type_hints(cls) -> Dict[str, Any]: def get_type_hints(cls) -> Dict[str, Any]:
"""Returns resolved type hints for the class""" """Returns resolved type hints for the class"""
if cls._type_cache is None: if cls._type_cache is None:
if not isinstance(next(iter(cls.__annotations__.values())), str): if not cls.__annotations__ or not isinstance(next(iter(cls.__annotations__.values())), str):
# non-str: assume already resolved # non-str: assume already resolved
cls._type_cache = cls.__annotations__ cls._type_cache = cls.__annotations__
else: else:
@@ -270,15 +270,20 @@ class Group:
# fetch class to avoid going through getattr # fetch class to avoid going through getattr
cls = self.__class__ cls = self.__class__
type_hints = cls.get_type_hints() type_hints = cls.get_type_hints()
entries = [e for e in self]
if not entries:
# write empty dict for empty Group with no instance values
cls._dump_value({}, f, indent=" " * level)
# validate group # validate group
for name in cls.__annotations__.keys(): for name in cls.__annotations__.keys():
assert hasattr(cls, name), f"{cls}.{name} is missing a default value" assert hasattr(cls, name), f"{cls}.{name} is missing a default value"
# dump ordered members # dump ordered members
for name in self: for name in entries:
attr = cast(object, getattr(self, name)) attr = cast(object, getattr(self, name))
attr_cls = type_hints[name] if name in type_hints else attr.__class__ attr_cls = type_hints[name] if name in type_hints else attr.__class__
attr_cls_origin = typing.get_origin(attr_cls) attr_cls_origin = typing.get_origin(attr_cls)
while attr_cls_origin is Union: # resolve to first type for doc string # resolve to first type for doc string
while attr_cls_origin is Union or attr_cls_origin is types.UnionType:
attr_cls = typing.get_args(attr_cls)[0] attr_cls = typing.get_args(attr_cls)[0]
attr_cls_origin = typing.get_origin(attr_cls) attr_cls_origin = typing.get_origin(attr_cls)
if attr_cls.__doc__ and attr_cls.__module__ != "builtins": if attr_cls.__doc__ and attr_cls.__module__ != "builtins":
@@ -787,7 +792,17 @@ class Settings(Group):
if location: if location:
from Utils import parse_yaml from Utils import parse_yaml
with open(location, encoding="utf-8-sig") as f: with open(location, encoding="utf-8-sig") as f:
options = parse_yaml(f.read()) from yaml.error import MarkedYAMLError
try:
options = parse_yaml(f.read())
except MarkedYAMLError as ex:
if ex.problem_mark:
f.seek(0)
lines = f.readlines()
problem_line = lines[ex.problem_mark.line]
error_line = " " * ex.problem_mark.column + "^"
raise Exception(f"{ex.context} {ex.problem}\n{problem_line}{error_line}")
raise ex
# TODO: detect if upgrade is required # TODO: detect if upgrade is required
# TODO: once we have a cache for _world_settings_name_cache, detect if any game section is missing # TODO: once we have a cache for _world_settings_name_cache, detect if any game section is missing
self.update(options or {}) self.update(options or {})

View File

@@ -19,7 +19,7 @@ from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple, Union
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it # This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
requirement = 'cx-Freeze==7.2.0' requirement = 'cx-Freeze==8.0.0'
try: try:
import pkg_resources import pkg_resources
try: try:
@@ -629,12 +629,13 @@ cx_Freeze.setup(
ext_modules=cythonize("_speedups.pyx"), ext_modules=cythonize("_speedups.pyx"),
options={ options={
"build_exe": { "build_exe": {
"packages": ["worlds", "kivy", "cymem", "websockets"], "packages": ["worlds", "kivy", "cymem", "websockets", "kivymd"],
"includes": [], "includes": [],
"excludes": ["numpy", "Cython", "PySide2", "PIL", "excludes": ["numpy", "Cython", "PySide2", "PIL",
"pandas", "zstandard"], "pandas"],
"zip_includes": [],
"zip_include_packages": ["*"], "zip_include_packages": ["*"],
"zip_exclude_packages": ["worlds", "sc2"], "zip_exclude_packages": ["worlds", "sc2", "kivymd"],
"include_files": [], # broken in cx 6.14.0, we use more special sauce now "include_files": [], # broken in cx 6.14.0, we use more special sauce now
"include_msvcr": False, "include_msvcr": False,
"replace_paths": ["*."], "replace_paths": ["*."],

View File

@@ -1,4 +1,4 @@
cmake_minimum_required(VERSION 3.5) cmake_minimum_required(VERSION 3.16)
project(ap-cpp-tests) project(ap-cpp-tests)
enable_testing() enable_testing()
@@ -7,8 +7,8 @@ find_package(GTest REQUIRED)
if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
add_definitions("/source-charset:utf-8") add_definitions("/source-charset:utf-8")
set(CMAKE_CXX_FLAGS_DEBUG "/MTd") # set(CMAKE_CXX_FLAGS_DEBUG "/MDd") # this is the default
set(CMAKE_CXX_FLAGS_RELEASE "/MT") # set(CMAKE_CXX_FLAGS_RELEASE "/MD") # this is the default
elseif (CMAKE_CXX_COMPILER_ID STREQUAL "GNU") elseif (CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
# enable static analysis for gcc # enable static analysis for gcc
add_compile_options(-fanalyzer -Werror) add_compile_options(-fanalyzer -Werror)

View File

@@ -65,8 +65,10 @@ class TestEntranceLookup(unittest.TestCase):
"""tests that get_targets shuffles targets between groups when requested""" """tests that get_targets shuffles targets between groups when requested"""
multiworld = generate_test_multiworld() multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5) generate_disconnected_region_grid(multiworld, 5)
exits_set = set([ex for region in multiworld.get_regions(1)
for ex in region.exits if not ex.connected_region])
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True) lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set)
er_targets = [entrance for region in multiworld.get_regions(1) er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region] for entrance in region.entrances if not entrance.parent_region]
for entrance in er_targets: for entrance in er_targets:
@@ -86,8 +88,10 @@ class TestEntranceLookup(unittest.TestCase):
"""tests that get_targets does not shuffle targets between groups when requested""" """tests that get_targets does not shuffle targets between groups when requested"""
multiworld = generate_test_multiworld() multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5) generate_disconnected_region_grid(multiworld, 5)
exits_set = set([ex for region in multiworld.get_regions(1)
for ex in region.exits if not ex.connected_region])
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True) lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set)
er_targets = [entrance for region in multiworld.get_regions(1) er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region] for entrance in region.entrances if not entrance.parent_region]
for entrance in er_targets: for entrance in er_targets:
@@ -99,6 +103,30 @@ class TestEntranceLookup(unittest.TestCase):
group_order = [prev := group.randomization_group for group in retrieved_targets if prev != group.randomization_group] group_order = [prev := group.randomization_group for group in retrieved_targets if prev != group.randomization_group]
self.assertEqual([ERTestGroups.TOP, ERTestGroups.BOTTOM], group_order) self.assertEqual([ERTestGroups.TOP, ERTestGroups.BOTTOM], group_order)
def test_selective_dead_ends(self):
"""test that entrances that EntranceLookup has not been told to consider are ignored when finding dead-ends"""
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5)
exits_set = set([ex for region in multiworld.get_regions(1)
for ex in region.exits if not ex.connected_region
and ex.name != "region20_right" and ex.name != "region21_left"])
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set)
er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region and
entrance.name != "region20_right" and entrance.name != "region21_left"]
for entrance in er_targets:
lookup.add(entrance)
# region 20 is the bottom left corner of the grid, and therefore only has a right entrance from region 21
# and a top entrance from region 15; since we've told lookup to ignore the right entrance from region 21,
# the top entrance from region 15 should be considered a dead-end
dead_end_region = multiworld.get_region("region20", 1)
for dead_end in dead_end_region.entrances:
if dead_end.name == "region20_top":
break
# there should be only this one dead-end
self.assertTrue(dead_end in lookup.dead_ends)
self.assertEqual(len(lookup.dead_ends), 1)
class TestBakeTargetGroupLookup(unittest.TestCase): class TestBakeTargetGroupLookup(unittest.TestCase):
def test_lookup_generation(self): def test_lookup_generation(self):
@@ -148,7 +176,7 @@ class TestDisconnectForRandomization(unittest.TestCase):
e.randomization_group = 1 e.randomization_group = 1
e.connect(r2) e.connect(r2)
disconnect_entrance_for_randomization(e) disconnect_entrance_for_randomization(e, one_way_target_name="foo")
self.assertIsNone(e.connected_region) self.assertIsNone(e.connected_region)
self.assertEqual([], r1.entrances) self.assertEqual([], r1.entrances)
@@ -158,10 +186,22 @@ class TestDisconnectForRandomization(unittest.TestCase):
self.assertEqual(1, len(r2.entrances)) self.assertEqual(1, len(r2.entrances))
self.assertIsNone(r2.entrances[0].parent_region) self.assertIsNone(r2.entrances[0].parent_region)
self.assertEqual("r2", r2.entrances[0].name) self.assertEqual("foo", r2.entrances[0].name)
self.assertEqual(EntranceType.ONE_WAY, r2.entrances[0].randomization_type) self.assertEqual(EntranceType.ONE_WAY, r2.entrances[0].randomization_type)
self.assertEqual(1, r2.entrances[0].randomization_group) self.assertEqual(1, r2.entrances[0].randomization_group)
def test_disconnect_default_1way_no_vanilla_target_raises(self):
multiworld = generate_test_multiworld()
r1 = Region("r1", 1, multiworld)
r2 = Region("r2", 1, multiworld)
e = r1.create_exit("e")
e.randomization_type = EntranceType.ONE_WAY
e.randomization_group = 1
e.connect(r2)
with self.assertRaises(ValueError):
disconnect_entrance_for_randomization(e)
def test_disconnect_uses_alternate_group(self): def test_disconnect_uses_alternate_group(self):
multiworld = generate_test_multiworld() multiworld = generate_test_multiworld()
r1 = Region("r1", 1, multiworld) r1 = Region("r1", 1, multiworld)
@@ -171,7 +211,7 @@ class TestDisconnectForRandomization(unittest.TestCase):
e.randomization_group = 1 e.randomization_group = 1
e.connect(r2) e.connect(r2)
disconnect_entrance_for_randomization(e, 2) disconnect_entrance_for_randomization(e, 2, "foo")
self.assertIsNone(e.connected_region) self.assertIsNone(e.connected_region)
self.assertEqual([], r1.entrances) self.assertEqual([], r1.entrances)
@@ -181,7 +221,7 @@ class TestDisconnectForRandomization(unittest.TestCase):
self.assertEqual(1, len(r2.entrances)) self.assertEqual(1, len(r2.entrances))
self.assertIsNone(r2.entrances[0].parent_region) self.assertIsNone(r2.entrances[0].parent_region)
self.assertEqual("r2", r2.entrances[0].name) self.assertEqual("foo", r2.entrances[0].name)
self.assertEqual(EntranceType.ONE_WAY, r2.entrances[0].randomization_type) self.assertEqual(EntranceType.ONE_WAY, r2.entrances[0].randomization_type)
self.assertEqual(2, r2.entrances[0].randomization_group) self.assertEqual(2, r2.entrances[0].randomization_group)
@@ -218,7 +258,7 @@ class TestRandomizeEntrances(unittest.TestCase):
self.assertEqual(80, len(result.pairings)) self.assertEqual(80, len(result.pairings))
self.assertEqual(80, len(result.placements)) self.assertEqual(80, len(result.placements))
def test_coupling(self): def test_coupled(self):
"""tests that in coupled mode, all 2 way transitions have an inverse""" """tests that in coupled mode, all 2 way transitions have an inverse"""
multiworld = generate_test_multiworld() multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5) generate_disconnected_region_grid(multiworld, 5)
@@ -236,6 +276,36 @@ class TestRandomizeEntrances(unittest.TestCase):
# if we didn't visit every placement the verification on_connect doesn't really mean much # if we didn't visit every placement the verification on_connect doesn't really mean much
self.assertEqual(len(result.placements), seen_placement_count) self.assertEqual(len(result.placements), seen_placement_count)
def test_uncoupled_succeeds_stage1_indirect_condition(self):
multiworld = generate_test_multiworld()
menu = multiworld.get_region("Menu", 1)
generate_entrance_pair(menu, "_right", ERTestGroups.RIGHT)
end = Region("End", 1, multiworld)
multiworld.regions.append(end)
generate_entrance_pair(end, "_left", ERTestGroups.LEFT)
multiworld.register_indirect_condition(end, None)
result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup)
self.assertSetEqual({
("Menu_right", "End_left"),
("End_left", "Menu_right")
}, set(result.pairings))
def test_coupled_succeeds_stage1_indirect_condition(self):
multiworld = generate_test_multiworld()
menu = multiworld.get_region("Menu", 1)
generate_entrance_pair(menu, "_right", ERTestGroups.RIGHT)
end = Region("End", 1, multiworld)
multiworld.regions.append(end)
generate_entrance_pair(end, "_left", ERTestGroups.LEFT)
multiworld.register_indirect_condition(end, None)
result = randomize_entrances(multiworld.worlds[1], True, directionally_matched_group_lookup)
self.assertSetEqual({
("Menu_right", "End_left"),
("End_left", "Menu_right")
}, set(result.pairings))
def test_uncoupled(self): def test_uncoupled(self):
"""tests that in uncoupled mode, no transitions have an (intentional) inverse""" """tests that in uncoupled mode, no transitions have an (intentional) inverse"""
multiworld = generate_test_multiworld() multiworld = generate_test_multiworld()
@@ -311,6 +381,37 @@ class TestRandomizeEntrances(unittest.TestCase):
self.assertEqual([], [exit_ for region in multiworld.get_regions() self.assertEqual([], [exit_ for region in multiworld.get_regions()
for exit_ in region.exits if not exit_.connected_region]) for exit_ in region.exits if not exit_.connected_region])
def test_minimal_entrance_rando_with_collect_override(self):
"""
tests that entrance randomization can complete with minimal accessibility and unreachable exits
when the world defines a collect override that add extra values to prog_items
"""
multiworld = generate_test_multiworld()
multiworld.worlds[1].options.accessibility = Accessibility.from_any(Accessibility.option_minimal)
multiworld.completion_condition[1] = lambda state: state.can_reach("region24", player=1)
generate_disconnected_region_grid(multiworld, 5, 1)
prog_items = generate_items(10, 1, True)
multiworld.itempool += prog_items
filler_items = generate_items(15, 1, False)
multiworld.itempool += filler_items
e = multiworld.get_entrance("region1_right", 1)
set_rule(e, lambda state: False)
old_collect = multiworld.worlds[1].collect
def new_collect(state, item):
old_collect(state, item)
state.prog_items[item.player]["counter"] += 300
multiworld.worlds[1].collect = new_collect
randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup)
self.assertEqual([], [entrance for region in multiworld.get_regions()
for entrance in region.entrances if not entrance.parent_region])
self.assertEqual([], [exit_ for region in multiworld.get_regions()
for exit_ in region.exits if not exit_.connected_region])
def test_restrictive_region_requirement_does_not_fail(self): def test_restrictive_region_requirement_does_not_fail(self):
multiworld = generate_test_multiworld() multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 2, 1) generate_disconnected_region_grid(multiworld, 2, 1)

View File

@@ -1,5 +1,6 @@
import unittest import unittest
from BaseClasses import CollectionState
from worlds.AutoWorld import AutoWorldRegister, call_all from worlds.AutoWorld import AutoWorldRegister, call_all
from . import setup_solo_multiworld from . import setup_solo_multiworld
@@ -8,12 +9,31 @@ class TestBase(unittest.TestCase):
def test_create_item(self): def test_create_item(self):
"""Test that a world can successfully create all items in its datapackage""" """Test that a world can successfully create all items in its datapackage"""
for game_name, world_type in AutoWorldRegister.world_types.items(): for game_name, world_type in AutoWorldRegister.world_types.items():
proxy_world = setup_solo_multiworld(world_type, ()).worlds[1] multiworld = setup_solo_multiworld(world_type, steps=("generate_early", "create_regions", "create_items"))
proxy_world = multiworld.worlds[1]
for item_name in world_type.item_name_to_id: for item_name in world_type.item_name_to_id:
test_state = CollectionState(multiworld)
with self.subTest("Create Item", item_name=item_name, game_name=game_name): with self.subTest("Create Item", item_name=item_name, game_name=game_name):
item = proxy_world.create_item(item_name) item = proxy_world.create_item(item_name)
with self.subTest("Item Name", item_name=item_name, game_name=game_name):
self.assertEqual(item.name, item_name) self.assertEqual(item.name, item_name)
if item.advancement:
with self.subTest("Item State Collect", item_name=item_name, game_name=game_name):
test_state.collect(item, True)
with self.subTest("Item State Remove", item_name=item_name, game_name=game_name):
test_state.remove(item)
self.assertEqual(test_state.prog_items, multiworld.state.prog_items,
"Item Collect -> Remove should restore empty state.")
else:
with self.subTest("Item State Collect No Change", item_name=item_name, game_name=game_name):
# Non-Advancement should not modify state.
test_state.collect(item)
self.assertEqual(test_state.prog_items, multiworld.state.prog_items)
def test_item_name_group_has_valid_item(self): def test_item_name_group_has_valid_item(self):
"""Test that all item name groups contain valid items. """ """Test that all item name groups contain valid items. """
# This cannot test for Event names that you may have declared for logic, only sendable Items. # This cannot test for Event names that you may have declared for logic, only sendable Items.

View File

@@ -1,5 +1,6 @@
import unittest import unittest
from BaseClasses import MultiWorld
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
from . import setup_solo_multiworld from . import setup_solo_multiworld
@@ -9,8 +10,12 @@ class TestWorldMemory(unittest.TestCase):
"""Tests that worlds don't leak references to MultiWorld or themselves with default options.""" """Tests that worlds don't leak references to MultiWorld or themselves with default options."""
import gc import gc
import weakref import weakref
refs: dict[str, weakref.ReferenceType[MultiWorld]] = {}
for game_name, world_type in AutoWorldRegister.world_types.items(): for game_name, world_type in AutoWorldRegister.world_types.items():
with self.subTest("Game", game_name=game_name): with self.subTest("Game creation", game_name=game_name):
weak = weakref.ref(setup_solo_multiworld(world_type)) weak = weakref.ref(setup_solo_multiworld(world_type))
gc.collect() refs[game_name] = weak
gc.collect()
for game_name, weak in refs.items():
with self.subTest("Game cleanup", game_name=game_name):
self.assertFalse(weak(), "World leaked a reference") self.assertFalse(weak(), "World leaked a reference")

View File

@@ -0,0 +1,14 @@
import unittest
import os
class TestPackages(unittest.TestCase):
def test_packages_have_init(self):
"""Test that all world folders containing .py files also have a __init__.py file,
to indicate full package rather than namespace package."""
import Utils
worlds_path = Utils.local_path("worlds")
for dirpath, dirnames, filenames in os.walk(worlds_path):
with self.subTest(directory=dirpath):
self.assertEqual("__init__.py" in filenames, any(file.endswith(".py") for file in filenames))

View File

@@ -0,0 +1,11 @@
import unittest
from worlds.AutoWorld import AutoWorldRegister
from worlds.Files import AutoPatchRegister
class TestPatches(unittest.TestCase):
def test_patch_name_matches_game(self) -> None:
for game_name in AutoPatchRegister.patch_types:
with self.subTest(game=game_name):
self.assertIn(game_name, AutoWorldRegister.world_types.keys(),
f"Patch '{game_name}' does not match the name of any world.")

View File

@@ -0,0 +1,19 @@
import unittest
import os
class TestBase(unittest.TestCase):
def test_requirements_file_ends_on_newline(self):
"""Test that all requirements files end on a newline"""
import Utils
requirements_files = [Utils.local_path("requirements.txt"),
Utils.local_path("WebHostLib", "requirements.txt")]
worlds_path = Utils.local_path("worlds")
for entry in os.listdir(worlds_path):
requirements_path = os.path.join(worlds_path, entry, "requirements.txt")
if os.path.isfile(requirements_path):
requirements_files.append(requirements_path)
for requirements_file in requirements_files:
with self.subTest(path=requirements_file):
with open(requirements_file) as f:
self.assertEqual(f.read()[-1], "\n")

View File

@@ -0,0 +1,29 @@
import unittest
from worlds.AutoWorld import AutoWorldRegister, call_all
from . import setup_solo_multiworld
class TestBase(unittest.TestCase):
gen_steps = (
"generate_early",
"create_regions",
)
test_steps = (
"create_items",
"set_rules",
"connect_entrances",
"generate_basic",
"pre_fill",
)
def test_all_state_is_available(self):
"""Ensure all_state can be created at certain steps."""
for game_name, world_type in AutoWorldRegister.world_types.items():
with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type, self.gen_steps)
for step in self.test_steps:
with self.subTest("Step", step=step):
call_all(multiworld, step)
self.assertTrue(multiworld.get_all_state(False, True))

View File

@@ -80,8 +80,8 @@ class Client:
"version": { "version": {
"class": "Version", "class": "Version",
"major": 0, "major": 0,
"minor": 4, "minor": 6,
"build": 6, "build": 0,
}, },
"items_handling": 0, "items_handling": 0,
"tags": [], "tags": [],

View File

@@ -1,5 +1,5 @@
import unittest import unittest
from typing import List, Tuple from typing import ClassVar, List, Tuple
from unittest import TestCase from unittest import TestCase
from BaseClasses import CollectionState, Location, MultiWorld from BaseClasses import CollectionState, Location, MultiWorld
@@ -7,6 +7,7 @@ from Fill import distribute_items_restrictive
from Options import Accessibility from Options import Accessibility
from worlds.AutoWorld import AutoWorldRegister, call_all, call_single from worlds.AutoWorld import AutoWorldRegister, call_all, call_single
from ..general import gen_steps, setup_multiworld from ..general import gen_steps, setup_multiworld
from ..param import classvar_matrix
class MultiworldTestBase(TestCase): class MultiworldTestBase(TestCase):
@@ -63,15 +64,18 @@ class TestAllGamesMultiworld(MultiworldTestBase):
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game") self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")
@classvar_matrix(game=AutoWorldRegister.world_types.keys())
class TestTwoPlayerMulti(MultiworldTestBase): class TestTwoPlayerMulti(MultiworldTestBase):
game: ClassVar[str]
def test_two_player_single_game_fills(self) -> None: def test_two_player_single_game_fills(self) -> None:
"""Tests that a multiworld of two players for each registered game world can generate.""" """Tests that a multiworld of two players for each registered game world can generate."""
for world_type in AutoWorldRegister.world_types.values(): world_type = AutoWorldRegister.world_types[self.game]
self.multiworld = setup_multiworld([world_type, world_type], ()) self.multiworld = setup_multiworld([world_type, world_type], ())
for world in self.multiworld.worlds.values(): for world in self.multiworld.worlds.values():
world.options.accessibility.value = Accessibility.option_full world.options.accessibility.value = Accessibility.option_full
self.assertSteps(gen_steps) self.assertSteps(gen_steps)
with self.subTest("filling multiworld", games=world_type.game, seed=self.multiworld.seed): with self.subTest("filling multiworld", games=world_type.game, seed=self.multiworld.seed):
distribute_items_restrictive(self.multiworld) distribute_items_restrictive(self.multiworld)
call_all(self.multiworld, "post_fill") call_all(self.multiworld, "post_fill")
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game") self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")

46
test/param.py Normal file
View File

@@ -0,0 +1,46 @@
import itertools
import sys
from typing import Any, Callable, Iterable
def classvar_matrix(**kwargs: Iterable[Any]) -> Callable[[type], None]:
"""
Create a new class for each variation of input, allowing to generate a TestCase matrix / parametrization that
supports multi-threading and has better reporting for ``unittest --durations=...`` and ``pytest --durations=...``
than subtests.
The kwargs will be set as ClassVars in the newly created classes. Use as ::
@classvar_matrix(var_name=[value1, value2])
class MyTestCase(unittest.TestCase):
var_name: typing.ClassVar[...]
:param kwargs: A dict of ClassVars to set, where key is the variable name and value is a list of all values.
:return: A decorator to be applied to a class.
"""
keys: tuple[str]
values: Iterable[Iterable[Any]]
keys, values = zip(*kwargs.items())
values = map(lambda v: sorted(v) if isinstance(v, (set, frozenset)) else v, values)
permutations_dicts = [dict(zip(keys, v)) for v in itertools.product(*values)]
def decorator(cls: type) -> None:
mod = sys.modules[cls.__module__]
for permutation in permutations_dicts:
class Unrolled(cls): # type: ignore
pass
for k, v in permutation.items():
setattr(Unrolled, k, v)
params = ", ".join([f"{k}={repr(v)}" for k, v in permutation.items()])
params = f"{{{params}}}"
Unrolled.__module__ = cls.__module__
Unrolled.__qualname__ = f"{cls.__qualname__}{params}"
setattr(mod, f"{cls.__name__}{params}", Unrolled)
return None
return decorator

View File

@@ -9,7 +9,8 @@ from worlds.LauncherComponents import Component, SuffixIdentifier, Type, compone
if TYPE_CHECKING: if TYPE_CHECKING:
from SNIClient import SNIContext from SNIClient import SNIContext
component = Component('SNI Client', 'SNIClient', component_type=Type.CLIENT, file_identifier=SuffixIdentifier(".apsoe")) component = Component('SNI Client', 'SNIClient', component_type=Type.CLIENT, file_identifier=SuffixIdentifier(".apsoe"),
description="A client for connecting to SNES consoles via Super Nintendo Interface.")
components.append(component) components.append(component)

View File

@@ -110,6 +110,16 @@ class AutoLogicRegister(type):
elif not item_name.startswith("__"): elif not item_name.startswith("__"):
if hasattr(CollectionState, item_name): if hasattr(CollectionState, item_name):
raise Exception(f"Name conflict on Logic Mixin {name} trying to overwrite {item_name}") raise Exception(f"Name conflict on Logic Mixin {name} trying to overwrite {item_name}")
assert callable(function) or "init_mixin" in dct, (
f"{name} defined class variable {item_name} without also having init_mixin.\n\n"
"Explanation:\n"
"Class variables that will be mutated need to be inintialized as instance variables in init_mixin.\n"
"If your LogicMixin variables aren't actually mutable / you don't intend to mutate them, "
"there is no point in using LogixMixin.\n"
"LogicMixin exists to track custom state variables that change when items are collected/removed."
)
setattr(CollectionState, item_name, function) setattr(CollectionState, item_name, function)
return new_class return new_class

View File

@@ -27,6 +27,8 @@ class Component:
""" """
display_name: str display_name: str
"""Used as the GUI button label and the component name in the CLI args""" """Used as the GUI button label and the component name in the CLI args"""
description: str
"""Optional description displayed on the GUI underneath the display name"""
type: Type type: Type
""" """
Enum "Type" classification of component intent, for filtering in the Launcher GUI Enum "Type" classification of component intent, for filtering in the Launcher GUI
@@ -58,8 +60,9 @@ class Component:
def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None, def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None,
cli: bool = False, icon: str = 'icon', component_type: Optional[Type] = None, cli: bool = False, icon: str = 'icon', component_type: Optional[Type] = None,
func: Optional[Callable] = None, file_identifier: Optional[Callable[[str], bool]] = None, func: Optional[Callable] = None, file_identifier: Optional[Callable[[str], bool]] = None,
game_name: Optional[str] = None, supports_uri: Optional[bool] = False): game_name: Optional[str] = None, supports_uri: Optional[bool] = False, description: str = "") -> None:
self.display_name = display_name self.display_name = display_name
self.description = description
self.script_name = script_name self.script_name = script_name
self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None
self.icon = icon self.icon = icon
@@ -88,7 +91,6 @@ processes = weakref.WeakSet()
def launch_subprocess(func: Callable, name: str | None = None, args: Tuple[str, ...] = ()) -> None: def launch_subprocess(func: Callable, name: str | None = None, args: Tuple[str, ...] = ()) -> None:
global processes
import multiprocessing import multiprocessing
process = multiprocessing.Process(target=func, name=name, args=args) process = multiprocessing.Process(target=func, name=name, args=args)
process.start() process.start()

View File

@@ -7,7 +7,7 @@ from __future__ import annotations
import abc import abc
from typing import TYPE_CHECKING, Any, ClassVar from typing import TYPE_CHECKING, Any, ClassVar
from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components, launch_subprocess from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components, launch as launch_component
if TYPE_CHECKING: if TYPE_CHECKING:
from .context import BizHawkClientContext from .context import BizHawkClientContext
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
def launch_client(*args) -> None: def launch_client(*args) -> None:
from .context import launch from .context import launch
launch_subprocess(launch, name="BizHawkClient", args=args) launch_component(launch, name="BizHawkClient", args=args)
component = Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client, component = Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client,

View File

@@ -41,6 +41,7 @@ class BizHawkClientCommandProcessor(ClientCommandProcessor):
class BizHawkClientContext(CommonContext): class BizHawkClientContext(CommonContext):
command_processor = BizHawkClientCommandProcessor command_processor = BizHawkClientCommandProcessor
server_seed_name: str | None = None
auth_status: AuthStatus auth_status: AuthStatus
password_requested: bool password_requested: bool
client_handler: BizHawkClient | None client_handler: BizHawkClient | None
@@ -68,6 +69,8 @@ class BizHawkClientContext(CommonContext):
if cmd == "Connected": if cmd == "Connected":
self.slot_data = args.get("slot_data", None) self.slot_data = args.get("slot_data", None)
self.auth_status = AuthStatus.AUTHENTICATED self.auth_status = AuthStatus.AUTHENTICATED
elif cmd == "RoomInfo":
self.server_seed_name = args.get("seed_name", None)
if self.client_handler is not None: if self.client_handler is not None:
self.client_handler.on_package(self, cmd, args) self.client_handler.on_package(self, cmd, args)
@@ -100,6 +103,7 @@ class BizHawkClientContext(CommonContext):
async def disconnect(self, allow_autoreconnect: bool=False): async def disconnect(self, allow_autoreconnect: bool=False):
self.auth_status = AuthStatus.NOT_AUTHENTICATED self.auth_status = AuthStatus.NOT_AUTHENTICATED
self.server_seed_name = None
await super().disconnect(allow_autoreconnect) await super().disconnect(allow_autoreconnect)
@@ -238,6 +242,7 @@ def _patch_and_run_game(patch_file: str):
return metadata return metadata
except Exception as exc: except Exception as exc:
logger.exception(exc) logger.exception(exc)
Utils.messagebox("Error Patching Game", str(exc), True)
return {} return {}
@@ -271,6 +276,6 @@ def launch(*launch_args: str) -> None:
Utils.init_logging("BizHawkClient", exception_logger="Client") Utils.init_logging("BizHawkClient", exception_logger="Client")
import colorama import colorama
colorama.init() colorama.just_fix_windows_console()
asyncio.run(main()) asyncio.run(main())
colorama.deinit() colorama.deinit()

View File

@@ -3,4 +3,4 @@ mpyq>=0.2.5
portpicker>=1.5.2 portpicker>=1.5.2
aiohttp>=3.8.4 aiohttp>=3.8.4
loguru>=0.7.0 loguru>=0.7.0
protobuf==3.20.3 protobuf==3.20.3

View File

@@ -238,14 +238,12 @@ class AdventureWorld(World):
def create_regions(self) -> None: def create_regions(self) -> None:
create_regions(self.options, self.multiworld, self.player, self.dragon_rooms) create_regions(self.options, self.multiworld, self.player, self.dragon_rooms)
set_rules = set_rules
def generate_basic(self) -> None:
self.multiworld.get_location("Chalice Home", self.player).place_locked_item( self.multiworld.get_location("Chalice Home", self.player).place_locked_item(
self.create_event("Victory", ItemClassification.progression)) self.create_event("Victory", ItemClassification.progression))
self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player) self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player)
set_rules = set_rules
def pre_fill(self): def pre_fill(self):
# Place empty items in filler locations here, to limit # Place empty items in filler locations here, to limit
# the number of exported empty items and the density of stuff in overworld. # the number of exported empty items and the density of stuff in overworld.

View File

@@ -261,6 +261,6 @@ def launch():
# options = Utils.get_options() # options = Utils.get_options()
import colorama import colorama
colorama.init() colorama.just_fix_windows_console()
asyncio.run(main()) asyncio.run(main())
colorama.deinit() colorama.deinit()

View File

@@ -206,7 +206,7 @@ ahit_locations = {
"Subcon Village - Graveyard Ice Cube": LocData(2000325077, "Subcon Forest Area"), "Subcon Village - Graveyard Ice Cube": LocData(2000325077, "Subcon Forest Area"),
"Subcon Village - House Top": LocData(2000325471, "Subcon Forest Area"), "Subcon Village - House Top": LocData(2000325471, "Subcon Forest Area"),
"Subcon Village - Ice Cube House": LocData(2000325469, "Subcon Forest Area"), "Subcon Village - Ice Cube House": LocData(2000325469, "Subcon Forest Area"),
"Subcon Village - Snatcher Statue Chest": LocData(2000323730, "Subcon Forest Area", paintings=1), "Subcon Village - Snatcher Statue Chest": LocData(2000323730, "Subcon Forest Behind Boss Firewall"),
"Subcon Village - Stump Platform Chest": LocData(2000323729, "Subcon Forest Area"), "Subcon Village - Stump Platform Chest": LocData(2000323729, "Subcon Forest Area"),
"Subcon Forest - Giant Tree Climb": LocData(2000325470, "Subcon Forest Area"), "Subcon Forest - Giant Tree Climb": LocData(2000325470, "Subcon Forest Area"),
@@ -233,7 +233,7 @@ ahit_locations = {
"Subcon Forest - Long Tree Climb Chest": LocData(2000323734, "Subcon Forest Area", "Subcon Forest - Long Tree Climb Chest": LocData(2000323734, "Subcon Forest Area",
required_hats=[HatType.DWELLER], paintings=2), required_hats=[HatType.DWELLER], paintings=2),
"Subcon Forest - Boss Arena Chest": LocData(2000323735, "Subcon Forest Area"), "Subcon Forest - Boss Arena Chest": LocData(2000323735, "Subcon Forest Boss Arena"),
"Subcon Forest - Manor Rooftop": LocData(2000325466, "Subcon Forest Area", "Subcon Forest - Manor Rooftop": LocData(2000325466, "Subcon Forest Area",
hit_type=HitType.dweller_bell, paintings=1), hit_type=HitType.dweller_bell, paintings=1),
@@ -411,7 +411,7 @@ act_completions = {
"Act Completion (Mail Delivery Service)": LocData(2000312032, "Mail Delivery Service", "Act Completion (Mail Delivery Service)": LocData(2000312032, "Mail Delivery Service",
required_hats=[HatType.SPRINT]), required_hats=[HatType.SPRINT]),
"Act Completion (Your Contract has Expired)": LocData(2000311390, "Your Contract has Expired", "Act Completion (Your Contract has Expired)": LocData(2000311390, "Your Contract has Expired - Post Fight",
hit_type=HitType.umbrella), hit_type=HitType.umbrella),
"Act Completion (Time Rift - Pipe)": LocData(2000313069, "Time Rift - Pipe", hookshot=True), "Act Completion (Time Rift - Pipe)": LocData(2000313069, "Time Rift - Pipe", hookshot=True),
@@ -976,7 +976,6 @@ event_locs = {
**snatcher_coins, **snatcher_coins,
"HUMT Access": LocData(0, "Heating Up Mafia Town"), "HUMT Access": LocData(0, "Heating Up Mafia Town"),
"TOD Access": LocData(0, "Toilet of Doom"), "TOD Access": LocData(0, "Toilet of Doom"),
"YCHE Access": LocData(0, "Your Contract has Expired"),
"AFR Access": LocData(0, "Alpine Free Roam"), "AFR Access": LocData(0, "Alpine Free Roam"),
"TIHS Access": LocData(0, "The Illness has Spread"), "TIHS Access": LocData(0, "The Illness has Spread"),

View File

@@ -2,7 +2,7 @@ from typing import List, TYPE_CHECKING, Dict, Any
from schema import Schema, Optional from schema import Schema, Optional
from dataclasses import dataclass from dataclasses import dataclass
from worlds.AutoWorld import PerGameCommonOptions from worlds.AutoWorld import PerGameCommonOptions
from Options import Range, Toggle, DeathLink, Choice, OptionDict, DefaultOnToggle, OptionGroup from Options import Range, Toggle, DeathLink, Choice, OptionDict, DefaultOnToggle, OptionGroup, StartInventoryPool
if TYPE_CHECKING: if TYPE_CHECKING:
from . import HatInTimeWorld from . import HatInTimeWorld
@@ -338,7 +338,7 @@ class MinExtraYarn(Range):
There must be at least this much more yarn over the total number of yarn needed to craft all hats. There must be at least this much more yarn over the total number of yarn needed to craft all hats.
For example, if this option's value is 10, and the total yarn needed to craft all hats is 40, For example, if this option's value is 10, and the total yarn needed to craft all hats is 40,
there must be at least 50 yarn in the pool.""" there must be at least 50 yarn in the pool."""
display_name = "Max Extra Yarn" display_name = "Min Extra Yarn"
range_start = 5 range_start = 5
range_end = 15 range_end = 15
default = 10 default = 10
@@ -625,6 +625,8 @@ class ParadeTrapWeight(Range):
@dataclass @dataclass
class AHITOptions(PerGameCommonOptions): class AHITOptions(PerGameCommonOptions):
start_inventory_from_pool: StartInventoryPool
EndGoal: EndGoal EndGoal: EndGoal
ActRandomizer: ActRandomizer ActRandomizer: ActRandomizer
ActPlando: ActPlando ActPlando: ActPlando

View File

@@ -347,7 +347,7 @@ def create_regions(world: "HatInTimeWorld"):
sf_act3 = create_region_and_connect(world, "Toilet of Doom", "Subcon Forest - Act 3", subcon_forest) sf_act3 = create_region_and_connect(world, "Toilet of Doom", "Subcon Forest - Act 3", subcon_forest)
sf_act4 = create_region_and_connect(world, "Queen Vanessa's Manor", "Subcon Forest - Act 4", subcon_forest) sf_act4 = create_region_and_connect(world, "Queen Vanessa's Manor", "Subcon Forest - Act 4", subcon_forest)
sf_act5 = create_region_and_connect(world, "Mail Delivery Service", "Subcon Forest - Act 5", subcon_forest) sf_act5 = create_region_and_connect(world, "Mail Delivery Service", "Subcon Forest - Act 5", subcon_forest)
create_region_and_connect(world, "Your Contract has Expired", "Subcon Forest - Finale", subcon_forest) sf_finale = create_region_and_connect(world, "Your Contract has Expired", "Subcon Forest - Finale", subcon_forest)
# ------------------------------------------- ALPINE SKYLINE ------------------------------------------ # # ------------------------------------------- ALPINE SKYLINE ------------------------------------------ #
alpine_skyline = create_region_and_connect(world, "Alpine Skyline", "Telescope -> Alpine Skyline", spaceship) alpine_skyline = create_region_and_connect(world, "Alpine Skyline", "Telescope -> Alpine Skyline", spaceship)
@@ -386,11 +386,24 @@ def create_regions(world: "HatInTimeWorld"):
create_rift_connections(world, create_region(world, "Time Rift - Bazaar")) create_rift_connections(world, create_region(world, "Time Rift - Bazaar"))
sf_area: Region = create_region(world, "Subcon Forest Area") sf_area: Region = create_region(world, "Subcon Forest Area")
sf_behind_boss_firewall: Region = create_region(world, "Subcon Forest Behind Boss Firewall")
sf_boss_arena: Region = create_region(world, "Subcon Forest Boss Arena")
sf_area.connect(sf_behind_boss_firewall, "SF Area -> SF Behind Boss Firewall")
sf_behind_boss_firewall.connect(sf_boss_arena, "SF Behind Boss Firewall -> SF Boss Arena")
sf_act1.connect(sf_area, "Subcon Forest Entrance CO") sf_act1.connect(sf_area, "Subcon Forest Entrance CO")
sf_act2.connect(sf_area, "Subcon Forest Entrance SW") sf_act2.connect(sf_area, "Subcon Forest Entrance SW")
sf_act3.connect(sf_area, "Subcon Forest Entrance TOD") sf_act3.connect(sf_area, "Subcon Forest Entrance TOD")
sf_act4.connect(sf_area, "Subcon Forest Entrance QVM") sf_act4.connect(sf_area, "Subcon Forest Entrance QVM")
sf_act5.connect(sf_area, "Subcon Forest Entrance MDS") sf_act5.connect(sf_area, "Subcon Forest Entrance MDS")
# YCHE puts the player directly in the boss arena, with no access to the rest of Subcon Forest by default.
sf_finale.connect(sf_boss_arena, "Subcon Forest Entrance YCHE")
# To support the Snatcher Hover expert logic for Act Completion (Your Contract has Expired), the act completion has
# to go in a separate region because the Snatcher Hover gives direct access to the Act Completion, but does not
# give access to the act itself.
sf_finale_post_fight: Region = create_region(world, "Your Contract has Expired - Post Fight")
# This connection must never have any rules placed on it because they will not be inherited when setting up act
# connections, only the rules for the entrances to the act and the rules for the Act Completion are inherited.
sf_finale.connect(sf_finale_post_fight, "YCHE -> YCHE - Post Fight")
create_rift_connections(world, create_region(world, "Time Rift - Sleepy Subcon")) create_rift_connections(world, create_region(world, "Time Rift - Sleepy Subcon"))
create_rift_connections(world, create_region(world, "Time Rift - Pipe")) create_rift_connections(world, create_region(world, "Time Rift - Pipe"))
@@ -947,6 +960,16 @@ def get_shuffled_region(world: "HatInTimeWorld", region: str) -> str:
return name return name
def get_region_shuffled_to(world: "HatInTimeWorld", region: str) -> str:
if world.options.ActRandomizer:
original_ci: str = chapter_act_info[region]
shuffled_ci = world.act_connections[original_ci]
return next(act_name for act_name, ci in chapter_act_info.items()
if ci == shuffled_ci)
else:
return region
def get_region_location_count(world: "HatInTimeWorld", region_name: str, included_only: bool = True) -> int: def get_region_location_count(world: "HatInTimeWorld", region_name: str, included_only: bool = True) -> int:
count = 0 count = 0
region = world.multiworld.get_region(region_name, world.player) region = world.multiworld.get_region(region_name, world.player)

View File

@@ -481,9 +481,8 @@ def set_hard_rules(world: "HatInTimeWorld"):
set_rule(world.multiworld.get_location("Subcon Forest - Dweller Platforming Tree B", world.player), set_rule(world.multiworld.get_location("Subcon Forest - Dweller Platforming Tree B", world.player),
lambda state: has_paintings(state, world, 3)) lambda state: has_paintings(state, world, 3))
# Cherry bridge over boss arena gap (painting still expected) # Cherry bridge over boss arena gap
set_rule(world.multiworld.get_location("Subcon Forest - Boss Arena Chest", world.player), set_rule(world.get_entrance("SF Behind Boss Firewall -> SF Boss Arena"), lambda state: True)
lambda state: has_paintings(state, world, 1, False) or state.has("YCHE Access", world.player))
set_rule(world.multiworld.get_location("Subcon Forest - Noose Treehouse", world.player), set_rule(world.multiworld.get_location("Subcon Forest - Noose Treehouse", world.player),
lambda state: has_paintings(state, world, 2, True)) lambda state: has_paintings(state, world, 2, True))
@@ -566,27 +565,61 @@ def set_expert_rules(world: "HatInTimeWorld"):
lambda state: True) lambda state: True)
# Expert: Cherry Hovering # Expert: Cherry Hovering
subcon_area = world.multiworld.get_region("Subcon Forest Area", world.player) # Skipping the boss firewall is possible with a Cherry Hover.
yche = world.multiworld.get_region("Your Contract has Expired", world.player) set_rule(world.get_entrance("SF Area -> SF Behind Boss Firewall"),
entrance = yche.connect(subcon_area, "Subcon Forest Entrance YCHE") lambda state: has_paintings(state, world, 1, True))
# The boss arena gap can be crossed in reverse with a Cherry Hover.
subcon_boss_arena = world.get_region("Subcon Forest Boss Arena")
subcon_behind_boss_firewall = world.get_region("Subcon Forest Behind Boss Firewall")
subcon_boss_arena.connect(subcon_behind_boss_firewall, "SF Boss Arena -> SF Behind Boss Firewall")
if world.options.NoPaintingSkips: subcon_area = world.get_region("Subcon Forest Area")
add_rule(entrance, lambda state: has_paintings(state, world, 1))
# The boss firewall can be skipped in reverse with a Cherry Hover, but it is not possible to remove the boss
# firewall from reverse because the paintings to burn to remove the firewall are on the other side of the firewall.
# Therefore, a painting skip is required. The paintings could be burned by already having access to
# "Subcon Forest Area" through another entrance, but making a new connection to "Subcon Forest Area" in that case
# would be pointless.
if not world.options.NoPaintingSkips:
# The import cannot be done at the module-level because it would cause a circular import.
from .Regions import get_region_shuffled_to
subcon_behind_boss_firewall.connect(subcon_area, "SF Behind Boss Firewall -> SF Area")
# Because the Your Contract has Expired entrance can now reach "Subcon Forest Area", it needs to be connected to
# each of the Subcon Forest Time Rift entrances, like the other Subcon Forest Acts.
yche = world.get_region("Your Contract has Expired")
def connect_to_shuffled_act_at(original_act_name):
region_name = get_region_shuffled_to(world, original_act_name)
return yche.connect(world.get_region(region_name), f"{original_act_name} Portal - Entrance YCHE")
# Rules copied from `Rules.set_rift_rules()` with painting logic removed because painting skips must be
# available.
entrance = connect_to_shuffled_act_at("Time Rift - Pipe")
add_rule(entrance, lambda state: can_clear_required_act(state, world, "Subcon Forest - Act 2"))
reg_act_connection(world, world.get_entrance("Subcon Forest - Act 2").connected_region, entrance)
entrance = connect_to_shuffled_act_at("Time Rift - Village")
add_rule(entrance, lambda state: can_clear_required_act(state, world, "Subcon Forest - Act 4"))
reg_act_connection(world, world.get_entrance("Subcon Forest - Act 4").connected_region, entrance)
entrance = connect_to_shuffled_act_at("Time Rift - Sleepy Subcon")
add_rule(entrance, lambda state: has_relic_combo(state, world, "UFO"))
set_rule(world.multiworld.get_location("Act Completion (Toilet of Doom)", world.player), set_rule(world.multiworld.get_location("Act Completion (Toilet of Doom)", world.player),
lambda state: can_use_hookshot(state, world) and can_hit(state, world) lambda state: can_use_hookshot(state, world) and can_hit(state, world)
and has_paintings(state, world, 1, True)) and has_paintings(state, world, 1, True))
# Set painting rules only. Skipping paintings is determined in has_paintings # Set painting rules only. Skipping paintings is determined in has_paintings
set_rule(world.multiworld.get_location("Subcon Forest - Boss Arena Chest", world.player),
lambda state: has_paintings(state, world, 1, True))
set_rule(world.multiworld.get_location("Subcon Forest - Magnet Badge Bush", world.player), set_rule(world.multiworld.get_location("Subcon Forest - Magnet Badge Bush", world.player),
lambda state: has_paintings(state, world, 3, True)) lambda state: has_paintings(state, world, 3, True))
# You can cherry hover to Snatcher's post-fight cutscene, which completes the level without having to fight him # You can cherry hover to Snatcher's post-fight cutscene, which completes the level without having to fight him
subcon_area.connect(yche, "Snatcher Hover") yche_post_fight = world.get_region("Your Contract has Expired - Post Fight")
set_rule(world.multiworld.get_location("Act Completion (Your Contract has Expired)", world.player), subcon_area.connect(yche_post_fight, "Snatcher Hover")
lambda state: True) # Cherry Hover from YCHE also works, so there are no requirements for the Act Completion.
set_rule(world.get_location("Act Completion (Your Contract has Expired)"), lambda state: True)
if world.is_dlc2(): if world.is_dlc2():
# Expert: clear Rush Hour with nothing # Expert: clear Rush Hour with nothing
@@ -681,12 +714,18 @@ def set_subcon_rules(world: "HatInTimeWorld"):
lambda state: can_use_hat(state, world, HatType.BREWING) or state.has("Umbrella", world.player) lambda state: can_use_hat(state, world, HatType.BREWING) or state.has("Umbrella", world.player)
or can_use_hat(state, world, HatType.DWELLER)) or can_use_hat(state, world, HatType.DWELLER))
# You can't skip over the boss arena wall without cherry hover, so these two need to be set this way # You can't skip over the boss arena wall without cherry hover.
set_rule(world.multiworld.get_location("Subcon Forest - Boss Arena Chest", world.player), set_rule(world.get_entrance("SF Area -> SF Behind Boss Firewall"),
lambda state: state.has("TOD Access", world.player) and can_use_hookshot(state, world) lambda state: has_paintings(state, world, 1, False))
and has_paintings(state, world, 1, False) or state.has("YCHE Access", world.player))
# The painting wall can't be skipped without cherry hover, which is Expert # The hookpoints to cross the boss arena gap are only present in Toilet of Doom.
set_rule(world.get_entrance("SF Behind Boss Firewall -> SF Boss Arena"),
lambda state: state.has("TOD Access", world.player)
and can_use_hookshot(state, world))
# The Act Completion is in the Toilet of Doom region, so the same rules as passing the boss firewall and crossing
# the boss arena gap are required. "TOD Access" is implied from the region so does not need to be included in the
# rule.
set_rule(world.multiworld.get_location("Act Completion (Toilet of Doom)", world.player), set_rule(world.multiworld.get_location("Act Completion (Toilet of Doom)", world.player),
lambda state: can_use_hookshot(state, world) and can_hit(state, world) lambda state: can_use_hookshot(state, world) and can_hit(state, world)
and has_paintings(state, world, 1, False)) and has_paintings(state, world, 1, False))

View File

@@ -1,6 +1,6 @@
from BaseClasses import Item, ItemClassification, Tutorial, Location, MultiWorld from BaseClasses import Item, ItemClassification, Tutorial, Location, MultiWorld
from .Items import item_table, create_item, relic_groups, act_contracts, create_itempool, get_shop_trap_name, \ from .Items import item_table, create_item, relic_groups, act_contracts, create_itempool, get_shop_trap_name, \
calculate_yarn_costs, alps_hooks calculate_yarn_costs, alps_hooks, junk_weights
from .Regions import create_regions, randomize_act_entrances, chapter_act_info, create_events, get_shuffled_region from .Regions import create_regions, randomize_act_entrances, chapter_act_info, create_events, get_shuffled_region
from .Locations import location_table, contract_locations, is_location_valid, get_location_names, TASKSANITY_START_ID, \ from .Locations import location_table, contract_locations, is_location_valid, get_location_names, TASKSANITY_START_ID, \
get_total_locations get_total_locations
@@ -12,13 +12,13 @@ from .DeathWishRules import set_dw_rules, create_enemy_events, hit_list, bosses
from worlds.AutoWorld import World, WebWorld, CollectionState from worlds.AutoWorld import World, WebWorld, CollectionState
from worlds.generic.Rules import add_rule from worlds.generic.Rules import add_rule
from typing import List, Dict, TextIO from typing import List, Dict, TextIO
from worlds.LauncherComponents import Component, components, icon_paths, launch_subprocess, Type from worlds.LauncherComponents import Component, components, icon_paths, launch as launch_component, Type
from Utils import local_path from Utils import local_path
def launch_client(): def launch_client():
from .Client import launch from .Client import launch
launch_subprocess(launch, name="AHITClient") launch_component(launch, name="AHITClient")
components.append(Component("A Hat in Time Client", "AHITClient", func=launch_client, components.append(Component("A Hat in Time Client", "AHITClient", func=launch_client,
@@ -78,6 +78,9 @@ class HatInTimeWorld(World):
self.nyakuza_thug_items: Dict[str, int] = {} self.nyakuza_thug_items: Dict[str, int] = {}
self.badge_seller_count: int = 0 self.badge_seller_count: int = 0
def get_filler_item_name(self) -> str:
return self.random.choices(list(junk_weights.keys()), weights=junk_weights.values(), k=1)[0]
def generate_early(self): def generate_early(self):
adjust_options(self) adjust_options(self)

View File

@@ -102,7 +102,7 @@ def KholdstareDefeatRule(state, player: int) -> bool:
state.has('Fire Rod', player) or state.has('Fire Rod', player) or
( (
state.has('Bombos', player) and state.has('Bombos', player) and
(has_sword(state, player) or state.multiworld.swordless[player]) (has_sword(state, player) or state.multiworld.worlds[player].options.swordless)
) )
) and ) and
( (
@@ -111,7 +111,7 @@ def KholdstareDefeatRule(state, player: int) -> bool:
( (
state.has('Fire Rod', player) and state.has('Fire Rod', player) and
state.has('Bombos', player) and state.has('Bombos', player) and
state.multiworld.swordless[player] and state.multiworld.worlds[player].options.swordless and
can_extend_magic(state, player, 16) can_extend_magic(state, player, 16)
) )
) )
@@ -137,7 +137,7 @@ def AgahnimDefeatRule(state, player: int) -> bool:
def GanonDefeatRule(state, player: int) -> bool: def GanonDefeatRule(state, player: int) -> bool:
if state.multiworld.swordless[player]: if state.multiworld.worlds[player].options.swordless:
return state.has('Hammer', player) and \ return state.has('Hammer', player) and \
has_fire_source(state, player) and \ has_fire_source(state, player) and \
state.has('Silver Bow', player) and \ state.has('Silver Bow', player) and \
@@ -146,7 +146,7 @@ def GanonDefeatRule(state, player: int) -> bool:
can_hurt = has_beam_sword(state, player) can_hurt = has_beam_sword(state, player)
common = can_hurt and has_fire_source(state, player) common = can_hurt and has_fire_source(state, player)
# silverless ganon may be needed in anything higher than no glitches # silverless ganon may be needed in anything higher than no glitches
if state.multiworld.glitches_required[player] != 'no_glitches': if state.multiworld.worlds[player].options.glitches_required != 'no_glitches':
# need to light torch a sufficient amount of times # need to light torch a sufficient amount of times
return common and (state.has('Tempered Sword', player) or state.has('Golden Sword', player) or ( return common and (state.has('Tempered Sword', player) or state.has('Golden Sword', player) or (
state.has('Silver Bow', player) and can_shoot_arrows(state, player)) or state.has('Silver Bow', player) and can_shoot_arrows(state, player)) or
@@ -248,7 +248,7 @@ for location in boss_location_table:
def place_boss(world: "ALTTPWorld", boss: str, location: str, level: Optional[str]) -> None: def place_boss(world: "ALTTPWorld", boss: str, location: str, level: Optional[str]) -> None:
player = world.player player = world.player
if location == 'Ganons Tower' and world.multiworld.mode[player] == 'inverted': if location == 'Ganons Tower' and world.options.mode == 'inverted':
location = 'Inverted Ganons Tower' location = 'Inverted Ganons Tower'
logging.debug('Placing boss %s at %s', boss, location + (' (' + level + ')' if level else '')) logging.debug('Placing boss %s at %s', boss, location + (' (' + level + ')' if level else ''))
world.dungeons[location].bosses[level] = BossFactory(boss, player) world.dungeons[location].bosses[level] = BossFactory(boss, player)
@@ -260,9 +260,8 @@ def format_boss_location(location_name: str, level: str) -> str:
def place_bosses(world: "ALTTPWorld") -> None: def place_bosses(world: "ALTTPWorld") -> None:
multiworld = world.multiworld multiworld = world.multiworld
player = world.player
# will either be an int or a lower case string with ';' between options # will either be an int or a lower case string with ';' between options
boss_shuffle: Union[str, int] = multiworld.boss_shuffle[player].value boss_shuffle: Union[str, int] = world.options.boss_shuffle.value
already_placed_bosses: List[str] = [] already_placed_bosses: List[str] = []
remaining_locations: List[Tuple[str, str]] = [] remaining_locations: List[Tuple[str, str]] = []
# handle plando # handle plando

View File

@@ -66,7 +66,7 @@ def create_dungeons(world: "ALTTPWorld"):
def make_dungeon(name, default_boss, dungeon_regions, big_key, small_keys, dungeon_items): def make_dungeon(name, default_boss, dungeon_regions, big_key, small_keys, dungeon_items):
dungeon = Dungeon(name, dungeon_regions, big_key, dungeon = Dungeon(name, dungeon_regions, big_key,
[] if multiworld.small_key_shuffle[player] == small_key_shuffle.option_universal else small_keys, [] if multiworld.worlds[player].options.small_key_shuffle == small_key_shuffle.option_universal else small_keys,
dungeon_items, player) dungeon_items, player)
for item in dungeon.all_items: for item in dungeon.all_items:
item.dungeon = dungeon item.dungeon = dungeon
@@ -143,7 +143,7 @@ def create_dungeons(world: "ALTTPWorld"):
item_factory(['Small Key (Turtle Rock)'] * 6, world), item_factory(['Small Key (Turtle Rock)'] * 6, world),
item_factory(['Map (Turtle Rock)', 'Compass (Turtle Rock)'], world)) item_factory(['Map (Turtle Rock)', 'Compass (Turtle Rock)'], world))
if multiworld.mode[player] != 'inverted': if multiworld.worlds[player].options.mode != 'inverted':
AT = make_dungeon('Agahnims Tower', 'Agahnim', ['Agahnims Tower', 'Agahnim 1'], None, AT = make_dungeon('Agahnims Tower', 'Agahnim', ['Agahnims Tower', 'Agahnim 1'], None,
item_factory(['Small Key (Agahnims Tower)'] * 4, world), []) item_factory(['Small Key (Agahnims Tower)'] * 4, world), [])
GT = make_dungeon('Ganons Tower', 'Agahnim2', GT = make_dungeon('Ganons Tower', 'Agahnim2',

View File

@@ -23,17 +23,17 @@ def link_entrances(world, player):
connect_simple(world, exitname, regionname, player) connect_simple(world, exitname, regionname, player)
# if we do not shuffle, set default connections # if we do not shuffle, set default connections
if world.entrance_shuffle[player] == 'vanilla': if world.worlds[player].options.entrance_shuffle == 'vanilla':
for exitname, regionname in default_connections: for exitname, regionname in default_connections:
connect_simple(world, exitname, regionname, player) connect_simple(world, exitname, regionname, player)
for exitname, regionname in default_dungeon_connections: for exitname, regionname in default_dungeon_connections:
connect_simple(world, exitname, regionname, player) connect_simple(world, exitname, regionname, player)
elif world.entrance_shuffle[player] == 'dungeons_simple': elif world.worlds[player].options.entrance_shuffle == 'dungeons_simple':
for exitname, regionname in default_connections: for exitname, regionname in default_connections:
connect_simple(world, exitname, regionname, player) connect_simple(world, exitname, regionname, player)
simple_shuffle_dungeons(world, player) simple_shuffle_dungeons(world, player)
elif world.entrance_shuffle[player] == 'dungeons_full': elif world.worlds[player].options.entrance_shuffle == 'dungeons_full':
for exitname, regionname in default_connections: for exitname, regionname in default_connections:
connect_simple(world, exitname, regionname, player) connect_simple(world, exitname, regionname, player)
@@ -43,7 +43,7 @@ def link_entrances(world, player):
lw_entrances = list(LW_Dungeon_Entrances) lw_entrances = list(LW_Dungeon_Entrances)
dw_entrances = list(DW_Dungeon_Entrances) dw_entrances = list(DW_Dungeon_Entrances)
if world.mode[player] == 'standard': if world.worlds[player].options.mode == 'standard':
# must connect front of hyrule castle to do escape # must connect front of hyrule castle to do escape
connect_two_way(world, 'Hyrule Castle Entrance (South)', 'Hyrule Castle Exit (South)', player) connect_two_way(world, 'Hyrule Castle Entrance (South)', 'Hyrule Castle Exit (South)', player)
else: else:
@@ -56,7 +56,7 @@ def link_entrances(world, player):
dw_entrances.append('Ganons Tower') dw_entrances.append('Ganons Tower')
dungeon_exits.append('Ganons Tower Exit') dungeon_exits.append('Ganons Tower Exit')
if world.mode[player] == 'standard': if world.worlds[player].options.mode == 'standard':
# rest of hyrule castle must be in light world, so it has to be the one connected to east exit of desert # rest of hyrule castle must be in light world, so it has to be the one connected to east exit of desert
hyrule_castle_exits = [('Hyrule Castle Exit (West)', 'Hyrule Castle Exit (East)')] hyrule_castle_exits = [('Hyrule Castle Exit (West)', 'Hyrule Castle Exit (East)')]
connect_mandatory_exits(world, lw_entrances, hyrule_castle_exits, list(LW_Dungeon_Entrances_Must_Exit), player) connect_mandatory_exits(world, lw_entrances, hyrule_castle_exits, list(LW_Dungeon_Entrances_Must_Exit), player)
@@ -65,9 +65,9 @@ def link_entrances(world, player):
connect_mandatory_exits(world, lw_entrances, dungeon_exits, list(LW_Dungeon_Entrances_Must_Exit), player) connect_mandatory_exits(world, lw_entrances, dungeon_exits, list(LW_Dungeon_Entrances_Must_Exit), player)
connect_mandatory_exits(world, dw_entrances, dungeon_exits, list(DW_Dungeon_Entrances_Must_Exit), player) connect_mandatory_exits(world, dw_entrances, dungeon_exits, list(DW_Dungeon_Entrances_Must_Exit), player)
connect_caves(world, lw_entrances, dw_entrances, dungeon_exits, player) connect_caves(world, lw_entrances, dw_entrances, dungeon_exits, player)
elif world.entrance_shuffle[player] == 'dungeons_crossed': elif world.worlds[player].options.entrance_shuffle == 'dungeons_crossed':
crossed_shuffle_dungeons(world, player) crossed_shuffle_dungeons(world, player)
elif world.entrance_shuffle[player] == 'simple': elif world.worlds[player].options.entrance_shuffle == 'simple':
simple_shuffle_dungeons(world, player) simple_shuffle_dungeons(world, player)
old_man_entrances = list(Old_Man_Entrances) old_man_entrances = list(Old_Man_Entrances)
@@ -138,7 +138,7 @@ def link_entrances(world, player):
# place remaining doors # place remaining doors
connect_doors(world, single_doors, door_targets, player) connect_doors(world, single_doors, door_targets, player)
elif world.entrance_shuffle[player] == 'restricted': elif world.worlds[player].options.entrance_shuffle == 'restricted':
simple_shuffle_dungeons(world, player) simple_shuffle_dungeons(world, player)
lw_entrances = list(LW_Entrances + LW_Single_Cave_Doors + Old_Man_Entrances) lw_entrances = list(LW_Entrances + LW_Single_Cave_Doors + Old_Man_Entrances)
@@ -210,7 +210,7 @@ def link_entrances(world, player):
# place remaining doors # place remaining doors
connect_doors(world, doors, door_targets, player) connect_doors(world, doors, door_targets, player)
elif world.entrance_shuffle[player] == 'full': elif world.worlds[player].options.entrance_shuffle == 'full':
skull_woods_shuffle(world, player) skull_woods_shuffle(world, player)
lw_entrances = list(LW_Entrances + LW_Dungeon_Entrances + LW_Single_Cave_Doors + Old_Man_Entrances) lw_entrances = list(LW_Entrances + LW_Dungeon_Entrances + LW_Single_Cave_Doors + Old_Man_Entrances)
@@ -227,7 +227,7 @@ def link_entrances(world, player):
# tavern back door cannot be shuffled yet # tavern back door cannot be shuffled yet
connect_doors(world, ['Tavern North'], ['Tavern'], player) connect_doors(world, ['Tavern North'], ['Tavern'], player)
if world.mode[player] == 'standard': if world.worlds[player].options.mode == 'standard':
# must connect front of hyrule castle to do escape # must connect front of hyrule castle to do escape
connect_two_way(world, 'Hyrule Castle Entrance (South)', 'Hyrule Castle Exit (South)', player) connect_two_way(world, 'Hyrule Castle Entrance (South)', 'Hyrule Castle Exit (South)', player)
else: else:
@@ -264,7 +264,7 @@ def link_entrances(world, player):
pass pass
else: #if the cave wasn't placed we get here else: #if the cave wasn't placed we get here
connect_caves(world, lw_entrances, [], old_man_house, player) connect_caves(world, lw_entrances, [], old_man_house, player)
if world.mode[player] == 'standard': if world.worlds[player].options.mode == 'standard':
# rest of hyrule castle must be in light world # rest of hyrule castle must be in light world
connect_caves(world, lw_entrances, [], [('Hyrule Castle Exit (West)', 'Hyrule Castle Exit (East)')], player) connect_caves(world, lw_entrances, [], [('Hyrule Castle Exit (West)', 'Hyrule Castle Exit (East)')], player)
@@ -316,7 +316,7 @@ def link_entrances(world, player):
# place remaining doors # place remaining doors
connect_doors(world, doors, door_targets, player) connect_doors(world, doors, door_targets, player)
elif world.entrance_shuffle[player] == 'crossed': elif world.worlds[player].options.entrance_shuffle == 'crossed':
skull_woods_shuffle(world, player) skull_woods_shuffle(world, player)
entrances = list(LW_Entrances + LW_Dungeon_Entrances + LW_Single_Cave_Doors + Old_Man_Entrances + DW_Entrances + DW_Dungeon_Entrances + DW_Single_Cave_Doors) entrances = list(LW_Entrances + LW_Dungeon_Entrances + LW_Single_Cave_Doors + Old_Man_Entrances + DW_Entrances + DW_Dungeon_Entrances + DW_Single_Cave_Doors)
@@ -331,7 +331,7 @@ def link_entrances(world, player):
# tavern back door cannot be shuffled yet # tavern back door cannot be shuffled yet
connect_doors(world, ['Tavern North'], ['Tavern'], player) connect_doors(world, ['Tavern North'], ['Tavern'], player)
if world.mode[player] == 'standard': if world.worlds[player].options.mode == 'standard':
# must connect front of hyrule castle to do escape # must connect front of hyrule castle to do escape
connect_two_way(world, 'Hyrule Castle Entrance (South)', 'Hyrule Castle Exit (South)', player) connect_two_way(world, 'Hyrule Castle Entrance (South)', 'Hyrule Castle Exit (South)', player)
else: else:
@@ -348,7 +348,7 @@ def link_entrances(world, player):
#place must-exit caves #place must-exit caves
connect_mandatory_exits(world, entrances, caves, must_exits, player) connect_mandatory_exits(world, entrances, caves, must_exits, player)
if world.mode[player] == 'standard': if world.worlds[player].options.mode == 'standard':
# rest of hyrule castle must be dealt with # rest of hyrule castle must be dealt with
connect_caves(world, entrances, [], [('Hyrule Castle Exit (West)', 'Hyrule Castle Exit (East)')], player) connect_caves(world, entrances, [], [('Hyrule Castle Exit (West)', 'Hyrule Castle Exit (East)')], player)
@@ -394,7 +394,7 @@ def link_entrances(world, player):
# place remaining doors # place remaining doors
connect_doors(world, entrances, door_targets, player) connect_doors(world, entrances, door_targets, player)
elif world.entrance_shuffle[player] == 'insanity': elif world.worlds[player].options.entrance_shuffle == 'insanity':
# beware ye who enter here # beware ye who enter here
entrances = LW_Entrances + LW_Dungeon_Entrances + DW_Entrances + DW_Dungeon_Entrances + Old_Man_Entrances + ['Skull Woods Second Section Door (East)', 'Skull Woods First Section Door', 'Kakariko Well Cave', 'Bat Cave Cave', 'North Fairy Cave', 'Sanctuary', 'Lost Woods Hideout Stump', 'Lumberjack Tree Cave'] entrances = LW_Entrances + LW_Dungeon_Entrances + DW_Entrances + DW_Dungeon_Entrances + Old_Man_Entrances + ['Skull Woods Second Section Door (East)', 'Skull Woods First Section Door', 'Kakariko Well Cave', 'Bat Cave Cave', 'North Fairy Cave', 'Sanctuary', 'Lost Woods Hideout Stump', 'Lumberjack Tree Cave']
@@ -431,7 +431,7 @@ def link_entrances(world, player):
# tavern back door cannot be shuffled yet # tavern back door cannot be shuffled yet
connect_doors(world, ['Tavern North'], ['Tavern'], player) connect_doors(world, ['Tavern North'], ['Tavern'], player)
if world.mode[player] == 'standard': if world.worlds[player].options.mode == 'standard':
# cannot move uncle cave # cannot move uncle cave
connect_entrance(world, 'Hyrule Castle Secret Entrance Drop', 'Hyrule Castle Secret Entrance', player) connect_entrance(world, 'Hyrule Castle Secret Entrance Drop', 'Hyrule Castle Secret Entrance', player)
connect_exit(world, 'Hyrule Castle Secret Entrance Exit', 'Hyrule Castle Secret Entrance Stairs', player) connect_exit(world, 'Hyrule Castle Secret Entrance Exit', 'Hyrule Castle Secret Entrance Stairs', player)
@@ -464,7 +464,7 @@ def link_entrances(world, player):
connect_entrance(world, hole, hole_targets.pop(), player) connect_entrance(world, hole, hole_targets.pop(), player)
# hyrule castle handling # hyrule castle handling
if world.mode[player] == 'standard': if world.worlds[player].options.mode == 'standard':
# must connect front of hyrule castle to do escape # must connect front of hyrule castle to do escape
connect_entrance(world, 'Hyrule Castle Entrance (South)', 'Hyrule Castle Exit (South)', player) connect_entrance(world, 'Hyrule Castle Entrance (South)', 'Hyrule Castle Exit (South)', player)
connect_exit(world, 'Hyrule Castle Exit (South)', 'Hyrule Castle Entrance (South)', player) connect_exit(world, 'Hyrule Castle Exit (South)', 'Hyrule Castle Entrance (South)', player)
@@ -544,12 +544,12 @@ def link_entrances(world, player):
else: else:
raise NotImplementedError( raise NotImplementedError(
f'{world.entrance_shuffle[player]} Shuffling not supported yet. Player {world.get_player_name(player)}') f'{world.worlds[player].options.entrance_shuffle} Shuffling not supported yet. Player {world.get_player_name(player)}')
if world.glitches_required[player] in ['overworld_glitches', 'hybrid_major_glitches', 'no_logic']: if world.worlds[player].options.glitches_required in ['overworld_glitches', 'hybrid_major_glitches', 'no_logic']:
overworld_glitch_connections(world, player) overworld_glitch_connections(world, player)
# mandatory hybrid major glitches connections # mandatory hybrid major glitches connections
if world.glitches_required[player] in ['hybrid_major_glitches', 'no_logic']: if world.worlds[player].options.glitches_required in ['hybrid_major_glitches', 'no_logic']:
underworld_glitch_connections(world, player) underworld_glitch_connections(world, player)
# check for swamp palace fix # check for swamp palace fix
@@ -584,17 +584,17 @@ def link_inverted_entrances(world, player):
connect_simple(world, exitname, regionname, player) connect_simple(world, exitname, regionname, player)
# if we do not shuffle, set default connections # if we do not shuffle, set default connections
if world.entrance_shuffle[player] == 'vanilla': if world.worlds[player].options.entrance_shuffle == 'vanilla':
for exitname, regionname in inverted_default_connections: for exitname, regionname in inverted_default_connections:
connect_simple(world, exitname, regionname, player) connect_simple(world, exitname, regionname, player)
for exitname, regionname in inverted_default_dungeon_connections: for exitname, regionname in inverted_default_dungeon_connections:
connect_simple(world, exitname, regionname, player) connect_simple(world, exitname, regionname, player)
elif world.entrance_shuffle[player] == 'dungeons_simple': elif world.worlds[player].options.entrance_shuffle == 'dungeons_simple':
for exitname, regionname in inverted_default_connections: for exitname, regionname in inverted_default_connections:
connect_simple(world, exitname, regionname, player) connect_simple(world, exitname, regionname, player)
simple_shuffle_dungeons(world, player) simple_shuffle_dungeons(world, player)
elif world.entrance_shuffle[player] == 'dungeons_full': elif world.worlds[player].options.entrance_shuffle == 'dungeons_full':
for exitname, regionname in inverted_default_connections: for exitname, regionname in inverted_default_connections:
connect_simple(world, exitname, regionname, player) connect_simple(world, exitname, regionname, player)
@@ -649,9 +649,9 @@ def link_inverted_entrances(world, player):
connect_mandatory_exits(world, lw_entrances, dungeon_exits, lw_dungeon_entrances_must_exit, player) connect_mandatory_exits(world, lw_entrances, dungeon_exits, lw_dungeon_entrances_must_exit, player)
connect_caves(world, lw_entrances, dw_entrances, dungeon_exits, player) connect_caves(world, lw_entrances, dw_entrances, dungeon_exits, player)
elif world.entrance_shuffle[player] == 'dungeons_crossed': elif world.worlds[player].options.entrance_shuffle == 'dungeons_crossed':
inverted_crossed_shuffle_dungeons(world, player) inverted_crossed_shuffle_dungeons(world, player)
elif world.entrance_shuffle[player] == 'simple': elif world.worlds[player].options.entrance_shuffle == 'simple':
simple_shuffle_dungeons(world, player) simple_shuffle_dungeons(world, player)
old_man_entrances = list(Inverted_Old_Man_Entrances) old_man_entrances = list(Inverted_Old_Man_Entrances)
@@ -748,7 +748,7 @@ def link_inverted_entrances(world, player):
# place remaining doors # place remaining doors
connect_doors(world, single_doors, door_targets, player) connect_doors(world, single_doors, door_targets, player)
elif world.entrance_shuffle[player] == 'restricted': elif world.worlds[player].options.entrance_shuffle == 'restricted':
simple_shuffle_dungeons(world, player) simple_shuffle_dungeons(world, player)
lw_entrances = list(Inverted_LW_Entrances + Inverted_LW_Single_Cave_Doors) lw_entrances = list(Inverted_LW_Entrances + Inverted_LW_Single_Cave_Doors)
@@ -833,7 +833,7 @@ def link_inverted_entrances(world, player):
doors = lw_entrances + dw_entrances doors = lw_entrances + dw_entrances
# place remaining doors # place remaining doors
connect_doors(world, doors, door_targets, player) connect_doors(world, doors, door_targets, player)
elif world.entrance_shuffle[player] == 'full': elif world.worlds[player].options.entrance_shuffle == 'full':
skull_woods_shuffle(world, player) skull_woods_shuffle(world, player)
lw_entrances = list(Inverted_LW_Entrances + Inverted_LW_Dungeon_Entrances + Inverted_LW_Single_Cave_Doors) lw_entrances = list(Inverted_LW_Entrances + Inverted_LW_Dungeon_Entrances + Inverted_LW_Single_Cave_Doors)
@@ -984,7 +984,7 @@ def link_inverted_entrances(world, player):
# place remaining doors # place remaining doors
connect_doors(world, doors, door_targets, player) connect_doors(world, doors, door_targets, player)
elif world.entrance_shuffle[player] == 'crossed': elif world.worlds[player].options.entrance_shuffle == 'crossed':
skull_woods_shuffle(world, player) skull_woods_shuffle(world, player)
entrances = list(Inverted_LW_Entrances + Inverted_LW_Dungeon_Entrances + Inverted_LW_Single_Cave_Doors + Inverted_Old_Man_Entrances + Inverted_DW_Entrances + Inverted_DW_Dungeon_Entrances + Inverted_DW_Single_Cave_Doors) entrances = list(Inverted_LW_Entrances + Inverted_LW_Dungeon_Entrances + Inverted_LW_Single_Cave_Doors + Inverted_Old_Man_Entrances + Inverted_DW_Entrances + Inverted_DW_Dungeon_Entrances + Inverted_DW_Single_Cave_Doors)
@@ -1095,7 +1095,7 @@ def link_inverted_entrances(world, player):
# place remaining doors # place remaining doors
connect_doors(world, entrances, door_targets, player) connect_doors(world, entrances, door_targets, player)
elif world.entrance_shuffle[player] == 'insanity': elif world.worlds[player].options.entrance_shuffle == 'insanity':
# beware ye who enter here # beware ye who enter here
entrances = Inverted_LW_Entrances + Inverted_LW_Dungeon_Entrances + Inverted_DW_Entrances + Inverted_DW_Dungeon_Entrances + Inverted_Old_Man_Entrances + Old_Man_Entrances + ['Skull Woods Second Section Door (East)', 'Skull Woods Second Section Door (West)', 'Skull Woods First Section Door', 'Kakariko Well Cave', 'Bat Cave Cave', 'North Fairy Cave', 'Sanctuary', 'Lost Woods Hideout Stump', 'Lumberjack Tree Cave', 'Hyrule Castle Entrance (South)'] entrances = Inverted_LW_Entrances + Inverted_LW_Dungeon_Entrances + Inverted_DW_Entrances + Inverted_DW_Dungeon_Entrances + Inverted_Old_Man_Entrances + Old_Man_Entrances + ['Skull Woods Second Section Door (East)', 'Skull Woods Second Section Door (West)', 'Skull Woods First Section Door', 'Kakariko Well Cave', 'Bat Cave Cave', 'North Fairy Cave', 'Sanctuary', 'Lost Woods Hideout Stump', 'Lumberjack Tree Cave', 'Hyrule Castle Entrance (South)']
@@ -1254,10 +1254,10 @@ def link_inverted_entrances(world, player):
else: else:
raise NotImplementedError('Shuffling not supported yet') raise NotImplementedError('Shuffling not supported yet')
if world.glitches_required[player] in ['overworld_glitches', 'hybrid_major_glitches', 'no_logic']: if world.worlds[player].options.glitches_required in ['overworld_glitches', 'hybrid_major_glitches', 'no_logic']:
overworld_glitch_connections(world, player) overworld_glitch_connections(world, player)
# mandatory hybrid major glitches connections # mandatory hybrid major glitches connections
if world.glitches_required[player] in ['hybrid_major_glitches', 'no_logic']: if world.worlds[player].options.glitches_required in ['hybrid_major_glitches', 'no_logic']:
underworld_glitch_connections(world, player) underworld_glitch_connections(world, player)
# patch swamp drain # patch swamp drain
@@ -1349,7 +1349,7 @@ def scramble_holes(world, player):
else: else:
hole_targets.append(('Pyramid Exit', 'Pyramid')) hole_targets.append(('Pyramid Exit', 'Pyramid'))
if world.mode[player] == 'standard': if world.worlds[player].options.mode == 'standard':
# cannot move uncle cave # cannot move uncle cave
connect_two_way(world, 'Hyrule Castle Secret Entrance Stairs', 'Hyrule Castle Secret Entrance Exit', player) connect_two_way(world, 'Hyrule Castle Secret Entrance Stairs', 'Hyrule Castle Secret Entrance Exit', player)
connect_entrance(world, 'Hyrule Castle Secret Entrance Drop', 'Hyrule Castle Secret Entrance', player) connect_entrance(world, 'Hyrule Castle Secret Entrance Drop', 'Hyrule Castle Secret Entrance', player)
@@ -1358,14 +1358,14 @@ def scramble_holes(world, player):
hole_targets.append(('Hyrule Castle Secret Entrance Exit', 'Hyrule Castle Secret Entrance')) hole_targets.append(('Hyrule Castle Secret Entrance Exit', 'Hyrule Castle Secret Entrance'))
# do not shuffle sanctuary into pyramid hole unless shuffle is crossed # do not shuffle sanctuary into pyramid hole unless shuffle is crossed
if world.entrance_shuffle[player] == 'crossed': if world.worlds[player].options.entrance_shuffle == 'crossed':
hole_targets.append(('Sanctuary Exit', 'Sewer Drop')) hole_targets.append(('Sanctuary Exit', 'Sewer Drop'))
if world.shuffle_ganon: if world.shuffle_ganon:
world.random.shuffle(hole_targets) world.random.shuffle(hole_targets)
exit, target = hole_targets.pop() exit, target = hole_targets.pop()
connect_two_way(world, 'Pyramid Entrance', exit, player) connect_two_way(world, 'Pyramid Entrance', exit, player)
connect_entrance(world, 'Pyramid Hole', target, player) connect_entrance(world, 'Pyramid Hole', target, player)
if world.entrance_shuffle[player] != 'crossed': if world.worlds[player].options.entrance_shuffle != 'crossed':
hole_targets.append(('Sanctuary Exit', 'Sewer Drop')) hole_targets.append(('Sanctuary Exit', 'Sewer Drop'))
world.random.shuffle(hole_targets) world.random.shuffle(hole_targets)
@@ -1400,14 +1400,14 @@ def scramble_inverted_holes(world, player):
hole_targets.append(('Hyrule Castle Secret Entrance Exit', 'Hyrule Castle Secret Entrance')) hole_targets.append(('Hyrule Castle Secret Entrance Exit', 'Hyrule Castle Secret Entrance'))
# do not shuffle sanctuary into pyramid hole unless shuffle is crossed # do not shuffle sanctuary into pyramid hole unless shuffle is crossed
if world.entrance_shuffle[player] == 'crossed': if world.worlds[player].options.entrance_shuffle == 'crossed':
hole_targets.append(('Sanctuary Exit', 'Sewer Drop')) hole_targets.append(('Sanctuary Exit', 'Sewer Drop'))
if world.shuffle_ganon: if world.shuffle_ganon:
world.random.shuffle(hole_targets) world.random.shuffle(hole_targets)
exit, target = hole_targets.pop() exit, target = hole_targets.pop()
connect_two_way(world, 'Inverted Pyramid Entrance', exit, player) connect_two_way(world, 'Inverted Pyramid Entrance', exit, player)
connect_entrance(world, 'Inverted Pyramid Hole', target, player) connect_entrance(world, 'Inverted Pyramid Hole', target, player)
if world.entrance_shuffle[player] != 'crossed': if world.worlds[player].options.entrance_shuffle != 'crossed':
hole_targets.append(('Sanctuary Exit', 'Sewer Drop')) hole_targets.append(('Sanctuary Exit', 'Sewer Drop'))
world.random.shuffle(hole_targets) world.random.shuffle(hole_targets)
@@ -1430,15 +1430,15 @@ def connect_random(world, exitlist, targetlist, player, two_way=False):
def connect_mandatory_exits(world, entrances, caves, must_be_exits, player): def connect_mandatory_exits(world, entrances, caves, must_be_exits, player):
# Keeps track of entrances that cannot be used to access each exit / cave # Keeps track of entrances that cannot be used to access each exit / cave
if world.mode[player] == 'inverted': if world.worlds[player].options.mode == 'inverted':
invalid_connections = Inverted_Must_Exit_Invalid_Connections.copy() invalid_connections = Inverted_Must_Exit_Invalid_Connections.copy()
else: else:
invalid_connections = Must_Exit_Invalid_Connections.copy() invalid_connections = Must_Exit_Invalid_Connections.copy()
invalid_cave_connections = defaultdict(set) invalid_cave_connections = defaultdict(set)
if world.glitches_required[player] in ['overworld_glitches', 'hybrid_major_glitches', 'no_logic']: if world.worlds[player].options.glitches_required in ['overworld_glitches', 'hybrid_major_glitches', 'no_logic']:
from . import OverworldGlitchRules from . import OverworldGlitchRules
for entrance in OverworldGlitchRules.get_non_mandatory_exits(world.mode[player] == 'inverted'): for entrance in OverworldGlitchRules.get_non_mandatory_exits(world.worlds[player].options.mode == 'inverted'):
invalid_connections[entrance] = set() invalid_connections[entrance] = set()
if entrance in must_be_exits: if entrance in must_be_exits:
must_be_exits.remove(entrance) must_be_exits.remove(entrance)
@@ -1449,7 +1449,7 @@ def connect_mandatory_exits(world, entrances, caves, must_be_exits, player):
world.random.shuffle(caves) world.random.shuffle(caves)
# Handle inverted Aga Tower - if it depends on connections, then so does Hyrule Castle Ledge # Handle inverted Aga Tower - if it depends on connections, then so does Hyrule Castle Ledge
if world.mode[player] == 'inverted': if world.worlds[player].options.mode == 'inverted':
for entrance in invalid_connections: for entrance in invalid_connections:
if world.get_entrance(entrance, player).connected_region == world.get_region('Inverted Agahnims Tower', if world.get_entrance(entrance, player).connected_region == world.get_region('Inverted Agahnims Tower',
player): player):
@@ -1490,7 +1490,7 @@ def connect_mandatory_exits(world, entrances, caves, must_be_exits, player):
entrance = next(e for e in entrances[::-1] if e not in invalid_connections[exit]) entrance = next(e for e in entrances[::-1] if e not in invalid_connections[exit])
cave_entrances.append(entrance) cave_entrances.append(entrance)
entrances.remove(entrance) entrances.remove(entrance)
connect_two_way(world,entrance,cave_exit, player) connect_two_way(world, entrance, cave_exit, player)
if entrance not in invalid_connections: if entrance not in invalid_connections:
invalid_connections[exit] = set() invalid_connections[exit] = set()
if all(entrance in invalid_connections for entrance in cave_entrances): if all(entrance in invalid_connections for entrance in cave_entrances):
@@ -1564,7 +1564,7 @@ def simple_shuffle_dungeons(world, player):
dungeon_entrances = ['Eastern Palace', 'Tower of Hera', 'Thieves Town', 'Skull Woods Final Section', 'Palace of Darkness', 'Ice Palace', 'Misery Mire', 'Swamp Palace'] dungeon_entrances = ['Eastern Palace', 'Tower of Hera', 'Thieves Town', 'Skull Woods Final Section', 'Palace of Darkness', 'Ice Palace', 'Misery Mire', 'Swamp Palace']
dungeon_exits = ['Eastern Palace Exit', 'Tower of Hera Exit', 'Thieves Town Exit', 'Skull Woods Final Section Exit', 'Palace of Darkness Exit', 'Ice Palace Exit', 'Misery Mire Exit', 'Swamp Palace Exit'] dungeon_exits = ['Eastern Palace Exit', 'Tower of Hera Exit', 'Thieves Town Exit', 'Skull Woods Final Section Exit', 'Palace of Darkness Exit', 'Ice Palace Exit', 'Misery Mire Exit', 'Swamp Palace Exit']
if world.mode[player] != 'inverted': if world.worlds[player].options.mode != 'inverted':
if not world.shuffle_ganon: if not world.shuffle_ganon:
connect_two_way(world, 'Ganons Tower', 'Ganons Tower Exit', player) connect_two_way(world, 'Ganons Tower', 'Ganons Tower Exit', player)
else: else:
@@ -1579,13 +1579,13 @@ def simple_shuffle_dungeons(world, player):
# mix up 4 door dungeons # mix up 4 door dungeons
multi_dungeons = ['Desert', 'Turtle Rock'] multi_dungeons = ['Desert', 'Turtle Rock']
if world.mode[player] == 'open' or (world.mode[player] == 'inverted' and world.shuffle_ganon): if world.worlds[player].options.mode == 'open' or (world.worlds[player].options.mode == 'inverted' and world.shuffle_ganon):
multi_dungeons.append('Hyrule Castle') multi_dungeons.append('Hyrule Castle')
world.random.shuffle(multi_dungeons) world.random.shuffle(multi_dungeons)
dp_target = multi_dungeons[0] dp_target = multi_dungeons[0]
tr_target = multi_dungeons[1] tr_target = multi_dungeons[1]
if world.mode[player] not in ['open', 'inverted'] or (world.mode[player] == 'inverted' and world.shuffle_ganon is False): if world.worlds[player].options.mode not in ['open', 'inverted'] or (world.worlds[player].options.mode == 'inverted' and world.shuffle_ganon is False):
# place hyrule castle as intended # place hyrule castle as intended
hc_target = 'Hyrule Castle' hc_target = 'Hyrule Castle'
else: else:
@@ -1593,7 +1593,7 @@ def simple_shuffle_dungeons(world, player):
# ToDo improve this? # ToDo improve this?
if world.mode[player] != 'inverted': if world.worlds[player].options.mode != 'inverted':
if hc_target == 'Hyrule Castle': if hc_target == 'Hyrule Castle':
connect_two_way(world, 'Hyrule Castle Entrance (South)', 'Hyrule Castle Exit (South)', player) connect_two_way(world, 'Hyrule Castle Entrance (South)', 'Hyrule Castle Exit (South)', player)
connect_two_way(world, 'Hyrule Castle Entrance (East)', 'Hyrule Castle Exit (East)', player) connect_two_way(world, 'Hyrule Castle Entrance (East)', 'Hyrule Castle Exit (East)', player)
@@ -1708,7 +1708,7 @@ def crossed_shuffle_dungeons(world, player: int):
dungeon_entrances.append('Ganons Tower') dungeon_entrances.append('Ganons Tower')
dungeon_exits.append('Ganons Tower Exit') dungeon_exits.append('Ganons Tower Exit')
if world.mode[player] == 'standard': if world.worlds[player].options.mode == 'standard':
# must connect front of hyrule castle to do escape # must connect front of hyrule castle to do escape
connect_two_way(world, 'Hyrule Castle Entrance (South)', 'Hyrule Castle Exit (South)', player) connect_two_way(world, 'Hyrule Castle Entrance (South)', 'Hyrule Castle Exit (South)', player)
else: else:
@@ -1718,7 +1718,7 @@ def crossed_shuffle_dungeons(world, player: int):
connect_mandatory_exits(world, dungeon_entrances, dungeon_exits, connect_mandatory_exits(world, dungeon_entrances, dungeon_exits,
LW_Dungeon_Entrances_Must_Exit + DW_Dungeon_Entrances_Must_Exit, player) LW_Dungeon_Entrances_Must_Exit + DW_Dungeon_Entrances_Must_Exit, player)
if world.mode[player] == 'standard': if world.worlds[player].options.mode == 'standard':
connect_caves(world, dungeon_entrances, [], [('Hyrule Castle Exit (West)', 'Hyrule Castle Exit (East)')], player) connect_caves(world, dungeon_entrances, [], [('Hyrule Castle Exit (West)', 'Hyrule Castle Exit (East)')], player)
connect_caves(world, dungeon_entrances, [], dungeon_exits, player) connect_caves(world, dungeon_entrances, [], dungeon_exits, player)
@@ -1823,14 +1823,14 @@ lookup = {
def plando_connect(world, player: int): def plando_connect(world, player: int):
if world.plando_connections[player]: if world.worlds[player].options.plando_connections:
for connection in world.plando_connections[player]: for connection in world.worlds[player].options.plando_connections:
func = lookup[connection.direction] func = lookup[connection.direction]
try: try:
func(world, connection.entrance, connection.exit, player) func(world, connection.entrance, connection.exit, player)
except Exception as e: except Exception as e:
raise Exception(f"Could not connect using {connection}") from e raise Exception(f"Could not connect using {connection}") from e
if world.mode[player] != 'inverted': if world.worlds[player].options.mode != 'inverted':
mark_light_world_regions(world, player) mark_light_world_regions(world, player)
else: else:
mark_dark_world_regions(world, player) mark_dark_world_regions(world, player)

View File

@@ -226,25 +226,25 @@ def generate_itempool(world):
player = world.player player = world.player
multiworld = world.multiworld multiworld = world.multiworld
if multiworld.item_pool[player].current_key not in difficulties: if world.options.item_pool.current_key not in difficulties:
raise NotImplementedError(f"Diffulty {multiworld.item_pool[player]}") raise NotImplementedError(f"Diffulty {world.options.item_pool}")
if multiworld.goal[player] not in ('ganon', 'pedestal', 'bosses', 'triforce_hunt', 'local_triforce_hunt', if world.options.goal not in ('ganon', 'pedestal', 'bosses', 'triforce_hunt', 'local_triforce_hunt',
'ganon_triforce_hunt', 'local_ganon_triforce_hunt', 'crystals', 'ganon_triforce_hunt', 'local_ganon_triforce_hunt', 'crystals',
'ganon_pedestal'): 'ganon_pedestal'):
raise NotImplementedError(f"Goal {multiworld.goal[player]} for player {player}") raise NotImplementedError(f"Goal {world.options.goal} for player {player}")
if multiworld.mode[player] not in ('open', 'standard', 'inverted'): if world.options.mode not in ('open', 'standard', 'inverted'):
raise NotImplementedError(f"Mode {multiworld.mode[player]} for player {player}") raise NotImplementedError(f"Mode {world.options.mode} for player {player}")
if multiworld.timer[player] not in (False, 'display', 'timed', 'timed_ohko', 'ohko', 'timed_countdown'): if world.options.timer not in (False, 'display', 'timed', 'timed_ohko', 'ohko', 'timed_countdown'):
raise NotImplementedError(f"Timer {multiworld.timer[player]} for player {player}") raise NotImplementedError(f"Timer {world.options.timer} for player {player}")
if multiworld.timer[player] in ['ohko', 'timed_ohko']: if world.options.timer in ['ohko', 'timed_ohko']:
world.can_take_damage = False world.can_take_damage = False
if multiworld.goal[player] in ['pedestal', 'triforce_hunt', 'local_triforce_hunt']: if world.options.goal in ['pedestal', 'triforce_hunt', 'local_triforce_hunt']:
multiworld.push_item(multiworld.get_location('Ganon', player), item_factory('Nothing', world), False) multiworld.push_item(multiworld.get_location('Ganon', player), item_factory('Nothing', world), False)
else: else:
multiworld.push_item(multiworld.get_location('Ganon', player), item_factory('Triforce', world), False) multiworld.push_item(multiworld.get_location('Ganon', player), item_factory('Triforce', world), False)
if multiworld.goal[player] in ['triforce_hunt', 'local_triforce_hunt']: if world.options.goal in ['triforce_hunt', 'local_triforce_hunt']:
region = multiworld.get_region('Light World', player) region = multiworld.get_region('Light World', player)
loc = ALttPLocation(player, "Murahdahla", parent=region) loc = ALttPLocation(player, "Murahdahla", parent=region)
@@ -288,7 +288,7 @@ def generate_itempool(world):
for item in precollected_items: for item in precollected_items:
multiworld.push_precollected(item_factory(item, world)) multiworld.push_precollected(item_factory(item, world))
if multiworld.mode[player] == 'standard' and not has_melee_weapon(multiworld.state, player): if world.options.mode == 'standard' and not has_melee_weapon(multiworld.state, player):
if "Link's Uncle" not in placed_items: if "Link's Uncle" not in placed_items:
found_sword = False found_sword = False
found_bow = False found_bow = False
@@ -304,10 +304,10 @@ def generate_itempool(world):
elif item in ['Hammer', 'Fire Rod', 'Cane of Somaria', 'Cane of Byrna']: elif item in ['Hammer', 'Fire Rod', 'Cane of Somaria', 'Cane of Byrna']:
if item not in possible_weapons: if item not in possible_weapons:
possible_weapons.append(item) possible_weapons.append(item)
elif (item == 'Bombs (10)' and (not multiworld.bombless_start[player]) and item not in elif (item == 'Bombs (10)' and (not world.options.bombless_start) and item not in
possible_weapons): possible_weapons):
possible_weapons.append(item) possible_weapons.append(item)
elif (item in ['Bomb Upgrade (+10)', 'Bomb Upgrade (50)'] and multiworld.bombless_start[player] and item elif (item in ['Bomb Upgrade (+10)', 'Bomb Upgrade (50)'] and world.options.bombless_start and item
not in possible_weapons): not in possible_weapons):
possible_weapons.append(item) possible_weapons.append(item)
@@ -315,21 +315,21 @@ def generate_itempool(world):
placed_items["Link's Uncle"] = starting_weapon placed_items["Link's Uncle"] = starting_weapon
pool.remove(starting_weapon) pool.remove(starting_weapon)
if (placed_items["Link's Uncle"] in ['Bow', 'Progressive Bow', 'Bombs (10)', 'Bomb Upgrade (+10)', if (placed_items["Link's Uncle"] in ['Bow', 'Progressive Bow', 'Bombs (10)', 'Bomb Upgrade (+10)',
'Bomb Upgrade (50)', 'Cane of Somaria', 'Cane of Byrna'] and multiworld.enemy_health[player] not in ['default', 'easy']): 'Bomb Upgrade (50)', 'Cane of Somaria', 'Cane of Byrna'] and world.options.enemy_health not in ['default', 'easy']):
if multiworld.bombless_start[player] and "Bomb Upgrade" not in placed_items["Link's Uncle"]: if world.options.bombless_start and "Bomb Upgrade" not in placed_items["Link's Uncle"]:
if 'Bow' in placed_items["Link's Uncle"]: if 'Bow' in placed_items["Link's Uncle"]:
multiworld.worlds[player].escape_assist.append('arrows') world.escape_assist.append('arrows')
elif 'Cane' in placed_items["Link's Uncle"]: elif 'Cane' in placed_items["Link's Uncle"]:
multiworld.worlds[player].escape_assist.append('magic') world.escape_assist.append('magic')
else: else:
multiworld.worlds[player].escape_assist.append('bombs') world.escape_assist.append('bombs')
for (location, item) in placed_items.items(): for (location, item) in placed_items.items():
multiworld.get_location(location, player).place_locked_item(item_factory(item, world)) multiworld.get_location(location, player).place_locked_item(item_factory(item, world))
items = item_factory(pool, world) items = item_factory(pool, world)
# convert one Progressive Bow into Progressive Bow (Alt), in ID only, for ganon silvers hint text # convert one Progressive Bow into Progressive Bow (Alt), in ID only, for ganon silvers hint text
if multiworld.worlds[player].has_progressive_bows: if world.has_progressive_bows:
for item in items: for item in items:
if item.code == 0x64: # Progressive Bow if item.code == 0x64: # Progressive Bow
item.code = 0x65 # Progressive Bow (Alt) item.code = 0x65 # Progressive Bow (Alt)
@@ -338,21 +338,21 @@ def generate_itempool(world):
if clock_mode: if clock_mode:
world.clock_mode = clock_mode world.clock_mode = clock_mode
multiworld.worlds[player].treasure_hunt_required = treasure_hunt_required % 999 world.treasure_hunt_required = treasure_hunt_required % 999
multiworld.worlds[player].treasure_hunt_total = treasure_hunt_total world.treasure_hunt_total = treasure_hunt_total
dungeon_items = [item for item in get_dungeon_item_pool_player(world) dungeon_items = [item for item in get_dungeon_item_pool_player(world)
if item.name not in multiworld.worlds[player].dungeon_local_item_names] if item.name not in world.dungeon_local_item_names]
for key_loc in key_drop_data: for key_loc in key_drop_data:
key_data = key_drop_data[key_loc] key_data = key_drop_data[key_loc]
drop_item = item_factory(key_data[3], world) drop_item = item_factory(key_data[3], world)
if not multiworld.key_drop_shuffle[player]: if not world.options.key_drop_shuffle:
if drop_item in dungeon_items: if drop_item in dungeon_items:
dungeon_items.remove(drop_item) dungeon_items.remove(drop_item)
else: else:
dungeon = drop_item.name.split("(")[1].split(")")[0] dungeon = drop_item.name.split("(")[1].split(")")[0]
if multiworld.mode[player] == 'inverted': if world.options.mode == 'inverted':
if dungeon == "Agahnims Tower": if dungeon == "Agahnims Tower":
dungeon = "Inverted Agahnims Tower" dungeon = "Inverted Agahnims Tower"
if dungeon == "Ganons Tower": if dungeon == "Ganons Tower":
@@ -365,7 +365,7 @@ def generate_itempool(world):
loc = multiworld.get_location(key_loc, player) loc = multiworld.get_location(key_loc, player)
loc.place_locked_item(drop_item) loc.place_locked_item(drop_item)
loc.address = None loc.address = None
elif "Small" in key_data[3] and multiworld.small_key_shuffle[player] == small_key_shuffle.option_universal: elif "Small" in key_data[3] and world.options.small_key_shuffle == small_key_shuffle.option_universal:
# key drop shuffle and universal keys are on. Add universal keys in place of key drop keys. # key drop shuffle and universal keys are on. Add universal keys in place of key drop keys.
multiworld.itempool.append(item_factory(GetBeemizerItem(multiworld, player, 'Small Key (Universal)'), world)) multiworld.itempool.append(item_factory(GetBeemizerItem(multiworld, player, 'Small Key (Universal)'), world))
dungeon_item_replacements = sum(difficulties[world.options.item_pool.current_key].extras, []) * 2 dungeon_item_replacements = sum(difficulties[world.options.item_pool.current_key].extras, []) * 2
@@ -373,10 +373,10 @@ def generate_itempool(world):
for x in range(len(dungeon_items)-1, -1, -1): for x in range(len(dungeon_items)-1, -1, -1):
item = dungeon_items[x] item = dungeon_items[x]
if ((multiworld.small_key_shuffle[player] == small_key_shuffle.option_start_with and item.type == 'SmallKey') if ((world.options.small_key_shuffle == small_key_shuffle.option_start_with and item.type == 'SmallKey')
or (multiworld.big_key_shuffle[player] == big_key_shuffle.option_start_with and item.type == 'BigKey') or (world.options.big_key_shuffle == big_key_shuffle.option_start_with and item.type == 'BigKey')
or (multiworld.compass_shuffle[player] == compass_shuffle.option_start_with and item.type == 'Compass') or (world.options.compass_shuffle == compass_shuffle.option_start_with and item.type == 'Compass')
or (multiworld.map_shuffle[player] == map_shuffle.option_start_with and item.type == 'Map')): or (world.options.map_shuffle == map_shuffle.option_start_with and item.type == 'Map')):
dungeon_items.pop(x) dungeon_items.pop(x)
multiworld.push_precollected(item) multiworld.push_precollected(item)
multiworld.itempool.append(item_factory(dungeon_item_replacements.pop(), world)) multiworld.itempool.append(item_factory(dungeon_item_replacements.pop(), world))
@@ -384,7 +384,7 @@ def generate_itempool(world):
set_up_shops(multiworld, player) set_up_shops(multiworld, player)
if multiworld.retro_bow[player]: if world.options.retro_bow:
shop_items = 0 shop_items = 0
shop_locations = [location for shop_locations in (shop.region.locations for shop in multiworld.shops if shop_locations = [location for shop_locations in (shop.region.locations for shop in multiworld.shops if
shop.type == ShopType.Shop and shop.region.player == player) for location in shop_locations if shop.type == ShopType.Shop and shop.region.player == player) for location in shop_locations if
@@ -395,12 +395,12 @@ def generate_itempool(world):
else: else:
shop_items += 1 shop_items += 1
else: else:
shop_items = min(multiworld.shop_item_slots[player], 30 if multiworld.include_witch_hut[player] else 27) shop_items = min(world.options.shop_item_slots, 30 if world.options.include_witch_hut else 27)
if multiworld.shuffle_capacity_upgrades[player]: if world.options.shuffle_capacity_upgrades:
shop_items += 2 shop_items += 2
chance_100 = int(multiworld.retro_bow[player]) * 0.25 + int( chance_100 = int(world.options.retro_bow) * 0.25 + int(
multiworld.small_key_shuffle[player] == small_key_shuffle.option_universal) * 0.5 world.options.small_key_shuffle == small_key_shuffle.option_universal) * 0.5
for _ in range(shop_items): for _ in range(shop_items):
if multiworld.random.random() < chance_100: if multiworld.random.random() < chance_100:
items.append(item_factory(GetBeemizerItem(multiworld, player, "Rupees (100)"), world)) items.append(item_factory(GetBeemizerItem(multiworld, player, "Rupees (100)"), world))
@@ -410,19 +410,19 @@ def generate_itempool(world):
multiworld.random.shuffle(items) multiworld.random.shuffle(items)
pool_count = len(items) pool_count = len(items)
new_items = ["Triforce Piece" for _ in range(additional_triforce_pieces)] new_items = ["Triforce Piece" for _ in range(additional_triforce_pieces)]
if multiworld.shuffle_capacity_upgrades[player] or multiworld.bombless_start[player]: if world.options.shuffle_capacity_upgrades or world.options.bombless_start:
progressive = multiworld.progressive[player] progressive = world.options.progressive
progressive = multiworld.random.choice([True, False]) if progressive == 'grouped_random' else progressive == 'on' progressive = multiworld.random.choice([True, False]) if progressive == 'grouped_random' else progressive == 'on'
if multiworld.shuffle_capacity_upgrades[player] == "on_combined": if world.options.shuffle_capacity_upgrades == "on_combined":
new_items.append("Bomb Upgrade (50)") new_items.append("Bomb Upgrade (50)")
elif multiworld.shuffle_capacity_upgrades[player] == "on": elif world.options.shuffle_capacity_upgrades == "on":
new_items += ["Bomb Upgrade (+5)"] * 6 new_items += ["Bomb Upgrade (+5)"] * 6
new_items.append("Bomb Upgrade (+5)" if progressive else "Bomb Upgrade (+10)") new_items.append("Bomb Upgrade (+5)" if progressive else "Bomb Upgrade (+10)")
if multiworld.shuffle_capacity_upgrades[player] != "on_combined" and multiworld.bombless_start[player]: if world.options.shuffle_capacity_upgrades != "on_combined" and world.options.bombless_start:
new_items.append("Bomb Upgrade (+5)" if progressive else "Bomb Upgrade (+10)") new_items.append("Bomb Upgrade (+5)" if progressive else "Bomb Upgrade (+10)")
if multiworld.shuffle_capacity_upgrades[player] and not multiworld.retro_bow[player]: if world.options.shuffle_capacity_upgrades and not world.options.retro_bow:
if multiworld.shuffle_capacity_upgrades[player] == "on_combined": if world.options.shuffle_capacity_upgrades == "on_combined":
new_items += ["Arrow Upgrade (70)"] new_items += ["Arrow Upgrade (70)"]
else: else:
new_items += ["Arrow Upgrade (+5)"] * 6 new_items += ["Arrow Upgrade (+5)"] * 6
@@ -481,7 +481,7 @@ def generate_itempool(world):
if len(items) < pool_count: if len(items) < pool_count:
items += removed_filler[len(items) - pool_count:] items += removed_filler[len(items) - pool_count:]
if multiworld.randomize_cost_types[player]: if world.options.randomize_cost_types:
# Heart and Arrow costs require all Heart Container/Pieces and Arrow Upgrades to be advancement items for logic # Heart and Arrow costs require all Heart Container/Pieces and Arrow Upgrades to be advancement items for logic
for item in items: for item in items:
if item.name in ("Boss Heart Container", "Sanctuary Heart Container", "Piece of Heart"): if item.name in ("Boss Heart Container", "Sanctuary Heart Container", "Piece of Heart"):
@@ -490,21 +490,21 @@ def generate_itempool(world):
# Otherwise, logic has some branches where having 4 hearts is one possible requirement (of several alternatives) # Otherwise, logic has some branches where having 4 hearts is one possible requirement (of several alternatives)
# rather than making all hearts/heart pieces progression items (which slows down generation considerably) # rather than making all hearts/heart pieces progression items (which slows down generation considerably)
# We mark one random heart container as an advancement item (or 4 heart pieces in expert mode) # We mark one random heart container as an advancement item (or 4 heart pieces in expert mode)
if multiworld.item_pool[player] in ['easy', 'normal', 'hard'] and not (multiworld.custom and multiworld.customitemarray[30] == 0): if world.options.item_pool in ['easy', 'normal', 'hard'] and not (multiworld.custom and multiworld.customitemarray[30] == 0):
next(item for item in items if item.name == 'Boss Heart Container').classification = ItemClassification.progression next(item for item in items if item.name == 'Boss Heart Container').classification = ItemClassification.progression
elif multiworld.item_pool[player] in ['expert'] and not (multiworld.custom and multiworld.customitemarray[29] < 4): elif world.options.item_pool in ['expert'] and not (multiworld.custom and multiworld.customitemarray[29] < 4):
adv_heart_pieces = (item for item in items if item.name == 'Piece of Heart') adv_heart_pieces = (item for item in items if item.name == 'Piece of Heart')
for i in range(4): for i in range(4):
next(adv_heart_pieces).classification = ItemClassification.progression next(adv_heart_pieces).classification = ItemClassification.progression
world.required_medallions = (multiworld.misery_mire_medallion[player].current_key.title(), world.required_medallions = (world.options.misery_mire_medallion.current_key.title(),
multiworld.turtle_rock_medallion[player].current_key.title()) world.options.turtle_rock_medallion.current_key.title())
place_bosses(world) place_bosses(world)
multiworld.itempool += items multiworld.itempool += items
if multiworld.retro_caves[player]: if world.options.retro_caves:
set_up_take_anys(multiworld, world, player) # depends on world.itempool to be set set_up_take_anys(multiworld, world, player) # depends on world.itempool to be set
@@ -527,7 +527,7 @@ take_any_locations.sort()
def set_up_take_anys(multiworld, world, player): def set_up_take_anys(multiworld, world, player):
# these are references, do not modify these lists in-place # these are references, do not modify these lists in-place
if multiworld.mode[player] == 'inverted': if world.options.mode == 'inverted':
take_any_locs = take_any_locations_inverted take_any_locs = take_any_locations_inverted
else: else:
take_any_locs = take_any_locations take_any_locs = take_any_locations
@@ -578,14 +578,14 @@ def set_up_take_anys(multiworld, world, player):
def get_pool_core(world, player: int): def get_pool_core(world, player: int):
shuffle = world.entrance_shuffle[player].current_key shuffle = world.worlds[player].options.entrance_shuffle.current_key
difficulty = world.item_pool[player].current_key difficulty = world.worlds[player].options.item_pool.current_key
timer = world.timer[player].current_key timer = world.worlds[player].options.timer.current_key
goal = world.goal[player].current_key goal = world.worlds[player].options.goal.current_key
mode = world.mode[player].current_key mode = world.worlds[player].options.mode.current_key
swordless = world.swordless[player] swordless = world.worlds[player].options.swordless
retro_bow = world.retro_bow[player] retro_bow = world.worlds[player].options.retro_bow
logic = world.glitches_required[player] logic = world.worlds[player].options.glitches_required
pool = [] pool = []
placed_items = {} placed_items = {}
@@ -602,11 +602,11 @@ def get_pool_core(world, player: int):
placed_items[loc] = item placed_items[loc] = item
# provide boots to major glitch dependent seeds # provide boots to major glitch dependent seeds
if logic.current_key in {'overworld_glitches', 'hybrid_major_glitches', 'no_logic'} and world.glitch_boots[player]: if logic.current_key in {'overworld_glitches', 'hybrid_major_glitches', 'no_logic'} and world.worlds[player].options.glitch_boots:
precollected_items.append('Pegasus Boots') precollected_items.append('Pegasus Boots')
pool.remove('Pegasus Boots') pool.remove('Pegasus Boots')
pool.append('Rupees (20)') pool.append('Rupees (20)')
want_progressives = world.progressive[player].want_progressives want_progressives = world.worlds[player].options.progressive.want_progressives
if want_progressives(world.random): if want_progressives(world.random):
pool.extend(diff.progressiveglove) pool.extend(diff.progressiveglove)
@@ -680,22 +680,22 @@ def get_pool_core(world, player: int):
additional_pieces_to_place = 0 additional_pieces_to_place = 0
if 'triforce_hunt' in goal: if 'triforce_hunt' in goal:
if world.triforce_pieces_mode[player].value == TriforcePiecesMode.option_extra: if world.worlds[player].options.triforce_pieces_mode.value == TriforcePiecesMode.option_extra:
treasure_hunt_total = (world.triforce_pieces_required[player].value treasure_hunt_total = (world.worlds[player].options.triforce_pieces_required.value
+ world.triforce_pieces_extra[player].value) + world.worlds[player].options.triforce_pieces_extra.value)
elif world.triforce_pieces_mode[player].value == TriforcePiecesMode.option_percentage: elif world.worlds[player].options.triforce_pieces_mode.value == TriforcePiecesMode.option_percentage:
percentage = float(world.triforce_pieces_percentage[player].value) / 100 percentage = float(world.worlds[player].options.triforce_pieces_percentage.value) / 100
treasure_hunt_total = int(round(world.triforce_pieces_required[player].value * percentage, 0)) treasure_hunt_total = int(round(world.worlds[player].options.triforce_pieces_required.value * percentage, 0))
else: # available else: # available
treasure_hunt_total = world.triforce_pieces_available[player].value treasure_hunt_total = world.worlds[player].options.triforce_pieces_available.value
triforce_pieces = min(90, max(treasure_hunt_total, world.triforce_pieces_required[player].value)) triforce_pieces = min(90, max(treasure_hunt_total, world.worlds[player].options.triforce_pieces_required.value))
pieces_in_core = min(extraitems, triforce_pieces) pieces_in_core = min(extraitems, triforce_pieces)
additional_pieces_to_place = triforce_pieces - pieces_in_core additional_pieces_to_place = triforce_pieces - pieces_in_core
pool.extend(["Triforce Piece"] * pieces_in_core) pool.extend(["Triforce Piece"] * pieces_in_core)
extraitems -= pieces_in_core extraitems -= pieces_in_core
treasure_hunt_required = world.triforce_pieces_required[player].value treasure_hunt_required = world.worlds[player].options.triforce_pieces_required.value
for extra in diff.extras: for extra in diff.extras:
if extraitems >= len(extra): if extraitems >= len(extra):
@@ -714,10 +714,10 @@ def get_pool_core(world, player: int):
if retro_bow: if retro_bow:
replace = {'Single Arrow', 'Arrows (10)', 'Arrow Upgrade (+5)', 'Arrow Upgrade (+10)', 'Arrow Upgrade (70)'} replace = {'Single Arrow', 'Arrows (10)', 'Arrow Upgrade (+5)', 'Arrow Upgrade (+10)', 'Arrow Upgrade (70)'}
pool = ['Rupees (5)' if item in replace else item for item in pool] pool = ['Rupees (5)' if item in replace else item for item in pool]
if world.small_key_shuffle[player] == small_key_shuffle.option_universal: if world.worlds[player].options.small_key_shuffle == small_key_shuffle.option_universal:
pool.extend(diff.universal_keys) pool.extend(diff.universal_keys)
if mode == 'standard': if mode == 'standard':
if world.key_drop_shuffle[player]: if world.worlds[player].options.key_drop_shuffle:
key_locations = ['Secret Passage', 'Hyrule Castle - Map Guard Key Drop'] key_locations = ['Secret Passage', 'Hyrule Castle - Map Guard Key Drop']
key_location = world.random.choice(key_locations) key_location = world.random.choice(key_locations)
key_locations.remove(key_location) key_locations.remove(key_location)
@@ -741,11 +741,11 @@ def get_pool_core(world, player: int):
def make_custom_item_pool(world, player): def make_custom_item_pool(world, player):
shuffle = world.entrance_shuffle[player] shuffle = world.worlds[player].options.entrance_shuffle
difficulty = world.item_pool[player] difficulty = world.worlds[player].options.item_pool
timer = world.timer[player] timer = world.worlds[player].options.timer
goal = world.goal[player] goal = world.worlds[player].options.goal
mode = world.mode[player] mode = world.worlds[player].options.mode
customitemarray = world.customitemarray customitemarray = world.customitemarray
pool = [] pool = []
@@ -845,10 +845,10 @@ def make_custom_item_pool(world, player):
thisbottle = world.random.choice(diff.bottles) thisbottle = world.random.choice(diff.bottles)
pool.append(thisbottle) pool.append(thisbottle)
if "triforce" in world.goal[player]: if "triforce" in world.worlds[player].options.goal:
pool.extend(["Triforce Piece"] * world.triforce_pieces_available[player]) pool.extend(["Triforce Piece"] * world.worlds[player].options.triforce_pieces_available)
itemtotal += world.triforce_pieces_available[player] itemtotal += world.worlds[player].options.triforce_pieces_available
treasure_hunt_required = world.triforce_pieces_required[player] treasure_hunt_required = world.worlds[player].options.triforce_pieces_required
if timer in ['display', 'timed', 'timed_countdown']: if timer in ['display', 'timed', 'timed_countdown']:
clock_mode = 'countdown' if timer == 'timed_countdown' else 'stopwatch' clock_mode = 'countdown' if timer == 'timed_countdown' else 'stopwatch'
@@ -862,7 +862,7 @@ def make_custom_item_pool(world, player):
itemtotal = itemtotal + 1 itemtotal = itemtotal + 1
if mode == 'standard': if mode == 'standard':
if world.small_key_shuffle[player] == small_key_shuffle.option_universal: if world.worlds[player].options.small_key_shuffle == small_key_shuffle.option_universal:
key_location = world.random.choice( key_location = world.random.choice(
['Secret Passage', 'Hyrule Castle - Boomerang Chest', 'Hyrule Castle - Map Chest', ['Secret Passage', 'Hyrule Castle - Boomerang Chest', 'Hyrule Castle - Map Chest',
'Hyrule Castle - Zelda\'s Chest', 'Sewers - Dark Cross']) 'Hyrule Castle - Zelda\'s Chest', 'Sewers - Dark Cross'])
@@ -885,9 +885,9 @@ def make_custom_item_pool(world, player):
pool.extend(['Magic Mirror'] * customitemarray[22]) pool.extend(['Magic Mirror'] * customitemarray[22])
pool.extend(['Moon Pearl'] * customitemarray[28]) pool.extend(['Moon Pearl'] * customitemarray[28])
if world.small_key_shuffle[player] == small_key_shuffle.option_universal: if world.worlds[player].options.small_key_shuffle == small_key_shuffle.option_universal:
itemtotal = itemtotal - 28 # Corrects for small keys not being in item pool in universal Mode itemtotal = itemtotal - 28 # Corrects for small keys not being in item pool in universal Mode
if world.key_drop_shuffle[player]: if world.worlds[player].options.key_drop_shuffle:
itemtotal = itemtotal - (len(key_drop_data) - 1) itemtotal = itemtotal - (len(key_drop_data) - 1)
if itemtotal < total_items_to_place: if itemtotal < total_items_to_place:
pool.extend(['Nothing'] * (total_items_to_place - itemtotal)) pool.extend(['Nothing'] * (total_items_to_place - itemtotal))

View File

@@ -11,11 +11,11 @@ def GetBeemizerItem(world, player: int, item):
return item return item
# first roll - replaceable item should be replaced, within beemizer_total_chance # first roll - replaceable item should be replaced, within beemizer_total_chance
if not world.beemizer_total_chance[player] or world.random.random() > (world.beemizer_total_chance[player] / 100): if not world.worlds[player].options.beemizer_total_chance or world.random.random() > (world.worlds[player].options.beemizer_total_chance / 100):
return item return item
# second roll - bee replacement should be trap, within beemizer_trap_chance # second roll - bee replacement should be trap, within beemizer_trap_chance
if not world.beemizer_trap_chance[player] or world.random.random() > (world.beemizer_trap_chance[player] / 100): if not world.worlds[player].options.beemizer_trap_chance or world.random.random() > (world.worlds[player].options.beemizer_trap_chance / 100):
return "Bee" if isinstance(item, str) else world.create_item("Bee", player) return "Bee" if isinstance(item, str) else world.create_item("Bee", player)
else: else:
return "Bee Trap" if isinstance(item, str) else world.create_item("Bee Trap", player) return "Bee Trap" if isinstance(item, str) else world.create_item("Bee Trap", player)

View File

@@ -156,10 +156,10 @@ class OpenPyramid(Choice):
def to_bool(self, world: MultiWorld, player: int) -> bool: def to_bool(self, world: MultiWorld, player: int) -> bool:
if self.value == self.option_goal: if self.value == self.option_goal:
return world.goal[player].current_key in {'crystals', 'ganon_triforce_hunt', 'local_ganon_triforce_hunt', 'ganon_pedestal'} return world.worlds[player].options.goal.current_key in {'crystals', 'ganon_triforce_hunt', 'local_ganon_triforce_hunt', 'ganon_pedestal'}
elif self.value == self.option_auto: elif self.value == self.option_auto:
return world.goal[player].current_key in {'crystals', 'ganon_triforce_hunt', 'local_ganon_triforce_hunt', 'ganon_pedestal'} \ return world.worlds[player].options.goal.current_key in {'crystals', 'ganon_triforce_hunt', 'local_ganon_triforce_hunt', 'ganon_pedestal'} \
and (world.entrance_shuffle[player].current_key in {'vanilla', 'dungeons_simple', 'dungeons_full', 'dungeons_crossed'} or not and (world.worlds[player].options.entrance_shuffle.current_key in {'vanilla', 'dungeons_simple', 'dungeons_full', 'dungeons_crossed'} or not
world.shuffle_ganon) world.shuffle_ganon)
elif self.value == self.option_open: elif self.value == self.option_open:
return True return True

View File

@@ -2,8 +2,6 @@
Helper functions to deliver entrance/exit/region sets to OWG rules. Helper functions to deliver entrance/exit/region sets to OWG rules.
""" """
from BaseClasses import Entrance
from .StateHelpers import can_lift_heavy_rocks, can_boots_clip_lw, can_boots_clip_dw, can_get_glitched_speed_dw from .StateHelpers import can_lift_heavy_rocks, can_boots_clip_lw, can_boots_clip_dw, can_get_glitched_speed_dw
@@ -222,14 +220,14 @@ def get_invalid_bunny_revival_dungeons():
def overworld_glitch_connections(world, player): def overworld_glitch_connections(world, player):
# Boots-accessible locations. # Boots-accessible locations.
create_owg_connections(player, world, get_boots_clip_exits_lw(world.mode[player] == 'inverted')) create_owg_connections(player, world, get_boots_clip_exits_lw(world.worlds[player].options.mode == 'inverted'))
create_owg_connections(player, world, get_boots_clip_exits_dw(world.mode[player] == 'inverted', player)) create_owg_connections(player, world, get_boots_clip_exits_dw(world.worlds[player].options.mode == 'inverted', player))
# Glitched speed drops. # Glitched speed drops.
create_owg_connections(player, world, get_glitched_speed_drops_dw(world.mode[player] == 'inverted')) create_owg_connections(player, world, get_glitched_speed_drops_dw(world.worlds[player].options.mode == 'inverted'))
# Mirror clip spots. # Mirror clip spots.
if world.mode[player] != 'inverted': if world.worlds[player].options.mode != 'inverted':
create_owg_connections(player, world, get_mirror_clip_spots_dw()) create_owg_connections(player, world, get_mirror_clip_spots_dw())
create_owg_connections(player, world, get_mirror_offset_spots_dw()) create_owg_connections(player, world, get_mirror_offset_spots_dw())
else: else:
@@ -239,24 +237,24 @@ def overworld_glitch_connections(world, player):
def overworld_glitches_rules(world, player): def overworld_glitches_rules(world, player):
# Boots-accessible locations. # Boots-accessible locations.
set_owg_connection_rules(player, world, get_boots_clip_exits_lw(world.mode[player] == 'inverted'), lambda state: can_boots_clip_lw(state, player)) set_owg_connection_rules(player, world, get_boots_clip_exits_lw(world.worlds[player].options.mode == 'inverted'), lambda state: can_boots_clip_lw(state, player))
set_owg_connection_rules(player, world, get_boots_clip_exits_dw(world.mode[player] == 'inverted', player), lambda state: can_boots_clip_dw(state, player)) set_owg_connection_rules(player, world, get_boots_clip_exits_dw(world.worlds[player].options.mode == 'inverted', player), lambda state: can_boots_clip_dw(state, player))
# Glitched speed drops. # Glitched speed drops.
set_owg_connection_rules(player, world, get_glitched_speed_drops_dw(world.mode[player] == 'inverted'), lambda state: can_get_glitched_speed_dw(state, player)) set_owg_connection_rules(player, world, get_glitched_speed_drops_dw(world.worlds[player].options.mode == 'inverted'), lambda state: can_get_glitched_speed_dw(state, player))
# Dark Death Mountain Ledge Clip Spot also accessible with mirror. # Dark Death Mountain Ledge Clip Spot also accessible with mirror.
if world.mode[player] != 'inverted': if world.worlds[player].options.mode != 'inverted':
add_alternate_rule(world.get_entrance('Dark Death Mountain Ledge Clip Spot', player), lambda state: state.has('Magic Mirror', player)) add_alternate_rule(world.get_entrance('Dark Death Mountain Ledge Clip Spot', player), lambda state: state.has('Magic Mirror', player))
# Mirror clip spots. # Mirror clip spots.
if world.mode[player] != 'inverted': if world.worlds[player].options.mode != 'inverted':
set_owg_connection_rules(player, world, get_mirror_clip_spots_dw(), lambda state: state.has('Magic Mirror', player)) set_owg_connection_rules(player, world, get_mirror_clip_spots_dw(), lambda state: state.has('Magic Mirror', player))
set_owg_connection_rules(player, world, get_mirror_offset_spots_dw(), lambda state: state.has('Magic Mirror', player) and can_boots_clip_lw(state, player)) set_owg_connection_rules(player, world, get_mirror_offset_spots_dw(), lambda state: state.has('Magic Mirror', player) and can_boots_clip_lw(state, player))
else: else:
set_owg_connection_rules(player, world, get_mirror_offset_spots_lw(player), lambda state: state.has('Magic Mirror', player) and can_boots_clip_dw(state, player)) set_owg_connection_rules(player, world, get_mirror_offset_spots_lw(player), lambda state: state.has('Magic Mirror', player) and can_boots_clip_dw(state, player))
# Regions that require the boots and some other stuff. # Regions that require the boots and some other stuff.
if world.mode[player] != 'inverted': if world.worlds[player].options.mode != 'inverted':
world.get_entrance('Turtle Rock Teleporter', player).access_rule = lambda state: (can_boots_clip_lw(state, player) or can_lift_heavy_rocks(state, player)) and state.has('Hammer', player) world.get_entrance('Turtle Rock Teleporter', player).access_rule = lambda state: (can_boots_clip_lw(state, player) or can_lift_heavy_rocks(state, player)) and state.has('Hammer', player)
add_alternate_rule(world.get_entrance('Waterfall of Wishing', player), lambda state: state.has('Moon Pearl', player) or state.has('Pegasus Boots', player)) add_alternate_rule(world.get_entrance('Waterfall of Wishing', player), lambda state: state.has('Moon Pearl', player) or state.has('Pegasus Boots', player))
else: else:
@@ -279,18 +277,14 @@ def create_no_logic_connections(player, world, connections):
for entrance, parent_region, target_region, *rule_override in connections: for entrance, parent_region, target_region, *rule_override in connections:
parent = world.get_region(parent_region, player) parent = world.get_region(parent_region, player)
target = world.get_region(target_region, player) target = world.get_region(target_region, player)
connection = Entrance(player, entrance, parent) parent.connect(target, entrance)
parent.exits.append(connection)
connection.connect(target)
def create_owg_connections(player, world, connections): def create_owg_connections(player, world, connections):
for entrance, parent_region, target_region, *rule_override in connections: for entrance, parent_region, target_region, *rule_override in connections:
parent = world.get_region(parent_region, player) parent = world.get_region(parent_region, player)
target = world.get_region(target_region, player) target = world.get_region(target_region, player)
connection = Entrance(player, entrance, parent) parent.connect(target, entrance)
parent.exits.append(connection)
connection.connect(target)
def set_owg_connection_rules(player, world, connections, default_rule): def set_owg_connection_rules(player, world, connections, default_rule):

View File

@@ -1,11 +1,11 @@
import collections import collections
import typing import typing
from BaseClasses import Entrance, MultiWorld from BaseClasses import MultiWorld
from .SubClasses import LTTPRegion, LTTPRegionType from .SubClasses import LTTPEntrance, LTTPRegion, LTTPRegionType
def is_main_entrance(entrance: Entrance) -> bool: def is_main_entrance(entrance: LTTPEntrance) -> bool:
return entrance.parent_region.type in {LTTPRegionType.DarkWorld, LTTPRegionType.LightWorld} if entrance.parent_region.type else True return entrance.parent_region.type in {LTTPRegionType.DarkWorld, LTTPRegionType.LightWorld} if entrance.parent_region.type else True
@@ -410,7 +410,7 @@ def _create_region(world: MultiWorld, player: int, name: str, type: LTTPRegionTy
ret = LTTPRegion(name, type, hint, player, world) ret = LTTPRegion(name, type, hint, player, world)
if exits: if exits:
for exit in exits: for exit in exits:
ret.exits.append(Entrance(player, exit, ret)) ret.create_exit(exit)
if locations: if locations:
for location in locations: for location in locations:
if location in key_drop_data: if location in key_drop_data:

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