Compare commits

..

3 Commits

Author SHA1 Message Date
NewSoupVi
930f627794 Release workflow as well 2025-03-10 14:32:09 +01:00
NewSoupVi
d4fc90410c Update build.yml 2025-03-10 14:09:20 +01:00
NewSoupVi
484c5f2671 Band-aid Linux Build breaking with the release of PyGObject 3.52.1 2025-03-10 14:06:59 +01:00
823 changed files with 28398 additions and 128187 deletions

1
.github/labeler.yml vendored
View File

@@ -21,6 +21,7 @@
- '!data/**'
- '!.run/**'
- '!.github/**'
- '!worlds_disabled/**'
- '!worlds/**'
- '!WebHost.py'
- '!WebHostLib/**'

View File

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

View File

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

View File

@@ -21,17 +21,12 @@ env:
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
permissions: # permissions required for attestation
id-token: 'write'
attestations: 'write'
jobs:
# build-release-macos: # LF volunteer
build-win: # RCs and releases may still be built and signed by hand
build-win: # RCs will still be built and signed by hand
runs-on: windows-latest
steps:
# - copy code below to release.yml -
- uses: actions/checkout@v4
- name: Install python
uses: actions/setup-python@v5
@@ -70,18 +65,6 @@ jobs:
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
$SETUP_NAME=$contents[0].Name
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
# - copy code above to release.yml -
- name: Attest Build
if: ${{ github.event_name == 'workflow_dispatch' }}
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher.exe
build/exe.*/ArchipelagoLauncherDebug.exe
build/exe.*/ArchipelagoGenerate.exe
build/exe.*/ArchipelagoServer.exe
dist/${{ env.ZIP_NAME }}
setups/${{ env.SETUP_NAME }}
- name: Check build loads expected worlds
shell: bash
run: |
@@ -116,8 +99,8 @@ jobs:
if-no-files-found: error
retention-days: 7 # keep for 7 days, should be enough
build-ubuntu2204:
runs-on: ubuntu-22.04
build-ubuntu2004:
runs-on: ubuntu-20.04
steps:
# - copy code below to release.yml -
- uses: actions/checkout@v4
@@ -159,16 +142,6 @@ jobs:
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - copy code above to release.yml -
- name: Attest Build
if: ${{ github.event_name == 'workflow_dispatch' }}
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher
build/exe.*/ArchipelagoGenerate
build/exe.*/ArchipelagoServer
dist/${{ env.APPIMAGE_NAME }}*
dist/${{ env.TAR_NAME }}
- name: Build Again
run: |
source venv/bin/activate

View File

@@ -36,9 +36,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
- uses: ilammy/msvc-dev-cmd@v1
if: startsWith(matrix.os,'windows')
- uses: Bacondish2023/setup-googletest@49065d1f7a6d21f6134864dd65980fe5dbe06c73
- uses: Bacondish2023/setup-googletest@v1
with:
build-type: 'Release'
- name: Build tests

View File

@@ -6,8 +6,6 @@ on:
permissions:
contents: read
pull-requests: write
env:
GH_REPO: ${{ github.repository }}
jobs:
labeler:

View File

@@ -11,11 +11,6 @@ env:
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
permissions: # permissions required for attestation
id-token: 'write'
attestations: 'write'
contents: 'write' # additionally required for release
jobs:
create-release:
runs-on: ubuntu-latest
@@ -31,79 +26,11 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# build-release-windows: # this is done by hand because of signing
# build-release-macos: # LF volunteer
build-release-win:
runs-on: windows-latest
if: ${{ true }} # change to false to skip if release is built by hand
needs: create-release
steps:
- name: Set env
shell: bash
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
# - code below copied from build.yml -
- uses: actions/checkout@v4
- name: Install python
uses: actions/setup-python@v5
with:
python-version: '~3.12.7'
check-latest: true
- name: Download run-time dependencies
run: |
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
choco install innosetup --version=6.2.2 --allow-downgrade
- name: Build
run: |
python -m pip install --upgrade pip
python setup.py build_exe --yes
if ( $? -eq $false ) {
Write-Error "setup.py failed!"
exit 1
}
$NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
$ZIP_NAME="Archipelago_$NAME.7z"
echo "$NAME -> $ZIP_NAME"
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
New-Item -Path dist -ItemType Directory -Force
cd build
Rename-Item "exe.$NAME" Archipelago
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
- name: Build Setup
run: |
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
if ( $? -eq $false ) {
Write-Error "Building setup failed!"
exit 1
}
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
$SETUP_NAME=$contents[0].Name
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
# - code above copied from build.yml -
- name: Attest Build
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher.exe
build/exe.*/ArchipelagoLauncherDebug.exe
build/exe.*/ArchipelagoGenerate.exe
build/exe.*/ArchipelagoServer.exe
setups/*
- name: Add to Release
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
with:
draft: true # see above
prerelease: false
name: Archipelago ${{ env.RELEASE_VERSION }}
files: |
setups/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-release-ubuntu2204:
runs-on: ubuntu-22.04
needs: create-release
build-release-ubuntu2004:
runs-on: ubuntu-20.04
steps:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
@@ -147,14 +74,6 @@ jobs:
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - code above copied from build.yml -
- name: Attest Build
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher
build/exe.*/ArchipelagoGenerate
build/exe.*/ArchipelagoServer
dist/*
- name: Add to Release
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
with:

9
.gitignore vendored
View File

@@ -4,13 +4,11 @@
*_Spoiler.txt
*.bmbp
*.apbp
*.apcivvi
*.apl2ac
*.apm3
*.apmc
*.apz5
*.aptloz
*.aptww
*.apemerald
*.pyc
*.pyd
@@ -56,6 +54,7 @@ success.txt
output/
Output Logs/
/factorio/
/Minecraft Forge Server/
/WebHostLib/static/generated
/freeze_requirements.txt
/Archipelago.zip
@@ -183,6 +182,12 @@ _speedups.c
_speedups.cpp
_speedups.html
# minecraft server stuff
jdk*/
minecraft*/
minecraft_versions.json
!worlds/minecraft/
# pyenv
.python-version

View File

@@ -1,4 +1,3 @@
import sys
from worlds.ahit.Client import launch
import Utils
import ModuleUpdate
@@ -6,4 +5,4 @@ ModuleUpdate.update()
if __name__ == "__main__":
Utils.init_logging("AHITClient", exception_logger="Client")
launch(*sys.argv[1:])
launch()

View File

@@ -11,7 +11,6 @@ from typing import List
import Utils
from settings import get_settings
from NetUtils import ClientStatus
from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
@@ -81,8 +80,8 @@ class AdventureContext(CommonContext):
self.local_item_locations = {}
self.dragon_speed_info = {}
options = get_settings().adventure_options
self.display_msgs = options.display_msgs
options = Utils.get_settings()
self.display_msgs = options["adventure_options"]["display_msgs"]
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -103,7 +102,7 @@ class AdventureContext(CommonContext):
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
self.locations_array = None
if get_settings().adventure_options.as_dict().get("death_link", False):
if Utils.get_settings()["adventure_options"].get("death_link", False):
self.set_deathlink = True
async_start(self.get_freeincarnates_used())
elif cmd == "RoomInfo":
@@ -416,9 +415,8 @@ async def atari_sync_task(ctx: AdventureContext):
async def run_game(romfile):
options = get_settings().adventure_options
auto_start = options.rom_start
rom_args = options.rom_args
auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True)
rom_args = Utils.get_settings()["adventure_options"].get("rom_args")
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
@@ -513,7 +511,7 @@ if __name__ == '__main__':
import colorama
colorama.just_fix_windows_console()
colorama.init()
asyncio.run(main())
colorama.deinit()

View File

@@ -9,9 +9,8 @@ from argparse import Namespace
from collections import Counter, deque
from collections.abc import Collection, MutableSequence
from enum import IntEnum, IntFlag
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple,
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple,
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING)
import dataclasses
from typing_extensions import NotRequired, TypedDict
@@ -55,21 +54,12 @@ class HasNameAndPlayer(Protocol):
player: int
@dataclasses.dataclass
class PlandoItemBlock:
player: int
from_pool: bool
force: bool | Literal["silent"]
worlds: set[int] = dataclasses.field(default_factory=set)
items: list[str] = dataclasses.field(default_factory=list)
locations: list[str] = dataclasses.field(default_factory=list)
resolved_locations: list[Location] = dataclasses.field(default_factory=list)
count: dict[str, int] = dataclasses.field(default_factory=dict)
class MultiWorld():
debug_types = False
player_name: Dict[int, str]
plando_texts: List[Dict[str, str]]
plando_items: List[List[Dict[str, Any]]]
plando_connections: List
worlds: Dict[int, "AutoWorld.World"]
groups: Dict[int, Group]
regions: RegionManager
@@ -93,8 +83,6 @@ class MultiWorld():
start_location_hints: Dict[int, Options.StartLocationHints]
item_links: Dict[int, Options.ItemLinks]
plando_item_blocks: Dict[int, List[PlandoItemBlock]]
game: Dict[int, str]
random: random.Random
@@ -172,12 +160,13 @@ class MultiWorld():
self.local_early_items = {player: {} for player in self.player_ids}
self.indirect_connections = {}
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
self.plando_item_blocks = {}
for player in range(1, players + 1):
def set_player_attr(attr: str, val) -> None:
self.__dict__.setdefault(attr, {})[player] = val
set_player_attr('plando_item_blocks', [])
set_player_attr('plando_items', [])
set_player_attr('plando_texts', {})
set_player_attr('plando_connections', [])
set_player_attr('game', "Archipelago")
set_player_attr('completion_condition', lambda state: True)
self.worlds = {}
@@ -234,7 +223,7 @@ class MultiWorld():
AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints}
for option_key in all_keys:
option = Utils.DeprecateDict(f"Getting options from multiworld is now deprecated. "
f"Please use `self.options.{option_key}` instead.", True)
f"Please use `self.options.{option_key}` instead.")
option.update(getattr(args, option_key, {}))
setattr(self, option_key, option)
@@ -438,8 +427,7 @@ class MultiWorld():
def get_location(self, location_name: str, player: int) -> Location:
return self.regions.location_cache[player][location_name]
def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False,
collect_pre_fill_items: bool = True, perform_sweep: bool = True) -> CollectionState:
def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False) -> CollectionState:
cached = getattr(self, "_all_state", None)
if use_cache and cached:
return cached.copy()
@@ -448,13 +436,11 @@ class MultiWorld():
for item in self.itempool:
self.worlds[item.player].collect(ret, item)
if collect_pre_fill_items:
for player in self.player_ids:
subworld = self.worlds[player]
for item in subworld.get_pre_fill_items():
subworld.collect(ret, item)
if perform_sweep:
ret.sweep_for_advancements()
for player in self.player_ids:
subworld = self.worlds[player]
for item in subworld.get_pre_fill_items():
subworld.collect(ret, item)
ret.sweep_for_advancements()
if use_cache:
self._all_state = ret
@@ -559,9 +545,7 @@ class MultiWorld():
else:
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1)))
def can_beat_game(self,
starting_state: Optional[CollectionState] = None,
locations: Optional[Iterable[Location]] = None) -> bool:
def can_beat_game(self, starting_state: Optional[CollectionState] = None) -> bool:
if starting_state:
if self.has_beaten_game(starting_state):
return True
@@ -570,9 +554,7 @@ class MultiWorld():
state = CollectionState(self)
if self.has_beaten_game(state):
return True
base_locations = self.get_locations() if locations is None else locations
prog_locations = {location for location in base_locations if location.item
prog_locations = {location for location in self.get_locations() if location.item
and location.item.advancement and location not in state.locations_checked}
while prog_locations:
@@ -634,7 +616,7 @@ class MultiWorld():
locations: Set[Location] = set()
events: Set[Location] = set()
for location in self.get_filled_locations():
if type(location.item.code) is int and type(location.address) is int:
if type(location.item.code) is int:
locations.add(location)
else:
events.add(location)
@@ -741,7 +723,6 @@ class CollectionState():
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False):
assert parent.worlds, "CollectionState created without worlds initialized in parent"
self.prog_items = {player: Counter() for player in parent.get_all_ids()}
self.multiworld = parent
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
@@ -1018,17 +999,6 @@ class CollectionState():
return changed
def add_item(self, item: str, player: int, count: int = 1) -> None:
"""
Adds the item to state.
:param item: The item to be added.
:param player: The player the item is for.
:param count: How many of the item to add.
"""
assert count > 0
self.prog_items[player][item] += count
def remove(self, item: Item):
changed = self.multiworld.worlds[item.player].remove(self, item)
if changed:
@@ -1037,33 +1007,6 @@ class CollectionState():
self.blocked_connections[item.player] = set()
self.stale[item.player] = True
def remove_item(self, item: str, player: int, count: int = 1) -> None:
"""
Removes the item from state.
:param item: The item to be removed.
:param player: The player the item is for.
:param count: How many of the item to remove.
"""
assert count > 0
self.prog_items[player][item] -= count
if self.prog_items[player][item] < 1:
del (self.prog_items[player][item])
def set_item(self, item: str, player: int, count: int) -> None:
"""
Sets the item in state equal to the provided count.
:param item: The item to modify.
:param player: The player the item is for.
:param count: How many of the item to now have.
"""
assert count >= 0
if count == 0:
del (self.prog_items[player][item])
else:
self.prog_items[player][item] = count
class EntranceType(IntEnum):
ONE_WAY = 1
@@ -1079,6 +1022,9 @@ class Entrance:
connected_region: Optional[Region] = None
randomization_group: int
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,
randomization_group: int = 0, randomization_type: EntranceType = EntranceType.ONE_WAY) -> None:
@@ -1097,8 +1043,10 @@ class Entrance:
return False
def connect(self, region: Region) -> None:
def connect(self, region: Region, addresses: Any = None, target: Any = None) -> None:
self.connected_region = region
self.target = target
self.addresses = addresses
region.entrances.append(self)
def is_valid_source_transition(self, er_state: "ERPlacementState") -> bool:
@@ -1158,9 +1106,6 @@ class Region:
def __len__(self) -> int:
return self._list.__len__()
def __iter__(self):
return iter(self._list)
# This seems to not be needed, but that's a bit suspicious.
# def __del__(self):
# self.clear()
@@ -1255,48 +1200,6 @@ class Region:
for location, address in locations.items():
self.locations.append(location_type(self.player, location, address, self))
def add_event(
self,
location_name: str,
item_name: str | None = None,
rule: Callable[[CollectionState], bool] | None = None,
location_type: type[Location] | None = None,
item_type: type[Item] | None = None,
show_in_spoiler: bool = True,
) -> Item:
"""
Adds an event location/item pair to the region.
:param location_name: Name for the event location.
:param item_name: Name for the event item. If not provided, defaults to location_name.
:param rule: Callable to determine access for this event location within its region.
:param location_type: Location class to create the event location with. Defaults to BaseClasses.Location.
:param item_type: Item class to create the event item with. Defaults to BaseClasses.Item.
:param show_in_spoiler: Will be passed along to the created event Location's show_in_spoiler attribute.
:return: The created Event Item
"""
if location_type is None:
location_type = Location
if item_name is None:
item_name = location_name
if item_type is None:
item_type = Item
event_location = location_type(self.player, location_name, None, self)
event_location.show_in_spoiler = show_in_spoiler
if rule is not None:
event_location.access_rule = rule
event_item = item_type(item_name, ItemClassification.progression, None, self.player)
event_location.place_locked_item(event_item)
self.locations.append(event_location)
return event_item
def connect(self, connecting_region: Region, name: Optional[str] = None,
rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
"""
@@ -1337,8 +1240,8 @@ class Region:
Connects current region to regions in exit dictionary. Passed region names must exist first.
:param exits: exits from the region. format is {"connecting_region": "exit_name"}. if a non dict is provided,
created entrances will be named "self.name -> connecting_region"
:param rules: rules for the exits from this region. format is {"connecting_region": rule}
created entrances will be named "self.name -> connecting_region"
:param rules: rules for the exits from this region. format is {"connecting_region", rule}
"""
if not isinstance(exits, Dict):
exits = dict.fromkeys(exits)
@@ -1407,6 +1310,9 @@ class Location:
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})'
def __hash__(self):
return hash((self.name, self.player))
def __lt__(self, other: Location):
return (self.player, self.name) < (other.player, other.name)
@@ -1510,10 +1416,6 @@ class Item:
def flags(self) -> int:
return self.classification.as_flag()
@property
def is_event(self) -> bool:
return self.code is None
def __eq__(self, other: object) -> bool:
if not isinstance(other, Item):
return NotImplemented
@@ -1607,19 +1509,21 @@ class Spoiler:
# in the second phase, we cull each sphere such that the game is still beatable,
# reducing each range of influence to the bare minimum required inside it
required_locations = {location for sphere in collection_spheres for location in sphere}
restore_later: Dict[Location, Item] = {}
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
to_delete: Set[Location] = set()
for location in sphere:
# we remove the location from required_locations to sweep from, and check if the game is still beatable
# we remove the item at location and check if game is still beatable
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
location.item.player)
required_locations.remove(location)
if multiworld.can_beat_game(state_cache[num], required_locations):
old_item = location.item
location.item = None
if multiworld.can_beat_game(state_cache[num]):
to_delete.add(location)
restore_later[location] = old_item
else:
# still required, got to keep it around
required_locations.add(location)
location.item = old_item
# cull entries in spheres for spoiler walkthrough at end
sphere -= to_delete
@@ -1636,7 +1540,7 @@ class Spoiler:
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
precollected_items.remove(item)
multiworld.state.remove(item)
if not multiworld.can_beat_game(multiworld.state, required_locations):
if not multiworld.can_beat_game():
# Add the item back into `precollected_items` and collect it into `multiworld.state`.
multiworld.push_precollected(item)
else:
@@ -1678,6 +1582,9 @@ class Spoiler:
self.create_paths(state, collection_spheres)
# repair the multiworld again
for location, item in restore_later.items():
location.item = item
for item in removed_precollected:
multiworld.push_precollected(item)

View File

@@ -196,11 +196,25 @@ class CommonContext:
self.lookup_type: typing.Literal["item", "location"] = lookup_type
self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
self._archipelago_lookup: typing.Dict[int, str] = {}
self._flat_store: typing.Dict[int, str] = Utils.KeyedDefaultDict(self._unknown_item)
self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict(
lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
self.warned: bool = False
# noinspection PyTypeChecker
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
# TODO: In a future version (0.6.0?) this should be simplified by removing implicit id lookups support.
if isinstance(key, int):
if not self.warned:
# Use warnings instead of logger to avoid deprecation message from appearing on user side.
self.warned = True
warnings.warn(f"Implicit name lookup by id only is deprecated and only supported to maintain "
f"backwards compatibility for now. If multiple games share the same id for a "
f"{self.lookup_type}, name could be incorrect. Please use "
f"`{self.lookup_type}_names.lookup_in_game()` or "
f"`{self.lookup_type}_names.lookup_in_slot()` instead.")
return self._flat_store[key] # type: ignore
return self._game_store[key]
def __len__(self) -> int:
@@ -240,6 +254,7 @@ class CommonContext:
id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
self._flat_store.update(id_to_name_lookup_table) # Only needed for legacy lookup method.
if game == "Archipelago":
# Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
# it updates in all chain maps automatically.
@@ -266,71 +281,38 @@ class CommonContext:
last_death_link: float = time.time() # last send/received death link on AP layer
# remaining type info
slot_info: dict[int, NetworkSlot]
"""Slot Info from the server for the current connection"""
server_address: str | None
"""Autoconnect address provided by the ctx constructor"""
password: str | None
"""Password used for Connecting, expected by server_auth"""
hint_cost: int | None
"""Current Hint Cost per Hint from the server"""
hint_points: int | None
"""Current avaliable Hint Points from the server"""
player_names: dict[int, str]
"""Current lookup of slot number to player display name from server (includes aliases)"""
slot_info: typing.Dict[int, NetworkSlot]
server_address: typing.Optional[str]
password: typing.Optional[str]
hint_cost: typing.Optional[int]
hint_points: typing.Optional[int]
player_names: typing.Dict[int, str]
finished_game: bool
"""
Bool to signal that status should be updated to Goal after reconnecting
to be used to ensure that a StatusUpdate packet does not get lost when disconnected
"""
ready: bool
"""Bool to keep track of state for the /ready command"""
team: int | None
"""Team number of currently connected slot"""
slot: int | None
"""Slot number of currently connected slot"""
auth: str | None
"""Name used in Connect packet"""
seed_name: str | None
"""Seed name that will be validated on opening a socket if present"""
team: typing.Optional[int]
slot: typing.Optional[int]
auth: typing.Optional[str]
seed_name: typing.Optional[str]
# locations
locations_checked: set[int]
"""
Local container of location ids checked to signal that LocationChecks should be resent after reconnecting
to be used to ensure that a LocationChecks packet does not get lost when disconnected
"""
locations_scouted: set[int]
"""
Local container of location ids scouted to signal that LocationScouts should be resent after reconnecting
to be used to ensure that a LocationScouts packet does not get lost when disconnected
"""
items_received: list[NetworkItem]
"""List of NetworkItems recieved from the server"""
missing_locations: set[int]
"""Container of Locations that are unchecked per server state"""
checked_locations: set[int]
"""Container of Locations that are checked per server state"""
server_locations: set[int]
"""Container of Locations that exist per server state; a combination between missing and checked locations"""
locations_info: dict[int, NetworkItem]
"""Dict of location id: NetworkItem info from LocationScouts request"""
locations_checked: typing.Set[int] # local state
locations_scouted: typing.Set[int]
items_received: typing.List[NetworkItem]
missing_locations: typing.Set[int] # server state
checked_locations: typing.Set[int] # server state
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
locations_info: typing.Dict[int, NetworkItem]
# data storage
stored_data: dict[str, typing.Any]
"""
Data Storage values by key that were retrieved from the server
any keys subscribed to with SetNotify will be kept up to date
"""
stored_data_notification_keys: set[str]
"""Current container of watched Data Storage keys, managed by ctx.set_notify"""
stored_data: typing.Dict[str, typing.Any]
stored_data_notification_keys: typing.Set[str]
# internals
# current message box through kvui
_messagebox: typing.Optional["kvui.MessageBox"] = None
"""Current message box through kvui"""
# message box reporting a loss of connection
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
"""Message box reporting a loss of connection"""
def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
# server state
@@ -374,6 +356,7 @@ class CommonContext:
self.item_names = self.NameLookupDict(self, "item")
self.location_names = self.NameLookupDict(self, "location")
self.versions = {}
self.checksums = {}
self.jsontotextparser = JSONtoTextParser(self)
@@ -430,8 +413,7 @@ class CommonContext:
await self.server.socket.close()
if self.server_task is not None:
await self.server_task
if self.ui:
self.ui.update_hints()
self.ui.update_hints()
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
""" `msgs` JSON serializable """
@@ -588,6 +570,7 @@ class CommonContext:
# DataPackage
async def prepare_data_package(self, relevant_games: typing.Set[str],
remote_date_package_versions: typing.Dict[str, int],
remote_data_package_checksums: typing.Dict[str, str]):
"""Validate that all data is present for the current multiworld.
Download, assimilate and cache missing data from the server."""
@@ -596,26 +579,33 @@ class CommonContext:
needed_updates: typing.Set[str] = set()
for game in relevant_games:
if game not in remote_data_package_checksums:
if game not in remote_date_package_versions and game not in remote_data_package_checksums:
continue
remote_version: int = remote_date_package_versions.get(game, 0)
remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game)
if not remote_checksum: # custom data package and no checksum for this game
if remote_version == 0 and not remote_checksum: # custom data package and no checksum for this game
needed_updates.add(game)
continue
cached_version: int = self.versions.get(game, 0)
cached_checksum: typing.Optional[str] = self.checksums.get(game)
# no action required if cached version is new enough
if remote_checksum != cached_checksum:
if (not remote_checksum and (remote_version > cached_version or remote_version == 0)) \
or remote_checksum != cached_checksum:
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
if remote_checksum == local_checksum:
if ((remote_checksum or remote_version <= local_version and remote_version != 0)
and remote_checksum == local_checksum):
self.update_game(network_data_package["games"][game], game)
else:
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
cache_version: int = cached_game.get("version", 0)
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
# download remote version if cache is not new enough
if remote_checksum != cache_checksum:
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
or remote_checksum != cache_checksum:
needed_updates.add(game)
else:
self.update_game(cached_game, game)
@@ -625,6 +615,7 @@ class CommonContext:
def update_game(self, game_package: dict, game: str):
self.item_names.update_game(game, game_package["item_name_to_id"])
self.location_names.update_game(game, game_package["location_name_to_id"])
self.versions[game] = game_package.get("version", 0)
self.checksums[game] = game_package.get("checksum")
def update_data_package(self, data_package: dict):
@@ -633,6 +624,9 @@ class CommonContext:
def consume_network_data_package(self, data_package: dict):
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'])}")
for game, game_data in data_package["games"].items():
Utils.store_data_package_for_checksum(game, game_data)
@@ -895,8 +889,9 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
# update data package
data_package_versions = args.get("datapackage_versions", {})
data_package_checksums = args.get("datapackage_checksums", {})
await ctx.prepare_data_package(set(args["games"]), data_package_checksums)
await ctx.prepare_data_package(set(args["games"]), data_package_versions, data_package_checksums)
await ctx.server_auth(args['password'])
@@ -1133,7 +1128,7 @@ def run_as_textclient(*args):
args = handle_url_arg(args, parser=parser)
# use colorama to display colored text highlighting on windows
colorama.just_fix_windows_console()
colorama.init()
asyncio.run(main(args))
colorama.deinit()

267
FF1Client.py Normal file
View File

@@ -0,0 +1,267 @@
import asyncio
import copy
import json
import time
from asyncio import StreamReader, StreamWriter
from typing import List
import Utils
from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
get_base_parser
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_ff1.lua"
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure connector_ff1.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_ff1.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
DISPLAY_MSGS = True
class FF1CommandProcessor(ClientCommandProcessor):
def __init__(self, ctx: CommonContext):
super().__init__(ctx)
def _cmd_nes(self):
"""Check NES Connection State"""
if isinstance(self.ctx, FF1Context):
logger.info(f"NES Status: {self.ctx.nes_status}")
def _cmd_toggle_msgs(self):
"""Toggle displaying messages in EmuHawk"""
global DISPLAY_MSGS
DISPLAY_MSGS = not DISPLAY_MSGS
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
class FF1Context(CommonContext):
command_processor = FF1CommandProcessor
game = 'Final Fantasy'
items_handling = 0b111 # full remote
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.nes_streams: (StreamReader, StreamWriter) = None
self.nes_sync_task = None
self.messages = {}
self.locations_array = None
self.nes_status = CONNECTION_INITIAL_STATUS
self.awaiting_rom = False
self.display_msgs = True
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(FF1Context, self).server_auth(password_requested)
if not self.auth:
self.awaiting_rom = True
logger.info('Awaiting connection to NES to get Player information')
return
await self.send_connect()
def _set_message(self, msg: str, msg_id: int):
if DISPLAY_MSGS:
self.messages[time.time(), msg_id] = msg
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
async_start(parse_locations(self.locations_array, self, True))
elif cmd == 'Print':
msg = args['text']
if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID)
def on_print_json(self, args: dict):
if self.ui:
self.ui.print_json(copy.deepcopy(args["data"]))
else:
text = self.jsontotextparser(copy.deepcopy(args["data"]))
logger.info(text)
relevant = args.get("type", None) in {"Hint", "ItemSend"}
if relevant:
item = args["item"]
# goes to this world
if self.slot_concerns_self(args["receiving"]):
relevant = True
# found in this world
elif self.slot_concerns_self(item.player):
relevant = True
# not related
else:
relevant = False
if relevant:
item = args["item"]
msg = self.raw_text_parser(copy.deepcopy(args["data"]))
self._set_message(msg, item.item)
def run_gui(self):
from kvui import GameManager
class FF1Manager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Final Fantasy 1 Client"
self.ui = FF1Manager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def get_payload(ctx: FF1Context):
current_time = time.time()
return json.dumps(
{
"items": [item.item for item in ctx.items_received],
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
if key[0] > current_time - 10}
}
)
async def parse_locations(locations_array: List[int], ctx: FF1Context, force: bool):
if locations_array == ctx.locations_array and not force:
return
else:
# print("New values")
ctx.locations_array = locations_array
locations_checked = []
if len(locations_array) > 0xFE and locations_array[0xFE] & 0x02 != 0 and not ctx.finished_game:
await ctx.send_msgs([
{"cmd": "StatusUpdate",
"status": 30}
])
ctx.finished_game = True
for location in ctx.missing_locations:
# index will be - 0x100 or 0x200
index = location
if location < 0x200:
# Location is a chest
index -= 0x100
flag = 0x04
else:
# Location is an NPC
index -= 0x200
flag = 0x02
# print(f"Location: {ctx.location_names[location]}")
# print(f"Index: {str(hex(index))}")
# print(f"value: {locations_array[index] & flag != 0}")
if locations_array[index] & flag != 0:
locations_checked.append(location)
if locations_checked:
# print([ctx.location_names[location] for location in locations_checked])
await ctx.send_msgs([
{"cmd": "LocationChecks",
"locations": locations_checked}
])
async def nes_sync_task(ctx: FF1Context):
logger.info("Starting nes connector. Use /nes for status information")
while not ctx.exit_event.is_set():
error_status = None
if ctx.nes_streams:
(reader, writer) = ctx.nes_streams
msg = get_payload(ctx).encode()
writer.write(msg)
writer.write(b'\n')
try:
await asyncio.wait_for(writer.drain(), timeout=1.5)
try:
# Data will return a dict with up to two fields:
# 1. A keepalive response of the Players Name (always)
# 2. An array representing the memory values of the locations area (if in game)
data = await asyncio.wait_for(reader.readline(), timeout=5)
data_decoded = json.loads(data.decode())
# print(data_decoded)
if ctx.game is not None and 'locations' in data_decoded:
# Not just a keep alive ping, parse
async_start(parse_locations(data_decoded['locations'], ctx, False))
if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '':
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate"
"the ROM using the same link but adding your slot name")
if ctx.awaiting_rom:
await ctx.server_auth(False)
except asyncio.TimeoutError:
logger.debug("Read Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.nes_streams = None
except ConnectionResetError as e:
logger.debug("Read failed due to Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.nes_streams = None
except TimeoutError:
logger.debug("Connection Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.nes_streams = None
except ConnectionResetError:
logger.debug("Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.nes_streams = None
if ctx.nes_status == CONNECTION_TENTATIVE_STATUS:
if not error_status:
logger.info("Successfully Connected to NES")
ctx.nes_status = CONNECTION_CONNECTED_STATUS
else:
ctx.nes_status = f"Was tentatively connected but error occured: {error_status}"
elif error_status:
ctx.nes_status = error_status
logger.info("Lost connection to nes and attempting to reconnect. Use /nes for status updates")
else:
try:
logger.debug("Attempting to connect to NES")
ctx.nes_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 52980), timeout=10)
ctx.nes_status = CONNECTION_TENTATIVE_STATUS
except TimeoutError:
logger.debug("Connection Timed Out, Trying Again")
ctx.nes_status = CONNECTION_TIMING_OUT_STATUS
continue
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.nes_status = CONNECTION_REFUSED_STATUS
continue
if __name__ == '__main__':
# Text Mode to use !hint and such with games that have no text entry
Utils.init_logging("FF1Client")
options = Utils.get_options()
DISPLAY_MSGS = options["ffr_options"]["display_msgs"]
async def main(args):
ctx = FF1Context(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
ctx.nes_sync_task = asyncio.create_task(nes_sync_task(ctx), name="NES Sync")
await ctx.exit_event.wait()
ctx.server_address = None
await ctx.shutdown()
if ctx.nes_sync_task:
await ctx.nes_sync_task
import colorama
parser = get_base_parser()
args = parser.parse_args()
colorama.init()
asyncio.run(main(args))
colorama.deinit()

12
FactorioClient.py Normal file
View File

@@ -0,0 +1,12 @@
from __future__ import annotations
import ModuleUpdate
ModuleUpdate.update()
from worlds.factorio.Client import check_stdin, launch
import Utils
if __name__ == "__main__":
Utils.init_logging("FactorioClient", exception_logger="Client")
check_stdin()
launch()

408
Fill.py
View File

@@ -4,7 +4,7 @@ import logging
import typing
from collections import Counter, deque
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, PlandoItemBlock
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
from Options import Accessibility
from worlds.AutoWorld import call_all
@@ -75,11 +75,9 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
items_to_place.append(reachable_items[next_player].pop())
for item in items_to_place:
# 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):
for p, pool_item in enumerate(item_pool):
if pool_item is item:
del item_pool[-p]
item_pool.pop(p)
break
maximum_exploration_state = sweep_from_pool(
@@ -100,7 +98,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
# if minimal accessibility, only check whether location is reachable if game not beatable
if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state,
item_to_place.player) \
item_to_place.player) \
if single_player_placement else not has_beaten_game
else:
perform_access_check = True
@@ -138,21 +136,32 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
# to clean that up later, so there is a chance generation fails.
if (not single_player_placement or location.player == item_to_place.player) \
and location.can_fill(swap_state, item_to_place, perform_access_check):
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
swap_count += 1
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
# Verify placing this item won't reduce available locations, which would be a useless swap.
prev_state = swap_state.copy()
prev_loc_count = len(
multiworld.get_reachable_locations(prev_state))
reachable_items[placed_item.player].appendleft(
placed_item)
item_pool.append(placed_item)
swap_state.collect(item_to_place, True)
new_loc_count = len(
multiworld.get_reachable_locations(swap_state))
# cleanup at the end to hopefully get better errors
cleanup_required = True
if new_loc_count >= prev_loc_count:
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
break
swap_count += 1
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
reachable_items[placed_item.player].appendleft(
placed_item)
item_pool.append(placed_item)
# cleanup at the end to hopefully get better errors
cleanup_required = True
break
# Item can't be placed here, restore original item
location.item = placed_item
@@ -231,7 +240,7 @@ def remaining_fill(multiworld: MultiWorld,
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
total = min(len(itempool), len(locations))
total = min(len(itempool), len(locations))
placed = 0
# Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule
@@ -332,19 +341,17 @@ def fast_fill(multiworld: MultiWorld,
def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]):
maximum_exploration_state = sweep_from_pool(state, pool)
minimal_players = {player for player in multiworld.player_ids if
multiworld.worlds[player].options.accessibility == "minimal"}
unreachable_locations = [location for location in multiworld.get_locations() if
location.player in minimal_players and
minimal_players = {player for player in multiworld.player_ids if multiworld.worlds[player].options.accessibility == "minimal"}
unreachable_locations = [location for location in multiworld.get_locations() if location.player in minimal_players and
not location.can_reach(maximum_exploration_state)]
for location in unreachable_locations:
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):
pool.append(location.item)
state.remove(location.item)
location.item = None
if location in state.advancements:
state.advancements.remove(location)
state.remove(location.item)
locations.append(location)
if pool and locations:
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
@@ -356,7 +363,7 @@ def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState,
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
if unreachable_locations:
def forbid_important_item_rule(item: Item):
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != "minimal")
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != 'minimal')
for location in unreachable_locations:
add_item_rule(location, forbid_important_item_rule)
@@ -493,15 +500,13 @@ def distribute_items_restrictive(multiworld: MultiWorld,
if prioritylocations:
# "priority fill"
maximum_exploration_state = sweep_from_pool(multiworld.state)
fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool,
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority", one_item_per_player=True, allow_partial=True)
if prioritylocations:
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
maximum_exploration_state = sweep_from_pool(multiworld.state)
fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool,
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority Retry", one_item_per_player=False)
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
@@ -509,15 +514,14 @@ def distribute_items_restrictive(multiworld: MultiWorld,
if progitempool:
# "advancement/progression fill"
maximum_exploration_state = sweep_from_pool(multiworld.state)
if panic_method == "swap":
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=True,
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=True,
name="Progression", single_player_placement=single_player)
elif panic_method == "raise":
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False,
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
name="Progression", single_player_placement=single_player)
elif panic_method == "start_inventory":
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False,
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
allow_partial=True, name="Progression", single_player_placement=single_player)
if progitempool:
for item in progitempool:
@@ -668,9 +672,9 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
if multiworld.worlds[player].options.progression_balancing > 0
}
if not balanceable_players:
logging.info("Skipping multiworld progression balancing.")
logging.info('Skipping multiworld progression balancing.')
else:
logging.info(f"Balancing multiworld progression for {len(balanceable_players)} Players.")
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
logging.debug(balanceable_players)
state: CollectionState = CollectionState(multiworld)
checked_locations: typing.Set[Location] = set()
@@ -768,7 +772,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
if player in threshold_percentages):
break
elif not balancing_sphere:
raise RuntimeError("Not all required items reachable. Something went terribly wrong here.")
raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
# Gather a set of locations which we can swap items into
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
for l in unchecked_locations:
@@ -784,8 +788,8 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
testing = items_to_test.pop()
reducing_state = state.copy()
for location in itertools.chain((
l for l in items_to_replace
if l.item.player == player
l for l in items_to_replace
if l.item.player == player
), items_to_test):
reducing_state.collect(location.item, True, location)
@@ -858,30 +862,52 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked:
location_2.item.location = location_2
def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlock]]:
def warn(warning: str, force: bool | str) -> None:
if isinstance(force, bool):
logging.warning(f"{warning}")
def distribute_planned(multiworld: MultiWorld) -> None:
def warn(warning: str, force: typing.Union[bool, str]) -> None:
if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']:
logging.warning(f'{warning}')
else:
logging.debug(f"{warning}")
logging.debug(f'{warning}')
def failed(warning: str, force: bool | str) -> None:
if force is True:
def failed(warning: str, force: typing.Union[bool, str]) -> None:
if force in [True, 'fail', 'failure']:
raise Exception(warning)
else:
warn(warning, force)
swept_state = multiworld.state.copy()
swept_state.sweep_for_advancements()
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
for loc in multiworld.get_unfilled_locations():
if loc in reachable:
early_locations[loc.player].append(loc.name)
else: # not reachable with swept state
non_early_locations[loc.player].append(loc.name)
world_name_lookup = multiworld.world_name_lookup
plando_blocks: dict[int, list[PlandoItemBlock]] = dict()
player_ids: set[int] = set(multiworld.player_ids)
block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
plando_blocks: typing.List[typing.Dict[str, typing.Any]] = []
player_ids = set(multiworld.player_ids)
for player in player_ids:
plando_blocks[player] = []
for block in multiworld.worlds[player].options.plando_items:
new_block: PlandoItemBlock = PlandoItemBlock(player, block.from_pool, block.force)
target_world = block.world
for block in multiworld.plando_items[player]:
block['player'] = player
if 'force' not in block:
block['force'] = 'silent'
if 'from_pool' not in block:
block['from_pool'] = True
elif not isinstance(block['from_pool'], bool):
from_pool_type = type(block['from_pool'])
raise Exception(f'Plando "from_pool" has to be boolean, not {from_pool_type} for player {player}.')
if 'world' not in block:
target_world = False
else:
target_world = block['world']
if target_world is False or multiworld.players == 1: # target own world
worlds: set[int] = {player}
worlds: typing.Set[int] = {player}
elif target_world is True: # target any worlds besides own
worlds = set(multiworld.player_ids) - {player}
elif target_world is None: # target all worlds
@@ -890,201 +916,173 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo
worlds = set()
for listed_world in target_world:
if listed_world not in world_name_lookup:
failed(f"Cannot place item to {listed_world}'s world as that world does not exist.",
block.force)
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
block['force'])
continue
worlds.add(world_name_lookup[listed_world])
elif type(target_world) == int: # target world by slot number
if target_world not in range(1, multiworld.players + 1):
failed(
f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
block.force)
block['force'])
continue
worlds = {target_world}
else: # target world by slot name
if target_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
block.force)
block['force'])
continue
worlds = {world_name_lookup[target_world]}
new_block.worlds = worlds
block['world'] = worlds
items: list[str] | dict[str, typing.Any] = block.items
items: block_value = []
if "items" in block:
items = block["items"]
if 'count' not in block:
block['count'] = False
elif "item" in block:
items = block["item"]
if 'count' not in block:
block['count'] = 1
else:
failed("You must specify at least one item to place items with plando.", block['force'])
continue
if isinstance(items, dict):
item_list: list[str] = []
item_list: typing.List[str] = []
for key, value in items.items():
if value is True:
value = multiworld.itempool.count(multiworld.worlds[player].create_item(key))
item_list += [key] * value
items = item_list
new_block.items = items
if isinstance(items, str):
items = [items]
block['items'] = items
locations: list[str] = block.locations
locations: block_value = []
if 'location' in block:
locations = block['location'] # just allow 'location' to keep old yamls compatible
elif 'locations' in block:
locations = block['locations']
if isinstance(locations, str):
locations = [locations]
resolved_locations: list[Location] = []
for target_player in worlds:
locations_from_groups: list[str] = []
world_locations = multiworld.get_unfilled_locations(target_player)
for group in multiworld.worlds[target_player].location_name_groups:
if group in locations:
locations_from_groups.extend(multiworld.worlds[target_player].location_name_groups[group])
resolved_locations.extend(location for location in world_locations
if location.name in [*locations, *locations_from_groups])
new_block.locations = sorted(dict.fromkeys(locations))
new_block.resolved_locations = sorted(set(resolved_locations))
if isinstance(locations, dict):
location_list = []
for key, value in locations.items():
location_list += [key] * value
locations = location_list
count = block.count
if not count:
count = (min(len(new_block.items), len(new_block.resolved_locations))
if new_block.resolved_locations else len(new_block.items))
if isinstance(count, int):
count = {"min": count, "max": count}
if "min" not in count:
count["min"] = 0
if "max" not in count:
count["max"] = (min(len(new_block.items), len(new_block.resolved_locations))
if new_block.resolved_locations else len(new_block.items))
new_block.count = count
plando_blocks[player].append(new_block)
return plando_blocks
def resolve_early_locations_for_planned(multiworld: MultiWorld):
def warn(warning: str, force: bool | str) -> None:
if isinstance(force, bool):
logging.warning(f"{warning}")
else:
logging.debug(f"{warning}")
def failed(warning: str, force: bool | str) -> None:
if force is True:
raise Exception(warning)
else:
warn(warning, force)
swept_state = multiworld.state.copy()
swept_state.sweep_for_advancements()
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
early_locations: dict[int, list[Location]] = collections.defaultdict(list)
non_early_locations: dict[int, list[Location]] = collections.defaultdict(list)
for loc in multiworld.get_unfilled_locations():
if loc in reachable:
early_locations[loc.player].append(loc)
else: # not reachable with swept state
non_early_locations[loc.player].append(loc)
for player in multiworld.plando_item_blocks:
removed = []
for block in multiworld.plando_item_blocks[player]:
locations = block.locations
resolved_locations = block.resolved_locations
worlds = block.worlds
if "early_locations" in locations:
locations.remove("early_locations")
for target_player in worlds:
resolved_locations += early_locations[target_player]
locations += early_locations[target_player]
if "non_early_locations" in locations:
locations.remove("non_early_locations")
for target_player in worlds:
resolved_locations += non_early_locations[target_player]
locations += non_early_locations[target_player]
if block.count["max"] > len(block.items):
count = block.count["max"]
failed(f"Plando count {count} greater than items specified", block.force)
block.count["max"] = len(block.items)
if block.count["min"] > len(block.items):
block.count["min"] = len(block.items)
if block.count["max"] > len(block.resolved_locations) > 0:
count = block.count["max"]
failed(f"Plando count {count} greater than locations specified", block.force)
block.count["max"] = len(block.resolved_locations)
if block.count["min"] > len(block.resolved_locations):
block.count["min"] = len(block.resolved_locations)
block.count["target"] = multiworld.random.randint(block.count["min"],
block.count["max"])
block['locations'] = list(dict.fromkeys(locations))
if not block.count["target"]:
removed.append(block)
if not block['count']:
block['count'] = (min(len(block['items']), len(block['locations'])) if
len(block['locations']) > 0 else len(block['items']))
if isinstance(block['count'], int):
block['count'] = {'min': block['count'], 'max': block['count']}
if 'min' not in block['count']:
block['count']['min'] = 0
if 'max' not in block['count']:
block['count']['max'] = (min(len(block['items']), len(block['locations'])) if
len(block['locations']) > 0 else len(block['items']))
if block['count']['max'] > len(block['items']):
count = block['count']
failed(f"Plando count {count} greater than items specified", block['force'])
block['count'] = len(block['items'])
if block['count']['max'] > len(block['locations']) > 0:
count = block['count']
failed(f"Plando count {count} greater than locations specified", block['force'])
block['count'] = len(block['locations'])
block['count']['target'] = multiworld.random.randint(block['count']['min'], block['count']['max'])
for block in removed:
multiworld.plando_item_blocks[player].remove(block)
def distribute_planned_blocks(multiworld: MultiWorld, plando_blocks: list[PlandoItemBlock]):
def warn(warning: str, force: bool | str) -> None:
if isinstance(force, bool):
logging.warning(f"{warning}")
else:
logging.debug(f"{warning}")
def failed(warning: str, force: bool | str) -> None:
if force is True:
raise Exception(warning)
else:
warn(warning, force)
if block['count']['target'] > 0:
plando_blocks.append(block)
# shuffle, but then sort blocks by number of locations minus number of items,
# so less-flexible blocks get priority
multiworld.random.shuffle(plando_blocks)
plando_blocks.sort(key=lambda block: (len(block.resolved_locations) - block.count["target"]
if len(block.resolved_locations) > 0
else len(multiworld.get_unfilled_locations(block.player)) -
block.count["target"]))
plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target']
if len(block['locations']) > 0
else len(multiworld.get_unfilled_locations(player)) - block['count']['target']))
for placement in plando_blocks:
player = placement.player
player = placement['player']
try:
worlds = placement.worlds
locations = placement.resolved_locations
items = placement.items
maxcount = placement.count["target"]
from_pool = placement.from_pool
worlds = placement['world']
locations = placement['locations']
items = placement['items']
maxcount = placement['count']['target']
from_pool = placement['from_pool']
item_candidates = []
if from_pool:
instances = [item for item in multiworld.itempool if item.player == player and item.name in items]
for item in multiworld.random.sample(items, maxcount):
candidate = next((i for i in instances if i.name == item), None)
if candidate is None:
warn(f"Could not remove {item} from pool for {multiworld.player_name[player]} as "
f"it's already missing from it", placement.force)
candidate = multiworld.worlds[player].create_item(item)
else:
multiworld.itempool.remove(candidate)
instances.remove(candidate)
item_candidates.append(candidate)
else:
item_candidates = [multiworld.worlds[player].create_item(item)
for item in multiworld.random.sample(items, maxcount)]
if any(item.code is None for item in item_candidates) \
and not all(item.code is None for item in item_candidates):
failed(f"Plando block for player {player} ({multiworld.player_name[player]}) contains both "
f"event items and non-event items. "
f"Event items: {[item for item in item_candidates if item.code is None]}, "
f"Non-event items: {[item for item in item_candidates if item.code is not None]}",
placement.force)
continue
else:
is_real = item_candidates[0].code is not None
candidates = [candidate for candidate in locations if candidate.item is None
and bool(candidate.address) == is_real]
candidates = list(multiworld.get_unfilled_locations_for_players(locations, sorted(worlds)))
multiworld.random.shuffle(candidates)
allstate = multiworld.get_all_state(False)
mincount = placement.count["min"]
allowed_margin = len(item_candidates) - mincount
fill_restrictive(multiworld, allstate, candidates, item_candidates, lock=True,
allow_partial=True, name="Plando Main Fill")
multiworld.random.shuffle(items)
count = 0
err: typing.List[str] = []
successful_pairs: typing.List[typing.Tuple[int, Item, Location]] = []
claimed_indices: typing.Set[typing.Optional[int]] = set()
for item_name in items:
index_to_delete: typing.Optional[int] = None
if from_pool:
try:
# If from_pool, try to find an existing item with this name & player in the itempool and use it
index_to_delete, item = next(
(i, item) for i, item in enumerate(multiworld.itempool)
if item.player == player and item.name == item_name and i not in claimed_indices
)
except StopIteration:
warn(
f"Could not remove {item_name} from pool for {multiworld.player_name[player]} as it's already missing from it.",
placement['force'])
item = multiworld.worlds[player].create_item(item_name)
else:
item = multiworld.worlds[player].create_item(item_name)
for location in reversed(candidates):
if (location.address is None) == (item.code is None): # either both None or both not None
if not location.item:
if location.item_rule(item):
if location.can_fill(multiworld.state, item, False):
successful_pairs.append((index_to_delete, item, location))
claimed_indices.add(index_to_delete)
candidates.remove(location)
count = count + 1
break
else:
err.append(f"Can't place item at {location} due to fill condition not met.")
else:
err.append(f"{item_name} not allowed at {location}.")
else:
err.append(f"Cannot place {item_name} into already filled location {location}.")
else:
err.append(f"Mismatch between {item_name} and {location}, only one is an event.")
if count == maxcount:
break
if count < placement['count']['min']:
m = placement['count']['min']
failed(
f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}",
placement['force'])
# Sort indices in reverse so we can remove them one by one
successful_pairs = sorted(successful_pairs, key=lambda successful_pair: successful_pair[0] or 0, reverse=True)
for (index, item, location) in successful_pairs:
multiworld.push_item(location, item, collect=False)
location.locked = True
logging.debug(f"Plando placed {item} at {location}")
if index is not None: # If this item is from_pool and was found in the pool, remove it.
multiworld.itempool.pop(index)
if len(item_candidates) > allowed_margin:
failed(f"Could not place {len(item_candidates)} "
f"of {mincount + allowed_margin} item(s) "
f"for {multiworld.player_name[player]}, "
f"remaining items: {item_candidates}",
placement.force)
if from_pool:
multiworld.itempool.extend([item for item in item_candidates if item.code is not None])
except Exception as e:
raise Exception(
f"Error running plando for player {player} ({multiworld.player_name[player]})") from e

View File

@@ -10,8 +10,8 @@ import sys
import urllib.parse
import urllib.request
from collections import Counter
from typing import Any, Dict, Tuple, Union
from itertools import chain
from typing import Any
import ModuleUpdate
@@ -54,22 +54,12 @@ def mystery_argparse():
parser.add_argument("--skip_output", action="store_true",
help="Skips generation assertion and output stages and skips multidata and spoiler output. "
"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()
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):
args.weights_file_path = os.path.join(args.player_files_path, args.weights_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.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
return args
@@ -77,7 +67,7 @@ def get_seed_name(random_source) -> str:
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
def main(args=None) -> tuple[argparse.Namespace, int]:
def main(args=None) -> Tuple[argparse.Namespace, int]:
# __name__ == "__main__" check so unittests that already imported worlds don't trip this.
if __name__ == "__main__" and "worlds" in sys.modules:
raise Exception("Worlds system should not be loaded before logging init.")
@@ -95,7 +85,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
logging.info("Race mode enabled. Using non-deterministic random source.")
random.seed() # reset to time-based random source
weights_cache: dict[str, tuple[Any, ...]] = {}
weights_cache: Dict[str, Tuple[Any, ...]] = {}
if args.weights_file_path and os.path.exists(args.weights_file_path):
try:
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
@@ -118,8 +108,6 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
raise Exception("Cannot mix --sameoptions with --meta")
else:
meta_weights = None
player_id = 1
player_files = {}
for file in os.scandir(args.player_files_path):
@@ -176,11 +164,10 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
erargs.outputpath = args.outputpath
erargs.skip_prog_balancing = args.skip_prog_balancing
erargs.skip_output = args.skip_output
erargs.spoiler_only = args.spoiler_only
erargs.name = {}
erargs.csv_output = args.csv_output
settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
for fname, yamls in weights_cache.items()}
@@ -212,7 +199,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
path = player_path_cache[player]
if path:
try:
settings: tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
settings: Tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
for settingsObject in settings:
for k, v in vars(settingsObject).items():
@@ -224,14 +211,10 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
except Exception as e:
raise Exception(f"Error setting {k} to {v} for player {player}") from e
# name was not specified
if player not in erargs.name:
if path == args.weights_file_path:
# weights file, so we need to make the name unique
erargs.name[player] = f"Player{player}"
else:
# use the filename
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
if path == args.weights_file_path: # if name came from the weights file, just use base player name
erargs.name[player] = f"Player{player}"
elif player not in erargs.name: # if name was not specified, generate it from filename
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
player += 1
@@ -246,7 +229,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
return erargs, seed
def read_weights_yamls(path) -> tuple[Any, ...]:
def read_weights_yamls(path) -> Tuple[Any, ...]:
try:
if urllib.parse.urlparse(path).scheme in ('https', 'file'):
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
@@ -256,20 +239,7 @@ def read_weights_yamls(path) -> tuple[Any, ...]:
except Exception as e:
raise Exception(f"Failed to read weights ({path})") from e
from yaml.error import MarkedYAMLError
try:
return tuple(parse_yamls(yaml))
except MarkedYAMLError as ex:
if ex.problem_mark:
lines = yaml.splitlines()
if ex.context_mark:
relevant_lines = "\n".join(lines[ex.context_mark.line:ex.problem_mark.line+1])
else:
relevant_lines = lines[ex.problem_mark.line]
error_line = " " * ex.problem_mark.column + "^"
raise Exception(f"{ex.context} {ex.problem} on line {ex.problem_mark.line}:"
f"\n{relevant_lines}\n{error_line}")
raise ex
return tuple(parse_yamls(yaml))
def interpret_on_off(value) -> bool:
@@ -309,35 +279,33 @@ def get_choice(option, root, value=None) -> Any:
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
class SafeFormatter(string.Formatter):
def get_value(self, key, args, kwargs):
if isinstance(key, int):
if key < len(args):
return args[key]
else:
return "{" + str(key) + "}"
else:
return kwargs.get(key, "{" + key + "}")
class SafeDict(dict):
def __missing__(self, key):
return '{' + key + '}'
def handle_name(name: str, player: int, name_counter: Counter):
name_counter[name.lower()] += 1
number = name_counter[name.lower()]
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
new_name = SafeFormatter().vformat(new_name, (), {"number": number,
"NUMBER": (number if number > 1 else ''),
"player": player,
"PLAYER": (player if player > 1 else '')})
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number,
NUMBER=(number if number > 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.
# Could cause issues for some clients that cannot handle the additional whitespace.
new_name = new_name.strip()[:16].strip()
if new_name == "Archipelago":
raise Exception(f"You cannot name yourself \"{new_name}\"")
return new_name
def roll_percentage(percentage: Union[int, float]) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
return random.random() < (float(percentage) / 100)
def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
logging.debug(f'Applying {new_weights}')
cleaned_weights = {}
@@ -382,7 +350,7 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
return weights
def roll_meta_option(option_key, game: str, category_dict: dict) -> Any:
def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
from worlds import AutoWorldRegister
if not game:
@@ -403,7 +371,7 @@ def roll_linked_options(weights: dict) -> dict:
if "name" not in option_set:
raise ValueError("One of your linked options does not have a name.")
try:
if Options.roll_percentage(option_set["percentage"]):
if roll_percentage(option_set["percentage"]):
logging.debug(f"Linked option {option_set['name']} triggered.")
new_options = option_set["options"]
for category_name, category_options in new_options.items():
@@ -436,7 +404,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
trigger_result = get_choice("option_result", option_set)
result = get_choice(key, currently_targeted_weights)
currently_targeted_weights[key] = result
if result == trigger_result and Options.roll_percentage(get_choice("percentage", option_set, 100)):
if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
for category_name, category_options in option_set["options"].items():
currently_targeted_weights = weights
if category_name:
@@ -467,14 +435,6 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
"""
Roll options from specified weights, usually originating from a .yaml options file.
Important note:
The same weights dict is shared between all slots using the same yaml (e.g. generic weights file for filler slots).
This means it should never be modified without making a deepcopy first.
"""
from worlds import AutoWorldRegister
if "linked_options" in weights:
@@ -540,6 +500,10 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
handle_option(ret, game_weights, option_key, option, plando_options)
valid_keys.add(option_key)
# TODO remove plando_items after moving it to the options system
valid_keys.add("plando_items")
if PlandoOptions.items in plando_options:
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
if ret.game == "A Link to the Past":
# TODO there are still more LTTP options not on the options system
valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"}

View File

@@ -1,30 +1,29 @@
"""
Archipelago Launcher
Archipelago launcher for bundled app.
* If run with a patch file as argument, launch corresponding client with the patch file as an argument.
* If run with component name as argument, run it passing argv[2:] as arguments.
* If run without arguments or unknown arguments, open launcher GUI.
* if run with APBP as argument, launch corresponding client.
* if run with executable as argument, run it passing argv[2:] as arguments
* if run without arguments, open launcher GUI
Additional components can be added to worlds.LauncherComponents.components.
Scroll down to components= to add components to the launcher as well as setup.py
"""
import argparse
import itertools
import logging
import multiprocessing
import os
import shlex
import subprocess
import sys
import urllib.parse
import webbrowser
from collections.abc import Callable, Sequence
from os.path import isfile
from shutil import which
from typing import Any
from typing import Callable, Optional, Sequence, Tuple, Union
if __name__ == "__main__":
import ModuleUpdate
ModuleUpdate.update()
import settings
@@ -42,17 +41,13 @@ def open_host_yaml():
if is_linux:
exe = which('sensible-editor') or which('gedit') or \
which('xdg-open') or which('gnome-open') or which('kde-open')
subprocess.Popen([exe, file])
elif is_macos:
exe = which("open")
subprocess.Popen([exe, file])
else:
webbrowser.open(file)
return
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
subprocess.Popen([exe, file], env=env)
def open_patch():
suffixes = []
@@ -90,20 +85,12 @@ def browse_files():
def open_folder(folder_path):
if is_linux:
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
subprocess.Popen([exe, folder_path])
elif is_macos:
exe = which("open")
subprocess.Popen([exe, folder_path])
else:
webbrowser.open(folder_path)
return
if exe:
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
subprocess.Popen([exe, folder_path], env=env)
else:
logging.warning(f"No file browser available to open {folder_path}")
def update_settings():
@@ -113,51 +100,74 @@ def update_settings():
components.extend([
# Functions
Component("Open host.yaml", func=open_host_yaml,
description="Open the host.yaml file to change settings for generation, games, and more."),
Component("Open Patch", func=open_patch,
description="Open a patch file, downloaded from the room page or provided by the host."),
Component("Generate Template Options", func=generate_yamls,
description="Generate template YAMLs for currently installed games."),
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/"),
description="Open archipelago.gg in your browser."),
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2"),
description="Join the Discord server to play public multiworlds, report issues, or just chat!"),
Component("Unrated/18+ Discord Server", icon="discord",
func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4"),
description="Find unrated and 18+ games in the After Dark Discord server."),
Component("Browse Files", func=browse_files,
description="Open the Archipelago installation folder in your file browser."),
Component("Open host.yaml", func=open_host_yaml),
Component("Open Patch", func=open_patch),
Component("Generate Template Options", func=generate_yamls),
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")),
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("Browse Files", func=browse_files),
])
def handle_uri(path: str) -> tuple[list[Component], Component]:
def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
url = urllib.parse.urlparse(path)
queries = urllib.parse.parse_qs(url.query)
client_components = []
launch_args = (path, *launch_args)
client_component = None
text_client_component = None
game = queries["game"][0]
if "game" in queries:
game = queries["game"][0]
else: # TODO around 0.6.0 - this is for pre this change webhost uri's
game = "Archipelago"
for component in components:
if component.supports_uri and component.game_name == game:
client_components.append(component)
client_component = component
elif component.display_name == "Text Client":
text_client_component = component
return client_components, text_client_component
if client_component is None:
run_component(text_client_component, *launch_args)
return
from kvui import App, Button, BoxLayout, Label, Window
class Popup(App):
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 build_uri_popup(component_list: list[Component], launch_args: tuple[str, ...]) -> None:
from kvui import ButtonsPrompt
component_options = {
component.display_name: component for component in component_list
}
popup = ButtonsPrompt("Connect to Multiworld",
"Select client to open and connect with.",
lambda component_name: run_component(component_options[component_name], *launch_args),
*component_options.keys())
popup.open()
def identify(path: None | str) -> tuple[None | str, None | Component]:
def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
if path is None:
return None, None
for component in components:
@@ -168,7 +178,7 @@ def identify(path: None | str) -> tuple[None | str, None | Component]:
return None, None
def get_exe(component: str | Component) -> Sequence[str] | None:
def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
if isinstance(component, str):
name = component
component = None
@@ -196,8 +206,7 @@ def get_exe(component: str | Component) -> Sequence[str] | None:
def launch(exe, in_terminal=False):
if in_terminal:
if is_windows:
# intentionally using a window title with a space so it gets quoted and treated as a title
subprocess.Popen(["start", "Running Archipelago", *exe], shell=True)
subprocess.Popen(['start', *exe], shell=True)
return
elif is_linux:
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
@@ -211,189 +220,100 @@ def launch(exe, in_terminal=False):
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: Callable[[], None] | None = None
def run_gui(launch_components: list[Component], args: Any) -> None:
from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox)
from kivy.properties import ObjectProperty
def run_gui():
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget, ApAsyncImage
from kivy.core.window import Window
from kivy.metrics import dp
from kivymd.uix.button import MDIconButton, MDButton
from kivymd.uix.card import MDCard
from kivymd.uix.menu import MDDropdownMenu
from kivymd.uix.snackbar import MDSnackbar, MDSnackbarText
from kivymd.uix.textfield import MDTextField
from kivy.uix.relativelayout import RelativeLayout
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):
class Launcher(App):
base_title: str = "Archipelago Launcher"
top_screen: MDFloatLayout = ObjectProperty(None)
navigation: MDGridLayout = ObjectProperty(None)
grid: MDGridLayout = ObjectProperty(None)
button_layout: ScrollBox = ObjectProperty(None)
search_box: MDTextField = ObjectProperty(None)
cards: list[LauncherCard]
current_filter: Sequence[str | Type] | None
container: ContainerLayout
grid: GridLayout
_tool_layout: Optional[ScrollBox] = None
_client_layout: Optional[ScrollBox] = None
def __init__(self, ctx=None, components=None, args=None):
def __init__(self, ctx=None):
self.title = self.base_title + " " + Utils.__version__
self.ctx = ctx
self.icon = r"data/icon.png"
self.favorites = []
self.launch_components = components
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__()
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 _refresh_components(self) -> None:
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.
def build_button(component: Component) -> Widget:
"""
button_card = LauncherCard(component=component,
image_path=icon_paths[component.icon])
Builds a button widget for a given component.
def open_menu(caller):
caller.menu.open()
Args:
component (Component): The component associated with the button.
menu_items = [
{
"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)
Returns:
None. The button is added to the parent grid layout.
return button_card
def _refresh_components(self, type_filter: Sequence[str | Type] | None = None) -> None:
if not type_filter:
type_filter = [Type.CLIENT, Type.ADJUSTER, Type.TOOL, Type.MISC]
favorites = "favorites" in type_filter
"""
button = Button(text=component.display_name, size_hint_y=None, height=40)
button.component = component
button.bind(on_release=self.component_action)
if component.icon != "icon":
image = ApAsyncImage(source=icon_paths[component.icon],
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
assert self.button_layout, "must call `build` first"
tool_children = reversed(self.button_layout.layout.children)
assert self._tool_layout and self._client_layout, "must call `build` first"
tool_children = reversed(self._tool_layout.layout.children)
for child in tool_children:
self.button_layout.layout.remove_widget(child)
self._tool_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)
cards = [card for card in self.cards if card.component.type in type_filter
or favorites and card.component.display_name in self.favorites]
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
self.current_filter = type_filter
for card in cards:
self.button_layout.layout.add_widget(card)
top = self.button_layout.children[0].y + self.button_layout.children[0].height \
- self.button_layout.height
scroll_percent = self.button_layout.convert_distance_to_scroll(0, top)
self.button_layout.scroll_y = max(0, min(1, scroll_percent[1]))
def filter_clients_by_type(self, caller: MDButton):
self._refresh_components(caller.type)
self.search_box.text = ""
def filter_clients_by_name(self, caller: MDTextField, name: str) -> None:
if len(name) == 0:
self._refresh_components(self.current_filter)
return
sub_matches = [
card for card in self.cards
if name.lower() in card.component.display_name.lower() and card.component.type != Type.HIDDEN
]
self.button_layout.layout.clear_widgets()
for card in sub_matches:
self.button_layout.layout.add_widget(card)
for (tool, client) in itertools.zip_longest(itertools.chain(
_tools.items(), _miscs.items(), _adjusters.items()
), _clients.items()):
# column 1
if tool:
self._tool_layout.layout.add_widget(build_button(tool[1]))
# column 2
if client:
self._client_layout.layout.add_widget(build_button(client[1]))
def build(self):
self.top_screen = Builder.load_file(Utils.local_path("data/launcher.kv"))
self.grid = self.top_screen.ids.grid
self.navigation = self.top_screen.ids.navigation
self.button_layout = self.top_screen.ids.button_layout
self.search_box = self.top_screen.ids.search_box
self.set_colors()
self.top_screen.md_bg_color = self.theme_cls.backgroundColor
self.container = ContainerLayout()
self.grid = GridLayout(cols=2)
self.container.add_widget(self.grid)
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
self._tool_layout = ScrollBox()
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
refresh_components = self._refresh_components
Window.bind(on_drop_file=self._on_drop_file)
Window.bind(on_keyboard=self._on_keyboard)
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_components:
build_uri_popup(self.launch_components, self.launch_args)
self.launch_components = None
self.launch_args = None
return self.container
@staticmethod
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:
button.component.func()
else:
@@ -405,16 +325,7 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
if file and component:
run_component(component, file)
else:
logging.warning(f"unable to identify component for {filename}")
def _on_keyboard(self, window: Window, key: int, scancode: int, codepoint: str, modifier: list[str]):
# Activate search as soon as we start typing, no matter if we are focused on the search box or not.
# Focus first, then capture the first character we type, otherwise it gets swallowed and lost.
# Limit text input to ASCII non-control characters (space bar to tilde).
if not self.search_box.focus:
self.search_box.focus = True
if key in range(32, 126):
self.search_box.text += codepoint
logging.warning(f"unable to identify component for {file}")
def _stop(self, *largs):
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
@@ -422,13 +333,7 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
self.root_window.close()
super()._stop(*largs)
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(components=launch_components, args=args).run()
Launcher().run()
# avoiding Launcher reference leak
# and don't try to do something with widgets after window closed
@@ -447,7 +352,7 @@ def run_component(component: Component, *args):
logging.warning(f"Component {component} does not appear to be executable.")
def main(args: argparse.Namespace | dict | None = None):
def main(args: Optional[Union[argparse.Namespace, dict]] = None):
if isinstance(args, argparse.Namespace):
args = {k: v for k, v in args._get_kwargs()}
elif not args:
@@ -456,21 +361,15 @@ def main(args: argparse.Namespace | dict | None = None):
path = args.get("Patch|Game|Component|url", None)
if path is not None:
if path.startswith("archipelago://"):
args["args"] = (path, *args.get("args", ()))
# add the url arg to the passthrough args
components, text_client_component = handle_uri(path)
if not components:
args["component"] = text_client_component
else:
args['launch_components'] = [text_client_component, *components]
else:
file, component = identify(path)
if file:
args['file'] = file
if component:
args['component'] = component
if not component:
logging.warning(f"Could not identify Component responsible for {path}")
handle_uri(path, args.get("args", ()))
return
file, component = identify(path)
if file:
args['file'] = file
if component:
args['component'] = component
if not component:
logging.warning(f"Could not identify Component responsible for {path}")
if args["update_settings"]:
update_settings()
@@ -479,7 +378,7 @@ def main(args: argparse.Namespace | dict | None = None):
elif "component" in args:
run_component(args["component"], *args["args"])
elif not args["update_settings"]:
run_gui(args.get("launch_components", None), args.get("args", ()))
run_gui()
if __name__ == '__main__':
@@ -501,7 +400,6 @@ if __name__ == '__main__':
main(parser.parse_args())
from worlds.LauncherComponents import processes
for process in processes:
# we await all child processes to close before we tear down the process host
# this makes it feel like each one is its own program, as the Launcher is closed now

View File

@@ -26,14 +26,13 @@ import typing
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
server_loop)
from NetUtils import ClientStatus
from worlds.ladx import LinksAwakeningWorld
from worlds.ladx.Common import BASE_ID as LABaseID
from worlds.ladx.GpsTracker import GpsTracker
from worlds.ladx.TrackerConsts import storage_key
from worlds.ladx.ItemTracker import ItemTracker
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
from worlds.ladx.Locations import get_locations_to_id, meta_to_name
from worlds.ladx.Tracker import LocationTracker, MagpieBridge, Check
from worlds.ladx.Tracker import LocationTracker, MagpieBridge
class GameboyException(Exception):
@@ -52,6 +51,22 @@ class BadRetroArchResponse(GameboyException):
pass
def magpie_logo():
from kivy.uix.image import CoreImage
binary_data = """
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAAXN
SR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA
7DAcdvqGQAAADGSURBVDhPhVLBEcIwDHOYhjHCBuXHj2OTbAL8+
MEGZIxOQ1CinOOk0Op0bmo7tlXXeR9FJMYDLOD9mwcLjQK7+hSZ
wgcWMZJOAGeGKtChNHFL0j+FZD3jSCuo0w7l03wDrWdg00C4/aW
eDEYNenuzPOfPspBnxf0kssE80vN0L8361j10P03DK4x6FHabuV
ear8fHme+b17rwSjbAXeUMLb+EVTV2QHm46MWQanmnydA98KsVS
XkV+qFpGQXrLhT/fqraQeQLuplpNH5g+WkAAAAASUVORK5CYII="""
binary_data = base64.b64decode(binary_data)
data = io.BytesIO(binary_data)
return CoreImage(data, ext="png").texture
class LAClientConstants:
# Connector version
VERSION = 0x01
@@ -124,7 +139,7 @@ class RAGameboy():
def set_checks_range(self, checks_start, checks_size):
self.checks_start = checks_start
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
@@ -222,7 +237,7 @@ class RAGameboy():
self.cache[start:start + len(hram_block)] = hram_block
self.last_cache_read = time.time()
async def read_memory_block(self, address: int, size: int):
block = bytearray()
remaining_size = size
@@ -230,7 +245,7 @@ class RAGameboy():
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):
@@ -491,7 +506,7 @@ class LinksAwakeningContext(CommonContext):
la_task = None
client = None
# TODO: does this need to re-read on reset?
found_checks = set()
found_checks = []
last_resend = time.time()
magpie_enabled = False
@@ -499,8 +514,8 @@ class LinksAwakeningContext(CommonContext):
magpie_task = None
won = False
@property
def slot_storage_key(self):
@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:
@@ -514,9 +529,9 @@ class LinksAwakeningContext(CommonContext):
def run_gui(self) -> None:
import webbrowser
from kvui import GameManager
from kivy.metrics import dp
from kivymd.uix.button import MDButton, MDButtonText
import kvui
from kvui import Button, GameManager
from kivy.uix.image import Image
class LADXManager(GameManager):
logging_pairs = [
@@ -529,17 +544,25 @@ class LinksAwakeningContext(CommonContext):
b = super().build()
if self.ctx.magpie_enabled:
button = MDButton(MDButtonText(text="Open Tracker"), style="filled", size=(dp(100), dp(70)), radius=5,
size_hint_x=None, size_hint_y=None, pos_hint={"center_y": 0.55},
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
button.height = self.server_connect_bar.height
self.connect_layout.add_widget(button)
button = Button(text="", size=(30, 30), size_hint_x=None,
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
image = Image(size=(16, 16), texture=magpie_logo())
button.add_widget(image)
def set_center(_, center):
image.center = center
button.bind(center=set_center)
self.connect_layout.add_widget(button)
return b
self.ui = LADXManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
async def send_checks(self):
message = [{"cmd": "LocationChecks", "locations": self.found_checks}]
await self.send_msgs(message)
async def send_new_entrances(self, entrances: typing.Dict[str, str]):
# Store the entrances we find on the server for future sessions
message = [{
@@ -578,20 +601,20 @@ class LinksAwakeningContext(CommonContext):
logger.info("victory!")
await self.send_msgs(message)
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]}])
# 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:
if self.ENABLE_DEATHLINK:
self.client.pending_deathlink = True
def new_checks(self, item_ids, ladxr_ids):
self.found_checks.update(item_ids)
create_task_log_exception(self.check_locations(self.found_checks))
self.found_checks += item_ids
create_task_log_exception(self.send_checks())
if self.magpie_enabled:
create_task_log_exception(self.magpie.send_new_checks(ladxr_ids))
@@ -619,23 +642,12 @@ class LinksAwakeningContext(CommonContext):
if cmd == "Connected":
self.game = self.slot_info[self.slot].game
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,
})
# We can process linked items on already-checked checks now that we have slot_data
if self.client.tracker:
checked_checks = set(self.client.tracker.all_checks) - set(self.client.tracker.remaining_checks)
self.add_linked_items(checked_checks)
# TODO - use watcher_event
if cmd == "ReceivedItems":
for index, item in enumerate(args["items"], start=args["index"]):
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])
@@ -646,13 +658,6 @@ class LinksAwakeningContext(CommonContext):
sync_msg = [{'cmd': 'Sync'}]
await self.send_msgs(sync_msg)
def add_linked_items(self, checks: typing.List[Check]):
for check in checks:
if check.value and check.linkedItem:
linkedItem = check.linkedItem
if 'condition' not in linkedItem or (self.slot_data and linkedItem['condition'](self.slot_data)):
self.client.item_tracker.setExtraItem(check.linkedItem['item'], check.linkedItem['qty'])
item_id_lookup = get_locations_to_id()
async def run_game_loop(self):
@@ -661,7 +666,11 @@ class LinksAwakeningContext(CommonContext):
checkMetadataTable[check.id])] for check in ladxr_checks]
self.new_checks(checks, [check.id for check in ladxr_checks])
self.add_linked_items(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():
await self.send_victory()
@@ -712,15 +721,13 @@ class LinksAwakeningContext(CommonContext):
if self.last_resend + 5.0 < now:
self.last_resend = now
await self.check_locations(self.found_checks)
await self.send_checks()
if self.magpie_enabled:
try:
self.magpie.set_checks(self.client.tracker.all_checks)
await self.magpie.set_item_tracker(self.client.item_tracker)
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
await self.magpie.send_slot_data()
self.magpie.slot_data = self.slot_data
if self.client.gps_tracker.needs_found_entrances:
await self.request_found_entrances()
self.client.gps_tracker.needs_found_entrances = False
@@ -738,8 +745,8 @@ class LinksAwakeningContext(CommonContext):
await asyncio.sleep(1.0)
def run_game(romfile: str) -> None:
auto_start = LinksAwakeningWorld.settings.rom_start
auto_start = typing.cast(typing.Union[bool, str],
Utils.get_options()["ladx_options"].get("rom_start", True))
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
@@ -796,6 +803,6 @@ async def main():
await ctx.shutdown()
if __name__ == '__main__':
colorama.just_fix_windows_console()
colorama.init()
asyncio.run(main())
colorama.deinit()

View File

@@ -290,9 +290,12 @@ async def gba_sync_task(ctx: MMBN3Context):
async def run_game(romfile):
from worlds.mmbn3 import MMBN3World
auto_start = MMBN3World.settings.rom_start
if auto_start is True:
options = Utils.get_options().get("mmbn3_options", None)
if options is None:
auto_start = True
else:
auto_start = options.get("rom_start", True)
if auto_start:
import webbrowser
webbrowser.open(romfile)
elif os.path.isfile(auto_start):
@@ -367,7 +370,7 @@ if __name__ == "__main__":
import colorama
colorama.just_fix_windows_console()
colorama.init()
asyncio.run(main())
colorama.deinit()

67
Main.py
View File

@@ -7,13 +7,14 @@ import tempfile
import time
import zipfile
import zlib
from typing import Dict, List, Optional, Set, Tuple, Union
import worlds
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, flood_items, \
parse_planned_blocks, distribute_planned_blocks, resolve_early_locations_for_planned
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \
flood_items
from Options import StartInventoryPool
from Utils import __version__, output_path, version_tuple
from Utils import __version__, output_path, version_tuple, get_settings
from settings import get_settings
from worlds import AutoWorld
from worlds.generic.Rules import exclusion_rules, locality_rules
@@ -21,7 +22,7 @@ from worlds.generic.Rules import exclusion_rules, locality_rules
__all__ = ["main"]
def main(args, seed=None, baked_server_options: dict[str, object] | None = None):
def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None):
if not baked_server_options:
baked_server_options = get_settings().server_options.as_dict()
assert isinstance(baked_server_options, dict)
@@ -36,6 +37,9 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
logger = logging.getLogger()
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
multiworld.plando_options = args.plando_options
multiworld.plando_items = args.plando_items.copy()
multiworld.plando_texts = args.plando_texts.copy()
multiworld.plando_connections = args.plando_connections.copy()
multiworld.game = args.game.copy()
multiworld.player_name = args.name.copy()
multiworld.sprite = args.sprite.copy()
@@ -52,18 +56,32 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} 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())))
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():
if not cls.hidden and len(cls.item_names) > 0:
logger.info(f" {name:{longest_name}}: Items: {len(cls.item_names):{item_count}} | "
f"Locations: {len(cls.location_names):{location_count}}")
logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} "
f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - "
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_count, location_count
del item_digits, location_digits, item_count, location_count
# This assertion method should not be necessary to run if we are not outputting any multidata.
if not args.skip_output and not args.spoiler_only:
if not args.skip_output:
AutoWorld.call_stage(multiworld, "assert_generate")
AutoWorld.call_all(multiworld, "generate_early")
@@ -131,15 +149,13 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
multiworld.worlds[1].options.non_local_items.value = set()
multiworld.worlds[1].options.local_items.value = set()
multiworld.plando_item_blocks = parse_planned_blocks(multiworld)
AutoWorld.call_all(multiworld, "connect_entrances")
AutoWorld.call_all(multiworld, "generate_basic")
# remove starting inventory from pool items.
# Because some worlds don't actually create items during create_items this has to be as late as possible.
fallback_inventory = StartInventoryPool({})
depletion_pool: dict[int, dict[str, int]] = {
depletion_pool: Dict[int, Dict[str, int]] = {
player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy()
for player in multiworld.player_ids
}
@@ -148,7 +164,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
}
if target_per_player:
new_itempool: list[Item] = []
new_itempool: List[Item] = []
# Make new itempool with start_inventory_from_pool items removed
for item in multiworld.itempool:
@@ -177,9 +193,8 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
multiworld._all_state = None
logger.info("Running Item Plando.")
resolve_early_locations_for_planned(multiworld)
distribute_planned_blocks(multiworld, [x for player in multiworld.plando_item_blocks
for x in multiworld.plando_item_blocks[player]])
distribute_planned(multiworld)
logger.info('Running Pre Main Fill.')
@@ -209,15 +224,6 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
logger.info(f'Beginning output...')
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()
with output as temp_dir:
output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__
@@ -232,7 +238,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
# collect ER hint info
er_hint_data: dict[int, dict[int, str]] = {}
er_hint_data: Dict[int, Dict[int, str]] = {}
AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
def write_multidata():
@@ -273,7 +279,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
for player in multiworld.groups[location.item.player]["players"]:
precollected_hints[player].add(hint)
locations_data: dict[int, dict[int, tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
for location in multiworld.get_filled_locations():
if type(location.address) == int:
assert location.item.code is not None, "item code None should be event, " \
@@ -300,14 +306,13 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
game_world.game: worlds.network_data_package["games"][game_world.game]
for game_world in multiworld.worlds.values()
}
data_package["Archipelago"] = worlds.network_data_package["games"]["Archipelago"]
checks_in_area: dict[int, dict[str, int | list[int]]] = {}
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
# get spheres -> filter address==None -> skip empty
spheres: list[dict[int, set[int]]] = []
spheres: List[Dict[int, Set[int]]] = []
for sphere in multiworld.get_sendable_spheres():
current_sphere: dict[int, set[int]] = collections.defaultdict(set)
current_sphere: Dict[int, Set[int]] = collections.defaultdict(set)
for sphere_location in sphere:
current_sphere[sphere_location.player].add(sphere_location.address)

344
MinecraftClient.py Normal file
View File

@@ -0,0 +1,344 @@
import argparse
import json
import os
import sys
import re
import atexit
import shutil
from subprocess import Popen
from shutil import copyfile
from time import strftime
import logging
import requests
import Utils
from Utils import is_windows
atexit.register(input, "Press enter to exit.")
# 1 or more digits followed by m or g, then optional b
max_heap_re = re.compile(r"^\d+[mMgG][bB]?$")
def prompt_yes_no(prompt):
yes_inputs = {'yes', 'ye', 'y'}
no_inputs = {'no', 'n'}
while True:
choice = input(prompt + " [y/n] ").lower()
if choice in yes_inputs:
return True
elif choice in no_inputs:
return False
else:
print('Please respond with "y" or "n".')
def find_ap_randomizer_jar(forge_dir):
"""Create mods folder if needed; find AP randomizer jar; return None if not found."""
mods_dir = os.path.join(forge_dir, 'mods')
if os.path.isdir(mods_dir):
for entry in os.scandir(mods_dir):
if entry.name.startswith("aprandomizer") and entry.name.endswith(".jar"):
logging.info(f"Found AP randomizer mod: {entry.name}")
return entry.name
return None
else:
os.mkdir(mods_dir)
logging.info(f"Created mods folder in {forge_dir}")
return None
def replace_apmc_files(forge_dir, apmc_file):
"""Create APData folder if needed; clean .apmc files from APData; copy given .apmc into directory."""
if apmc_file is None:
return
apdata_dir = os.path.join(forge_dir, 'APData')
copy_apmc = True
if not os.path.isdir(apdata_dir):
os.mkdir(apdata_dir)
logging.info(f"Created APData folder in {forge_dir}")
for entry in os.scandir(apdata_dir):
if entry.name.endswith(".apmc") and entry.is_file():
if not os.path.samefile(apmc_file, entry.path):
os.remove(entry.path)
logging.info(f"Removed {entry.name} in {apdata_dir}")
else: # apmc already in apdata
copy_apmc = False
if copy_apmc:
copyfile(apmc_file, os.path.join(apdata_dir, os.path.basename(apmc_file)))
logging.info(f"Copied {os.path.basename(apmc_file)} to {apdata_dir}")
def read_apmc_file(apmc_file):
from base64 import b64decode
with open(apmc_file, 'r') as f:
return json.loads(b64decode(f.read()))
def update_mod(forge_dir, url: str):
"""Check mod version, download new mod from GitHub releases page if needed. """
ap_randomizer = find_ap_randomizer_jar(forge_dir)
os.path.basename(url)
if ap_randomizer is not None:
logging.info(f"Your current mod is {ap_randomizer}.")
else:
logging.info(f"You do not have the AP randomizer mod installed.")
if ap_randomizer != os.path.basename(url):
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
f"{os.path.basename(url)}")
if prompt_yes_no("Would you like to update?"):
old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None
new_ap_mod = os.path.join(forge_dir, 'mods', os.path.basename(url))
logging.info("Downloading AP randomizer mod. This may take a moment...")
apmod_resp = requests.get(url)
if apmod_resp.status_code == 200:
with open(new_ap_mod, 'wb') as f:
f.write(apmod_resp.content)
logging.info(f"Wrote new mod file to {new_ap_mod}")
if old_ap_mod is not None:
os.remove(old_ap_mod)
logging.info(f"Removed old mod file from {old_ap_mod}")
else:
logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
logging.error(f"Please report this issue on the Archipelago Discord server.")
sys.exit(1)
def check_eula(forge_dir):
"""Check if the EULA is agreed to, and prompt the user to read and agree if necessary."""
eula_path = os.path.join(forge_dir, "eula.txt")
if not os.path.isfile(eula_path):
# Create eula.txt
with open(eula_path, 'w') as f:
f.write("#By changing the setting below to TRUE you are indicating your agreement to our EULA (https://account.mojang.com/documents/minecraft_eula).\n")
f.write(f"#{strftime('%a %b %d %X %Z %Y')}\n")
f.write("eula=false\n")
with open(eula_path, 'r+') as f:
text = f.read()
if 'false' in text:
# Prompt user to agree to the EULA
logging.info("You need to agree to the Minecraft EULA in order to run the server.")
logging.info("The EULA can be found at https://account.mojang.com/documents/minecraft_eula")
if prompt_yes_no("Do you agree to the EULA?"):
f.seek(0)
f.write(text.replace('false', 'true'))
f.truncate()
logging.info(f"Set {eula_path} to true")
else:
sys.exit(0)
def find_jdk_dir(version: str) -> str:
"""get the specified versions jdk directory"""
for entry in os.listdir():
if os.path.isdir(entry) and entry.startswith(f"jdk{version}"):
return os.path.abspath(entry)
def find_jdk(version: str) -> str:
"""get the java exe location"""
if is_windows:
jdk = find_jdk_dir(version)
jdk_exe = os.path.join(jdk, "bin", "java.exe")
if os.path.isfile(jdk_exe):
return jdk_exe
else:
jdk_exe = shutil.which(options["minecraft_options"].get("java", "java"))
if not jdk_exe:
raise Exception("Could not find Java. Is Java installed on the system?")
return jdk_exe
def download_java(java: str):
"""Download Corretto (Amazon JDK)"""
jdk = find_jdk_dir(java)
if jdk is not None:
print(f"Removing old JDK...")
from shutil import rmtree
rmtree(jdk)
print(f"Downloading Java...")
jdk_url = f"https://corretto.aws/downloads/latest/amazon-corretto-{java}-x64-windows-jdk.zip"
resp = requests.get(jdk_url)
if resp.status_code == 200: # OK
print(f"Extracting...")
import zipfile
from io import BytesIO
with zipfile.ZipFile(BytesIO(resp.content)) as zf:
zf.extractall()
else:
print(f"Error downloading Java (status code {resp.status_code}).")
print(f"If this was not expected, please report this issue on the Archipelago Discord server.")
if not prompt_yes_no("Continue anyways?"):
sys.exit(0)
def install_forge(directory: str, forge_version: str, java_version: str):
"""download and install forge"""
java_exe = find_jdk(java_version)
if java_exe is not None:
print(f"Downloading Forge {forge_version}...")
forge_url = f"https://maven.minecraftforge.net/net/minecraftforge/forge/{forge_version}/forge-{forge_version}-installer.jar"
resp = requests.get(forge_url)
if resp.status_code == 200: # OK
forge_install_jar = os.path.join(directory, "forge_install.jar")
if not os.path.exists(directory):
os.mkdir(directory)
with open(forge_install_jar, 'wb') as f:
f.write(resp.content)
print(f"Installing Forge...")
install_process = Popen([java_exe, "-jar", forge_install_jar, "--installServer", directory])
install_process.wait()
os.remove(forge_install_jar)
def run_forge_server(forge_dir: str, java_version: str, heap_arg: str) -> Popen:
"""Run the Forge server."""
java_exe = find_jdk(java_version)
if not os.path.isfile(java_exe):
java_exe = "java" # try to fall back on java in the PATH
heap_arg = max_heap_re.match(heap_arg).group()
if heap_arg[-1] in ['b', 'B']:
heap_arg = heap_arg[:-1]
heap_arg = "-Xmx" + heap_arg
os_args = "win_args.txt" if is_windows else "unix_args.txt"
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, os_args)
forge_args = []
with open(args_file) as argfile:
for line in argfile:
forge_args.extend(line.strip().split(" "))
args = [java_exe, heap_arg, *forge_args, "-nogui"]
logging.info(f"Running Forge server: {args}")
os.chdir(forge_dir)
return Popen(args)
def get_minecraft_versions(version, release_channel="release"):
version_file_endpoint = "https://raw.githubusercontent.com/KonoTyran/Minecraft_AP_Randomizer/master/versions/minecraft_versions.json"
resp = requests.get(version_file_endpoint)
local = False
if resp.status_code == 200: # OK
try:
data = resp.json()
except requests.exceptions.JSONDecodeError:
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
local = True
else:
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
local = True
if local:
with open(Utils.user_path("minecraft_versions.json"), 'r') as f:
data = json.load(f)
else:
with open(Utils.user_path("minecraft_versions.json"), 'w') as f:
json.dump(data, f)
try:
if version:
return next(filter(lambda entry: entry["version"] == version, data[release_channel]))
else:
return resp.json()[release_channel][0]
except (StopIteration, KeyError):
logging.error(f"No compatible mod version found for client version {version} on \"{release_channel}\" channel.")
if release_channel != "release":
logging.error("Consider switching \"release_channel\" to \"release\" in your Host.yaml file")
else:
logging.error("No suitable mod found on the \"release\" channel. Please Contact us on discord to report this error.")
sys.exit(0)
def is_correct_forge(forge_dir) -> bool:
if os.path.isdir(os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version)):
return True
return False
if __name__ == '__main__':
Utils.init_logging("MinecraftClient")
parser = argparse.ArgumentParser()
parser.add_argument("apmc_file", default=None, nargs='?', help="Path to an Archipelago Minecraft data file (.apmc)")
parser.add_argument('--install', '-i', dest='install', default=False, action='store_true',
help="Download and install Java and the Forge server. Does not launch the client afterwards.")
parser.add_argument('--release_channel', '-r', dest="channel", type=str, action='store',
help="Specify release channel to use.")
parser.add_argument('--java', '-j', metavar='17', dest='java', type=str, default=False, action='store',
help="specify java version.")
parser.add_argument('--forge', '-f', metavar='1.18.2-40.1.0', dest='forge', type=str, default=False, action='store',
help="specify forge version. (Minecraft Version-Forge Version)")
parser.add_argument('--version', '-v', metavar='9', dest='data_version', type=int, action='store',
help="specify Mod data version to download.")
args = parser.parse_args()
apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None
# Change to executable's working directory
os.chdir(os.path.abspath(os.path.dirname(sys.argv[0])))
options = Utils.get_options()
channel = args.channel or options["minecraft_options"]["release_channel"]
apmc_data = None
data_version = args.data_version or None
if apmc_file is None and not args.install:
apmc_file = Utils.open_filename('Select APMC file', (('APMC File', ('.apmc',)),))
if apmc_file is not None and data_version is None:
apmc_data = read_apmc_file(apmc_file)
data_version = apmc_data.get('client_version', '')
versions = get_minecraft_versions(data_version, channel)
forge_dir = options["minecraft_options"]["forge_directory"]
max_heap = options["minecraft_options"]["max_heap_size"]
forge_version = args.forge or versions["forge"]
java_version = args.java or versions["java"]
mod_url = versions["url"]
java_dir = find_jdk_dir(java_version)
if args.install:
if is_windows:
print("Installing Java")
download_java(java_version)
if not is_correct_forge(forge_dir):
print("Installing Minecraft Forge")
install_forge(forge_dir, forge_version, java_version)
else:
print("Correct Forge version already found, skipping install.")
sys.exit(0)
if apmc_data is None:
raise FileNotFoundError(f"APMC file does not exist or is inaccessible at the given location ({apmc_file})")
if is_windows:
if java_dir is None or not os.path.isdir(java_dir):
if prompt_yes_no("Did not find java directory. Download and install java now?"):
download_java(java_version)
java_dir = find_jdk_dir(java_version)
if java_dir is None or not os.path.isdir(java_dir):
raise NotADirectoryError(f"Path {java_dir} does not exist or could not be accessed.")
if not is_correct_forge(forge_dir):
if prompt_yes_no(f"Did not find forge version {forge_version} download and install it now?"):
install_forge(forge_dir, forge_version, java_version)
if not os.path.isdir(forge_dir):
raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.")
if not max_heap_re.match(max_heap):
raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.")
update_mod(forge_dir, mod_url)
replace_apmc_files(forge_dir, apmc_file)
check_eula(forge_dir)
server_process = run_forge_server(forge_dir, java_version, max_heap)
server_process.wait()

View File

@@ -46,9 +46,8 @@ from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, Networ
SlotType, LocationStore, Hint, HintStatus
from BaseClasses import ItemClassification
min_client_version = Version(0, 5, 0)
colorama.just_fix_windows_console()
min_client_version = Version(0, 1, 6)
colorama.init()
def remove_from_list(container, value):
@@ -67,13 +66,9 @@ def pop_from_container(container, value):
return container
def update_container_unique(container, entries):
if isinstance(container, list):
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 update_dict(dictionary, entries):
dictionary.update(entries)
return dictionary
def queue_gc():
@@ -114,7 +109,7 @@ modify_functions = {
# lists/dicts:
"remove": remove_from_list,
"pop": pop_from_container,
"update": update_container_unique,
"update": update_dict,
}
@@ -458,12 +453,8 @@ class Context:
self.generator_version = Version(*decoded_obj["version"])
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
self.minimum_client_versions = {}
if self.generator_version < Version(0, 6, 2):
min_version = Version(0, 1, 6)
else:
min_version = min_client_version
for player, version in clients_ver.items():
self.minimum_client_versions[player] = max(Version(*version), min_version)
self.minimum_client_versions[player] = max(Version(*version), min_client_version)
self.slot_info = decoded_obj["slot_info"]
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
@@ -1830,7 +1821,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
ctx.clients[team][slot].append(client)
client.version = args['version']
client.tags = args['tags']
client.no_locations = bool(client.tags & _non_game_messages.keys())
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 = {
@@ -1904,7 +1895,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
old_tags = client.tags
client.tags = args["tags"]
if set(old_tags) != set(client.tags):
client.no_locations = bool(client.tags & _non_game_messages.keys())
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)
)
@@ -1987,21 +1978,14 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
new_hint = new_hint.re_prioritize(ctx, status)
if hint == new_hint:
return
concerning_slots = ctx.slot_set(hint.receiving_player) | {hint.finding_player}
for slot in concerning_slots:
ctx.replace_hint(client.team, slot, hint, new_hint)
ctx.replace_hint(client.team, hint.finding_player, hint, new_hint)
ctx.replace_hint(client.team, hint.receiving_player, hint, new_hint)
ctx.save()
for slot in concerning_slots:
ctx.on_changed_hints(client.team, slot)
ctx.on_changed_hints(client.team, hint.finding_player)
ctx.on_changed_hints(client.team, hint.receiving_player)
elif cmd == 'StatusUpdate':
if client.no_locations and args["status"] == ClientStatus.CLIENT_GOAL:
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
"text": "Trackers can't register Goal Complete",
"original_cmd": cmd}])
else:
update_client_status(ctx, client, args["status"])
update_client_status(ctx, client, args["status"])
elif cmd == 'Say':
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():
@@ -2053,7 +2037,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
value = func(value, operation["value"])
ctx.stored_data[args["key"]] = args["value"] = value
targets = set(ctx.stored_data_notification_clients[args["key"]])
if args.get("want_reply", False):
if args.get("want_reply", True):
targets.add(client)
if targets:
ctx.broadcast(targets, [args])
@@ -2428,10 +2412,8 @@ async def console(ctx: Context):
def parse_args() -> argparse.Namespace:
from settings import get_settings
parser = argparse.ArgumentParser()
defaults = get_settings().server_options.as_dict()
defaults = Utils.get_settings()["server_options"].as_dict()
parser.add_argument('multidata', nargs="?", default=defaults["multidata"])
parser.add_argument('--host', default=defaults["host"])
parser.add_argument('--port', default=defaults["port"], type=int)

View File

@@ -12,7 +12,6 @@ from CommonClient import CommonContext, server_loop, gui_enabled, \
import Utils
from Utils import async_start
from worlds import network_data_package
from worlds.oot import OOTWorld
from worlds.oot.Rom import Rom, compress_rom_file
from worlds.oot.N64Patch import apply_patch_file
from worlds.oot.Utils import data_path
@@ -281,7 +280,7 @@ async def n64_sync_task(ctx: OoTContext):
async def run_game(romfile):
auto_start = OOTWorld.settings.rom_start
auto_start = Utils.get_options()["oot_options"].get("rom_start", True)
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
@@ -296,7 +295,7 @@ async def patch_and_run_game(apz5_file):
decomp_path = base_name + '-decomp.z64'
comp_path = base_name + '.z64'
# Load vanilla ROM, patch file, compress ROM
rom_file_name = OOTWorld.settings.rom_file
rom_file_name = Utils.get_options()["oot_options"]["rom_file"]
rom = Rom(rom_file_name)
sub_file = None
@@ -347,7 +346,7 @@ if __name__ == '__main__':
import colorama
colorama.just_fix_windows_console()
colorama.init()
asyncio.run(main())
colorama.deinit()

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import abc
import collections
import functools
import logging
import math
@@ -24,12 +23,6 @@ if typing.TYPE_CHECKING:
import pathlib
def roll_percentage(percentage: int | float) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
return random.random() < (float(percentage) / 100)
class OptionError(ValueError):
pass
@@ -873,49 +866,15 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
def __len__(self) -> int:
return self.value.__len__()
# __getitem__ fallback fails for Counters, so we define this explicitly
def __contains__(self, item) -> bool:
return item in self.value
class OptionCounter(OptionDict):
min: int | None = None
max: int | None = None
def __init__(self, value: dict[str, int]) -> None:
super(OptionCounter, self).__init__(collections.Counter(value))
def verify(self, world: type[World], player_name: str, plando_options: PlandoOptions) -> None:
super(OptionCounter, self).verify(world, player_name, plando_options)
range_errors = []
if self.max is not None:
range_errors += [
f"\"{key}: {value}\" is higher than maximum allowed value {self.max}."
for key, value in self.value.items() if value > self.max
]
if self.min is not None:
range_errors += [
f"\"{key}: {value}\" is lower than minimum allowed value {self.min}."
for key, value in self.value.items() if value < self.min
]
if range_errors:
range_errors = [f"For option {getattr(self, 'display_name', self)}:"] + range_errors
raise OptionError("\n".join(range_errors))
class ItemDict(OptionCounter):
class ItemDict(OptionDict):
verify_item_name = True
min = 0
def __init__(self, value: dict[str, int]) -> None:
# Backwards compatibility: Cull 0s to make "in" checks behave the same as when this wasn't a OptionCounter
value = {item_name: amount for item_name, amount in value.items() if amount != 0}
def __init__(self, value: typing.Dict[str, int]):
if any(item_count is None for item_count in value.values()):
raise Exception("Items must have counts associated with them. Please provide positive integer values in the format \"item\": count .")
if any(item_count < 1 for item_count in value.values()):
raise Exception("Cannot have non-positive item counts.")
super(ItemDict, self).__init__(value)
@@ -1025,7 +984,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
if isinstance(data, typing.Iterable):
for text in data:
if isinstance(text, typing.Mapping):
if roll_percentage(text.get("percentage", 100)):
if random.random() < float(text.get("percentage", 100)/100):
at = text.get("at", None)
if at is not None:
if isinstance(at, dict):
@@ -1051,7 +1010,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
else:
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
elif isinstance(text, PlandoText):
if roll_percentage(text.percentage):
if random.random() < float(text.percentage/100):
texts.append(text)
else:
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
@@ -1175,7 +1134,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
for connection in data:
if isinstance(connection, typing.Mapping):
percentage = connection.get("percentage", 100)
if roll_percentage(percentage):
if random.random() < float(percentage / 100):
entrance = connection.get("entrance", None)
if is_iterable_except_str(entrance):
entrance = random.choice(sorted(entrance))
@@ -1193,7 +1152,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
percentage
))
elif isinstance(connection, PlandoConnection):
if roll_percentage(connection.percentage):
if random.random() < float(connection.percentage / 100):
value.append(connection)
else:
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
@@ -1298,47 +1257,42 @@ class CommonOptions(metaclass=OptionsMetaProperty):
progression_balancing: ProgressionBalancing
accessibility: Accessibility
def as_dict(
self,
*option_names: str,
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
toggles_as_bools: bool = False,
) -> dict[str, typing.Any]:
def as_dict(self,
*option_names: str,
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
toggles_as_bools: bool = False) -> typing.Dict[str, typing.Any]:
"""
Returns a dictionary of [str, Option.value]
:param option_names: Names of the options to get the values of.
:param casing: Casing of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`.
:param toggles_as_bools: Whether toggle options should be returned as bools instead of ints.
:return: A dictionary of each option name to the value of its Option. If the option is an OptionSet, the value
will be returned as a sorted list.
:param option_names: names of the options to return
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
:param toggles_as_bools: whether toggle options should be output as bools instead of strings
"""
assert option_names, "options.as_dict() was used without any option names."
option_results = {}
for option_name in option_names:
if option_name not in type(self).type_hints:
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
if casing == "snake":
display_name = option_name
elif casing == "camel":
split_name = [name.title() for name in option_name.split("_")]
split_name[0] = split_name[0].lower()
display_name = "".join(split_name)
elif casing == "pascal":
display_name = "".join([name.title() for name in option_name.split("_")])
elif casing == "kebab":
display_name = option_name.replace("_", "-")
if option_name in type(self).type_hints:
if casing == "snake":
display_name = option_name
elif casing == "camel":
split_name = [name.title() for name in option_name.split("_")]
split_name[0] = split_name[0].lower()
display_name = "".join(split_name)
elif casing == "pascal":
display_name = "".join([name.title() for name in option_name.split("_")])
elif casing == "kebab":
display_name = option_name.replace("_", "-")
else:
raise ValueError(f"{casing} is invalid casing for as_dict. "
"Valid names are 'snake', 'camel', 'pascal', 'kebab'.")
value = getattr(self, option_name).value
if isinstance(value, set):
value = sorted(value)
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
value = bool(value)
option_results[display_name] = value
else:
raise ValueError(f"{casing} is invalid casing for as_dict. "
"Valid names are 'snake', 'camel', 'pascal', 'kebab'.")
value = getattr(self, option_name).value
if isinstance(value, set):
value = sorted(value)
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
value = bool(value)
option_results[display_name] = value
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
return option_results
@@ -1359,7 +1313,6 @@ class StartInventory(ItemDict):
verify_item_name = True
display_name = "Start Inventory"
rich_text_doc = True
max = 10000
class StartInventoryPool(StartInventory):
@@ -1475,133 +1428,6 @@ class ItemLinks(OptionList):
link["item_pool"] = list(pool)
@dataclass(frozen=True)
class PlandoItem:
items: list[str] | dict[str, typing.Any]
locations: list[str]
world: int | str | bool | None | typing.Iterable[str] | set[int] = False
from_pool: bool = True
force: bool | typing.Literal["silent"] = "silent"
count: int | bool | dict[str, int] = False
percentage: int = 100
class PlandoItems(Option[typing.List[PlandoItem]]):
"""Generic items plando."""
default = ()
supports_weighting = False
display_name = "Plando Items"
def __init__(self, value: typing.Iterable[PlandoItem]) -> None:
self.value = list(deepcopy(value))
super().__init__()
@classmethod
def from_any(cls, data: typing.Any) -> Option[typing.List[PlandoItem]]:
if not isinstance(data, typing.Iterable):
raise OptionError(f"Cannot create plando items from non-Iterable type, got {type(data)}")
value: typing.List[PlandoItem] = []
for item in data:
if isinstance(item, typing.Mapping):
percentage = item.get("percentage", 100)
if not isinstance(percentage, int):
raise OptionError(f"Plando `percentage` has to be int, not {type(percentage)}.")
if not (0 <= percentage <= 100):
raise OptionError(f"Plando `percentage` has to be between 0 and 100 (inclusive) not {percentage}.")
if roll_percentage(percentage):
count = item.get("count", False)
items = item.get("items", [])
if not items:
items = item.get("item", None) # explicitly throw an error here if not present
if not items:
raise OptionError("You must specify at least one item to place items with plando.")
count = 1
if isinstance(items, str):
items = [items]
elif not isinstance(items, (dict, list)):
raise OptionError(f"Plando 'items' has to be string, list, or "
f"dictionary, not {type(items)}")
locations = item.get("locations", [])
if not locations:
locations = item.get("location", [])
if locations:
count = 1
else:
locations = ["Everywhere"]
if isinstance(locations, str):
locations = [locations]
if not isinstance(locations, list):
raise OptionError(f"Plando `location` has to be string or list, not {type(locations)}")
world = item.get("world", False)
from_pool = item.get("from_pool", True)
force = item.get("force", "silent")
if not isinstance(from_pool, bool):
raise OptionError(f"Plando 'from_pool' has to be true or false, not {from_pool!r}.")
if not (isinstance(force, bool) or force == "silent"):
raise OptionError(f"Plando `force` has to be true or false or `silent`, not {force!r}.")
value.append(PlandoItem(items, locations, world, from_pool, force, count, percentage))
elif isinstance(item, PlandoItem):
if roll_percentage(item.percentage):
value.append(item)
else:
raise OptionError(f"Cannot create plando item from non-Dict type, got {type(item)}.")
return cls(value)
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
if not self.value:
return
from BaseClasses import PlandoOptions
if not (PlandoOptions.items & plando_options):
# plando is disabled but plando options were given so overwrite the options
self.value = []
logging.warning(f"The plando items module is turned off, "
f"so items for {player_name} will be ignored.")
else:
# filter down item groups
for plando in self.value:
# confirm a valid count
if isinstance(plando.count, dict):
if "min" in plando.count and "max" in plando.count:
if plando.count["min"] > plando.count["max"]:
raise OptionError("Plando cannot have count `min` greater than `max`.")
items_copy = plando.items.copy()
if isinstance(plando.items, dict):
for item in items_copy:
if item in world.item_name_groups:
value = plando.items.pop(item)
group = world.item_name_groups[item]
filtered_items = sorted(group.difference(list(plando.items.keys())))
if not filtered_items:
raise OptionError(f"Plando `items` contains the group \"{item}\" "
f"and every item in it. This is not allowed.")
if value is True:
for key in filtered_items:
plando.items[key] = True
else:
for key in random.choices(filtered_items, k=value):
plando.items[key] = plando.items.get(key, 0) + 1
else:
assert isinstance(plando.items, list) # pycharm can't figure out the hinting without the hint
for item in items_copy:
if item in world.item_name_groups:
plando.items.remove(item)
plando.items.extend(sorted(world.item_name_groups[item]))
@classmethod
def get_option_name(cls, value: list[PlandoItem]) -> str:
return ", ".join(["(%s: %s)" % (item.items, item.locations) for item in value]) #TODO: see what a better way to display would be
def __getitem__(self, index: typing.SupportsIndex) -> PlandoItem:
return self.value.__getitem__(index)
def __iter__(self) -> typing.Iterator[PlandoItem]:
yield from self.value
def __len__(self) -> int:
return len(self.value)
class Removed(FreeText):
"""This Option has been Removed."""
rich_text_doc = True
@@ -1624,7 +1450,6 @@ class PerGameCommonOptions(CommonOptions):
exclude_locations: ExcludeLocations
priority_locations: PriorityLocations
item_links: ItemLinks
plando_items: PlandoItems
@dataclass
@@ -1678,7 +1503,6 @@ def get_option_groups(world: typing.Type[World], visibility_level: Visibility =
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None:
import os
from inspect import cleandoc
import yaml
from jinja2 import Template
@@ -1717,21 +1541,19 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
# yaml dump may add end of document marker and newlines.
return yaml.dump(scalar).replace("...\n", "").strip()
with open(local_path("data", "options.yaml")) as f:
file_data = f.read()
template = Template(file_data)
for game_name, world in AutoWorldRegister.world_types.items():
if not world.hidden or generate_hidden:
option_groups = get_option_groups(world)
res = template.render(
with open(local_path("data", "options.yaml")) as f:
file_data = f.read()
res = Template(file_data).render(
option_groups=option_groups,
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
dictify_range=dictify_range,
cleandoc=cleandoc,
)
del file_data
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f:
f.write(res)
@@ -1757,7 +1579,6 @@ def dump_player_options(multiworld: MultiWorld) -> None:
player_output = {
"Game": multiworld.game[player],
"Name": multiworld.get_player_name(player),
"ID": player,
}
output.append(player_output)
for option_key, option in world.options_dataclass.type_hints.items():
@@ -1770,7 +1591,7 @@ def dump_player_options(multiworld: MultiWorld) -> None:
game_option_names.append(display_name)
with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file:
fields = ["ID", "Game", "Name", *all_option_names]
fields = ["Game", "Name", *all_option_names]
writer = DictWriter(file, fields)
writer.writeheader()
writer.writerows(output)

View File

@@ -7,7 +7,9 @@ Currently, the following games are supported:
* The Legend of Zelda: A Link to the Past
* Factorio
* Minecraft
* Subnautica
* Slay the Spire
* Risk of Rain 2
* The Legend of Zelda: Ocarina of Time
* Timespinner
@@ -61,6 +63,7 @@ Currently, the following games are supported:
* TUNIC
* Kirby's Dream Land 3
* Celeste 64
* Zork Grand Inquisitor
* Castlevania 64
* A Short Hike
* Yoshi's Island
@@ -77,11 +80,6 @@ Currently, the following games are supported:
* Saving Princess
* Castlevania: Circle of the Moon
* Inscryption
* Civilization VI
* The Legend of Zelda: The Wind Waker
* Jak and Daxter: The Precursor Legacy
* Super Mario Land 2: 6 Golden Coins
* shapez
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled

View File

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

View File

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

View File

@@ -47,7 +47,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self)
__version__ = "0.6.2"
__version__ = "0.6.0"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -114,8 +114,6 @@ def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[
cache[arg] = res
return res
wrap.__defaults__ = function.__defaults__
return wrap
@@ -139,11 +137,8 @@ def local_path(*path: str) -> str:
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
else:
import __main__
if globals().get("__file__") and os.path.isfile(__file__):
if hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
# we are running in a normal Python environment
local_path.cached_path = os.path.dirname(os.path.abspath(__file__))
elif hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
# we are running in a normal Python environment, but AP was imported weirdly
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
else:
# pray
@@ -166,10 +161,6 @@ def home_path(*path: str) -> str:
os.symlink(home_path.cached_path, legacy_home_path)
else:
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
elif sys.platform == 'darwin':
import platformdirs
home_path.cached_path = platformdirs.user_data_dir("Archipelago", False)
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
else:
# not implemented
home_path.cached_path = local_path() # this will generate the same exceptions we got previously
@@ -181,7 +172,7 @@ def user_path(*path: str) -> str:
"""Returns either local_path or home_path based on write permissions."""
if hasattr(user_path, "cached_path"):
pass
elif os.access(local_path(), os.W_OK) and not (is_macos and is_frozen()):
elif os.access(local_path(), os.W_OK):
user_path.cached_path = local_path()
else:
user_path.cached_path = home_path()
@@ -230,12 +221,7 @@ def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None:
from shutil import which
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
assert open_command, "Didn't find program for open_file! Please report this together with system details."
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
subprocess.call([open_command, filename], env=env)
subprocess.call([open_command, filename])
# from https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-4015118 with some changes
@@ -441,9 +427,6 @@ class RestrictedUnpickler(pickle.Unpickler):
def find_class(self, module: str, name: str) -> type:
if module == "builtins" and name in safe_builtins:
return getattr(builtins, name)
# used by OptionCounter
if module == "collections" and name == "Counter":
return collections.Counter
# used by MultiServer -> savegame/multidata
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint",
"SlotType", "NetworkSlot", "HintStatus"}:
@@ -549,8 +532,6 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
if add_timestamp:
stream_handler.setFormatter(formatter)
root_logger.addHandler(stream_handler)
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
# Relay unhandled exceptions to logger.
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
@@ -649,8 +630,6 @@ def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit:
import jellyfish
def get_fuzzy_ratio(word1: str, word2: str) -> float:
if word1 == word2:
return 1.01
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
/ max(len(word1), len(word2)))
@@ -671,10 +650,8 @@ def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bo
picks = get_fuzzy_results(input_text, possible_answers, limit=2)
if len(picks) > 1:
dif = picks[0][1] - picks[1][1]
if picks[0][1] == 101:
if picks[0][1] == 100:
return picks[0][0], True, "Perfect Match"
elif picks[0][1] == 100:
return picks[0][0], True, "Case Insensitive Perfect Match"
elif picks[0][1] < 75:
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
@@ -717,30 +694,25 @@ def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args:
res.put(open_filename(*args))
def _run_for_stdout(*args: str):
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
return subprocess.run(args, capture_output=True, text=True, env=env).stdout.split("\n", 1)[0] or None
def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
-> typing.Optional[str]:
logging.info(f"Opening file input dialog for {title}.")
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
if is_linux:
# prefer native dialog
from shutil import which
kdialog = which("kdialog")
if kdialog:
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
return _run_for_stdout(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters)
return run(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters)
zenity = which("zenity")
if zenity:
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
selection = (f"--filename={suggest}",) if suggest else ()
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
# fall back to tk
try:
@@ -774,18 +746,21 @@ def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args
def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
if is_linux:
# prefer native dialog
from shutil import which
kdialog = which("kdialog")
if kdialog:
return _run_for_stdout(kdialog, f"--title={title}", "--getexistingdirectory",
return run(kdialog, f"--title={title}", "--getexistingdirectory",
os.path.abspath(suggest) if suggest else ".")
zenity = which("zenity")
if zenity:
z_filters = ("--directory",)
selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else ()
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
# fall back to tk
try:
@@ -812,6 +787,9 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
def messagebox(title: str, text: str, error: bool = False) -> None:
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
if is_kivy_running():
from kvui import MessageBox
MessageBox(title, text, error).open()
@@ -822,10 +800,10 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
from shutil import which
kdialog = which("kdialog")
if kdialog:
return _run_for_stdout(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
return run(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
zenity = which("zenity")
if zenity:
return _run_for_stdout(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
elif is_windows:
import ctypes

View File

@@ -2,15 +2,14 @@ from __future__ import annotations
import atexit
import os
import pkgutil
import sys
import asyncio
import random
import typing
import shutil
from typing import Tuple, List, Iterable, Dict
from . import WargrooveWorld
from .Items import item_table, faction_table, CommanderData, ItemData
from worlds.wargroove import WargrooveWorld
from worlds.wargroove.Items import item_table, faction_table, CommanderData, ItemData
import ModuleUpdate
ModuleUpdate.update()
@@ -22,7 +21,7 @@ import logging
if __name__ == "__main__":
Utils.init_logging("WargrooveClient", exception_logger="Client")
from NetUtils import ClientStatus
from NetUtils import NetworkItem, ClientStatus
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
CommonContext, server_loop
@@ -30,34 +29,6 @@ wg_logger = logging.getLogger("WG")
class WargrooveClientCommandProcessor(ClientCommandProcessor):
def _cmd_sacrifice_summon(self):
"""Toggles sacrifices and summons On/Off"""
if isinstance(self.ctx, WargrooveContext):
self.ctx.has_sacrifice_summon = not self.ctx.has_sacrifice_summon
if self.ctx.has_sacrifice_summon:
self.output(f"Sacrifices and summons are enabled.")
else:
unit_summon_response_file = os.path.join(self.ctx.game_communication_path, "unitSummonResponse")
if os.path.exists(unit_summon_response_file):
os.remove(unit_summon_response_file)
self.output(f"Sacrifices and summons are disabled.")
def _cmd_deathlink(self):
"""Toggles deathlink On/Off"""
if isinstance(self.ctx, WargrooveContext):
self.ctx.has_death_link = not self.ctx.has_death_link
Utils.async_start(self.ctx.update_death_link(self.ctx.has_death_link), name="Update Deathlink")
if self.ctx.has_death_link:
death_link_send_file = os.path.join(self.ctx.game_communication_path, "deathLinkSend")
if os.path.exists(death_link_send_file):
os.remove(death_link_send_file)
self.output(f"Deathlink enabled.")
else:
death_link_receive_file = os.path.join(self.ctx.game_communication_path, "deathLinkReceive")
if os.path.exists(death_link_receive_file):
os.remove(death_link_receive_file)
self.output(f"Deathlink disabled.")
def _cmd_resync(self):
"""Manually trigger a resync."""
self.output(f"Syncing items.")
@@ -87,11 +58,6 @@ class WargrooveContext(CommonContext):
commander_defense_boost_multiplier: int = 0
income_boost_multiplier: int = 0
starting_groove_multiplier: float
has_death_link: bool = False
has_sacrifice_summon: bool = True
player_stored_units_key: str = ""
ai_stored_units_key: str = ""
max_stored_units: int = 1000
faction_item_ids = {
'Starter': 0,
'Cherrystone': 52025,
@@ -105,31 +71,6 @@ class WargrooveContext(CommonContext):
'Income Boost': 52023,
'Commander Defense Boost': 52024,
}
unit_classes = {
"archer",
"ballista",
"balloon",
"dog",
"dragon",
"giant",
"harpoonship",
"harpy",
"knight",
"mage",
"merman",
"rifleman",
"soldier",
"spearman",
"thief",
"thief_with_gold",
"travelboat",
"trebuchet",
"turtle",
"villager",
"wagon",
"warship",
"witch",
}
def __init__(self, server_address, password):
super(WargrooveContext, self).__init__(server_address, password)
@@ -137,80 +78,31 @@ class WargrooveContext(CommonContext):
self.syncing = False
self.awaiting_bridge = False
# self.game_communication_path: files go in this path to pass data between us and the actual game
game_options = WargrooveWorld.settings
# Validate the AppData directory with Wargroove save data.
# By default, Windows sets an environment variable we can leverage.
# However, other OSes don't usually have this value set, so we need to rely on a settings value instead.
appdata_wargroove = None
if "appdata" in os.environ:
appdata_wargroove = os.environ['appdata']
else:
try:
appdata_wargroove = game_options.save_directory
except FileNotFoundError:
print_error_and_close("WargrooveClient couldn't detect a path to the AppData folder.\n"
"Unable to infer required game_communication_path.\n"
"Try setting the \"save_directory\" value in your local options file "
"to the AppData folder containing your Wargroove saves.")
appdata_wargroove = os.path.expandvars(os.path.join(appdata_wargroove, "Chucklefish", "Wargroove"))
if not os.path.isdir(appdata_wargroove):
print_error_and_close(f"WargrooveClient couldn't find Wargroove data in your AppData folder.\n"
f"Looked in \"{appdata_wargroove}\".\n"
f"If you haven't yet booted the game at least once, boot Wargroove "
f"and then close it to attempt to fix this error.\n"
f"If the AppData folder above seems wrong, try setting the "
f"\"save_directory\" value in your local options file "
f"to the AppData folder containing your Wargroove saves.")
# Check for the Wargroove game executable path.
# This should always be set regardless of the OS.
root_directory = game_options["root_directory"]
if not os.path.isfile(os.path.join(root_directory, "win64_bin", "wargroove64.exe")):
print_error_and_close(f"WargrooveClient couldn't find wargroove64.exe in "
f"\"{root_directory}/win64_bin/\".\n"
f"Unable to infer required game_communication_path.\n"
f"Please verify the \"root_directory\" value in your local "
f"options file is set correctly.")
self.game_communication_path = os.path.join(root_directory, "AP")
if not os.path.exists(self.game_communication_path):
os.makedirs(self.game_communication_path)
self.remove_communication_files()
atexit.register(self.remove_communication_files)
if not os.path.isdir(appdata_wargroove):
print_error_and_close("WargrooveClient couldn't find Wargoove in appdata!"
"Boot Wargroove and then close it to attempt to fix this error")
mods_directory = os.path.join(appdata_wargroove, "mods", "ArchipelagoMod")
save_directory = os.path.join(appdata_wargroove, "save")
# Wargroove doesn't always create the mods directory, so we have to do it
if not os.path.isdir(mods_directory):
os.makedirs(mods_directory)
resources = ["data/mods/ArchipelagoMod/maps.dat",
"data/mods/ArchipelagoMod/mod.dat",
"data/mods/ArchipelagoMod/modAssets.dat",
"data/save/campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp",
"data/save/campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp.bak"]
file_paths = [os.path.join(mods_directory, "maps.dat"),
os.path.join(mods_directory, "mod.dat"),
os.path.join(mods_directory, "modAssets.dat"),
os.path.join(save_directory, "campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp"),
os.path.join(save_directory, "campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp.bak")]
for resource, destination in zip(resources, file_paths):
file_data = pkgutil.get_data("worlds.wargroove", resource)
if file_data is None:
options = Utils.get_options()
root_directory = os.path.join(options["wargroove_options"]["root_directory"])
data_directory = os.path.join("lib", "worlds", "wargroove", "data")
dev_data_directory = os.path.join("worlds", "wargroove", "data")
appdata_wargroove = os.path.expandvars(os.path.join("%APPDATA%", "Chucklefish", "Wargroove"))
if not os.path.isfile(os.path.join(root_directory, "win64_bin", "wargroove64.exe")):
print_error_and_close("WargrooveClient couldn't find wargroove64.exe. "
"Unable to infer required game_communication_path")
self.game_communication_path = os.path.join(root_directory, "AP")
if not os.path.exists(self.game_communication_path):
os.makedirs(self.game_communication_path)
self.remove_communication_files()
atexit.register(self.remove_communication_files)
if not os.path.isdir(appdata_wargroove):
print_error_and_close("WargrooveClient couldn't find Wargoove in appdata!"
"Boot Wargroove and then close it to attempt to fix this error")
if not os.path.isdir(data_directory):
data_directory = dev_data_directory
if not os.path.isdir(data_directory):
print_error_and_close("WargrooveClient couldn't find Wargoove mod and save files in install!")
with open(destination, 'wb') as f:
f.write(file_data)
def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
with open(os.path.join(self.game_communication_path, "deathLinkReceive"), 'w+') as f:
text = data.get("cause", "")
if text:
f.write(f"DeathLink: {text}")
else:
f.write(f"DeathLink: Received from {data['source']}")
super(WargrooveContext, self).on_deathlink(data)
shutil.copytree(data_directory, appdata_wargroove, dirs_exist_ok=True)
else:
print_error_and_close("WargrooveClient couldn't detect system type. "
"Unable to infer required game_communication_path")
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -246,25 +138,20 @@ class WargrooveContext(CommonContext):
def on_package(self, cmd: str, args: dict):
if cmd in {"Connected"}:
slot_data = args["slot_data"]
self.has_death_link = slot_data.get("death_link", False)
filename = f"AP_settings.json"
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
json.dump(slot_data, f)
slot_data = args["slot_data"]
json.dump(args["slot_data"], f)
self.can_choose_commander = slot_data["can_choose_commander"]
print('can choose commander:', self.can_choose_commander)
self.starting_groove_multiplier = slot_data["starting_groove_multiplier"]
self.income_boost_multiplier = slot_data["income_boost"]
self.commander_defense_boost_multiplier = slot_data["commander_defense_boost"]
f.close()
for ss in self.checked_locations:
filename = f"send{ss}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
pass
self.player_stored_units_key = f"wargroove_player_units_{self.team}"
self.ai_stored_units_key = f"wargroove_ai_units_{self.team}"
self.set_notify(self.player_stored_units_key, self.ai_stored_units_key)
f.close()
self.update_commander_data()
self.ui.update_tracker()
@@ -274,6 +161,7 @@ class WargrooveContext(CommonContext):
filename = f"seed{i}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
f.write(str(random.randint(0, 4294967295)))
f.close()
if cmd in {"RoomInfo"}:
self.seed_name = args["seed_name"]
@@ -301,6 +189,7 @@ class WargrooveContext(CommonContext):
f.write(f"{item_count * self.commander_defense_boost_multiplier}")
else:
f.write(f"{item_count}")
f.close()
print_filename = f"AP_{str(network_item.item)}.item.print"
print_path = os.path.join(self.game_communication_path, print_filename)
@@ -311,6 +200,7 @@ class WargrooveContext(CommonContext):
self.item_names.lookup_in_game(network_item.item) +
" from " +
self.player_names[network_item.player])
f.close()
self.update_commander_data()
self.ui.update_tracker()
@@ -319,16 +209,22 @@ class WargrooveContext(CommonContext):
for ss in self.checked_locations:
filename = f"send{ss}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
pass
f.close()
def run_gui(self):
"""Import kivy UI system and start running it as self.ui_task."""
from kvui import GameManager, HoverBehavior, ServerToolTip
from kivymd.uix.tab import MDTabsItem, MDTabsItemText
from kivy.uix.tabbedpanel import TabbedPanelItem
from kivy.lang import Builder
from kivy.uix.button import Button
from kivy.uix.togglebutton import ToggleButton
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.properties import ColorProperty
from kivy.uix.image import Image
import pkgutil
class TrackerLayout(BoxLayout):
@@ -495,75 +391,32 @@ class WargrooveContext(CommonContext):
async def game_watcher(ctx: WargrooveContext):
from worlds.wargroove.Locations import location_table
while not ctx.exit_event.is_set():
try:
if ctx.syncing == True:
sync_msg = [{'cmd': 'Sync'}]
if ctx.locations_checked:
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
await ctx.send_msgs(sync_msg)
ctx.syncing = False
sending = []
victory = False
for root, dirs, files in os.walk(ctx.game_communication_path):
for file in files:
if file == "deathLinkSend" and ctx.has_death_link:
with open(os.path.join(ctx.game_communication_path, file), 'r') as f:
failed_mission = f.read()
if ctx.slot is not None:
await ctx.send_death(f"{ctx.player_names[ctx.slot]} failed {failed_mission}")
os.remove(os.path.join(ctx.game_communication_path, file))
if file.find("send") > -1:
st = file.split("send", -1)[1]
sending = sending+[(int(st))]
os.remove(os.path.join(ctx.game_communication_path, file))
if file.find("victory") > -1:
victory = True
os.remove(os.path.join(ctx.game_communication_path, file))
if file == "unitSacrifice" or file == "unitSacrificeAI":
if ctx.has_sacrifice_summon:
stored_units_key = ctx.player_stored_units_key
if file == "unitSacrificeAI":
stored_units_key = ctx.ai_stored_units_key
with open(os.path.join(ctx.game_communication_path, file), 'r') as f:
unit_class = f.read()
message = [{"cmd": 'Set', "key": stored_units_key,
"default": [],
"want_reply": True,
"operations": [{"operation": "add", "value": [unit_class[:64]]}]}]
await ctx.send_msgs(message)
os.remove(os.path.join(ctx.game_communication_path, file))
if file == "unitSummonRequestAI" or file == "unitSummonRequest":
if ctx.has_sacrifice_summon:
stored_units_key = ctx.player_stored_units_key
if file == "unitSummonRequestAI":
stored_units_key = ctx.ai_stored_units_key
with open(os.path.join(ctx.game_communication_path, "unitSummonResponse"), 'w') as f:
if stored_units_key in ctx.stored_data:
stored_units = ctx.stored_data[stored_units_key]
if stored_units is None:
stored_units = []
wg1_stored_units = [unit for unit in stored_units if unit in ctx.unit_classes]
if len(wg1_stored_units) != 0:
summoned_unit = random.choice(wg1_stored_units)
message = [{"cmd": 'Set', "key": stored_units_key,
"default": [],
"want_reply": True,
"operations": [{"operation": "remove", "value": summoned_unit[:64]}]}]
await ctx.send_msgs(message)
f.write(summoned_unit)
os.remove(os.path.join(ctx.game_communication_path, file))
ctx.locations_checked = sending
message = [{"cmd": 'LocationChecks', "locations": sending}]
await ctx.send_msgs(message)
if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
await asyncio.sleep(0.1)
except Exception as err:
logger.warn("Exception in communication thread, a check may not have been sent: " + str(err))
if ctx.syncing == True:
sync_msg = [{'cmd': 'Sync'}]
if ctx.locations_checked:
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
await ctx.send_msgs(sync_msg)
ctx.syncing = False
sending = []
victory = False
for root, dirs, files in os.walk(ctx.game_communication_path):
for file in files:
if file.find("send") > -1:
st = file.split("send", -1)[1]
sending = sending+[(int(st))]
os.remove(os.path.join(ctx.game_communication_path, file))
if file.find("victory") > -1:
victory = True
os.remove(os.path.join(ctx.game_communication_path, file))
ctx.locations_checked = sending
message = [{"cmd": 'LocationChecks', "locations": sending}]
await ctx.send_msgs(message)
if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
await asyncio.sleep(0.1)
def print_error_and_close(msg):
@@ -571,9 +424,8 @@ def print_error_and_close(msg):
Utils.messagebox("Error", msg, error=True)
sys.exit(1)
def launch(*launch_args: str):
async def main():
args = parser.parse_args(launch_args)
if __name__ == '__main__':
async def main(args):
ctx = WargrooveContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
if gui_enabled:
@@ -593,6 +445,7 @@ def launch(*launch_args: str):
parser = get_base_parser(description="Wargroove Client, for text interfacing.")
colorama.just_fix_windows_console()
asyncio.run(main())
args, rest = parser.parse_known_args()
colorama.init()
asyncio.run(main(args))
colorama.deinit()

View File

@@ -80,8 +80,10 @@ def register():
"""Import submodules, triggering their registering on flask routing.
Note: initializes worlds subsystem."""
# has automatic patch integration
import worlds.AutoWorld
import worlds.Files
app.jinja_env.filters['is_applayercontainer'] = worlds.Files.is_ap_player_container
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
game_name in worlds.Files.AutoPatchRegister.patch_types
from WebHostLib.customserver import run_server_process
# to trigger app routing picking up on it

View File

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

View File

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

View File

@@ -227,9 +227,6 @@ def set_up_logging(room_id) -> logging.Logger:
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
from setproctitle import setproctitle
setproctitle(name)
Utils.init_logging(name)
try:
import resource
@@ -250,23 +247,8 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
raise Exception("Worlds system should not be loaded in the custom server.")
import gc
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
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
del cert_file, cert_key_file, ponyconfig
gc.collect() # free intermediate objects used during setup
loop = asyncio.get_event_loop()
@@ -281,12 +263,12 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
assert ctx.server is None
try:
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=get_ssl_context())
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
await ctx.server
except OSError: # likely port in use
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=get_ssl_context())
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
await ctx.server
port = 0

View File

@@ -61,7 +61,12 @@ def download_slot_file(room_id, player_id: int):
else:
import io
if slot_data.game == "Factorio":
if slot_data.game == "Minecraft":
from worlds.minecraft import mc_update_output
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
data = mc_update_output(slot_data.data, server=app.config['HOST_ADDRESS'], port=room.last_port)
return send_file(io.BytesIO(data), as_attachment=True, download_name=fname)
elif slot_data.game == "Factorio":
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
for name in zf.namelist():
if name.endswith("info.json"):

View File

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

View File

@@ -35,12 +35,6 @@ def start_playing():
@app.route('/games/<string:game>/info/<string:lang>')
@cache.cached()
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))
@@ -58,12 +52,6 @@ def games():
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
@cache.cached()
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))

View File

@@ -6,7 +6,7 @@ from typing import Dict, Union
from docutils.core import publish_parts
import yaml
from flask import redirect, render_template, request, Response, abort
from flask import redirect, render_template, request, Response
import Options
from Utils import local_path
@@ -108,7 +108,7 @@ def option_presets(game: str) -> Response:
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
presets[preset_name][preset_option_name] = option.value
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.OptionCounter)):
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.ItemDict)):
presets[preset_name][preset_option_name] = option.value
elif isinstance(preset_option, str):
# Ensure the option value is valid for Choice and Toggle options
@@ -142,10 +142,7 @@ def weighted_options_old():
@app.route("/games/<string:game>/weighted-options")
@cache.cached()
def weighted_options(game: str):
try:
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
except KeyError:
return abort(404)
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
@app.route("/games/<string:game>/generate-weighted-yaml", methods=["POST"])
@@ -200,10 +197,7 @@ def generate_weighted_yaml(game: str):
@app.route("/games/<string:game>/player-options")
@cache.cached()
def player_options(game: str):
try:
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
except KeyError:
return abort(404)
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
# YAML generator for player-options
@@ -222,7 +216,7 @@ def generate_yaml(game: str):
for key, val in options.copy().items():
key_parts = key.rsplit("||", 2)
# Detect and build OptionCounter options from their name pattern
# Detect and build ItemDict options from their name pattern
if key_parts[-1] == "qty":
if key_parts[0] not in options:
options[key_parts[0]] = {}

View File

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

View File

@@ -23,6 +23,7 @@ window.addEventListener('load', () => {
showdown.setOption('strikethrough', true);
showdown.setOption('literalMidWordUnderscores', true);
gameInfo.innerHTML += (new showdown.Converter()).makeHtml(results);
adjustHeaderWidth();
// Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
@@ -41,5 +42,10 @@ window.addEventListener('load', () => {
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,4 +6,6 @@ window.addEventListener('load', () => {
document.getElementById('file-input').addEventListener('change', () => {
document.getElementById('host-game-form').submit();
});
adjustFooterHeight();
});

View File

@@ -0,0 +1,49 @@
window.addEventListener('load', () => {
// Reload tracker every 15 seconds
const url = window.location;
setInterval(() => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
// Create a fake DOM using the returned HTML
const domParser = new DOMParser();
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
// Update item tracker
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
// Update only counters in the location-table
let counters = document.getElementsByClassName('counter');
const fakeCounters = fakeDOM.getElementsByClassName('counter');
for (let i = 0; i < counters.length; i++) {
counters[i].innerHTML = fakeCounters[i].innerHTML;
}
};
ajax.open('GET', url);
ajax.send();
}, 15000)
// Collapsible advancement sections
const categories = document.getElementsByClassName("location-category");
for (let i = 0; i < categories.length; i++) {
let hide_id = categories[i].id.split('-')[0];
if (hide_id == 'Total') {
continue;
}
categories[i].addEventListener('click', function() {
// Toggle the advancement list
document.getElementById(hide_id).classList.toggle("hide");
// Change text of the header
const tab_header = document.getElementById(hide_id+'-header').children[0];
const orig_text = tab_header.innerHTML;
let new_text;
if (orig_text.includes("▼")) {
new_text = orig_text.replace("▼", "▲");
}
else {
new_text = orig_text.replace("▲", "▼");
}
tab_header.innerHTML = new_text;
});
}
});

View File

@@ -0,0 +1,47 @@
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,6 +25,7 @@ window.addEventListener('load', () => {
showdown.setOption('literalMidWordUnderscores', true);
showdown.setOption('disableForced4SpacesIndentedSublists', true);
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
adjustHeaderWidth();
const title = document.querySelector('h1')
if (title) {
@@ -48,5 +49,10 @@ window.addEventListener('load', () => {
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,13 +36,6 @@ html{
body{
margin: 0;
display: flex;
flex-direction: column;
min-height: calc(100vh - 110px);
}
main {
flex-grow: 1;
}
a{

View File

@@ -0,0 +1,102 @@
#player-tracker-wrapper{
margin: 0;
}
#inventory-table{
border-top: 2px solid #000000;
border-left: 2px solid #000000;
border-right: 2px solid #000000;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
padding: 3px 3px 10px;
width: 384px;
background-color: #42b149;
}
#inventory-table td{
width: 40px;
height: 40px;
text-align: center;
vertical-align: middle;
}
#inventory-table img{
height: 100%;
max-width: 40px;
max-height: 40px;
filter: grayscale(100%) contrast(75%) brightness(30%);
}
#inventory-table img.acquired{
filter: none;
}
#inventory-table div.counted-item {
position: relative;
}
#inventory-table div.item-count {
position: absolute;
color: white;
font-family: "Minecraftia", monospace;
font-weight: bold;
bottom: 0;
right: 0;
}
#location-table{
width: 384px;
border-left: 2px solid #000000;
border-right: 2px solid #000000;
border-bottom: 2px solid #000000;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
background-color: #42b149;
padding: 0 3px 3px;
font-family: "Minecraftia", monospace;
font-size: 14px;
cursor: default;
}
#location-table th{
vertical-align: middle;
text-align: left;
padding-right: 10px;
}
#location-table td{
padding-top: 2px;
padding-bottom: 2px;
line-height: 20px;
}
#location-table td.counter {
text-align: right;
font-size: 14px;
}
#location-table td.toggle-arrow {
text-align: right;
}
#location-table tr#Total-header {
font-weight: bold;
}
#location-table img{
height: 100%;
max-width: 30px;
max-height: 30px;
}
#location-table tbody.locations {
font-size: 12px;
}
#location-table td.location-name {
padding-left: 16px;
}
.hide {
display: none;
}

View File

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

View File

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

View File

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

View File

@@ -26,15 +26,30 @@
<td>{{ patch.game }}</td>
<td>
{% if patch.data %}
{% if patch.game == "VVVVVV" and room.seed.slots|length == 1 %}
{% if patch.game == "Minecraft" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APMC File...</a>
{% elif patch.game == "Factorio" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download Factorio Mod...</a>
{% elif patch.game == "Kingdom Hearts 2" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download Kingdom Hearts 2 Mod...</a>
{% elif patch.game == "Ocarina of Time" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APZ5 File...</a>
{% elif patch.game == "VVVVVV" and room.seed.slots|length == 1 %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APV6 File...</a>
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APSM64EX File...</a>
{% elif patch.game | is_applayercontainer(patch.data, patch.player_id) %}
{% elif patch.game | supports_apdeltapatch %}
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
Download Patch File...</a>
{% elif patch.game == "Final Fantasy Mystic Quest" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APMQ File...</a>
{% else %}
No file to download for this game.
{% endif %}

View File

@@ -5,29 +5,26 @@
<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/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>
{% block head %}
<title>Archipelago</title>
{% endblock %}
</head>
<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 %}
{% block body %}
{% endblock %}
</main>
{% if show_footer %}
{% include "islandFooter.html" %}
{% with messages = get_flashed_messages() %}
{% if messages %}
<div>
{% for message in messages | unique %}
<div class="user-message">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block body %}
{% endblock %}
</body>
</html>

View File

@@ -111,19 +111,10 @@
</div>
{% endmacro %}
{% macro OptionCounter(option_name, option) %}
{% set relevant_keys = option.valid_keys %}
{% if not relevant_keys %}
{% if option.verify_item_name %}
{% set relevant_keys = world.item_names %}
{% elif option.verify_location_name %}
{% set relevant_keys = world.location_names %}
{% endif %}
{% endif %}
{% macro ItemDict(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<div class="option-container">
{% for item_name in (relevant_keys if relevant_keys is ordered else relevant_keys|sort) %}
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
<div class="option-entry">
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
<input type="number" id="{{ option_name }}-{{ item_name }}-qty" name="{{ option_name }}||{{ item_name }}||qty" value="{{ option.default[item_name]|default("0") }}" data-option-name="{{ option_name }}" data-item-name="{{ item_name }}" />
@@ -222,7 +213,7 @@
{% endmacro %}
{% macro RandomizeButton(option_name, option) %}
<div class="randomize-button" data-tooltip="Pick a random value for this option.">
<div class="randomize-button" data-tooltip="Toggle randomization for this option!">
<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" }} />
🎲

View File

@@ -93,10 +93,8 @@
{% elif issubclass(option, Options.FreeText) %}
{{ inputs.FreeText(option_name, option) }}
{% elif issubclass(option, Options.OptionCounter) and (
option.valid_keys or option.verify_item_name or option.verify_location_name
) %}
{{ inputs.OptionCounter(option_name, option) }}
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
{{ inputs.ItemDict(option_name, option) }}
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
{{ inputs.OptionList(option_name, option) }}
@@ -135,10 +133,8 @@
{% elif issubclass(option, Options.FreeText) %}
{{ inputs.FreeText(option_name, option) }}
{% elif issubclass(option, Options.OptionCounter) and (
option.valid_keys or option.verify_item_name or option.verify_location_name
) %}
{{ inputs.OptionCounter(option_name, option) }}
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
{{ inputs.ItemDict(option_name, option) }}
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
{{ inputs.OptionList(option_name, option) }}

View File

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

View File

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

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/minecraftTracker.css') }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/minecraftTracker.js') }}"></script>
<link rel="stylesheet" media="screen" href="https://fontlibrary.org//face/minecraftia" type="text/css"/>
</head>
<body>
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
<div style="margin-bottom: 0.5rem">
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
</div>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>
<td><img src="{{ tools_url }}" class="{{ 'acquired' }}" title="Progressive Tools" /></td>
<td><img src="{{ weapons_url }}" class="{{ 'acquired' }}" title="Progressive Weapons" /></td>
<td><img src="{{ armor_url }}" class="{{ 'acquired' }}" title="Progressive Armor" /></td>
<td><img src="{{ resource_crafting_url }}" class="{{ 'acquired' if 'Progressive Resource Crafting' in acquired_items }}"
title="Progressive Resource Crafting" /></td>
<td><img src="{{ icons['Brewing Stand'] }}" class="{{ 'acquired' if 'Brewing' in acquired_items }}" title="Brewing" /></td>
<td>
<div class="counted-item">
<img src="{{ icons['Ender Pearl'] }}" class="{{ 'acquired' if '3 Ender Pearls' in acquired_items }}" title="Ender Pearls" />
<div class="item-count">{{ pearls_count }}</div>
</div>
</td>
</tr>
<tr>
<td><img src="{{ icons['Bucket'] }}" class="{{ 'acquired' if 'Bucket' in acquired_items }}" title="Bucket" /></td>
<td><img src="{{ icons['Bow'] }}" class="{{ 'acquired' if 'Archery' in acquired_items }}" title="Archery" /></td>
<td><img src="{{ icons['Shield'] }}" class="{{ 'acquired' if 'Shield' in acquired_items }}" title="Shield" /></td>
<td><img src="{{ icons['Red Bed'] }}" class="{{ 'acquired' if 'Bed' in acquired_items }}" title="Bed" /></td>
<td><img src="{{ icons['Water Bottle'] }}" class="{{ 'acquired' if 'Bottles' in acquired_items }}" title="Bottles" /></td>
<td>
<div class="counted-item">
<img src="{{ icons['Netherite Scrap'] }}" class="{{ 'acquired' if '8 Netherite Scrap' in acquired_items }}" title="Netherite Scrap" />
<div class="item-count">{{ scrap_count }}</div>
</div>
</td>
</tr>
<tr>
<td><img src="{{ icons['Flint and Steel'] }}" class="{{ 'acquired' if 'Flint and Steel' in acquired_items }}" title="Flint and Steel" /></td>
<td><img src="{{ icons['Enchanting Table'] }}" class="{{ 'acquired' if 'Enchanting' in acquired_items }}" title="Enchanting" /></td>
<td><img src="{{ icons['Fishing Rod'] }}" class="{{ 'acquired' if 'Fishing Rod' in acquired_items }}" title="Fishing Rod" /></td>
<td><img src="{{ icons['Campfire'] }}" class="{{ 'acquired' if 'Campfire' in acquired_items }}" title="Campfire" /></td>
<td><img src="{{ icons['Spyglass'] }}" class="{{ 'acquired' if 'Spyglass' in acquired_items }}" title="Spyglass" /></td>
<td>
<div class="counted-item">
<img src="{{ icons['Dragon Egg Shard'] }}" class="{{ 'acquired' if 'Dragon Egg Shard' in acquired_items }}" title="Dragon Egg Shard" />
<div class="item-count">{{ shard_count }}</div>
</div>
</td>
</tr>
<tr>
<td><img src="{{ icons['Lead'] }}" class="{{ 'acquired' if 'Lead' in acquired_items }}" title="Lead" /></td>
<td><img src="{{ icons['Saddle'] }}" class="{{ 'acquired' if 'Saddle' in acquired_items }}" title="Saddle" /></td>
<td><img src="{{ icons['Channeling Book'] }}" class="{{ 'acquired' if 'Channeling Book' in acquired_items }}" title="Channeling Book" /></td>
<td><img src="{{ icons['Silk Touch Book'] }}" class="{{ 'acquired' if 'Silk Touch Book' in acquired_items }}" title="Silk Touch Book" /></td>
<td><img src="{{ icons['Piercing IV Book'] }}" class="{{ 'acquired' if 'Piercing IV Book' in acquired_items }}" title="Piercing IV Book" /></td>
</tr>
</table>
<table id="location-table">
{% for area in checks_done %}
<tr class="location-category" id="{{area}}-header">
<td>{{ area }} {{'▼' if area != 'Total'}}</td>
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
</tr>
<tbody class="locations hide" id="{{area}}">
{% for location in location_info[area] %}
<tr>
<td class="location-name">{{ location }}</td>
<td class="counter">{{ '✔' if location_info[area][location] else '' }}</td>
</tr>
{% endfor %}
</tbody>
{% endfor %}
</table>
</div>
</body>
</html>

View File

@@ -29,8 +29,7 @@
<div id="user-content-wrapper" class="markdown">
<div id="user-content" class="grass-island">
<h1>User Content</h1>
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>
Below is a list of all the content you have generated on this site. Rooms and seeds are listed separately.
<h2>Your Rooms</h2>
{% if rooms %}

View File

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

View File

@@ -1,12 +1,9 @@
{% extends 'pageWrapper.html' %}
{% import "macros.html" as macros %}
{% set show_footer = True %}
{% block head %}
<title>Generation in Progress</title>
<noscript>
<meta http-equiv="refresh" content="1">
</noscript>
<meta http-equiv="refresh" content="1">
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/waitSeed.css") }}"/>
{% endblock %}
@@ -18,34 +15,5 @@
Waiting for game to generate, this page auto-refreshes to check.
</div>
</div>
<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>
{% include 'islandFooter.html' %}
{% endblock %}

View File

@@ -113,18 +113,9 @@
{{ TextChoice(option_name, option) }}
{% endmacro %}
{% macro OptionCounter(option_name, option, world) %}
{% set relevant_keys = option.valid_keys %}
{% if not relevant_keys %}
{% if option.verify_item_name %}
{% set relevant_keys = world.item_names %}
{% elif option.verify_location_name %}
{% set relevant_keys = world.location_names %}
{% endif %}
{% endif %}
{% macro ItemDict(option_name, option, world) %}
<div class="dict-container">
{% for item_name in (relevant_keys if relevant_keys is ordered else relevant_keys|sort) %}
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
<div class="dict-entry">
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
<input

View File

@@ -83,10 +83,8 @@
{% elif issubclass(option, Options.FreeText) %}
{{ inputs.FreeText(option_name, option) }}
{% elif issubclass(option, Options.OptionCounter) and (
option.valid_keys or option.verify_item_name or option.verify_location_name
) %}
{{ inputs.OptionCounter(option_name, option, world) }}
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
{{ inputs.ItemDict(option_name, option, world) }}
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
{{ inputs.OptionList(option_name, option) }}
@@ -102,7 +100,7 @@
{% else %}
<div class="unsupported-option">
This option cannot be modified here. Please edit your .yaml file manually.
This option is not supported. Please edit your .yaml file manually.
</div>
{% endif %}

View File

@@ -706,6 +706,127 @@ if "A Link to the Past" in network_data_package["games"]:
_multiworld_trackers["A Link to the Past"] = render_ALinkToThePast_multiworld_tracker
_player_trackers["A Link to the Past"] = render_ALinkToThePast_tracker
if "Minecraft" in network_data_package["games"]:
def render_Minecraft_tracker(tracker_data: TrackerData, team: int, player: int) -> str:
icons = {
"Wooden Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d2/Wooden_Pickaxe_JE3_BE3.png",
"Stone Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c4/Stone_Pickaxe_JE2_BE2.png",
"Iron Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d1/Iron_Pickaxe_JE3_BE2.png",
"Diamond Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/e/e7/Diamond_Pickaxe_JE3_BE3.png",
"Wooden Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d5/Wooden_Sword_JE2_BE2.png",
"Stone Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b1/Stone_Sword_JE2_BE2.png",
"Iron Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/8/8e/Iron_Sword_JE2_BE2.png",
"Diamond Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/4/44/Diamond_Sword_JE3_BE3.png",
"Leather Tunic": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b7/Leather_Tunic_JE4_BE2.png",
"Iron Chestplate": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/31/Iron_Chestplate_JE2_BE2.png",
"Diamond Chestplate": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/e/e0/Diamond_Chestplate_JE3_BE2.png",
"Iron Ingot": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Iron_Ingot_JE3_BE2.png",
"Block of Iron": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/7e/Block_of_Iron_JE4_BE3.png",
"Brewing Stand": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b3/Brewing_Stand_%28empty%29_JE10.png",
"Ender Pearl": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/f6/Ender_Pearl_JE3_BE2.png",
"Bucket": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Bucket_JE2_BE2.png",
"Bow": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/a/ab/Bow_%28Pull_2%29_JE1_BE1.png",
"Shield": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c6/Shield_JE2_BE1.png",
"Red Bed": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/6/6a/Red_Bed_%28N%29.png",
"Netherite Scrap": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/33/Netherite_Scrap_JE2_BE1.png",
"Flint and Steel": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/94/Flint_and_Steel_JE4_BE2.png",
"Enchanting Table": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/31/Enchanting_Table.gif",
"Fishing Rod": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/7f/Fishing_Rod_JE2_BE2.png",
"Campfire": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/91/Campfire_JE2_BE2.gif",
"Water Bottle": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/75/Water_Bottle_JE2_BE2.png",
"Spyglass": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c1/Spyglass_JE2_BE1.png",
"Dragon Egg Shard": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/38/Dragon_Egg_JE4.png",
"Lead": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/1/1f/Lead_JE2_BE2.png",
"Saddle": "https://i.imgur.com/2QtDyR0.png",
"Channeling Book": "https://i.imgur.com/J3WsYZw.png",
"Silk Touch Book": "https://i.imgur.com/iqERxHQ.png",
"Piercing IV Book": "https://i.imgur.com/OzJptGz.png",
}
minecraft_location_ids = {
"Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070,
42041, 42049, 42004, 42031, 42025, 42029, 42051, 42077],
"Nether": [42017, 42044, 42069, 42058, 42034, 42060, 42066, 42076, 42064, 42071, 42021,
42062, 42008, 42061, 42033, 42011, 42006, 42019, 42000, 42040, 42001, 42015, 42104, 42014],
"The End": [42052, 42005, 42012, 42032, 42030, 42042, 42018, 42038, 42046],
"Adventure": [42047, 42050, 42096, 42097, 42098, 42059, 42055, 42072, 42003, 42109, 42035, 42016, 42020,
42048, 42054, 42068, 42043, 42106, 42074, 42075, 42024, 42026, 42037, 42045, 42056, 42105,
42099, 42103, 42110, 42100],
"Husbandry": [42065, 42067, 42078, 42022, 42113, 42107, 42007, 42079, 42013, 42028, 42036, 42108, 42111,
42112,
42057, 42063, 42053, 42102, 42101, 42092, 42093, 42094, 42095],
"Archipelago": [42080, 42081, 42082, 42083, 42084, 42085, 42086, 42087, 42088, 42089, 42090, 42091],
}
display_data = {}
# Determine display for progressive items
progressive_items = {
"Progressive Tools": 45013,
"Progressive Weapons": 45012,
"Progressive Armor": 45014,
"Progressive Resource Crafting": 45001
}
progressive_names = {
"Progressive Tools": ["Wooden Pickaxe", "Stone Pickaxe", "Iron Pickaxe", "Diamond Pickaxe"],
"Progressive Weapons": ["Wooden Sword", "Stone Sword", "Iron Sword", "Diamond Sword"],
"Progressive Armor": ["Leather Tunic", "Iron Chestplate", "Diamond Chestplate"],
"Progressive Resource Crafting": ["Iron Ingot", "Iron Ingot", "Block of Iron"]
}
inventory = tracker_data.get_player_inventory_counts(team, player)
for item_name, item_id in progressive_items.items():
level = min(inventory[item_id], len(progressive_names[item_name]) - 1)
display_name = progressive_names[item_name][level]
base_name = item_name.split(maxsplit=1)[1].lower().replace(" ", "_")
display_data[base_name + "_url"] = icons[display_name]
# Multi-items
multi_items = {
"3 Ender Pearls": 45029,
"8 Netherite Scrap": 45015,
"Dragon Egg Shard": 45043
}
for item_name, item_id in multi_items.items():
base_name = item_name.split()[-1].lower()
count = inventory[item_id]
if count >= 0:
display_data[base_name + "_count"] = count
# Victory condition
game_state = tracker_data.get_player_client_status(team, player)
display_data["game_finished"] = game_state == 30
# Turn location IDs into advancement tab counts
checked_locations = tracker_data.get_player_checked_locations(team, player)
lookup_name = lambda id: tracker_data.location_id_to_name["Minecraft"][id]
location_info = {tab_name: {lookup_name(id): (id in checked_locations) for id in tab_locations}
for tab_name, tab_locations in minecraft_location_ids.items()}
checks_done = {tab_name: len([id for id in tab_locations if id in checked_locations])
for tab_name, tab_locations in minecraft_location_ids.items()}
checks_done["Total"] = len(checked_locations)
checks_in_area = {tab_name: len(tab_locations) for tab_name, tab_locations in minecraft_location_ids.items()}
checks_in_area["Total"] = sum(checks_in_area.values())
lookup_any_item_id_to_name = tracker_data.item_id_to_name["Minecraft"]
return render_template(
"tracker__Minecraft.html",
inventory=inventory,
icons=icons,
acquired_items={lookup_any_item_id_to_name[id] for id, count in inventory.items() if count > 0},
player=player,
team=team,
room=tracker_data.room,
player_name=tracker_data.get_player_name(team, player),
saving_second=tracker_data.get_room_saving_second(),
checks_done=checks_done,
checks_in_area=checks_in_area,
location_info=location_info,
**display_data,
)
_player_trackers["Minecraft"] = render_Minecraft_tracker
if "Ocarina of Time" in network_data_package["games"]:
def render_OcarinaOfTime_tracker(tracker_data: TrackerData, team: int, player: int) -> str:
icons = {

View File

@@ -119,9 +119,9 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
# AP Container
elif handler:
data = zfile.open(file, "r").read()
with zipfile.ZipFile(BytesIO(data)) as container:
player = json.loads(container.open("archipelago.json").read())["player"]
files[player] = data
patch = handler(BytesIO(data))
patch.read()
files[patch.player] = data
# Spoiler
elif file.filename.endswith(".txt"):
@@ -135,6 +135,11 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
flash("Could not load multidata. File may be corrupted or incompatible.")
multidata = None
# Minecraft
elif file.filename.endswith(".apmc"):
data = zfile.open(file, "r").read()
metadata = json.loads(base64.b64decode(data).decode("utf-8"))
files[metadata["player_id"]] = data
# Factorio
elif file.filename.endswith(".zip"):

View File

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

View File

@@ -14,71 +14,23 @@
salmon: "FA8072" # typically trap item
white: "FFFFFF" # not used, if you want to change the generic text color change color in Label
orange: "FF7700" # Used for command echo
# KivyMD theming parameters
theme_style: "Dark" # Light/Dark
primary_palette: "Lightsteelblue" # Many options
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
<MDNavigationItemBase>:
on_release: app.screens.switch_screens(self)
MDNavigationItemLabel:
text: root.text
theme_text_color: "Custom"
text_color_active: self.theme_cls.primaryColor
text_color_normal: 1, 1, 1, 1
# indicator is on icon only for some reason
canvas.before:
Color:
rgba: self.theme_cls.secondaryContainerColor if root.active else self.theme_cls.transparentColor
Rectangle:
size: root.size
<Label>:
color: "FFFFFF"
<TabbedPanel>:
tab_width: root.width / app.tab_count
<TooltipLabel>:
adaptive_height: True
theme_font_size: "Custom"
font_size: "20dp"
text_size: self.width, None
size_hint_y: None
height: self.texture_size[1]
font_size: dp(20)
markup: True
halign: "left"
<SelectableLabel>:
size_hint: 1, None
theme_text_color: "Custom"
text_color: 1, 1, 1, 1
canvas.before:
Color:
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
rgba: (.0, 0.9, .1, .3) if self.selected else (0.2, 0.2, 0.2, 1)
Rectangle:
size: self.size
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>:
messages: 1000 # amount of messages stored in client logs.
cols: 1
@@ -97,7 +49,7 @@
<HintLabel>:
canvas.before:
Color:
rgba: (.0, 0.9, .1, .3) if self.selected else self.theme_cls.surfaceContainerHighColor if self.striped else self.theme_cls.surfaceContainerLowColor
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)
Rectangle:
size: self.size
pos: self.pos
@@ -174,12 +126,9 @@
<ToolTip>:
size: self.texture_size
size_hint: None, None
theme_font_size: "Custom"
font_size: dp(18)
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
halign: "left"
theme_text_color: "Custom"
text_color: (1, 1, 1, 1)
canvas.before:
Color:
rgba: 0.2, 0.2, 0.2, 1
@@ -198,43 +147,8 @@
rectangle: self.x-2, self.y-2, self.width+4, self.height+4
<ServerToolTip>:
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
<AutocompleteHintInput>:
<AutocompleteHintInput>
size_hint_y: None
height: "30dp"
height: dp(30)
multiline: 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
<MessageBoxLabel>:
valign: "middle"
halign: "center"
text_size: self.width, None
height: self.texture_size[1]

View File

@@ -1,161 +0,0 @@
<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_by_type(self)
MDButtonIcon:
icon: "asterisk"
MDButtonText:
text: "All"
MDButton:
id: client
style: "text"
type: (Type.CLIENT, )
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
icon: "controller"
MDButtonText:
text: "Client"
MDButton:
id: Tool
style: "text"
type: (Type.TOOL, )
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
icon: "desktop-classic"
MDButtonText:
text: "Tool"
MDButton:
id: adjuster
style: "text"
type: (Type.ADJUSTER, )
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
icon: "wrench"
MDButtonText:
text: "Adjuster"
MDButton:
id: misc
style: "text"
type: (Type.MISC, )
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
icon: "dots-horizontal-circle-outline"
MDButtonText:
text: "Misc"
MDButton:
id: favorites
style: "text"
type: ("favorites", )
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
icon: "star"
MDButtonText:
text: "Favorites"
MDNavigationDrawerDivider:
MDGridLayout:
id: main_layout
cols: 1
spacing: "10dp"
MDTextField:
id: search_box
mode: "outlined"
set_text: app.filter_clients_by_name
MDTextFieldLeadingIcon:
icon: "magnify"
MDTextFieldHintText:
text: "Search"
ScrollBox:
id: button_layout

View File

@@ -365,14 +365,18 @@ request_handlers = {
["PREFERRED_CORES"] = function (req)
local res = {}
local preferred_cores = client.getconfig().PreferredCores
local systems_enumerator = preferred_cores.Keys:GetEnumerator()
res["type"] = "PREFERRED_CORES_RESPONSE"
res["value"] = {}
while systems_enumerator:MoveNext() do
res["value"][systems_enumerator.Current] = preferred_cores[systems_enumerator.Current]
end
res["value"]["NES"] = preferred_cores.NES
res["value"]["SNES"] = preferred_cores.SNES
res["value"]["GB"] = preferred_cores.GB
res["value"]["GBC"] = preferred_cores.GBC
res["value"]["DGB"] = preferred_cores.DGB
res["value"]["SGB"] = preferred_cores.SGB
res["value"]["PCE"] = preferred_cores.PCE
res["value"]["PCECD"] = preferred_cores.PCECD
res["value"]["SGX"] = preferred_cores.SGX
return res
end,

462
data/lua/connector_ff1.lua Normal file
View File

@@ -0,0 +1,462 @@
local socket = require("socket")
local json = require('json')
local math = require('math')
require("common")
local STATE_OK = "Ok"
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
local STATE_UNINITIALIZED = "Uninitialized"
local ITEM_INDEX = 0x03
local WEAPON_INDEX = 0x07
local ARMOR_INDEX = 0x0B
local goldLookup = {
[0x16C] = 10,
[0x16D] = 20,
[0x16E] = 25,
[0x16F] = 30,
[0x170] = 55,
[0x171] = 70,
[0x172] = 85,
[0x173] = 110,
[0x174] = 135,
[0x175] = 155,
[0x176] = 160,
[0x177] = 180,
[0x178] = 240,
[0x179] = 255,
[0x17A] = 260,
[0x17B] = 295,
[0x17C] = 300,
[0x17D] = 315,
[0x17E] = 330,
[0x17F] = 350,
[0x180] = 385,
[0x181] = 400,
[0x182] = 450,
[0x183] = 500,
[0x184] = 530,
[0x185] = 575,
[0x186] = 620,
[0x187] = 680,
[0x188] = 750,
[0x189] = 795,
[0x18A] = 880,
[0x18B] = 1020,
[0x18C] = 1250,
[0x18D] = 1455,
[0x18E] = 1520,
[0x18F] = 1760,
[0x190] = 1975,
[0x191] = 2000,
[0x192] = 2750,
[0x193] = 3400,
[0x194] = 4150,
[0x195] = 5000,
[0x196] = 5450,
[0x197] = 6400,
[0x198] = 6720,
[0x199] = 7340,
[0x19A] = 7690,
[0x19B] = 7900,
[0x19C] = 8135,
[0x19D] = 9000,
[0x19E] = 9300,
[0x19F] = 9500,
[0x1A0] = 9900,
[0x1A1] = 10000,
[0x1A2] = 12350,
[0x1A3] = 13000,
[0x1A4] = 13450,
[0x1A5] = 14050,
[0x1A6] = 14720,
[0x1A7] = 15000,
[0x1A8] = 17490,
[0x1A9] = 18010,
[0x1AA] = 19990,
[0x1AB] = 20000,
[0x1AC] = 20010,
[0x1AD] = 26000,
[0x1AE] = 45000,
[0x1AF] = 65000
}
local extensionConsumableLookup = {
[432] = 0x3C,
[436] = 0x3C,
[440] = 0x3C,
[433] = 0x3D,
[437] = 0x3D,
[441] = 0x3D,
[434] = 0x3E,
[438] = 0x3E,
[442] = 0x3E,
[435] = 0x3F,
[439] = 0x3F,
[443] = 0x3F
}
local noOverworldItemsLookup = {
[499] = 0x2B,
[500] = 0x12,
}
local consumableStacks = nil
local prevstate = ""
local curstate = STATE_UNINITIALIZED
local ff1Socket = nil
local frame = 0
local isNesHawk = false
--Sets correct memory access functions based on whether NesHawk or QuickNES is loaded
local function defineMemoryFunctions()
local memDomain = {}
local domains = memory.getmemorydomainlist()
if domains[1] == "System Bus" then
--NesHawk
isNesHawk = true
memDomain["systembus"] = function() memory.usememorydomain("System Bus") end
memDomain["saveram"] = function() memory.usememorydomain("Battery RAM") end
memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end
elseif domains[1] == "WRAM" then
--QuickNES
memDomain["systembus"] = function() memory.usememorydomain("System Bus") end
memDomain["saveram"] = function() memory.usememorydomain("WRAM") end
memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end
end
return memDomain
end
local memDomain = defineMemoryFunctions()
local function StateOKForMainLoop()
memDomain.saveram()
local A = u8(0x102) -- Party Made
local B = u8(0x0FC)
local C = u8(0x0A3)
return A ~= 0x00 and not (A== 0xF2 and B == 0xF2 and C == 0xF2)
end
function generateLocationChecked()
memDomain.saveram()
data = uRange(0x01FF, 0x101)
data[0] = nil
return data
end
function setConsumableStacks()
memDomain.rom()
consumableStacks = {}
-- In order shards, tent, cabin, house, heal, pure, soft, ext1, ext2, ext3, ex4
consumableStacks[0x35] = 1
consumableStacks[0x36] = u8(0x47400) + 1
consumableStacks[0x37] = u8(0x47401) + 1
consumableStacks[0x38] = u8(0x47402) + 1
consumableStacks[0x39] = u8(0x47403) + 1
consumableStacks[0x3A] = u8(0x47404) + 1
consumableStacks[0x3B] = u8(0x47405) + 1
consumableStacks[0x3C] = u8(0x47406) + 1
consumableStacks[0x3D] = u8(0x47407) + 1
consumableStacks[0x3E] = u8(0x47408) + 1
consumableStacks[0x3F] = u8(0x47409) + 1
end
function getEmptyWeaponSlots()
memDomain.saveram()
ret = {}
count = 1
slot1 = uRange(0x118, 0x4)
slot2 = uRange(0x158, 0x4)
slot3 = uRange(0x198, 0x4)
slot4 = uRange(0x1D8, 0x4)
for i,v in pairs(slot1) do
if v == 0 then
ret[count] = 0x118 + i
count = count + 1
end
end
for i,v in pairs(slot2) do
if v == 0 then
ret[count] = 0x158 + i
count = count + 1
end
end
for i,v in pairs(slot3) do
if v == 0 then
ret[count] = 0x198 + i
count = count + 1
end
end
for i,v in pairs(slot4) do
if v == 0 then
ret[count] = 0x1D8 + i
count = count + 1
end
end
return ret
end
function getEmptyArmorSlots()
memDomain.saveram()
ret = {}
count = 1
slot1 = uRange(0x11C, 0x4)
slot2 = uRange(0x15C, 0x4)
slot3 = uRange(0x19C, 0x4)
slot4 = uRange(0x1DC, 0x4)
for i,v in pairs(slot1) do
if v == 0 then
ret[count] = 0x11C + i
count = count + 1
end
end
for i,v in pairs(slot2) do
if v == 0 then
ret[count] = 0x15C + i
count = count + 1
end
end
for i,v in pairs(slot3) do
if v == 0 then
ret[count] = 0x19C + i
count = count + 1
end
end
for i,v in pairs(slot4) do
if v == 0 then
ret[count] = 0x1DC + i
count = count + 1
end
end
return ret
end
local function slice (tbl, s, e)
local pos, new = 1, {}
for i = s + 1, e do
new[pos] = tbl[i]
pos = pos + 1
end
return new
end
function processBlock(block)
local msgBlock = block['messages']
if msgBlock ~= nil then
for i, v in pairs(msgBlock) do
if itemMessages[i] == nil then
local msg = {TTL=450, message=v, color=0xFFFF0000}
itemMessages[i] = msg
end
end
end
local itemsBlock = block["items"]
memDomain.saveram()
isInGame = u8(0x102)
if itemsBlock ~= nil and isInGame ~= 0x00 then
if consumableStacks == nil then
setConsumableStacks()
end
memDomain.saveram()
-- print('ITEMBLOCK: ')
-- print(itemsBlock)
itemIndex = u8(ITEM_INDEX)
-- print('ITEMINDEX: '..itemIndex)
for i, v in pairs(slice(itemsBlock, itemIndex, #itemsBlock)) do
-- Minus the offset and add to the correct domain
local memoryLocation = v
if v >= 0x100 and v <= 0x114 then
-- This is a key item
memoryLocation = memoryLocation - 0x0E0
wU8(memoryLocation, 0x01)
elseif v >= 0x1E0 and v <= 0x1F2 then
-- This is a movement item
-- Minus Offset (0x100) - movement offset (0xE0)
memoryLocation = memoryLocation - 0x1E0
-- Canal is a flipped bit
if memoryLocation == 0x0C then
wU8(memoryLocation, 0x00)
else
wU8(memoryLocation, 0x01)
end
elseif v >= 0x1F3 and v <= 0x1F4 then
-- NoOverworld special items
memoryLocation = noOverworldItemsLookup[v]
wU8(memoryLocation, 0x01)
elseif v >= 0x16C and v <= 0x1AF then
-- This is a gold item
amountToAdd = goldLookup[v]
biggest = u8(0x01E)
medium = u8(0x01D)
smallest = u8(0x01C)
currentValue = 0x10000 * biggest + 0x100 * medium + smallest
newValue = currentValue + amountToAdd
newBiggest = math.floor(newValue / 0x10000)
newMedium = math.floor(math.fmod(newValue, 0x10000) / 0x100)
newSmallest = math.floor(math.fmod(newValue, 0x100))
wU8(0x01E, newBiggest)
wU8(0x01D, newMedium)
wU8(0x01C, newSmallest)
elseif v >= 0x115 and v <= 0x11B then
-- This is a regular consumable OR a shard
-- Minus Offset (0x100) + item offset (0x20)
memoryLocation = memoryLocation - 0x0E0
currentValue = u8(memoryLocation)
amountToAdd = consumableStacks[memoryLocation]
if currentValue < 99 then
wU8(memoryLocation, currentValue + amountToAdd)
end
elseif v >= 0x1B0 and v <= 0x1BB then
-- This is an extension consumable
memoryLocation = extensionConsumableLookup[v]
currentValue = u8(memoryLocation)
amountToAdd = consumableStacks[memoryLocation]
if currentValue < 99 then
value = currentValue + amountToAdd
if value > 99 then
value = 99
end
wU8(memoryLocation, value)
end
end
end
if #itemsBlock > itemIndex then
wU8(ITEM_INDEX, #itemsBlock)
end
memDomain.saveram()
weaponIndex = u8(WEAPON_INDEX)
emptyWeaponSlots = getEmptyWeaponSlots()
lastUsedWeaponIndex = weaponIndex
-- print('WEAPON_INDEX: '.. weaponIndex)
memDomain.saveram()
for i, v in pairs(slice(itemsBlock, weaponIndex, #itemsBlock)) do
if v >= 0x11C and v <= 0x143 then
-- Minus the offset and add to the correct domain
local itemValue = v - 0x11B
if #emptyWeaponSlots > 0 then
slot = table.remove(emptyWeaponSlots, 1)
wU8(slot, itemValue)
lastUsedWeaponIndex = weaponIndex + i
else
break
end
end
end
if lastUsedWeaponIndex ~= weaponIndex then
wU8(WEAPON_INDEX, lastUsedWeaponIndex)
end
memDomain.saveram()
armorIndex = u8(ARMOR_INDEX)
emptyArmorSlots = getEmptyArmorSlots()
lastUsedArmorIndex = armorIndex
-- print('ARMOR_INDEX: '.. armorIndex)
memDomain.saveram()
for i, v in pairs(slice(itemsBlock, armorIndex, #itemsBlock)) do
if v >= 0x144 and v <= 0x16B then
-- Minus the offset and add to the correct domain
local itemValue = v - 0x143
if #emptyArmorSlots > 0 then
slot = table.remove(emptyArmorSlots, 1)
wU8(slot, itemValue)
lastUsedArmorIndex = armorIndex + i
else
break
end
end
end
if lastUsedArmorIndex ~= armorIndex then
wU8(ARMOR_INDEX, lastUsedArmorIndex)
end
end
end
function receive()
l, e = ff1Socket:receive()
if e == 'closed' then
if curstate == STATE_OK then
print("Connection closed")
end
curstate = STATE_UNINITIALIZED
return
elseif e == 'timeout' then
print("timeout")
return
elseif e ~= nil then
print(e)
curstate = STATE_UNINITIALIZED
return
end
processBlock(json.decode(l))
-- Determine Message to send back
memDomain.rom()
local playerName = uRange(0x7BCBF, 0x41)
playerName[0] = nil
local retTable = {}
retTable["playerName"] = playerName
if StateOKForMainLoop() then
retTable["locations"] = generateLocationChecked()
end
msg = json.encode(retTable).."\n"
local ret, error = ff1Socket:send(msg)
if ret == nil then
print(error)
elseif curstate == STATE_INITIAL_CONNECTION_MADE then
curstate = STATE_TENTATIVELY_CONNECTED
elseif curstate == STATE_TENTATIVELY_CONNECTED then
print("Connected!")
itemMessages["(0,0)"] = {TTL=240, message="Connected", color="green"}
curstate = STATE_OK
end
end
function main()
if not checkBizHawkVersion() then
return
end
server, error = socket.bind('localhost', 52980)
while true do
gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow")
frame = frame + 1
drawMessages()
if not (curstate == prevstate) then
-- console.log("Current state: "..curstate)
prevstate = curstate
end
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
if (frame % 60 == 0) then
gui.drawEllipse(248, 9, 6, 6, "Black", "Blue")
receive()
else
gui.drawEllipse(248, 9, 6, 6, "Black", "Green")
end
elseif (curstate == STATE_UNINITIALIZED) then
gui.drawEllipse(248, 9, 6, 6, "Black", "White")
if (frame % 60 == 0) then
gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow")
drawText(5, 8, "Waiting for client", 0xFFFF0000)
drawText(5, 32, "Please start FF1Client.exe", 0xFFFF0000)
-- Advance so the messages are drawn
emu.frameadvance()
server:settimeout(2)
print("Attempting to connect")
local client, timeout = server:accept()
if timeout == nil then
-- print('Initial Connection Made')
curstate = STATE_INITIAL_CONNECTION_MADE
ff1Socket = client
ff1Socket:settimeout(0)
end
end
end
emu.frameadvance()
end
end
main()

View File

@@ -477,7 +477,7 @@ function main()
elseif (curstate == STATE_UNINITIALIZED) then
-- If we're uninitialized, attempt to make the connection.
if (frame % 120 == 0) then
server:settimeout(120)
server:settimeout(2)
local client, timeout = server:accept()
if timeout == nil then
print('Initial Connection Made')

BIN
data/mcicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -51,9 +51,10 @@ requires:
{%- for option_key, option in group_options.items() %}
{{ option_key }}:
{%- if option.__doc__ %}
# {{ cleandoc(option.__doc__)
# {{ option.__doc__
| trim
| replace('\n', '\n# ')
| replace('\n\n', '\n \n')
| replace('\n ', '\n# ')
| indent(4, first=False)
}}
{%- endif -%}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -45,9 +45,6 @@
# ChecksFinder
/worlds/checksfinder/ @SunCatMC
# Civilization VI
/worlds/civ6/ @hesto2
# Clique
/worlds/clique/ @ThePhar
@@ -87,9 +84,6 @@
# Inscryption
/worlds/inscryption/ @DrBibop @Glowbuzz
# Jak and Daxter: The Precursor Legacy
/worlds/jakanddaxter/ @massimilianodelliubaldini
# Kirby's Dream Land 3
/worlds/kdl3/ @Silvris
@@ -121,6 +115,9 @@
# The Messenger
/worlds/messenger/ @alwaysintreble
# Minecraft
/worlds/minecraft/ @KonoTyran @espeon65536
# Mega Man 2
/worlds/mm2/ @Silvris
@@ -157,9 +154,6 @@
# Saving Princess
/worlds/saving_princess/ @LeonarthCG
# shapez
/worlds/shapez/ @BlastSlimey
# Shivers
/worlds/shivers/ @GodlFire @korydondzila
@@ -178,9 +172,6 @@
# Super Mario 64
/worlds/sm64ex/ @N00byKing
# Super Mario Land 2: 6 Golden Coins
/worlds/marioland2/ @Alchav
# Super Mario World
/worlds/smw/ @PoryGone
@@ -190,6 +181,9 @@
# Secret of Evermore
/worlds/soe/ @black-sliver
# Slay the Spire
/worlds/spire/ @KonoTyran
# Stardew Valley
/worlds/stardew_valley/ @agilbert1412
@@ -217,9 +211,6 @@
# Wargroove
/worlds/wargroove/ @FlySniper
# The Wind Waker
/worlds/tww/ @tanjo3
# The Witness
/worlds/witness/ @NewSoupVi @blastron
@@ -235,10 +226,14 @@
# Zillion
/worlds/zillion/ @beauxq
# Zork Grand Inquisitor
/worlds/zork_grand_inquisitor/ @nbrochu
## Active Unmaintained Worlds
# The following worlds in this repo are currently unmaintained, but currently still work in core. If any update breaks
# compatibility, these worlds may be deleted. If you are interested in stepping up as maintainer for
# compatibility, these worlds may be moved to `worlds_disabled`. If you are interested in stepping up as maintainer for
# any of these worlds, please review `/docs/world maintainer.md` documentation.
# Final Fantasy (1)
@@ -247,6 +242,15 @@
# Ocarina of Time
# /worlds/oot/
## Disabled Unmaintained Worlds
# The following worlds in this repo are currently unmaintained and disabled as they do not work in core. If you are
# interested in stepping up as maintainer for any of these worlds, please review `/docs/world maintainer.md`
# documentation.
# Ori and the Blind Forest
# /worlds_disabled/oribf/
###################
## Documentation ##
###################

View File

@@ -1,8 +1,5 @@
# 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:
* Game Modification to communicate with Archipelago server (hereafter referred to as "client")
@@ -16,51 +13,30 @@ 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
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. Libraries for most modern languages and the spec for
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:
must fulfill a few requirements in order to function as expected. The specific requirements the game client must follow
to behave as expected are:
* Handle both secure and unsecure websocket connections
* Reconnect if the connection is unstable and lost while playing
* Detect and react when a location has been "checked" by the player by sending a network packet to the server
* 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
* 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
Regarding items and locations, the game client must be able to handle these tasks:
#### 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.
Libraries for most modern languages and the spec for various packets can be found in the
[network protocol](/docs/network%20protocol.md) API reference document.
## World
@@ -68,94 +44,35 @@ 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
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
repository and creating a new world package in `/worlds/`.
repository and creating a new world package in `/worlds/`. A bare minimum world implementation must satisfy the
following requirements:
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).
### 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 folder within `/worlds/` that contains an `__init__.py`
* A `World` subclass where you create your world and define all of its rules
* A unique game name
* For webhost documentation and behaviors, a `WebWorld` subclass that must be instantiated in the `World` class
definition
* The game_info doc must follow the format `{language_code}_{game_name}.md`
* 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.
* An implementation of `create_item` that can create an item when called by either your code or by another process
within Archipelago
* At least one `Region` for your player to start from (i.e. the Origin Region)
* The default name of this region is "Menu" but you may configure a different name with
[origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L298-L299)
* A non-zero number of locations, added to your regions
* 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.
### Encouraged Features
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.
`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 `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.
* A `Region` for your player with the name "Menu" to start from
* Create a non-zero number of locations and add them to your regions
* Create a non-zero number of items **equal** to the number of locations and add them to the multiworld itempool
* 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.
### 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
Notable caveats:
* 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
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
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

@@ -8,11 +8,7 @@ including [Contributing](contributing.md), [Adding Games](<adding games.md>), an
### My game has a restrictive start that leads to fill errors
A "restrictive start" here means having a combination of very few sphere 1 locations and potentially requiring more
than one item to get a player to sphere 2.
One way to fix this is to hint to the Generator that an item needs to be in sphere one with local_early_items.
Here, `1` represents the number of "Sword" items the Generator will attempt to place in sphere one.
Hint to the Generator that an item needs to be in sphere one with local_early_items. Here, `1` represents the number of "Sword" items to attempt to place in sphere one.
```py
early_item_name = "Sword"
self.multiworld.local_early_items[self.player][early_item_name] = 1
@@ -22,19 +18,15 @@ Some alternative ways to try to fix this problem are:
* Add more locations to sphere one of your world, potentially only when there would be a restrictive start
* Pre-place items yourself, such as during `create_items`
* Put items into the player's starting inventory using `push_precollected`
* Raise an exception, such as an `OptionError` during `generate_early`, to disallow options that would lead to a
restrictive start
* Raise an exception, such as an `OptionError` during `generate_early`, to disallow options that would lead to a restrictive start
---
### I have multiple options that change the item/location pool counts and need to make sure I am not submitting more/fewer items than locations
### I have multiple settings that change the item/location pool counts and need to balance them out
In an ideal situation your system for producing locations and items wouldn't leave any opportunity for them to be
unbalanced. But in real, complex situations, that might be unfeasible.
In an ideal situation your system for producing locations and items wouldn't leave any opportunity for them to be unbalanced. But in real, complex situations, that might be unfeasible.
If that's the case, you can create extra filler based on the difference between your unfilled locations and your
itempool by comparing [get_unfilled_locations](https://github.com/ArchipelagoMW/Archipelago/blob/main/BaseClasses.py#:~:text=get_unfilled_locations)
to your list of items to submit
If that's the case, you can create extra filler based on the difference between your unfilled locations and your itempool by comparing [get_unfilled_locations](https://github.com/ArchipelagoMW/Archipelago/blob/main/BaseClasses.py#:~:text=get_unfilled_locations) to your list of items to submit
Note: to use self.create_filler(), self.get_filler_item_name() should be defined to only return valid filler item names
```py
@@ -47,8 +39,7 @@ for _ in range(total_locations - len(item_pool)):
self.multiworld.itempool += item_pool
```
A faster alternative to the `for` loop would be to use a
[list comprehension](https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions):
A faster alternative to the `for` loop would be to use a [list comprehension](https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions):
```py
item_pool += [self.create_filler() for _ in range(total_locations - len(item_pool))]
```
@@ -57,86 +48,21 @@ item_pool += [self.create_filler() for _ in range(total_locations - len(item_poo
### I learned about indirect conditions in the world API document, but I want to know more. What are they and why are they necessary?
The world API document mentions how to use `multiworld.register_indirect_condition` to register indirect conditions and
**when** you should use them, but not *how* they work and *why* they are necessary. This is because the explanation is
quite complicated.
The world API document mentions how to use `multiworld.register_indirect_condition` to register indirect conditions and **when** you should use them, but not *how* they work and *why* they are necessary. This is because the explanation is quite complicated.
Region sweep (the algorithm that determines which regions are reachable) is a Breadth-First Search of the region graph.
It starts from the origin region, checks entrances one by one, and adds newly reached regions and their entrances to
the queue until there is nothing more to check.
Region sweep (the algorithm that determines which regions are reachable) is a Breadth-First Search of the region graph. It starts from the origin region, checks entrances one by one, and adds newly reached regions and their entrances to the queue until there is nothing more to check.
For performance reasons, AP only checks every entrance once. However, if an entrance's access_rule depends on region
access, then the following may happen:
1. The entrance is checked and determined to be nontraversable because the region in its access_rule hasn't been
reached yet during the graph search.
For performance reasons, AP only checks every entrance once. However, if an entrance's access_rule depends on region access, then the following may happen:
1. The entrance is checked and determined to be nontraversable because the region in its access_rule hasn't been reached yet during the graph search.
2. Then, the region in its access_rule is determined to be reachable.
This entrance *would* be in logic if it were rechecked, but it won't be rechecked this cycle.
To account for this case, AP would have to recheck all entrances every time a new region is reached until no new
regions are reached.
To account for this case, AP would have to recheck all entrances every time a new region is reached until no new regions are reached.
An indirect condition is how you can manually define that a specific entrance needs to be rechecked during region sweep
if a specific region is reached during it.
This keeps most of the performance upsides. Even in a game making heavy use of indirect conditions (ex: The Witness),
using them is significantly faster than just "rechecking each entrance until nothing new is found".
The reason entrance access rules using `location.can_reach` and `entrance.can_reach` are also affected is because they
call `region.can_reach` on their respective parent/source region.
An indirect condition is how you can manually define that a specific entrance needs to be rechecked during region sweep if a specific region is reached during it.
This keeps most of the performance upsides. Even in a game making heavy use of indirect conditions (ex: The Witness), using them is significantly faster than just "rechecking each entrance until nothing new is found".
The reason entrance access rules using `location.can_reach` and `entrance.can_reach` are also affected is because they call `region.can_reach` on their respective parent/source region.
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.
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.
---
### Some locations are technically possible to check with few or no items, but they'd be very tedious or frustrating. How do worlds deal with this?
Sometimes the game can be modded to skip these locations or make them less tedious. But when this issue is due to a fundamental aspect of the game, then the general answer is "soft logic" (and its subtypes like "combat logic", "money logic", etc.). For example: you can logically require that a player have several helpful items before fighting the final boss, even if a skilled player technically needs no items to beat it. Randomizer logic should describe what's *fun* rather than what's technically possible.
Concrete examples of soft logic include:
- Defeating a boss might logically require health upgrades, damage upgrades, certain weapons, etc. that aren't strictly necessary.
- Entering a high-level area might logically require access to enough other parts of the game that checking other locations should naturally get the player to the soft-required level.
- Buying expensive shop items might logically require access to a place where you can quickly farm money, or logically require access to enough parts of the game that checking other locations should naturally generate enough money without grinding.
Remember that all items referenced by logic (however hard or soft) must be `progression`. Since you typically don't want to turn a ton of `filler` items into `progression` just for this, it's common to e.g. write money logic using only the rare "$100" item, so the dozens of "$1" and "$10" items in your world can remain `filler`.
---
### What if my game has "missable" or "one-time-only" locations or region connections?
Archipelago logic assumes that once a region or location becomes reachable, it stays reachable forever, no matter what
the player does in-game. Slightly more formally: Receiving an AP item must never cause a region connection or location
to "go out of logic" (become unreachable when it was previously reachable), and receiving AP items is the only kind of
state change that AP logic acknowledges. No other actions or events can change reachability.
So when the game itself does not follow this assumption, the options are:
- Modify the game to make that location/connection repeatable
- If there are both missable and repeatable ways to check the location/traverse the connection, then write logic for
only the repeatable ways
- Don't generate the missable location/connection at all
- For connections, any logical regions will still need to be reachable through other, *repeatable* connections
- For locations, this may require game changes to remove the vanilla item if it affects logic
- Decide that resetting the save file is part of the game's logic, and warn players about that
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.
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.

View File

@@ -117,6 +117,14 @@ flowchart LR
%% Java Based Games
subgraph Java
JM[Mod with Archipelago.MultiClient.Java]
STS[Slay the Spire]
JM <-- Mod the Spire --> STS
subgraph Minecraft
MCS[Minecraft Forge Server]
JMC[Any Java Minecraft Clients]
MCS <-- TCP --> JMC
end
JM <-- Forge Mod Loader --> MCS
end
AS <-- WebSockets --> JM

View File

@@ -231,11 +231,11 @@ Sent to clients after a client requested this message be sent to them, more info
Sent to clients if the server caught a problem with a packet. This only occurs for errors that are explicitly checked for.
#### Arguments
| Name | Type | Notes |
| ---- |-------------| ----- |
| type | str | The [PacketProblemType](#PacketProblemType) that was detected in the packet. |
| original_cmd | str \| None | The `cmd` argument of the faulty packet, will be `None` if the `cmd` failed to be parsed. |
| text | str | A descriptive message of the problem at hand. |
| Name | Type | Notes |
| ---- | ---- | ----- |
| type | str | The [PacketProblemType](#PacketProblemType) that was detected in the packet. |
| original_cmd | Optional[str] | The `cmd` argument of the faulty packet, will be `None` if the `cmd` failed to be parsed. |
| text | str | A descriptive message of the problem at hand. |
##### PacketProblemType
`PacketProblemType` indicates the type of problem that was detected in the faulty packet, the known problem types are below but others may be added in the future.
@@ -470,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`. |
| 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`. |
| 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. |
| update | Dict only: Updates the dictionary with the specified elements given in `value` creating new keys, or updating old ones if they previously existed. |
### 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.
@@ -551,14 +551,14 @@ In JSON this may look like:
Message nodes sent along with [PrintJSON](#PrintJSON) packet to be reconstructed into a legible message. The nodes are intended to be read in the order they are listed in the packet.
```python
from typing import TypedDict
from typing import TypedDict, Optional
class JSONMessagePart(TypedDict):
type: str | None
text: str | None
color: str | None # only available if type is a color
flags: int | None # only available if type is an item_id or item_name
player: int | None # only available if type is either item or location
hint_status: HintStatus | None # only available if type is hint_status
type: Optional[str]
text: Optional[str]
color: Optional[str] # only available if type is a color
flags: Optional[int] # only available if type is an item_id or item_name
player: Optional[int] # only available if type is either item or location
hint_status: Optional[HintStatus] # only available if type is hint_status
```
`type` is used to denote the intent of the message part. This can be used to indicate special information which may be rendered differently depending on client. How these types are displayed in Archipelago's ALttP client is not the end-all be-all. Other clients may choose to interpret and display these messages differently.
@@ -756,8 +756,8 @@ Tags are represented as a list of strings, the common client tags follow:
### 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:
| Name | Type | Notes |
|--------|-------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| time | float | Unix Time Stamp of time of death. |
| 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. |
| Name | Type | Notes |
|--------|-------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
| 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." |
| 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

@@ -333,7 +333,7 @@ within the world.
### TextChoice
Like choice allows you to predetermine options and has all of the same comparison methods and handling. Also accepts any
user defined string as a valid option, so will either need to be validated by adding a validation step to the option
class or within world, if necessary. Value for this class is `str | int` so if you need the value at a specified
class or within world, if necessary. Value for this class is `Union[str, int]` so if you need the value at a specified
point, `self.options.my_option.current_key` will always return a string.
### PlandoBosses
@@ -352,15 +352,8 @@ template. If you set a [Schema](https://pypi.org/project/schema/) on the class w
options system will automatically validate the user supplied data against the schema to ensure it's in the correct
format.
### OptionCounter
This is a special case of OptionDict where the dictionary values can only be integers.
It returns a [collections.Counter](https://docs.python.org/3/library/collections.html#collections.Counter).
This means that if you access a key that isn't present, its value will be 0.
The upside of using an OptionCounter (instead of an OptionDict with integer values) is that an OptionCounter can be
displayed on the Options page on WebHost.
### ItemDict
An OptionCounter that will verify that every key in the dictionary is a valid name for an item for your world.
Like OptionDict, except this will verify that every key in the dictionary is a valid name for an item for your world.
### OptionList
This option defines a List, where the user can add any number of strings to said list, allowing duplicate values. You

View File

@@ -102,16 +102,17 @@ In worlds, this should only be used for the top level to avoid issues when upgra
### Bool
Since `bool` can not be subclassed, use the `settings.Bool` helper in a union to get a comment in host.yaml.
Since `bool` can not be subclassed, use the `settings.Bool` helper in a `typing.Union` to get a comment in host.yaml.
```python
import settings
import typing
class MySettings(settings.Group):
class MyBool(settings.Bool):
"""Doc string"""
my_value: MyBool | bool = True
my_value: typing.Union[MyBool, bool] = True
```
### UserFilePath
@@ -133,15 +134,15 @@ Checks the file against [md5s](#md5s) by default.
Resolves to an executable (varying file extension based on platform)
#### description: str | None
#### description: Optional\[str\]
Human-readable name to use in file browser
#### copy_to: str | None
#### copy_to: Optional\[str\]
Instead of storing the path, copy the file.
#### md5s: list[str | bytes]
#### md5s: List[Union[str, bytes]]
Provide md5 hashes as hex digests or raw bytes for automatic validation.

View File

@@ -11,13 +11,8 @@ found in the [general test directory](/test/general).
## Defining World Tests
In order to run tests from your world, you will need to create a `test` package within your world package. This can be
done by creating a `test` directory inside your world with an (empty) `__init__.py` inside it. By convention, a base
for your world tests can be created in `bases.py` or any file that does not start with `test`, that you can then import
into other modules. All tests should be defined in files named `test_*.py` (all lower case) and be member functions
(named `test_*`) of classes (named `Test*` or `*Test`) that inherit from `unittest.TestCase` or a test base.
Defining anything inside `test/__init__.py` is deprecated. Defining TestBase there was previously the norm; however,
it complicates test discovery because some worlds also put actual tests into `__init__.py`.
done by creating a `test` directory with a file named `__init__.py` inside it inside your world. By convention, a base
for your world tests can be created in this file that you can then import into other modules.
### WorldTestBase
@@ -26,7 +21,7 @@ interactions in the world interact as expected, you will want to use the [WorldT
comes with the basics for test setup as well as a few preloaded tests that most worlds might want to check on varying
options combinations.
Example `/worlds/<my_game>/test/bases.py`:
Example `/worlds/<my_game>/test/__init__.py`:
```python
from test.bases import WorldTestBase
@@ -54,7 +49,7 @@ with `test_`.
Example `/worlds/<my_game>/test/test_chest_access.py`:
```python
from .bases import MyGameTestBase
from . import MyGameTestBase
class TestChestAccess(MyGameTestBase):
@@ -87,49 +82,13 @@ Unit tests can also be created using [TestBase](/test/bases.py#L16) or
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.
#### 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
#### Using Pycharm
In PyCharm, running all tests can be done by right-clicking the root test directory and selecting Run 'Archipelago Unittests'.
If you have never previously run ModuleUpdate.py, then you will need to do this once before the tests will run.
You can run ModuleUpdate.py by right-clicking ModuleUpdate.py and selecting `Run 'ModuleUpdate'`.
After running ModuleUpdate.py you may still get a `ModuleNotFoundError: No module named 'flask'` for the webhost tests.
If this happens, run WebHost.py by right-clicking it and selecting `Run 'WebHost'`. Make sure to press enter when prompted.
Unless you configured PyCharm to use pytest as a test runner, you may get import failures. To solve this,
edit the run configuration, and set the working directory to the Archipelago directory which contains all the project files.
Unless you configured PyCharm to use pytest as a test runner, you may get import failures. To solve this, edit the run configuration,
and set the working directory to the Archipelago directory which contains all the project files.
If you only want to run your world's defined tests, repeat the steps for the test directory within your world.
Your working directory should be the directory of your world in the worlds directory and the script should be the
@@ -141,11 +100,3 @@ next to the run and debug buttons.
#### Running Tests without Pycharm
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

@@ -258,6 +258,31 @@ another flag like "progression", it means "an especially useful progression item
* `progression_skip_balancing`: the combination of `progression` and `skip_balancing`, i.e., a progression item that
will not be moved around by progression balancing; used, e.g., for currency or tokens, to not flood early spheres
### Events
An Event is a special combination of a Location and an Item, with both having an `id` of `None`. These can be used to
track certain logic interactions, with the Event Item being required for access in other locations or regions, but not
being "real". Since the item and location have no ID, they get dropped at the end of generation and so the server is
never made aware of them and these locations can never be checked, nor can the items be received during play.
They may also be used for making the spoiler log look nicer, i.e. by having a `"Victory"` Event Item, that
is required to finish the game. This makes it very clear when the player finishes, rather than only seeing their last
relevant Item. Events function just like any other Location, and can still have their own access rules, etc.
By convention, the Event "pair" of Location and Item typically have the same name, though this is not a requirement.
They must not exist in the `name_to_id` lookups, as they have no ID.
The most common way to create an Event pair is to create and place the Item on the Location as soon as it's created:
```python
from worlds.AutoWorld import World
from BaseClasses import ItemClassification
from .subclasses import MyGameLocation, MyGameItem
class MyGameWorld(World):
victory_loc = MyGameLocation(self.player, "Victory", None)
victory_loc.place_locked_item(MyGameItem("Victory", ItemClassification.progression, None, self.player))
```
### Regions
Regions are logical containers that typically hold locations that share some common access rules. If location logic is
@@ -266,7 +291,7 @@ like entrance randomization in logic.
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#L310-L311)),
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").
### Entrances
@@ -314,63 +339,6 @@ avoiding the need for indirect conditions at the expense of performance.
An item rule is a function that returns `True` or `False` for a `Location` based on a single item. It can be used to
reject the placement of an item there.
### Events (or "generation-only items/locations")
An event item or location is one that only exists during multiworld generation; the server is never made aware of them.
Event locations can never be checked by the player, and event items cannot be received during play.
Events are used to represent in-game actions (that aren't regular Archipelago locations) when either:
* We want to show in the spoiler log when the player is expected to perform the in-game action.
* It's the cleanest way to represent how that in-game action impacts logic.
Typical examples include completing the goal, defeating a boss, or flipping a switch that affects multiple areas.
To be precise: the term "event" on its own refers to the special combination of an "event item" placed on an "event
location". Event items and locations are created the same way as normal items and locations, except that they have an
`id` of `None`, and an event item must be placed on an event location
(and vice versa). Finally, although events are often described as "fake" items and locations, it's important to
understand that they are perfectly real during generation.
The most common way to create an event is to create the event item and the event location, then immediately call
`Location.place_locked_item()`:
```python
victory_loc = MyGameLocation(self.player, "Defeat the Final Boss", None, final_boss_arena_region)
victory_loc.place_locked_item(MyGameItem("Victory", ItemClassification.progression, None, self.player))
self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player)
set_rule(victory_loc, lambda state: state.has("Boss Defeating Sword", self.player))
```
Requiring an event to finish the game will make the spoiler log display an additional
`Defeat the Final Boss: Victory` line when the player is expected to finish, rather than only showing their last
relevant item. But events aren't just about the spoiler log; a more substantial example of using events to structure
your logic might be:
```python
water_loc = MyGameLocation(self.player, "Water Level Switch", None, pump_station_region)
water_loc.place_locked_item(MyGameItem("Lowered Water Level", ItemClassification.progression, None, self.player))
pump_station_region.locations.append(water_loc)
set_rule(water_loc, lambda state: state.has("Double Jump", self.player)) # the switch is really high up
...
basement_loc = MyGameLocation(self.player, "Flooded House - Basement Chest", None, flooded_house_region)
flooded_house_region.locations += [upstairs_loc, ground_floor_loc, basement_loc]
...
set_rule(basement_loc, lambda state: state.has("Lowered Water Level", self.player))
```
This creates a "Lowered Water Level" event and a regular location whose access rule depends on that
event being reachable. If you made several more locations the same way, this would ensure all of those locations can
only become reachable when the event location is reachable (i.e. when the water level can be lowered), without
copy-pasting the event location's access rule and then repeatedly re-evaluating it. Also, the spoiler log will show
`Water Level Switch: Lowered Water Level` when the player is expected to do this.
To be clear, this example could also be modeled with a second Region (perhaps "Un-Flooded House"). Or you could modify
the game so flipping that switch checks a regular AP location in addition to lowering the water level.
Events are never required, but it may be cleaner to use an event if e.g. flipping that switch affects the logic in
dozens of half-flooded areas that would all otherwise need additional Regions, and you don't want it to be a regular
location. It depends on the game.
## Implementation
### Your World
@@ -520,8 +488,8 @@ In addition, the following methods can be implemented and are called in this ord
If it's hard to separate, this can be done during `generate_early` or `create_items` as well.
* `create_items(self)`
called to place player's items into the MultiWorld's itempool. By the end of this step all regions, locations and
items have to be in the MultiWorld's regions and itempool. You cannot add or remove items, locations, or regions after
this step. Locations cannot be moved to different regions after this step. This includes event items and locations.
items have to be in the MultiWorld's regions and itempool. You cannot add or remove items, locations, or regions
after this step. Locations cannot be moved to different regions after this step.
* `set_rules(self)`
called to set access and item rules on locations and entrances.
* `connect_entrances(self)`
@@ -533,7 +501,7 @@ In addition, the following methods can be implemented and are called in this ord
called to modify item placement before, during, and after the regular fill process; all finishing before
`generate_output`. Any items that need to be placed during `pre_fill` should not exist in the itempool, and if there
are any items that need to be filled this way, but need to be in state while you fill other items, they can be
returned from `get_pre_fill_items`.
returned from `get_prefill_items`.
* `generate_output(self, output_directory: str)`
creates the output files if there is output to be generated. When this is called,
`self.multiworld.get_locations(self.player)` has all locations for the player, with attribute `item` pointing to the
@@ -593,7 +561,7 @@ from .items import is_progression # this is just a dummy
def create_item(self, item: str) -> MyGameItem:
# this is called when AP wants to create an item by name (for plando, start inventory, item links) 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 ItemClassification.filler
return MyGameItem(item, classification, self.item_name_to_id[item], self.player)
@@ -638,8 +606,8 @@ from .items import get_item_type
def set_rules(self) -> None:
# For some worlds this step can be omitted if either a Logic mixin
# (see below) is used or it's easier to apply the rules from data during
# location generation
# (see below) is used, it's easier to apply the rules from data during
# location generation or everything is in generate_basic
# set a simple rule for an region
set_rule(self.multiworld.get_entrance("Boss Door", self.player),

View File

@@ -65,5 +65,5 @@ date, voting members and final result in the commit message.
## Handling of Unmaintained Worlds
As long as worlds are known to work for the most part, they can stay included. Once the world becomes broken, it shall
be deleted.
As long as worlds are known to work for the most part, they can stay included. Once a world becomes broken it shall be
moved from `worlds/` to `worlds_disabled/`.

View File

@@ -50,15 +50,13 @@ class EntranceLookup:
_random: random.Random
_expands_graph_cache: dict[Entrance, bool]
_coupled: bool
_usable_exits: set[Entrance]
def __init__(self, rng: random.Random, coupled: bool, usable_exits: set[Entrance]):
def __init__(self, rng: random.Random, coupled: bool):
self.dead_ends = EntranceLookup.GroupLookup()
self.others = EntranceLookup.GroupLookup()
self._random = rng
self._expands_graph_cache = {}
self._coupled = coupled
self._usable_exits = usable_exits
def _can_expand_graph(self, entrance: Entrance) -> bool:
"""
@@ -97,8 +95,7 @@ class EntranceLookup:
# 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
# actually lead somewhere new
if (not exit_.connected_region and (not self._coupled or exit_.name != entrance.name)
and exit_ in self._usable_exits):
if not exit_.connected_region and (not self._coupled or exit_.name != entrance.name):
self._expands_graph_cache[entrance] = True
return True
elif exit_.connected_region and exit_.connected_region not in visited:
@@ -268,19 +265,14 @@ def bake_target_group_lookup(world: World, get_target_groups: Callable[[int], li
return { group: get_target_groups(group) for group in unique_groups }
def disconnect_entrance_for_randomization(entrance: Entrance, target_group: int | None = None,
one_way_target_name: str | None = None) -> None:
def disconnect_entrance_for_randomization(entrance: Entrance, target_group: int | None = None) -> None:
"""
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. 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.
in randomize_entrances. This should be done after setting the type and group of the entrance.
: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
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
parent_region = entrance.parent_region
@@ -295,11 +287,8 @@ 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
target = parent_region.create_er_target(entrance.name)
else:
# for 1-ways, the child region needs a target. naming is not a concern for coupling so we
# 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)
# for 1-ways, the child region needs a target and coupling/naming is not a concern
target = child_region.create_er_target(child_region.name)
target.randomization_type = entrance.randomization_type
target.randomization_group = target_group or entrance.randomization_group
@@ -336,6 +325,7 @@ def randomize_entrances(
start_time = time.perf_counter()
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
perform_validity_check = True
@@ -351,7 +341,6 @@ def randomize_entrances(
# 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)
@@ -369,34 +358,6 @@ def randomize_entrances(
if on_connect:
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:
nonlocal perform_validity_check
placeable_exits = er_state.find_placeable_exits(perform_validity_check, exits)
@@ -410,9 +371,11 @@ def randomize_entrances(
# 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.
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 (needs_speculative_sweep(dead_end, require_new_exits, placeable_exits)
if (needs_speculative_sweep
and not er_state.test_speculative_connection(source_exit, target_entrance, exits_set)):
continue
do_placement(source_exit, target_entrance)

View File

@@ -45,8 +45,7 @@ MinVersion={#min_windows}
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}";
Name: "deletelib"; Description: "Clean existing /lib folder and subfolders including /worlds (leave checked if unsure)"; Check: ShouldShowDeleteLibTask
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}";
[Types]
Name: "full"; Description: "Full installation"
@@ -84,9 +83,18 @@ Filename: "{app}\ArchipelagoLauncher"; Description: "{cm:LaunchProgram,{#StringC
Type: dirifempty; Name: "{app}"
[InstallDelete]
Type: files; Name: "{app}\*.exe"
Type: files; Name: "{app}\lib\worlds\_bizhawk.apworld"
Type: files; Name: "{app}\ArchipelagoLttPClient.exe"
Type: files; Name: "{app}\ArchipelagoPokemonClient.exe"
Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua"
Type: files; Name: "{app}\data\lua\connector_ff1.lua"
Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy"
Type: dirifempty; Name: "{app}\lib\worlds\rogue-legacy"
Type: files; Name: "{app}\lib\worlds\sc2wol.apworld"
Type: filesandordirs; Name: "{app}\lib\worlds\sc2wol"
Type: dirifempty; Name: "{app}\lib\worlds\sc2wol"
Type: filesandordirs; Name: "{app}\lib\worlds\bk_sudoku"
Type: dirifempty; Name: "{app}\lib\worlds\bk_sudoku"
Type: files; Name: "{app}\ArchipelagoLauncher(DEBUG).exe"
Type: filesandordirs; Name: "{app}\SNI\lua*"
Type: filesandordirs; Name: "{app}\EnemizerCLI*"
#include "installdelete.iss"
@@ -138,6 +146,11 @@ Root: HKCR; Subkey: "{#MyAppName}kdl3patch"; ValueData: "Arc
Root: HKCR; Subkey: "{#MyAppName}kdl3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}kdl3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apmc"; ValueData: "{#MyAppName}mcdata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mcdata"; ValueData: "Archipelago Minecraft Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mcdata\DefaultIcon"; ValueData: "{app}\ArchipelagoMinecraftClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mcdata\shell\open\command"; ValueData: """{app}\ArchipelagoMinecraftClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apz5"; ValueData: "{#MyAppName}n64zpf"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}n64zpf"; ValueData: "Archipelago Ocarina of Time Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}n64zpf\DefaultIcon"; ValueData: "{app}\ArchipelagoOoTClient.exe,0"; ValueType: string; ValueName: "";
@@ -208,11 +221,6 @@ 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\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: "{#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: "";
@@ -248,17 +256,3 @@ begin
Result := True;
end;
end;
function ShouldShowDeleteLibTask: Boolean;
begin
Result := DirExists(ExpandConstant('{app}\lib'));
end;
procedure CurStepChanged(CurStep: TSetupStep);
begin
if CurStep = ssInstall then
begin
if WizardIsTaskSelected('deletelib') then
DelTree(ExpandConstant('{app}\lib'), True, True, True);
end;
end;

718
kvui.py

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
[pytest]
python_files = test_*.py Test*.py __init__.py # TODO: remove Test* once all worlds have been ported
python_files = test_*.py Test*.py # TODO: remove Test* once all worlds have been ported
python_classes = Test
python_functions = test
testpaths =

View File

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

View File

@@ -10,10 +10,9 @@ import sys
import types
import typing
import warnings
from collections.abc import Iterator, Sequence
from enum import IntEnum
from threading import Lock
from typing import cast, Any, BinaryIO, ClassVar, TextIO, TypeVar, Union
from typing import cast, Any, BinaryIO, ClassVar, Dict, Iterator, List, Optional, TextIO, Tuple, Union, TypeVar
__all__ = [
"get_settings", "fmt_doc", "no_gui",
@@ -24,7 +23,7 @@ __all__ = [
no_gui = False
skip_autosave = False
_world_settings_name_cache: dict[str, str] = {} # TODO: cache on disk and update when worlds change
_world_settings_name_cache: Dict[str, str] = {} # TODO: cache on disk and update when worlds change
_world_settings_name_cache_updated = False
_lock = Lock()
@@ -54,7 +53,7 @@ def fmt_doc(cls: type, level: int) -> str:
class Group:
_type_cache: ClassVar[dict[str, Any] | None] = None
_type_cache: ClassVar[Optional[Dict[str, Any]]] = None
_dumping: bool = False
_has_attr: bool = False
_changed: bool = False
@@ -107,7 +106,7 @@ class Group:
self.__dict__.values()))
@classmethod
def get_type_hints(cls) -> dict[str, Any]:
def get_type_hints(cls) -> Dict[str, Any]:
"""Returns resolved type hints for the class"""
if cls._type_cache is None:
if not cls.__annotations__ or not isinstance(next(iter(cls.__annotations__.values())), str):
@@ -125,10 +124,10 @@ class Group:
return self[key]
return default
def items(self) -> list[tuple[str, Any]]:
def items(self) -> List[Tuple[str, Any]]:
return [(key, getattr(self, key)) for key in self]
def update(self, dct: dict[str, Any]) -> None:
def update(self, dct: Dict[str, Any]) -> None:
assert isinstance(dct, dict), f"{self.__class__.__name__}.update called with " \
f"{dct.__class__.__name__} instead of dict."
@@ -197,7 +196,7 @@ class Group:
warnings.warn(f"{self.__class__.__name__}.{k} "
f"assigned from incompatible type {type(v).__name__}")
def as_dict(self, *args: str, downcast: bool = True) -> dict[str, Any]:
def as_dict(self, *args: str, downcast: bool = True) -> Dict[str, Any]:
return {
name: _to_builtin(cast(object, getattr(self, name))) if downcast else getattr(self, name)
for name in self if not args or name in args
@@ -212,7 +211,7 @@ class Group:
f.write(f"{indent}{yaml_line}")
@classmethod
def _dump_item(cls, name: str | None, attr: object, f: TextIO, level: int) -> None:
def _dump_item(cls, name: Optional[str], attr: object, f: TextIO, level: int) -> None:
"""Write a group, dict or sequence item to f, where attr can be a scalar or a collection"""
# lazy construction of yaml Dumper to avoid loading Utils early
@@ -224,7 +223,7 @@ class Group:
def represent_mapping(self, tag: str, mapping: Any, flow_style: Any = None) -> MappingNode:
from yaml import ScalarNode
res: MappingNode = super().represent_mapping(tag, mapping, flow_style)
pairs = cast(list[tuple[ScalarNode, Any]], res.value)
pairs = cast(List[Tuple[ScalarNode, Any]], res.value)
for k, v in pairs:
k.style = None # remove quotes from keys
return res
@@ -330,9 +329,9 @@ class Path(str):
"""Marks the file as required and opens a file browser when missing"""
is_exe: bool = False
"""Special cross-platform handling for executables"""
description: str | None = None
description: Optional[str] = None
"""Title to display when browsing for the file"""
copy_to: str | None = None
copy_to: Optional[str] = None
"""If not None, copy to AP folder instead of linking it"""
@classmethod
@@ -340,7 +339,7 @@ class Path(str):
"""Overload and raise to validate input files from browse"""
pass
def browse(self: T, **kwargs: Any) -> T | None:
def browse(self: T, **kwargs: Any) -> Optional[T]:
"""Opens a file browser to search for the file"""
raise NotImplementedError(f"Please use a subclass of Path for {self.__class__.__name__}")
@@ -370,12 +369,12 @@ class _LocalPath(str):
class FilePath(Path):
# path to a file
md5s: ClassVar[list[str | bytes]] = []
md5s: ClassVar[List[Union[str, bytes]]] = []
"""MD5 hashes for default validator."""
def browse(self: T,
filetypes: Sequence[tuple[str, Sequence[str]]] | None = None, **kwargs: Any)\
-> T | None:
filetypes: Optional[typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]] = None, **kwargs: Any)\
-> Optional[T]:
from Utils import open_filename, is_windows
if not filetypes:
if self.is_exe:
@@ -440,7 +439,7 @@ class FilePath(Path):
class FolderPath(Path):
# path to a folder
def browse(self: T, **kwargs: Any) -> T | None:
def browse(self: T, **kwargs: Any) -> Optional[T]:
from Utils import open_directory
res = open_directory(f"Select {self.description or self.__class__.__name__}", self)
if res:
@@ -598,16 +597,16 @@ class ServerOptions(Group):
OFF = 0
ON = 1
host: str | None = None
host: Optional[str] = None
port: int = 38281
password: str | None = None
multidata: str | None = None
savefile: str | None = None
password: Optional[str] = None
multidata: Optional[str] = None
savefile: Optional[str] = None
disable_save: bool = False
loglevel: str = "info"
logtime: bool = False
server_password: ServerPassword | None = None
disable_item_cheat: DisableItemCheat | bool = False
server_password: Optional[ServerPassword] = None
disable_item_cheat: Union[DisableItemCheat, bool] = False
location_check_points: LocationCheckPoints = LocationCheckPoints(1)
hint_cost: HintCost = HintCost(10)
release_mode: ReleaseMode = ReleaseMode("auto")
@@ -703,7 +702,7 @@ does nothing if not found
"""
sni_path: SNIPath = SNIPath("SNI")
snes_rom_start: SnesRomStart | bool = True
snes_rom_start: Union[SnesRomStart, bool] = True
class BizHawkClientOptions(Group):
@@ -722,7 +721,7 @@ class BizHawkClientOptions(Group):
"""
emuhawk_path: EmuHawkPath = EmuHawkPath(None)
rom_start: RomStart | bool = True
rom_start: Union[RomStart, bool] = True
# Top-level group with lazy loading of worlds
@@ -734,7 +733,7 @@ class Settings(Group):
sni_options: SNIOptions = SNIOptions()
bizhawkclient_options: BizHawkClientOptions = BizHawkClientOptions()
_filename: str | None = None
_filename: Optional[str] = None
def __getattribute__(self, key: str) -> Any:
if key.startswith("_") or key in self.__class__.__dict__:
@@ -788,7 +787,7 @@ class Settings(Group):
return super().__getattribute__(key)
def __init__(self, location: str | None): # change to PathLike[str] once we drop 3.8?
def __init__(self, location: Optional[str]): # change to PathLike[str] once we drop 3.8?
super().__init__()
if location:
from Utils import parse_yaml
@@ -822,7 +821,7 @@ class Settings(Group):
import atexit
atexit.register(autosave)
def save(self, location: str | None = None) -> None: # as above
def save(self, location: Optional[str] = None) -> None: # as above
from Utils import parse_yaml
location = location or self._filename
assert location, "No file specified"
@@ -855,7 +854,7 @@ class Settings(Group):
super().dump(f, level)
@property
def filename(self) -> str | None:
def filename(self) -> Optional[str]:
return self._filename
@@ -868,7 +867,7 @@ def get_settings() -> Settings:
if not res:
from Utils import user_path, local_path
filenames = ("options.yaml", "host.yaml")
locations: list[str] = []
locations: List[str] = []
if os.path.join(os.getcwd()) != local_path():
locations += filenames # use files from cwd only if it's not the local_path
locations += [user_path(filename) for filename in filenames]

View File

@@ -1,23 +1,25 @@
import base64
import datetime
import io
import json
import os
import platform
import shutil
import subprocess
import sys
import sysconfig
import threading
import urllib.request
import warnings
import zipfile
from collections.abc import Iterable, Sequence
import urllib.request
import io
import json
import threading
import subprocess
from hashlib import sha3_512
from pathlib import Path
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
requirement = 'cx-Freeze==8.0.0'
requirement = 'cx-Freeze==7.2.0'
try:
import pkg_resources
try:
@@ -58,17 +60,19 @@ from Cython.Build import cythonize
# On Python < 3.10 LogicMixin is not currently supported.
non_apworlds: set[str] = {
non_apworlds: Set[str] = {
"A Link to the Past",
"Adventure",
"ArchipIDLE",
"Archipelago",
"Clique",
"Final Fantasy",
"Lufia II Ancient Cave",
"Meritous",
"Ocarina of Time",
"Overcooked! 2",
"Raft",
"Slay the Spire",
"Sudoku",
"Super Mario 64",
"VVVVVV",
@@ -144,13 +148,13 @@ def download_SNI() -> None:
print(f"No SNI found for system spec {platform_name} {machine_name}")
signtool: str | None
signtool: Optional[str]
if os.path.exists("X:/pw.txt"):
print("Using signtool")
with open("X:/pw.txt", encoding="utf-8-sig") as f:
pw = f.read()
signtool = r'signtool sign /f X:/_SITS_Zertifikat_.pfx /p "' + pw + \
r'" /fd sha256 /td sha256 /tr http://timestamp.digicert.com/ '
r'" /fd sha256 /tr http://timestamp.digicert.com/ '
else:
signtool = None
@@ -202,7 +206,7 @@ def remove_sprites_from_folder(folder: Path) -> None:
os.remove(folder / file)
def _threaded_hash(filepath: str | Path) -> str:
def _threaded_hash(filepath: Union[str, Path]) -> str:
hasher = sha3_512()
hasher.update(open(filepath, "rb").read())
return base64.b85encode(hasher.digest()).decode()
@@ -252,7 +256,7 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
self.libfolder = Path(self.buildfolder, "lib")
self.library = Path(self.libfolder, "library.zip")
def installfile(self, path: Path, subpath: str | Path | None = None, keep_content: bool = False) -> None:
def installfile(self, path: Path, subpath: Optional[Union[str, Path]] = None, keep_content: bool = False) -> None:
folder = self.buildfolder
if subpath:
folder /= subpath
@@ -371,7 +375,11 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
from worlds.AutoWorld import AutoWorldRegister
assert not non_apworlds - set(AutoWorldRegister.world_types), \
f"Unknown world {non_apworlds - set(AutoWorldRegister.world_types)} designated for .apworld"
folders_to_remove: list[str] = []
folders_to_remove: List[str] = []
disabled_worlds_folder = "worlds_disabled"
for entry in os.listdir(disabled_worlds_folder):
if os.path.isdir(os.path.join(disabled_worlds_folder, entry)):
folders_to_remove.append(entry)
generate_yaml_templates(self.buildfolder / "Players" / "Templates", False)
for worldname, worldtype in AutoWorldRegister.world_types.items():
if worldname not in non_apworlds:
@@ -439,12 +447,12 @@ class AppImageCommand(setuptools.Command):
("app-exec=", None, "The application to run inside the image."),
("yes", "y", 'Answer "yes" to all questions.'),
]
build_folder: Path | None
dist_file: Path | None
app_dir: Path | None
build_folder: Optional[Path]
dist_file: Optional[Path]
app_dir: Optional[Path]
app_name: str
app_exec: Path | None
app_icon: Path | None # source file
app_exec: Optional[Path]
app_icon: Optional[Path] # source file
app_id: str # lower case name, used for icon and .desktop
yes: bool
@@ -481,12 +489,12 @@ tmp="${{exe#*/}}"
if [ ! "${{#tmp}}" -lt "${{#exe}}" ]; then
exe="{default_exe.parent}/$exe"
fi
export LD_LIBRARY_PATH="${{LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}}$APPDIR/{default_exe.parent}/lib"
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$APPDIR/{default_exe.parent}/lib"
$APPDIR/$exe "$@"
""")
launcher_filename.chmod(0o755)
def install_icon(self, src: Path, name: str | None = None, symlink: Path | None = None) -> None:
def install_icon(self, src: Path, name: Optional[str] = None, symlink: Optional[Path] = None) -> None:
assert self.app_dir, "Invalid app_dir"
try:
from PIL import Image
@@ -549,7 +557,7 @@ $APPDIR/$exe "$@"
subprocess.call(f'ARCH={build_arch} ./appimagetool -n "{self.app_dir}" "{self.dist_file}"', shell=True)
def find_libs(*args: str) -> Sequence[tuple[str, str]]:
def find_libs(*args: str) -> Sequence[Tuple[str, str]]:
"""Try to find system libraries to be included."""
if not args:
return []
@@ -557,7 +565,7 @@ def find_libs(*args: str) -> Sequence[tuple[str, str]]:
arch = build_arch.replace('_', '-')
libc = 'libc6' # we currently don't support musl
def parse(line: str) -> tuple[tuple[str, str, str], str]:
def parse(line: str) -> Tuple[Tuple[str, str, str], str]:
lib, path = line.strip().split(' => ')
lib, typ = lib.split(' ', 1)
for test_arch in ('x86-64', 'i386', 'aarch64'):
@@ -582,8 +590,8 @@ def find_libs(*args: str) -> Sequence[tuple[str, str]]:
k: v for k, v in (parse(line) for line in data if "=>" in line)
}
def find_lib(lib: str, arch: str, libc: str) -> str | None:
cache: dict[tuple[str, str, str], str] = getattr(find_libs, "cache")
def find_lib(lib: str, arch: str, libc: str) -> Optional[str]:
cache: Dict[Tuple[str, str, str], str] = getattr(find_libs, "cache")
for k, v in cache.items():
if k == (lib, arch, libc):
return v
@@ -592,7 +600,7 @@ def find_libs(*args: str) -> Sequence[tuple[str, str]]:
return v
return None
res: list[tuple[str, str]] = []
res: List[Tuple[str, str]] = []
for arg in args:
# try exact match, empty libc, empty arch, empty arch and libc
file = find_lib(arg, arch, libc)
@@ -621,13 +629,12 @@ cx_Freeze.setup(
ext_modules=cythonize("_speedups.pyx"),
options={
"build_exe": {
"packages": ["worlds", "kivy", "cymem", "websockets", "kivymd"],
"packages": ["worlds", "kivy", "cymem", "websockets"],
"includes": [],
"excludes": ["numpy", "Cython", "PySide2", "PIL",
"pandas"],
"zip_includes": [],
"pandas", "zstandard"],
"zip_include_packages": ["*"],
"zip_exclude_packages": ["worlds", "sc2", "kivymd"],
"zip_exclude_packages": ["worlds", "sc2"],
"include_files": [], # broken in cx 6.14.0, we use more special sauce now
"include_msvcr": False,
"replace_paths": ["*."],

View File

@@ -159,6 +159,7 @@ class WorldTestBase(unittest.TestCase):
self.multiworld.game[self.player] = self.game
self.multiworld.player_name = {self.player: "Tester"}
self.multiworld.set_seed(seed)
self.multiworld.state = CollectionState(self.multiworld)
random.seed(self.multiworld.seed)
self.multiworld.seed_name = get_seed_name(random) # only called to get same RNG progression as Generate.py
args = Namespace()
@@ -167,7 +168,6 @@ class WorldTestBase(unittest.TestCase):
1: option.from_any(self.options.get(name, option.default))
})
self.multiworld.set_options(args)
self.multiworld.state = CollectionState(self.multiworld)
self.world = self.multiworld.worlds[self.player]
for step in gen_steps:
call_all(self.multiworld, step)

View File

@@ -59,13 +59,13 @@ def run_locations_benchmark():
multiworld.game[1] = game
multiworld.player_name = {1: "Tester"}
multiworld.set_seed(0)
multiworld.state = CollectionState(multiworld)
args = argparse.Namespace()
for name, option in AutoWorld.AutoWorldRegister.world_types[game].options_dataclass.type_hints.items():
setattr(args, name, {
1: option.from_any(getattr(option, "default"))
})
multiworld.set_options(args)
multiworld.state = CollectionState(multiworld)
gc.collect()
for step in self.gen_steps:

View File

@@ -49,6 +49,7 @@ def setup_multiworld(worlds: Union[List[Type[World]], Type[World]], steps: Tuple
multiworld.game = {player: world_type.game for player, world_type in enumerate(worlds, 1)}
multiworld.player_name = {player: f"Tester{player}" for player in multiworld.player_ids}
multiworld.set_seed(seed)
multiworld.state = CollectionState(multiworld)
args = Namespace()
for player, world_type in enumerate(worlds, 1):
for key, option in world_type.options_dataclass.type_hints.items():
@@ -56,7 +57,6 @@ def setup_multiworld(worlds: Union[List[Type[World]], Type[World]], steps: Tuple
updated_options[player] = option.from_any(option.default)
setattr(args, key, updated_options)
multiworld.set_options(args)
multiworld.state = CollectionState(multiworld)
for step in steps:
call_all(multiworld, step)
return multiworld

View File

@@ -1,4 +1,3 @@
from typing import Callable
import unittest
from enum import IntEnum
@@ -35,7 +34,7 @@ def generate_entrance_pair(region: Region, name_suffix: str, group: int):
def generate_disconnected_region_grid(multiworld: MultiWorld, grid_side_length: int, region_size: int = 0,
region_creator: Callable[[str, int, MultiWorld], Region] = Region):
region_type: type[Region] = Region):
"""
Generates a grid-like region structure for ER testing, where menu is connected to the top-left region, and each
region "in vanilla" has 2 2-way exits going either down or to the right, until reaching the goal region in the
@@ -45,7 +44,7 @@ def generate_disconnected_region_grid(multiworld: MultiWorld, grid_side_length:
for col in range(grid_side_length):
index = row * grid_side_length + col
name = f"region{index}"
region = region_creator(name, 1, multiworld)
region = region_type(name, 1, multiworld)
multiworld.regions.append(region)
generate_locations(region_size, 1, region=region, tag=f"_{name}")
@@ -66,10 +65,8 @@ class TestEntranceLookup(unittest.TestCase):
"""tests that get_targets shuffles targets between groups when requested"""
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])
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set)
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True)
er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region]
for entrance in er_targets:
@@ -89,10 +86,8 @@ class TestEntranceLookup(unittest.TestCase):
"""tests that get_targets does not shuffle targets between groups when requested"""
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])
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set)
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True)
er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region]
for entrance in er_targets:
@@ -104,30 +99,6 @@ class TestEntranceLookup(unittest.TestCase):
group_order = [prev := group.randomization_group for group in retrieved_targets if prev != group.randomization_group]
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):
def test_lookup_generation(self):
@@ -177,7 +148,7 @@ class TestDisconnectForRandomization(unittest.TestCase):
e.randomization_group = 1
e.connect(r2)
disconnect_entrance_for_randomization(e, one_way_target_name="foo")
disconnect_entrance_for_randomization(e)
self.assertIsNone(e.connected_region)
self.assertEqual([], r1.entrances)
@@ -187,22 +158,10 @@ class TestDisconnectForRandomization(unittest.TestCase):
self.assertEqual(1, len(r2.entrances))
self.assertIsNone(r2.entrances[0].parent_region)
self.assertEqual("foo", r2.entrances[0].name)
self.assertEqual("r2", r2.entrances[0].name)
self.assertEqual(EntranceType.ONE_WAY, r2.entrances[0].randomization_type)
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):
multiworld = generate_test_multiworld()
r1 = Region("r1", 1, multiworld)
@@ -212,7 +171,7 @@ class TestDisconnectForRandomization(unittest.TestCase):
e.randomization_group = 1
e.connect(r2)
disconnect_entrance_for_randomization(e, 2, "foo")
disconnect_entrance_for_randomization(e, 2)
self.assertIsNone(e.connected_region)
self.assertEqual([], r1.entrances)
@@ -222,7 +181,7 @@ class TestDisconnectForRandomization(unittest.TestCase):
self.assertEqual(1, len(r2.entrances))
self.assertIsNone(r2.entrances[0].parent_region)
self.assertEqual("foo", r2.entrances[0].name)
self.assertEqual("r2", r2.entrances[0].name)
self.assertEqual(EntranceType.ONE_WAY, r2.entrances[0].randomization_type)
self.assertEqual(2, r2.entrances[0].randomization_group)
@@ -259,7 +218,7 @@ class TestRandomizeEntrances(unittest.TestCase):
self.assertEqual(80, len(result.pairings))
self.assertEqual(80, len(result.placements))
def test_coupled(self):
def test_coupling(self):
"""tests that in coupled mode, all 2 way transitions have an inverse"""
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5)
@@ -277,36 +236,6 @@ class TestRandomizeEntrances(unittest.TestCase):
# if we didn't visit every placement the verification on_connect doesn't really mean much
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):
"""tests that in uncoupled mode, no transitions have an (intentional) inverse"""
multiworld = generate_test_multiworld()
@@ -466,7 +395,7 @@ class TestRandomizeEntrances(unittest.TestCase):
entrance_type = CustomEntrance
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5, region_creator=CustomRegion)
generate_disconnected_region_grid(multiworld, 5, region_type=CustomRegion)
self.assertRaises(EntranceRandomizationError, randomize_entrances, multiworld.worlds[1], False,
directionally_matched_group_lookup)

View File

@@ -47,39 +47,13 @@ class TestIDs(unittest.TestCase):
"""Test that a game doesn't have item id overlap within its own datapackage"""
for gamename, world_type in AutoWorldRegister.world_types.items():
with self.subTest(game=gamename):
len_item_id_to_name = len(world_type.item_id_to_name)
len_item_name_to_id = len(world_type.item_name_to_id)
if len_item_id_to_name != len_item_name_to_id:
self.assertCountEqual(
world_type.item_id_to_name.values(),
world_type.item_name_to_id.keys(),
"\nThese items have overlapping ids with other items in its own world")
self.assertCountEqual(
world_type.item_id_to_name.keys(),
world_type.item_name_to_id.values(),
"\nThese items have overlapping names with other items in its own world")
self.assertEqual(len_item_id_to_name, len_item_name_to_id)
self.assertEqual(len(world_type.item_id_to_name), len(world_type.item_name_to_id))
def test_duplicate_location_ids(self):
"""Test that a game doesn't have location id overlap within its own datapackage"""
for gamename, world_type in AutoWorldRegister.world_types.items():
with self.subTest(game=gamename):
len_location_id_to_name = len(world_type.location_id_to_name)
len_location_name_to_id = len(world_type.location_name_to_id)
if len_location_id_to_name != len_location_name_to_id:
self.assertCountEqual(
world_type.location_id_to_name.values(),
world_type.location_name_to_id.keys(),
"\nThese locations have overlapping ids with other locations in its own world")
self.assertCountEqual(
world_type.location_id_to_name.keys(),
world_type.location_name_to_id.values(),
"\nThese locations have overlapping names with other locations in its own world")
self.assertEqual(len_location_id_to_name, len_location_name_to_id)
self.assertEqual(len(world_type.location_id_to_name), len(world_type.location_name_to_id))
def test_postgen_datapackage(self):
"""Generates a solo multiworld and checks that the datapackage is still valid"""

View File

@@ -53,22 +53,6 @@ class TestImplemented(unittest.TestCase):
if failed_world_loads:
self.fail(f"The following worlds failed to load: {failed_world_loads}")
def test_prefill_items(self):
"""Test that every world can reach every location from allstate before pre_fill."""
for gamename, world_type in AutoWorldRegister.world_types.items():
if gamename not in ("Archipelago", "Sudoku", "Final Fantasy", "Test Game"):
with self.subTest(gamename):
multiworld = setup_solo_multiworld(world_type, ("generate_early", "create_regions", "create_items",
"set_rules", "connect_entrances", "generate_basic"))
allstate = multiworld.get_all_state(False)
locations = multiworld.get_locations()
reachable = multiworld.get_reachable_locations(allstate)
unreachable = [location for location in locations if location not in reachable]
self.assertTrue(not unreachable,
f"Locations were not reachable with all state before prefill: "
f"{unreachable}. Seed: {multiworld.seed}")
def test_explicit_indirect_conditions_spheres(self):
"""Tests that worlds using explicit indirect conditions produce identical spheres as when using implicit
indirect conditions"""

View File

@@ -1,11 +1,7 @@
import unittest
from argparse import Namespace
from typing import Type
from BaseClasses import CollectionState, MultiWorld
from Fill import distribute_items_restrictive
from Options import ItemLinks
from worlds.AutoWorld import AutoWorldRegister, World, call_all
from BaseClasses import CollectionState
from worlds.AutoWorld import AutoWorldRegister, call_all
from . import setup_solo_multiworld
@@ -87,47 +83,6 @@ class TestBase(unittest.TestCase):
multiworld = setup_solo_multiworld(world_type)
for item in multiworld.itempool:
self.assertIn(item.name, world_type.item_name_to_id)
def test_item_links(self) -> None:
"""
Tests item link creation by creating a multiworld of 2 worlds for every game and linking their items together.
"""
def setup_link_multiworld(world: Type[World], link_replace: bool) -> None:
multiworld = MultiWorld(2)
multiworld.game = {1: world.game, 2: world.game}
multiworld.player_name = {1: "Linker 1", 2: "Linker 2"}
multiworld.set_seed()
item_link_group = [{
"name": "ItemLinkTest",
"item_pool": ["Everything"],
"link_replacement": link_replace,
"replacement_item": None,
}]
args = Namespace()
for name, option in world.options_dataclass.type_hints.items():
setattr(args, name, {1: option.from_any(option.default), 2: option.from_any(option.default)})
setattr(args, "item_links",
{1: ItemLinks.from_any(item_link_group), 2: ItemLinks.from_any(item_link_group)})
multiworld.set_options(args)
multiworld.set_item_links()
# groups get added to state during its constructor so this has to be after item links are set
multiworld.state = CollectionState(multiworld)
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "connect_entrances", "generate_basic")
for step in gen_steps:
call_all(multiworld, step)
# link the items together and attempt to fill
multiworld.link_items()
multiworld._all_state = None
call_all(multiworld, "pre_fill")
distribute_items_restrictive(multiworld)
call_all(multiworld, "post_fill")
self.assertTrue(multiworld.can_beat_game(CollectionState(multiworld)), f"seed = {multiworld.seed}")
for game_name, world_type in AutoWorldRegister.world_types.items():
with self.subTest("Can generate with link replacement", game=game_name):
setup_link_multiworld(world_type, True)
with self.subTest("Can generate without link replacement", game=game_name):
setup_link_multiworld(world_type, False)
def test_itempool_not_modified(self):
"""Test that worlds don't modify the itempool after `create_items`"""

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