Compare commits

..

1 Commits

Author SHA1 Message Date
Exempt-Medic
b70a5b8dbd Account for multiclass items in progression balancing 2025-04-26 07:58:20 -04:00
390 changed files with 5941 additions and 96610 deletions

1
.github/labeler.yml vendored
View File

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

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: |
@@ -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

@@ -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
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:

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

@@ -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 = {}
@@ -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:
@@ -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
@@ -1607,19 +1550,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 +1581,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 +1623,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)
@@ -588,6 +571,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 +580,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 +616,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):
@@ -895,8 +887,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'])

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.just_fix_windows_console()
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()

385
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
@@ -100,7 +100,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 +138,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 +242,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,10 +343,8 @@ 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
@@ -356,7 +365,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)
@@ -668,9 +677,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 +777,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 +793,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 +867,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
@@ -891,200 +922,172 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo
for listed_world in target_world:
if listed_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.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]
locations_from_groups: list[str] = []
resolved_locations: list[Location] = []
for target_player in worlds:
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
@@ -77,7 +77,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 +95,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)
@@ -180,7 +180,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
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 +212,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 +224,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 +242,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 +252,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:
@@ -338,6 +321,12 @@ def handle_name(name: str, player: int, name_counter: Counter):
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 +371,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 +392,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 +425,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:
@@ -540,6 +529,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

@@ -11,16 +11,14 @@ Additional components can be added to worlds.LauncherComponents.components.
import argparse
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, Any
if __name__ == "__main__":
import ModuleUpdate
@@ -42,17 +40,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 +84,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 +99,66 @@ 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("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"),
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."),
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 = []
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.append(component)
elif component.display_name == "Text Client":
text_client_component = component
return client_components, text_client_component
from kvui import MDButton, MDButtonText
from kivymd.uix.dialog import MDDialog, MDDialogHeadlineText, MDDialogContentContainer, MDDialogSupportingText
from kivymd.uix.divider import MDDivider
if not client_component:
run_component(text_client_component, *launch_args)
return
else:
popup_text = MDDialogSupportingText(text="Select client to open and connect with.")
component_buttons = [MDDivider()]
for component in [text_client_component, *client_component]:
component_buttons.append(MDButton(
MDButtonText(text=component.display_name),
on_release=lambda *args, comp=component: run_component(comp, *launch_args),
style="text"
))
component_buttons.append(MDDivider())
MDDialog(
# Headline
MDDialogHeadlineText(text="Connect to Multiworld"),
# Text
popup_text,
# Content
MDDialogContentContainer(
*component_buttons,
orientation="vertical"
),
).open()
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 +169,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 +197,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')
@@ -222,10 +222,10 @@ def create_shortcut(button: Any, component: Component) -> None:
button.menu.dismiss()
refresh_components: Callable[[], None] | None = None
refresh_components: Optional[Callable[[], None]] = None
def run_gui(launch_components: list[Component], args: Any) -> None:
def run_gui(path: str, args: Any) -> None:
from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox)
from kivy.properties import ObjectProperty
from kivy.core.window import Window
@@ -258,12 +258,12 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
cards: list[LauncherCard]
current_filter: Sequence[str | Type] | None
def __init__(self, ctx=None, components=None, args=None):
def __init__(self, ctx=None, path=None, args=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_uri = path
self.launch_args = args
self.cards = []
self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
@@ -385,9 +385,9 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
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
if self.launch_uri:
handle_uri(self.launch_uri, self.launch_args)
self.launch_uri = None
self.launch_args = None
@staticmethod
@@ -405,7 +405,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}")
logging.warning(f"unable to identify component for {file}")
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.
@@ -428,7 +428,7 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
for filter in self.current_filter))
super().on_stop()
Launcher(components=launch_components, args=args).run()
Launcher(path=path, args=args).run()
# avoiding Launcher reference leak
# and don't try to do something with widgets after window closed
@@ -447,7 +447,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:
@@ -455,15 +455,7 @@ 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:
if not path.startswith("archipelago://"):
file, component = identify(path)
if file:
args['file'] = file
@@ -479,7 +471,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(path, args.get("args", ()))
if __name__ == '__main__':

View File

@@ -33,7 +33,7 @@ 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 +52,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
@@ -514,9 +530,7 @@ 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
from kvui import GameManager, ImageButton
class LADXManager(GameManager):
logging_pairs = [
@@ -529,10 +543,8 @@ 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
button = ImageButton(texture=magpie_logo(), fit_mode="cover", image_size=(32, 32), size_hint_x=None,
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
self.connect_layout.add_widget(button)
return b
@@ -626,11 +638,6 @@ class LinksAwakeningContext(CommonContext):
"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"]):
@@ -646,13 +653,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 +661,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()

36
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()
@@ -131,15 +135,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 +150,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 +179,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.')
@@ -232,7 +233,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 +274,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 +301,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)

View File

@@ -14,7 +14,6 @@ import requests
import Utils
from Utils import is_windows
from settings import get_settings
atexit.register(input, "Press enter to exit.")
@@ -148,11 +147,9 @@ def find_jdk(version: str) -> str:
if os.path.isfile(jdk_exe):
return jdk_exe
else:
jdk_exe = shutil.which(options.java)
jdk_exe = shutil.which(options["minecraft_options"].get("java", "java"))
if not jdk_exe:
jdk_exe = shutil.which("java") # try to fall back to system java
if not jdk_exe:
raise Exception("Could not find Java. Is Java installed on the system?")
raise Exception("Could not find Java. Is Java installed on the system?")
return jdk_exe
@@ -288,8 +285,8 @@ if __name__ == '__main__':
# Change to executable's working directory
os.chdir(os.path.abspath(os.path.dirname(sys.argv[0])))
options = get_settings().minecraft_options
channel = args.channel or options.release_channel
options = Utils.get_options()
channel = args.channel or options["minecraft_options"]["release_channel"]
apmc_data = None
data_version = args.data_version or None
@@ -302,8 +299,8 @@ if __name__ == '__main__':
versions = get_minecraft_versions(data_version, channel)
forge_dir = options.forge_directory
max_heap = options.max_heap_size
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"]

View File

@@ -458,12 +458,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 +1826,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 +1900,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)
)
@@ -1994,14 +1990,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
ctx.save()
for slot in concerning_slots:
ctx.on_changed_hints(client.team, slot)
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():

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

View File

@@ -24,12 +24,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
@@ -1025,7 +1019,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 +1045,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 +1169,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 +1187,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 +1292,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 +1348,6 @@ class StartInventory(ItemDict):
verify_item_name = True
display_name = "Start Inventory"
rich_text_doc = True
max = 10000
class StartInventoryPool(StartInventory):
@@ -1475,133 +1463,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 +1485,6 @@ class PerGameCommonOptions(CommonOptions):
exclude_locations: ExcludeLocations
priority_locations: PriorityLocations
item_links: ItemLinks
plando_items: PlandoItems
@dataclass
@@ -1678,7 +1538,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 +1576,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)

View File

@@ -80,9 +80,6 @@ Currently, the following games are supported:
* 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

@@ -139,11 +139,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
@@ -226,12 +223,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
@@ -545,8 +537,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
@@ -645,8 +635,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)))
@@ -667,10 +655,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)"
@@ -713,30 +699,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:
@@ -770,18 +751,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:
@@ -808,6 +792,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()
@@ -818,10 +805,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,7 +209,7 @@ 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."""
@@ -495,6 +385,7 @@ class WargrooveContext(CommonContext):
async def game_watcher(ctx: WargrooveContext):
from worlds.wargroove.Locations import location_table
while not ctx.exit_event.is_set():
if ctx.syncing == True:
sync_msg = [{'cmd': 'Sync'}]
@@ -506,12 +397,6 @@ async def game_watcher(ctx: WargrooveContext):
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))]
@@ -519,40 +404,6 @@ async def game_watcher(ctx: WargrooveContext):
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)
@@ -567,9 +418,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:
@@ -589,6 +439,7 @@ def launch(*launch_args: str):
parser = get_base_parser(description="Wargroove Client, for text interfacing.")
args, rest = parser.parse_known_args()
colorama.just_fix_windows_console()
asyncio.run(main())
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

@@ -1,4 +1,4 @@
flask>=3.1.1
flask>=3.1.0
werkzeug>=3.1.3
pony>=0.7.19
waitress>=3.0.2

View File

@@ -29,15 +29,27 @@
{% 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

@@ -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"):

View File

@@ -222,8 +222,3 @@
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

@@ -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

@@ -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

@@ -87,9 +87,6 @@
# Inscryption
/worlds/inscryption/ @DrBibop @Glowbuzz
# Jak and Daxter: The Precursor Legacy
/worlds/jakanddaxter/ @massimilianodelliubaldini
# Kirby's Dream Land 3
/worlds/kdl3/ @Silvris
@@ -160,9 +157,6 @@
# Saving Princess
/worlds/saving_princess/ @LeonarthCG
# shapez
/worlds/shapez/ @BlastSlimey
# Shivers
/worlds/shivers/ @GodlFire @korydondzila
@@ -181,9 +175,6 @@
# Super Mario 64
/worlds/sm64ex/ @N00byKing
# Super Mario Land 2: 6 Golden Coins
/worlds/marioland2/ @Alchav
# Super Mario World
/worlds/smw/ @PoryGone
@@ -241,7 +232,7 @@
## 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)
@@ -250,6 +241,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

@@ -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,39 +48,24 @@ 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.
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.
---
@@ -109,34 +85,3 @@ Common situations where this can happen include:
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

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.
@@ -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.

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

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):
@@ -124,12 +119,8 @@ variable to keep all the benefits of the test framework while not running the ma
#### 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

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

@@ -86,7 +86,6 @@ Type: dirifempty; Name: "{app}"
[InstallDelete]
Type: files; Name: "{app}\*.exe"
Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua"
Type: files; Name: "{app}\data\lua\connector_ff1.lua"
Type: filesandordirs; Name: "{app}\SNI\lua*"
Type: filesandordirs; Name: "{app}\EnemizerCLI*"
#include "installdelete.iss"

54
kvui.py
View File

@@ -6,6 +6,7 @@ import re
import io
import pkgutil
from collections import deque
assert "kivy" not in sys.modules, "kvui should be imported before kivy for frozen compatibility"
if sys.platform == "win32":
@@ -56,7 +57,6 @@ from kivy.animation import Animation
from kivy.uix.popup import Popup
from kivy.uix.image import AsyncImage
from kivymd.app import MDApp
from kivymd.uix.dialog import MDDialog, MDDialogHeadlineText, MDDialogSupportingText, MDDialogButtonContainer
from kivymd.uix.gridlayout import MDGridLayout
from kivymd.uix.floatlayout import MDFloatLayout
from kivymd.uix.boxlayout import MDBoxLayout
@@ -710,62 +710,20 @@ class CommandPromptTextInput(ResizableTextField):
self.text = self._command_history[self._command_history_index]
class MessageBoxLabel(MDLabel):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._label.refresh()
class MessageBox(Popup):
class MessageBoxLabel(MDLabel):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._label.refresh()
def __init__(self, title, text, error=False, **kwargs):
label = MessageBoxLabel(text=text)
label = MessageBox.MessageBoxLabel(text=text)
separator_color = [217 / 255, 129 / 255, 122 / 255, 1.] if error else [47 / 255., 167 / 255., 212 / 255, 1.]
super().__init__(title=title, content=label, size_hint=(0.5, None), width=max(100, int(label.width) + 40),
separator_color=separator_color, **kwargs)
self.height += max(0, label.height - 18)
class ButtonsPrompt(MDDialog):
def __init__(self, title: str, text: str, response: typing.Callable[[str], None],
*prompts: str, **kwargs) -> None:
"""
Customizable popup box that lets you create any number of buttons. The text of the pressed button is returned to
the callback.
:param title: The title of the popup.
:param text: The message prompt in the popup.
:param response: A callable that will get called when the user presses a button. The prompt will not close
itself so should be done here if you want to close it when certain buttons are pressed.
:param prompts: Any number of strings to be used for the buttons.
"""
layout = MDBoxLayout(orientation="vertical")
label = MessageBoxLabel(text=text)
layout.add_widget(label)
def on_release(button: MDButton, *args) -> None:
response(button.text)
buttons = [MDDivider()]
for prompt in prompts:
button = MDButton(
MDButtonText(text=prompt, pos_hint={"center_x": 0.5, "center_y": 0.5}),
on_release=on_release,
style="text",
theme_width="Custom",
size_hint_x=1,
)
button.text = prompt
buttons.extend([button, MDDivider()])
super().__init__(
MDDialogHeadlineText(text=title),
MDDialogSupportingText(text=text),
MDDialogButtonContainer(*buttons, orientation="vertical"),
**kwargs,
)
class ClientTabs(MDTabsSecondary):
carousel: MDTabsCarousel
lock_swiping = True

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

@@ -7,7 +7,7 @@ schema>=0.7.7
kivy>=2.3.1
bsdiff4>=1.2.6
platformdirs>=4.3.6
certifi>=2025.4.26
certifi>=2025.1.31
cython>=3.0.12
cymem>=2.0.11
orjson>=3.10.15

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,20 +1,22 @@
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'
@@ -58,12 +60,13 @@ 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",
@@ -144,7 +147,7 @@ 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:
@@ -202,7 +205,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 +255,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 +374,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 +446,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 +488,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 +556,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 +564,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 +589,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 +599,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)

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

@@ -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

@@ -26,4 +26,4 @@ class TestBase(unittest.TestCase):
for step in self.test_steps:
with self.subTest("Step", step=step):
call_all(multiworld, step)
self.assertTrue(multiworld.get_all_state(False, allow_partial_entrances=True))
self.assertTrue(multiworld.get_all_state(False, True))

View File

@@ -1,12 +1,13 @@
import re
import shutil
from pathlib import Path
from typing import Dict
__all__ = ["copy", "delete"]
_new_worlds: dict[str, str] = {}
_new_worlds: Dict[str, str] = {}
def copy(src: str, dst: str) -> None:

View File

@@ -47,6 +47,17 @@ class TestCommonContext(unittest.IsolatedAsyncioTestCase):
assert "Archipelago" in self.ctx.item_names, "Archipelago item names entry does not exist"
assert "Archipelago" in self.ctx.location_names, "Archipelago location names entry does not exist"
async def test_implicit_name_lookups(self):
# Items
assert self.ctx.item_names[2**54 + 1] == "Test Item 1 - Safe"
assert self.ctx.item_names[2**54 + 3] == f"Unknown item (ID: {2**54+3})"
assert self.ctx.item_names[-1] == "Nothing"
# Locations
assert self.ctx.location_names[2**54 + 1] == "Test Location 1 - Safe"
assert self.ctx.location_names[2**54 + 3] == f"Unknown location (ID: {2**54+3})"
assert self.ctx.location_names[-1] == "Cheat Console"
async def test_explicit_name_lookups(self):
# Items
assert self.ctx.item_names["__TestGame1"][2**54+1] == "Test Item 1 - Safe"

View File

@@ -485,7 +485,7 @@ class World(metaclass=AutoWorldRegister):
def get_filler_item_name(self) -> str:
"""Called when the item pool needs to be filled with additional items to match location count."""
logging.warning(f"World {self} is generating a filler item without custom filler pool.")
return self.random.choice(tuple(self.item_name_to_id.keys()))
return self.multiworld.random.choice(tuple(self.item_name_to_id.keys()))
@classmethod
def create_group(cls, multiworld: "MultiWorld", new_player_id: int, players: Set[int]) -> World:
@@ -528,7 +528,7 @@ class World(metaclass=AutoWorldRegister):
"""Called when an item is collected in to state. Useful for things such as progressive items or currency."""
name = self.collect_item(state, item)
if name:
state.add_item(name, self.player)
state.prog_items[self.player][name] += 1
return True
return False
@@ -536,7 +536,9 @@ class World(metaclass=AutoWorldRegister):
"""Called when an item is removed from to state. Useful for things such as progressive items or currency."""
name = self.collect_item(state, item, True)
if name:
state.remove_item(name, self.player)
state.prog_items[self.player][name] -= 1
if state.prog_items[self.player][name] < 1:
del (state.prog_items[self.player][name])
return True
return False

View File

@@ -6,7 +6,6 @@ import zipfile
from enum import IntEnum
import os
import threading
from io import BytesIO
from typing import ClassVar, Dict, List, Literal, Tuple, Any, Optional, Union, BinaryIO, overload, Sequence
@@ -71,18 +70,6 @@ class AutoPatchExtensionRegister(abc.ABCMeta):
container_version: int = 6
def is_ap_player_container(game: str, data: bytes, player: int):
if not zipfile.is_zipfile(BytesIO(data)):
return False
with zipfile.ZipFile(BytesIO(data), mode='r') as zf:
if "archipelago.json" in zf.namelist():
manifest = json.loads(zf.read("archipelago.json"))
if "game" in manifest and "player" in manifest:
if game == manifest["game"] and player == manifest["player"]:
return True
return False
class InvalidDataError(Exception):
"""
Since games can override `read_contents` in APContainer,
@@ -91,15 +78,24 @@ class InvalidDataError(Exception):
class APContainer:
"""A zipfile containing at least archipelago.json, which contains a manifest json payload."""
version: ClassVar[int] = container_version
compression_level: ClassVar[int] = 9
compression_method: ClassVar[int] = zipfile.ZIP_DEFLATED
"""A zipfile containing at least archipelago.json"""
version: int = container_version
compression_level: int = 9
compression_method: int = zipfile.ZIP_DEFLATED
game: Optional[str] = None
# instance attributes:
path: Optional[str]
player: Optional[int]
player_name: str
server: str
def __init__(self, path: Optional[str] = None):
def __init__(self, path: Optional[str] = None, player: Optional[int] = None,
player_name: str = "", server: str = ""):
self.path = path
self.player = player
self.player_name = player_name
self.server = server
def write(self, file: Optional[Union[str, BinaryIO]] = None) -> None:
zip_file = file if file else self.path
@@ -139,60 +135,31 @@ class APContainer:
message = f"{arg0} - "
raise InvalidDataError(f"{message}This might be the incorrect world version for this file") from e
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]:
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
with opened_zipfile.open("archipelago.json", "r") as f:
manifest = json.load(f)
if manifest["compatible_version"] > self.version:
raise Exception(f"File (version: {manifest['compatible_version']}) too new "
f"for this handler (version: {self.version})")
return manifest
self.player = manifest["player"]
self.server = manifest["server"]
self.player_name = manifest["player_name"]
def get_manifest(self) -> Dict[str, Any]:
return {
"server": self.server, # allow immediate connection to server in multiworld. Empty string otherwise
"player": self.player,
"player_name": self.player_name,
"game": self.game,
# minimum version of patch system expected for patching to be successful
"compatible_version": 5,
"version": container_version,
}
class APPlayerContainer(APContainer):
"""A zipfile containing at least archipelago.json meant for a player"""
game: ClassVar[Optional[str]] = None
patch_file_ending: str = ""
player: Optional[int]
player_name: str
server: str
def __init__(self, path: Optional[str] = None, player: Optional[int] = None,
player_name: str = "", server: str = ""):
super().__init__(path)
self.player = player
self.player_name = player_name
self.server = server
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]:
manifest = super().read_contents(opened_zipfile)
self.player = manifest["player"]
self.server = manifest["server"]
self.player_name = manifest["player_name"]
return manifest
def get_manifest(self) -> Dict[str, Any]:
manifest = super().get_manifest()
manifest.update({
"server": self.server, # allow immediate connection to server in multiworld. Empty string otherwise
"player": self.player,
"player_name": self.player_name,
"game": self.game,
"patch_file_ending": self.patch_file_ending,
})
return manifest
class APPatch(APPlayerContainer):
class APPatch(APContainer):
"""
An `APPlayerContainer` that represents a patch file.
An `APContainer` that represents a patch file.
It includes the `procedure` key in the manifest to indicate that it is a patch.
Your implementation should inherit from this if your output file
@@ -225,6 +192,7 @@ class APProcedurePatch(APAutoPatchInterface):
"""
hash: Optional[str] # base checksum of source file
source_data: bytes
patch_file_ending: str = ""
files: Dict[str, bytes]
@classmethod
@@ -246,6 +214,7 @@ class APProcedurePatch(APAutoPatchInterface):
manifest = super(APProcedurePatch, self).get_manifest()
manifest["base_checksum"] = self.hash
manifest["result_file_ending"] = self.result_file_ending
manifest["patch_file_ending"] = self.patch_file_ending
manifest["procedure"] = self.procedure
if self.procedure == APDeltaPatch.procedure:
manifest["compatible_version"] = 5

View File

@@ -210,14 +210,10 @@ components: List[Component] = [
Component('Launcher', 'Launcher', component_type=Type.HIDDEN),
# Core
Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True,
file_identifier=SuffixIdentifier('.archipelago', '.zip'),
description="Host a generated multiworld on your computer."),
Component('Generate', 'Generate', cli=True,
description="Generate a multiworld with the YAMLs in the players folder."),
Component("Install APWorld", func=install_apworld, file_identifier=SuffixIdentifier(".apworld"),
description="Install an APWorld to play games not included with Archipelago by default."),
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient,
description="Connect to a multiworld using the text client."),
file_identifier=SuffixIdentifier('.archipelago', '.zip')),
Component('Generate', 'Generate', cli=True),
Component("Install APWorld", func=install_apworld, file_identifier=SuffixIdentifier(".apworld")),
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient),
Component('Links Awakening DX Client', 'LinksAwakeningClient',
file_identifier=SuffixIdentifier('.apladx')),
Component('LttP Adjuster', 'LttPAdjuster'),
@@ -228,12 +224,16 @@ components: List[Component] = [
Component('OoT Client', 'OoTClient',
file_identifier=SuffixIdentifier('.apz5')),
Component('OoT Adjuster', 'OoTAdjuster'),
# FF1
Component('FF1 Client', 'FF1Client'),
# TLoZ
Component('Zelda 1 Client', 'Zelda1Client', file_identifier=SuffixIdentifier('.aptloz')),
# ChecksFinder
Component('ChecksFinder Client', 'ChecksFinderClient'),
# Starcraft 2
Component('Starcraft 2 Client', 'Starcraft2Client'),
# Wargroove
Component('Wargroove Client', 'WargrooveClient'),
# Zillion
Component('Zillion Client', 'ZillionClient',
file_identifier=SuffixIdentifier('.apzl')),

View File

@@ -19,8 +19,7 @@ def launch_client(*args) -> None:
component = Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client,
file_identifier=SuffixIdentifier(),
description="Open the BizHawk client, to play games using the Bizhawk emulator.")
file_identifier=SuffixIdentifier())
components.append(component)

View File

@@ -182,11 +182,10 @@ class AdventureDeltaPatch(APPatch, metaclass=AutoPatchRegister):
json.dumps(self.rom_deltas),
compress_type=zipfile.ZIP_LZMA)
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> dict[str, Any]:
manifest = super(AdventureDeltaPatch, self).read_contents(opened_zipfile)
def read_contents(self, opened_zipfile: zipfile.ZipFile):
super(AdventureDeltaPatch, self).read_contents(opened_zipfile)
self.foreign_items = AdventureDeltaPatch.read_foreign_items(opened_zipfile)
self.autocollect_items = AdventureDeltaPatch.read_autocollect_items(opened_zipfile)
return manifest
@classmethod
def get_source_data(cls) -> bytes:

View File

@@ -238,10 +238,10 @@ async def proxy_loop(ctx: AHITContext):
logger.info("Aborting AHIT Proxy Client due to errors")
def launch(*launch_args: str):
def launch():
async def main():
parser = get_base_parser()
args = parser.parse_args(launch_args)
args = parser.parse_args()
ctx = AHITContext(args.connect, args.password)
logger.info("Starting A Hat in Time proxy server")

View File

@@ -477,7 +477,7 @@ act_completions = {
"Act Completion (Rush Hour)": LocData(2000311210, "Rush Hour",
dlc_flags=HatDLC.dlc2,
hookshot=True,
required_hats=[HatType.ICE, HatType.BREWING, HatType.DWELLER]),
required_hats=[HatType.ICE, HatType.BREWING]),
"Act Completion (Time Rift - Rumbi Factory)": LocData(2000312736, "Time Rift - Rumbi Factory",
dlc_flags=HatDLC.dlc2),

View File

@@ -455,7 +455,7 @@ def set_moderate_rules(world: "HatInTimeWorld"):
if "Pink Paw Station Thug" in key and is_location_valid(world, key):
set_rule(world.multiworld.get_location(key, world.player), lambda state: True)
# Moderate: clear Rush Hour without Hookshot or Dweller Mask
# Moderate: clear Rush Hour without Hookshot
set_rule(world.multiworld.get_location("Act Completion (Rush Hour)", world.player),
lambda state: state.has("Metro Ticket - Pink", world.player)
and state.has("Metro Ticket - Yellow", world.player)

View File

@@ -16,9 +16,9 @@ from worlds.LauncherComponents import Component, components, icon_paths, launch
from Utils import local_path
def launch_client(*args: str):
def launch_client():
from .Client import launch
launch_component(launch, name="AHITClient", args=args)
launch_component(launch, name="AHITClient")
components.append(Component("A Hat in Time Client", "AHITClient", func=launch_client,

View File

@@ -54,13 +54,16 @@ def parse_arguments(argv, no_defaults=False):
ret = parser.parse_args(argv)
# cannot be set through CLI currently
ret.plando_items = []
ret.plando_texts = {}
ret.plando_connections = []
if multiargs.multi:
defaults = copy.deepcopy(ret)
for player in range(1, multiargs.multi + 1):
playerargs = parse_arguments(shlex.split(getattr(ret, f"p{player}")), True)
for name in ["game", "sprite", "sprite_pool"]:
for name in ["plando_items", "plando_texts", "plando_connections", "game", "sprite", "sprite_pool"]:
value = getattr(defaults, name) if getattr(playerargs, name) is None else getattr(playerargs, name)
if player == 1:
setattr(ret, name, {1: value})

View File

@@ -393,7 +393,9 @@ def global_rules(multiworld: MultiWorld, player: int):
if world.options.pot_shuffle:
# it could move the key to the top right platform which can only be reached with bombs
add_rule(multiworld.get_location('Swamp Palace - Hookshot Pot Key', player), lambda state: can_use_bombs(state, player))
set_rule(multiworld.get_entrance('Swamp Palace (West)', player), lambda state: state._lttp_has_key('Small Key (Swamp Palace)', player, 6))
set_rule(multiworld.get_entrance('Swamp Palace (West)', player), lambda state: state._lttp_has_key('Small Key (Swamp Palace)', player, 6)
if state.has('Hookshot', player)
else state._lttp_has_key('Small Key (Swamp Palace)', player, 4))
set_rule(multiworld.get_location('Swamp Palace - Big Chest', player), lambda state: state.has('Big Key (Swamp Palace)', player))
if world.options.accessibility != 'full':
allow_self_locking_items(multiworld.get_location('Swamp Palace - Big Chest', player), 'Big Key (Swamp Palace)')

View File

@@ -505,20 +505,20 @@ class ALTTPWorld(World):
def pre_fill(self):
from Fill import fill_restrictive, FillError
attempts = 5
all_state = self.multiworld.get_all_state(use_cache=False)
world = self.multiworld
player = self.player
all_state = world.get_all_state(use_cache=True)
crystals = [self.create_item(name) for name in ['Red Pendant', 'Blue Pendant', 'Green Pendant', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 7', 'Crystal 5', 'Crystal 6']]
for crystal in crystals:
all_state.remove(crystal)
crystal_locations = [self.get_location('Turtle Rock - Prize'),
self.get_location('Eastern Palace - Prize'),
self.get_location('Desert Palace - Prize'),
self.get_location('Tower of Hera - Prize'),
self.get_location('Palace of Darkness - Prize'),
self.get_location('Thieves\' Town - Prize'),
self.get_location('Skull Woods - Prize'),
self.get_location('Swamp Palace - Prize'),
self.get_location('Ice Palace - Prize'),
self.get_location('Misery Mire - Prize')]
crystal_locations = [world.get_location('Turtle Rock - Prize', player),
world.get_location('Eastern Palace - Prize', player),
world.get_location('Desert Palace - Prize', player),
world.get_location('Tower of Hera - Prize', player),
world.get_location('Palace of Darkness - Prize', player),
world.get_location('Thieves\' Town - Prize', player),
world.get_location('Skull Woods - Prize', player),
world.get_location('Swamp Palace - Prize', player),
world.get_location('Ice Palace - Prize', player),
world.get_location('Misery Mire - Prize', player)]
placed_prizes = {loc.item.name for loc in crystal_locations if loc.item}
unplaced_prizes = [crystal for crystal in crystals if crystal.name not in placed_prizes]
empty_crystal_locations = [loc for loc in crystal_locations if not loc.item]
@@ -526,8 +526,8 @@ class ALTTPWorld(World):
try:
prizepool = unplaced_prizes.copy()
prize_locs = empty_crystal_locations.copy()
self.multiworld.random.shuffle(prize_locs)
fill_restrictive(self.multiworld, all_state, prize_locs, prizepool, True, lock=True,
world.random.shuffle(prize_locs)
fill_restrictive(world, all_state, prize_locs, prizepool, True, lock=True,
name="LttP Dungeon Prizes")
except FillError as e:
lttp_logger.exception("Failed to place dungeon prizes (%s). Will retry %s more times", e,
@@ -541,7 +541,7 @@ class ALTTPWorld(World):
if self.options.mode == 'standard' and self.options.small_key_shuffle \
and self.options.small_key_shuffle != small_key_shuffle.option_universal and \
self.options.small_key_shuffle != small_key_shuffle.option_own_dungeons:
self.multiworld.local_early_items[self.player]["Small Key (Hyrule Castle)"] = 1
world.local_early_items[player]["Small Key (Hyrule Castle)"] = 1
@classmethod
def stage_pre_fill(cls, world):
@@ -811,15 +811,12 @@ class ALTTPWorld(World):
return GetBeemizerItem(self.multiworld, self.player, item)
def get_pre_fill_items(self):
res = [self.create_item(name) for name in ('Red Pendant', 'Blue Pendant', 'Green Pendant', 'Crystal 1',
'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 7', 'Crystal 5',
'Crystal 6')]
res = []
if self.dungeon_local_item_names:
for dungeon in self.dungeons.values():
for item in dungeon.all_items:
if item.name in self.dungeon_local_item_names:
res.append(item)
return res
def fill_slot_data(self):

View File

@@ -10,12 +10,12 @@ class LTTPTestBase(unittest.TestCase):
from worlds.alttp.Options import Medallion
self.multiworld = MultiWorld(1)
self.multiworld.game[1] = "A Link to the Past"
self.multiworld.state = CollectionState(self.multiworld)
self.multiworld.set_seed(None)
args = Namespace()
for name, option in AutoWorldRegister.world_types["A Link to the Past"].options_dataclass.type_hints.items():
setattr(args, name, {1: option.from_any(getattr(option, "default"))})
self.multiworld.set_options(args)
self.multiworld.state = CollectionState(self.multiworld)
self.world = self.multiworld.worlds[1]
# by default medallion access is randomized, for unittests we set it to vanilla
self.world.options.misery_mire_medallion.value = Medallion.option_ether

View File

@@ -24,7 +24,7 @@ class TestSwampPalace(TestDungeon):
["Swamp Palace - Big Key Chest", False, [], ['Open Floodgate']],
["Swamp Palace - Big Key Chest", False, [], ['Hammer']],
["Swamp Palace - Big Key Chest", False, [], ['Small Key (Swamp Palace)']],
["Swamp Palace - Big Key Chest", True, ['Open Floodgate', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Flippers', 'Hammer']],
["Swamp Palace - Big Key Chest", True, ['Open Floodgate', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Flippers', 'Hammer']],
["Swamp Palace - Map Chest", False, []],
["Swamp Palace - Map Chest", False, [], ['Flippers']],
@@ -38,7 +38,7 @@ class TestSwampPalace(TestDungeon):
["Swamp Palace - West Chest", False, [], ['Open Floodgate']],
["Swamp Palace - West Chest", False, [], ['Hammer']],
["Swamp Palace - West Chest", False, [], ['Small Key (Swamp Palace)']],
["Swamp Palace - West Chest", True, ['Open Floodgate', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Flippers', 'Hammer']],
["Swamp Palace - West Chest", True, ['Open Floodgate', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Flippers', 'Hammer']],
["Swamp Palace - Compass Chest", False, []],
["Swamp Palace - Compass Chest", False, [], ['Flippers']],

View File

@@ -207,6 +207,7 @@ class BlasphemousWorld(World):
if not self.options.skill_randomizer:
self.place_items_from_dict(skill_dict)
def place_items_from_set(self, location_set: Set[str], name: str):
for loc in location_set:
self.get_location(loc).place_locked_item(self.create_item(name))

View File

@@ -3,8 +3,7 @@
## Required Software
- ChecksFinder from
the [Github releases Page for the game](https://github.com/jonloveslegos/ChecksFinder/releases) (latest version), or
from the [itch.io Page for the game](https://suncat0.itch.io/checksfinder) (including web version)
the [Github releases Page for the game](https://github.com/jonloveslegos/ChecksFinder/releases) (latest version)
## Configuring your YAML file
@@ -19,13 +18,13 @@ You can customize your options by visiting the [ChecksFinder Player Options Page
## Joining a MultiWorld Game
1. Start ChecksFinder and press `Play Online`
2. Switch to the console window/tab
3. Enter the following information:
- Server url
- Server port
- The name of the slot you wish to connect to
- The room password (optional)
4. Press `Connect` to connect
5. Switch to the game window/tab
6. Start playing!
1. Start ChecksFinder
2. Enter the following information:
- Enter the server url (starting from `wss://` for https connection like archipelago.gg, and starting from `ws://` for http connection and local multiserver)
- Enter server port
- Enter the name of the slot you wish to connect to
- Enter the room password (optional)
- Press `Play Online` to connect
3. Start playing!
Game options and controls are described in the readme on the github repository for the game

View File

@@ -1,9 +1,10 @@
from dataclasses import dataclass
import os
import io
from typing import TYPE_CHECKING, Dict, List, Optional, cast
import zipfile
from BaseClasses import Location
from worlds.Files import APPlayerContainer
from worlds.Files import APContainer, AutoPatchRegister
from .Enum import CivVICheckType
from .Locations import CivVILocation, CivVILocationData
@@ -25,19 +26,22 @@ class CivTreeItem:
ui_tree_row: int
class CivVIContainer(APPlayerContainer):
class CivVIContainer(APContainer, metaclass=AutoPatchRegister):
"""
Responsible for generating the dynamic mod files for the Civ VI multiworld
"""
game: Optional[str] = "Civilization VI"
patch_file_ending = ".apcivvi"
def __init__(self, patch_data: Dict[str, str], base_path: str = "", output_directory: str = "",
def __init__(self, patch_data: Dict[str, str] | io.BytesIO, base_path: str = "", output_directory: str = "",
player: Optional[int] = None, player_name: str = "", server: str = ""):
self.patch_data = patch_data
self.file_path = base_path
container_path = os.path.join(output_directory, base_path + ".apcivvi")
super().__init__(container_path, player, player_name, server)
if isinstance(patch_data, io.BytesIO):
super().__init__(patch_data, player, player_name, server)
else:
self.patch_data = patch_data
self.file_path = base_path
container_path = os.path.join(output_directory, base_path + ".apcivvi")
super().__init__(container_path, player, player_name, server)
def write_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
for filename, yml in self.patch_data.items():

View File

@@ -2893,18 +2893,3 @@ dog_bite_ice_trap_fix = [
0x25291CB8, # ADDIU T1, T1, 0x1CB8
0x01200008 # JR T1
]
shimmy_speed_modifier = [
# Increases the player's speed while shimmying as long as they are not holding down Z. If they are holding Z, it
# will be the normal speed, allowing it to still be used to set up any tricks that might require the normal speed
# (like Left Tower Skip).
0x3C088038, # LUI T0, 0x8038
0x91087D7E, # LBU T0, 0x7D7E (T0)
0x31090020, # ANDI T1, T0, 0x0020
0x3C0A800A, # LUI T2, 0x800A
0x240B005A, # ADDIU T3, R0, 0x005A
0x55200001, # BNEZL T1, [forward 0x01]
0x240B0032, # ADDIU T3, R0, 0x0032
0xA14B3641, # SB T3, 0x3641 (T2)
0x0800B7C3 # J 0x8002DF0C
]

View File

@@ -424,7 +424,6 @@ class PantherDash(Choice):
class IncreaseShimmySpeed(Toggle):
"""
Increases the speed at which characters shimmy left and right while hanging on ledges.
Hold Z to use the regular speed in case it's needed to do something.
"""
display_name = "Increase Shimmy Speed"

View File

@@ -607,10 +607,9 @@ class CV64PatchExtensions(APPatchExtension):
rom_data.write_int32(0xAA530, 0x080FF880) # J 0x803FE200
rom_data.write_int32s(0xBFE200, patches.coffin_cutscene_skipper)
# Shimmy speed increase hack
# Increase shimmy speed
if options["increase_shimmy_speed"]:
rom_data.write_int32(0x97EB4, 0x803FE9F0)
rom_data.write_int32s(0xBFE9F0, patches.shimmy_speed_modifier)
rom_data.write_byte(0xA4241, 0x5A)
# Disable landing fall damage
if options["fall_guard"]:

View File

@@ -211,8 +211,7 @@ class CVCotMWorld(World):
"ignore_cleansing": self.options.ignore_cleansing.value,
"skip_tutorials": self.options.skip_tutorials.value,
"required_last_keys": self.required_last_keys,
"completion_goal": self.options.completion_goal.value,
"nerf_roc_wing": self.options.nerf_roc_wing.value}
"completion_goal": self.options.completion_goal.value}
def get_filler_item_name(self) -> str:
return self.random.choice(FILLER_ITEM_NAMES)

View File

@@ -48,17 +48,11 @@ class OtherGameAppearancesInfo(TypedDict):
other_game_item_appearances: Dict[str, Dict[str, OtherGameAppearancesInfo]] = {
# NOTE: Symphony of the Night and Harmony of Dissonance are custom worlds that are not core verified.
# NOTE: Symphony of the Night is currently an unsupported world not in main.
"Symphony of the Night": {"Life Vessel": {"type": 0xE4,
"appearance": 0x01},
"Heart Vessel": {"type": 0xE4,
"appearance": 0x00}},
"Castlevania - Harmony of Dissonance": {"Life Max Up": {"type": 0xE4,
"appearance": 0x01},
"Heart Max Up": {"type": 0xE4,
"appearance": 0x00}},
"Timespinner": {"Max HP": {"type": 0xE4,
"appearance": 0x01},
"Max Aura": {"type": 0xE4,

View File

@@ -3,7 +3,7 @@
## Quick Links
- [Setup](/tutorial/Castlevania%20-%20Circle%20of%20the%20Moon/setup/en)
- [Options Page](/games/Castlevania%20-%20Circle%20of%20the%20Moon/player-options)
- [PopTracker Pack](https://github.com/BowserCrusher/Circle-of-the-Moon-AP-Tracker/releases/latest)
- [PopTracker Pack](https://github.com/sassyvania/Circle-of-the-Moon-Rando-AP-Map-Tracker-/releases/latest)
- [Repo for the original, standalone CotMR](https://github.com/calm-palm/cotm-randomizer)
- [Web version of the above randomizer](https://rando.circleofthemoon.com/)
- [A more in-depth guide to CotMR's nuances](https://docs.google.com/document/d/1uot4BD9XW7A--A8ecgoY8mLK_vSoQRpY5XCkzgas87c/view?usp=sharing)

View File

@@ -22,7 +22,7 @@ clear it.
## Optional Software
- [Castlevania: Circle of the Moon AP Tracker](https://github.com/BowserCrusher/Circle-of-the-Moon-AP-Tracker/releases/latest), for use with
- [Castlevania: Circle of the Moon AP Tracker](https://github.com/sassyvania/Circle-of-the-Moon-Rando-AP-Map-Tracker-/releases/latest), for use with
[PopTracker](https://github.com/black-sliver/PopTracker/releases).
## Generating and Patching a Game
@@ -64,7 +64,7 @@ perfectly safe to make progress offline; everything will re-sync when you reconn
Castlevania: Circle of the Moon has a fully functional map tracker that supports auto-tracking.
1. Download [Castlevania: Circle of the Moon AP Tracker](https://github.com/BowserCrusher/Circle-of-the-Moon-AP-Tracker/releases/latest) and
1. Download [Castlevania: Circle of the Moon AP Tracker](https://github.com/sassyvania/Circle-of-the-Moon-Rando-AP-Map-Tracker-/releases/latest) and
[PopTracker](https://github.com/black-sliver/PopTracker/releases).
2. Put the tracker pack into `packs/` in your PopTracker install.
3. Open PopTracker, and load the Castlevania: Circle of the Moon pack.

View File

@@ -511,7 +511,7 @@ _vanilla_items = [
DS3ItemData("Elkhorn Round Shield", 0x0133C510, DS3ItemCategory.SHIELD_INFUSIBLE),
DS3ItemData("Warrior's Round Shield", 0x0133EC20, DS3ItemCategory.SHIELD_INFUSIBLE),
DS3ItemData("Caduceus Round Shield", 0x01341330, DS3ItemCategory.SHIELD_INFUSIBLE),
DS3ItemData("Red and White Round Shield", 0x01343A40, DS3ItemCategory.SHIELD_INFUSIBLE),
DS3ItemData("Red and White Shield", 0x01343A40, DS3ItemCategory.SHIELD_INFUSIBLE),
DS3ItemData("Blessed Red and White Shield+1", 0x01343FB9, DS3ItemCategory.SHIELD),
DS3ItemData("Plank Shield", 0x01346150, DS3ItemCategory.SHIELD_INFUSIBLE),
DS3ItemData("Leather Shield", 0x01348860, DS3ItemCategory.SHIELD_INFUSIBLE),

View File

@@ -706,7 +706,7 @@ location_tables: Dict[str, List[DS3LocationData]] = {
DS3LocationData("US: Whip - back alley, behind wooden wall", "Whip", hidden=True),
DS3LocationData("US: Great Scythe - building by white tree, balcony", "Great Scythe"),
DS3LocationData("US: Homeward Bone - foot, drop overlook", "Homeward Bone",
static='02,0:53100950::'),
static='02,0:53100540::'),
DS3LocationData("US: Large Soul of a Deserted Corpse - around corner by Cliff Underside",
"Large Soul of a Deserted Corpse", hidden=True), # Hidden corner
DS3LocationData("US: Ember - behind burning tree", "Ember"),
@@ -732,9 +732,8 @@ location_tables: Dict[str, List[DS3LocationData]] = {
missable=True), # requires projectile
DS3LocationData("US: Flame Stoneplate Ring - hanging corpse by Mound-Maker transport",
"Flame Stoneplate Ring"),
DS3LocationData("US: Red and White Round Shield - chasm, hanging corpse",
"Red and White Round Shield", static="02,0:53100740::",
missable=True), # requires projectile
DS3LocationData("US: Red and White Shield - chasm, hanging corpse", "Red and White Shield",
static="02,0:53100740::", missable=True), # requires projectile
DS3LocationData("US: Small Leather Shield - first building, hanging corpse by entrance",
"Small Leather Shield"),
DS3LocationData("US: Pale Tongue - tower village, hanging corpse", "Pale Tongue"),
@@ -884,7 +883,7 @@ location_tables: Dict[str, List[DS3LocationData]] = {
DS3LocationData("RS: Homeward Bone - balcony by Farron Keep", "Homeward Bone x2"),
DS3LocationData("RS: Titanite Shard - woods, surrounded by enemies", "Titanite Shard"),
DS3LocationData("RS: Twin Dragon Greatshield - woods by Crucifixion Woods bonfire",
"Twin Dragon Greatshield", missable=True), # After Eclipse
"Twin Dragon Greatshield"),
DS3LocationData("RS: Sorcerer Hood - water beneath stronghold", "Sorcerer Hood",
hidden=True), # Hidden fall
DS3LocationData("RS: Sorcerer Robe - water beneath stronghold", "Sorcerer Robe",
@@ -1887,7 +1886,7 @@ location_tables: Dict[str, List[DS3LocationData]] = {
DS3LocationData("AL: Twinkling Titanite - lizard after light cathedral #2",
"Twinkling Titanite", lizard=True),
DS3LocationData("AL: Aldrich's Ruby - dark cathedral, miniboss", "Aldrich's Ruby",
miniboss=True, missable=True), # Deep Accursed drop, missable after defeating Aldrich
miniboss=True), # Deep Accursed drop
DS3LocationData("AL: Aldrich Faithful - water reserves, talk to McDonnel", "Aldrich Faithful",
hidden=True), # Behind illusory wall

View File

@@ -273,7 +273,9 @@ class DarkSouls3World(World):
self.player,
location,
parent = new_region,
event = True,
)
event_item.code = None
new_location.place_locked_item(event_item)
if location.name in excluded:
excluded.remove(location.name)
@@ -705,7 +707,7 @@ class DarkSouls3World(World):
if self._is_location_available("US: Young White Branch - by white tree #2"):
self._add_item_rule(
"US: Young White Branch - by white tree #2",
lambda item: item.player != self.player or not item.data.unique
lambda item: item.player == self.player and not item.data.unique
)
# Make sure the Storm Ruler is available BEFORE Yhorm the Giant

View File

@@ -2239,7 +2239,7 @@ static _Dark Souls III_ randomizer].
<tr><td>US: Pyromancy Flame - Cornyx</td><td>Given by Cornyx in Firelink Shrine or dropped.</td></tr>
<tr><td>US: Red Bug Pellet - tower village building, basement</td><td>On the floor of the building after the Fire Demon encounter</td></tr>
<tr><td>US: Red Hilted Halberd - chasm crypt</td><td>In the skeleton area accessible from Grave Key or dropping down from near Eygon</td></tr>
<tr><td>US: Red and White Round Shield - chasm, hanging corpse</td><td>On a hanging corpse in the ravine accessible with the Grave Key or dropping down near Eygon, to the entrance of Irina&#x27;s prison. Must be shot down with an arrow or projective.</td></tr>
<tr><td>US: Red and White Shield - chasm, hanging corpse</td><td>On a hanging corpse in the ravine accessible with the Grave Key or dropping down near Eygon, to the entrance of Irina&#x27;s prison. Must be shot down with an arrow or projective.</td></tr>
<tr><td>US: Reinforced Club - by white tree</td><td>Near the Birch Tree where giant shoots arrows</td></tr>
<tr><td>US: Repair Powder - first building, balcony</td><td>On the balcony of the first Undead Settlement building</td></tr>
<tr><td>US: Rusted Coin - awning above Dilapidated Bridge</td><td>On a wooden ledge near the Dilapidated Bridge bonfire. Must be jumped to from near Cathedral Evangelist enemy</td></tr>

View File

@@ -802,10 +802,8 @@ def connect_regions(world: World, level_list):
for i in range(0, len(kremwood_forest_levels) - 1):
connect(world, world.player, names, LocationName.kremwood_forest_region, kremwood_forest_levels[i])
connection = connect(world, world.player, names, LocationName.kremwood_forest_region, kremwood_forest_levels[-1],
lambda state: (state.can_reach(LocationName.riverside_race_flag, "Location", world.player)))
world.multiworld.register_indirect_condition(world.get_location(LocationName.riverside_race_flag).parent_region,
connection)
connect(world, world.player, names, LocationName.kremwood_forest_region, kremwood_forest_levels[-1],
lambda state: (state.can_reach(LocationName.riverside_race_flag, "Location", world.player)))
# Cotton-Top Cove Connections
cotton_top_cove_levels = [
@@ -839,11 +837,8 @@ def connect_regions(world: World, level_list):
connect(world, world.player, names, LocationName.mekanos_region, LocationName.sky_high_secret_region,
lambda state: (state.has(ItemName.bowling_ball, world.player, 1)))
else:
connection = connect(world, world.player, names, LocationName.mekanos_region,
LocationName.sky_high_secret_region,
lambda state: (state.can_reach(LocationName.bleaks_house, "Location", world.player)))
world.multiworld.register_indirect_condition(world.get_location(LocationName.bleaks_house).parent_region,
connection)
connect(world, world.player, names, LocationName.mekanos_region, LocationName.sky_high_secret_region,
lambda state: (state.can_reach(LocationName.bleaks_house, "Location", world.player)))
# K3 Connections
k3_levels = [
@@ -951,4 +946,3 @@ def connect(world: World, player: int, used_names: typing.Dict[str, int], source
source_region.exits.append(connection)
connection.connect(target_region)
return connection

View File

@@ -280,19 +280,16 @@ def set_boss_door_requirements_rules(player, world):
set_rule(world.get_entrance("Boss Door", player), has_3_swords)
def set_lfod_self_obtained_items_rules(world_options, player, multiworld):
def set_lfod_self_obtained_items_rules(world_options, player, world):
if world_options.item_shuffle != Options.ItemShuffle.option_disabled:
return
world = multiworld.worlds[player]
set_rule(world.get_entrance("Vines"),
set_rule(world.get_entrance("Vines", player),
lambda state: state.has("Incredibly Important Pack", player))
set_rule(world.get_entrance("Behind Rocks"),
set_rule(world.get_entrance("Behind Rocks", player),
lambda state: state.can_reach("Cut Content", 'region', player))
multiworld.register_indirect_condition(world.get_region("Cut Content"), world.get_entrance("Behind Rocks"))
set_rule(world.get_entrance("Pickaxe Hard Cave"),
set_rule(world.get_entrance("Pickaxe Hard Cave", player),
lambda state: state.can_reach("Cut Content", 'region', player) and
state.has("Name Change Pack", player))
multiworld.register_indirect_condition(world.get_region("Cut Content"), world.get_entrance("Pickaxe Hard Cave"))
def set_lfod_shuffled_items_rules(world_options, player, world):

View File

@@ -9,6 +9,7 @@ import random
import re
import string
import subprocess
import sys
import time
import typing
@@ -16,16 +17,15 @@ from queue import Queue
import factorio_rcon
import Utils
from CommonClient import ClientCommandProcessor, CommonContext, logger, server_loop, gui_enabled, get_base_parser
from MultiServer import mark_raw
from NetUtils import ClientStatus, NetworkItem, JSONtoTextParser, JSONMessagePart
from Utils import async_start, get_file_safe_name, is_windows, Version, format_SI_prefix, get_text_between
from .settings import FactorioSettings
from settings import get_settings
from Utils import async_start, get_file_safe_name
def check_stdin() -> None:
if is_windows and sys.stdin:
if Utils.is_windows and sys.stdin:
print("WARNING: Console input is not routed reliably on Windows, use the GUI instead.")
@@ -67,11 +67,9 @@ class FactorioContext(CommonContext):
items_handling = 0b111 # full remote
# updated by spinup server
mod_version: Version = Version(0, 0, 0)
mod_version: Utils.Version = Utils.Version(0, 0, 0)
def __init__(self, server_address, password, filter_item_sends: bool, bridge_chat_out: bool,
rcon_port: int, rcon_password: str, server_settings_path: str | None,
factorio_server_args: tuple[str, ...]):
def __init__(self, server_address, password, filter_item_sends: bool, bridge_chat_out: bool):
super(FactorioContext, self).__init__(server_address, password)
self.send_index: int = 0
self.rcon_client = None
@@ -84,10 +82,6 @@ class FactorioContext(CommonContext):
self.filter_item_sends: bool = filter_item_sends
self.multiplayer: bool = False # whether multiple different players have connected
self.bridge_chat_out: bool = bridge_chat_out
self.rcon_port: int = rcon_port
self.rcon_password: str = rcon_password
self.server_settings_path: str = server_settings_path
self.additional_factorio_server_args = factorio_server_args
@property
def energylink_key(self) -> str:
@@ -132,18 +126,6 @@ class FactorioContext(CommonContext):
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
f"{text}")
@property
def server_args(self) -> tuple[str, ...]:
if self.server_settings_path:
return (
"--rcon-port", str(self.rcon_port),
"--rcon-password", self.rcon_password,
"--server-settings", self.server_settings_path,
*self.additional_factorio_server_args)
else:
return ("--rcon-port", str(self.rcon_port), "--rcon-password", self.rcon_password,
*self.additional_factorio_server_args)
@property
def energy_link_status(self) -> str:
if not self.energy_link_increment:
@@ -151,7 +133,7 @@ class FactorioContext(CommonContext):
elif self.current_energy_link_value is None:
return "Standby"
else:
return f"{format_SI_prefix(self.current_energy_link_value)}J"
return f"{Utils.format_SI_prefix(self.current_energy_link_value)}J"
def on_deathlink(self, data: dict):
if self.rcon_client:
@@ -173,10 +155,10 @@ class FactorioContext(CommonContext):
if self.energy_link_increment and args.get("last_deplete", -1) == self.last_deplete:
# it's our deplete request
gained = int(args["original_value"] - args["value"])
gained_text = format_SI_prefix(gained) + "J"
gained_text = Utils.format_SI_prefix(gained) + "J"
if gained:
logger.debug(f"EnergyLink: Received {gained_text}. "
f"{format_SI_prefix(args['value'])}J remaining.")
f"{Utils.format_SI_prefix(args['value'])}J remaining.")
self.rcon_client.send_command(f"/ap-energylink {gained}")
def on_user_say(self, text: str) -> typing.Optional[str]:
@@ -296,7 +278,7 @@ async def game_watcher(ctx: FactorioContext):
}]))
ctx.rcon_client.send_command(
f"/ap-energylink -{value}")
logger.debug(f"EnergyLink: Sent {format_SI_prefix(value)}J")
logger.debug(f"EnergyLink: Sent {Utils.format_SI_prefix(value)}J")
await asyncio.sleep(0.1)
@@ -329,7 +311,7 @@ async def factorio_server_watcher(ctx: FactorioContext):
executable, "--create", savegame_name, "--preset", "archipelago"
))
factorio_process = subprocess.Popen((executable, "--start-server", savegame_name,
*ctx.server_args),
*(str(elem) for elem in server_args)),
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
stdin=subprocess.DEVNULL,
@@ -349,7 +331,7 @@ async def factorio_server_watcher(ctx: FactorioContext):
factorio_queue.task_done()
if not ctx.rcon_client and "Starting RCON interface at IP ADDR:" in msg:
ctx.rcon_client = factorio_rcon.RCONClient("localhost", ctx.rcon_port, ctx.rcon_password,
ctx.rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password,
timeout=5)
if not ctx.server:
logger.info("Established bridge to Factorio Server. "
@@ -440,7 +422,7 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
executable, "--create", savegame_name
))
factorio_process = subprocess.Popen(
(executable, "--start-server", savegame_name, *ctx.server_args),
(executable, "--start-server", savegame_name, *(str(elem) for elem in server_args)),
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
stdin=subprocess.DEVNULL,
@@ -457,9 +439,9 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
factorio_server_logger.info(msg)
if "Loading mod AP-" in msg and msg.endswith("(data.lua)"):
parts = msg.split()
ctx.mod_version = Version(*(int(number) for number in parts[-2].split(".")))
ctx.mod_version = Utils.Version(*(int(number) for number in parts[-2].split(".")))
elif "Write data path: " in msg:
ctx.write_data_path = get_text_between(msg, "Write data path: ", " [")
ctx.write_data_path = Utils.get_text_between(msg, "Write data path: ", " [")
if "AppData" in ctx.write_data_path:
logger.warning("It appears your mods are loaded from Appdata, "
"this can lead to problems with multiple Factorio instances. "
@@ -469,7 +451,7 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
"or a Factorio sharing data directories is already running. "
"Server could not start up.")
if not rcon_client and "Starting RCON interface at IP ADDR:" in msg:
rcon_client = factorio_rcon.RCONClient("localhost", ctx.rcon_port, ctx.rcon_password)
rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
if ctx.mod_version == ctx.__class__.mod_version:
raise Exception("No Archipelago mod was loaded. Aborting.")
await get_info(ctx, rcon_client)
@@ -492,8 +474,9 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
return False
async def main(make_context):
ctx = make_context()
async def main(args, filter_item_sends: bool, filter_bridge_chat_out: bool):
ctx = FactorioContext(args.connect, args.password, filter_item_sends, filter_bridge_chat_out)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
@@ -526,44 +509,38 @@ class FactorioJSONtoTextParser(JSONtoTextParser):
return self._handle_text(node)
parser = get_base_parser(description="Optional arguments to FactorioClient follow. "
"Remaining arguments get passed into bound Factorio instance."
"Refer to Factorio --help for those.")
parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio')
parser.add_argument('--rcon-password', help='Password to authenticate with RCON.')
parser.add_argument('--server-settings', help='Factorio server settings configuration file.')
args, rest = parser.parse_known_args()
rcon_port = args.rcon_port
rcon_password = args.rcon_password if args.rcon_password else ''.join(
random.choice(string.ascii_letters) for x in range(32))
factorio_server_logger = logging.getLogger("FactorioServer")
settings: FactorioSettings = get_settings().factorio_options
if os.path.samefile(settings.executable, sys.executable):
selected_executable = settings.executable
settings.executable = FactorioSettings.executable # reset to default
raise Exception(f"Factorio Client was set to run itself {selected_executable}, aborting process bomb.")
executable = settings.executable
options = Utils.get_settings()
executable = options["factorio_options"]["executable"]
server_settings = args.server_settings if args.server_settings \
else options["factorio_options"].get("server_settings", None)
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password)
def launch(*new_args: str):
def launch():
import colorama
global executable
global executable, server_settings, server_args
colorama.just_fix_windows_console()
# args handling
parser = get_base_parser(description="Optional arguments to Factorio Client follow. "
"Remaining arguments get passed into bound Factorio instance."
"Refer to Factorio --help for those.")
parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio')
parser.add_argument('--rcon-password', help='Password to authenticate with RCON.')
parser.add_argument('--server-settings', help='Factorio server settings configuration file.')
args, rest = parser.parse_known_args(args=new_args)
rcon_port = args.rcon_port
rcon_password = args.rcon_password if args.rcon_password else ''.join(
random.choice(string.ascii_letters) for _ in range(32))
server_settings = args.server_settings if args.server_settings \
else getattr(settings, "server_settings", None)
if server_settings:
server_settings = os.path.abspath(server_settings)
if not os.path.isfile(server_settings):
raise FileNotFoundError(f"Could not find file {server_settings} for server_settings. Aborting.")
initial_filter_item_sends = bool(settings.filter_item_sends)
initial_bridge_chat_out = bool(settings.bridge_chat_out)
if not isinstance(options["factorio_options"]["filter_item_sends"], bool):
logging.warning(f"Warning: Option filter_item_sends should be a bool.")
initial_filter_item_sends = bool(options["factorio_options"]["filter_item_sends"])
if not isinstance(options["factorio_options"]["bridge_chat_out"], bool):
logging.warning(f"Warning: Option bridge_chat_out should be a bool.")
initial_bridge_chat_out = bool(options["factorio_options"]["bridge_chat_out"])
if not os.path.exists(os.path.dirname(executable)):
raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.")
@@ -575,9 +552,14 @@ def launch(*new_args: str):
else:
raise FileNotFoundError(f"Path {executable} is not an executable file.")
asyncio.run(main(lambda: FactorioContext(
args.connect, args.password,
initial_filter_item_sends, initial_bridge_chat_out,
rcon_port, rcon_password, server_settings, rest
)))
if server_settings and os.path.isfile(server_settings):
server_args = (
"--rcon-port", rcon_port,
"--rcon-password", rcon_password,
"--server-settings", server_settings,
*rest)
else:
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
asyncio.run(main(args, initial_filter_item_sends, initial_bridge_chat_out))
colorama.deinit()

View File

@@ -63,11 +63,10 @@ recipe_time_ranges = {
}
class FactorioModFile(worlds.Files.APPlayerContainer):
class FactorioModFile(worlds.Files.APContainer):
game = "Factorio"
compression_method = zipfile.ZIP_DEFLATED # Factorio can't load LZMA archives
writing_tasks: List[Callable[[], Tuple[str, Union[str, bytes]]]]
patch_file_ending = ".zip"
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)

View File

@@ -5,6 +5,7 @@ import logging
import typing
import Utils
import settings
from BaseClasses import Region, Location, Item, Tutorial, ItemClassification
from worlds.AutoWorld import World, WebWorld
from worlds.LauncherComponents import Component, components, Type, launch as launch_component
@@ -19,15 +20,37 @@ from .Technologies import base_tech_table, recipe_sources, base_technology_table
progressive_technology_table, common_tech_table, tech_to_progressive_lookup, progressive_tech_table, \
get_science_pack_pools, Recipe, recipes, technology_table, tech_table, factorio_base_id, useless_technologies, \
fluids, stacking_items, valid_ingredients, progressive_rows
from .settings import FactorioSettings
def launch_client(*args: str):
def launch_client():
from .Client import launch
launch_component(launch, name="Factorio Client", args=args)
launch_component(launch, name="FactorioClient")
components.append(Component("Factorio Client", func=launch_client, component_type=Type.CLIENT))
components.append(Component("Factorio Client", "FactorioClient", func=launch_client, component_type=Type.CLIENT))
class FactorioSettings(settings.Group):
class Executable(settings.UserFilePath):
is_exe = True
class ServerSettings(settings.OptionalUserFilePath):
"""
by default, no settings are loaded if this file does not exist. \
If this file does exist, then it will be used.
server_settings: "factorio\\\\data\\\\server-settings.json"
"""
class FilterItemSends(settings.Bool):
"""Whether to filter item send messages displayed in-game to only those that involve you."""
class BridgeChatOut(settings.Bool):
"""Whether to send chat messages from players on the Factorio server to Archipelago."""
executable: Executable = Executable("factorio/bin/x64/factorio")
server_settings: typing.Optional[FactorioSettings.ServerSettings] = None
filter_item_sends: typing.Union[FilterItemSends, bool] = False
bridge_chat_out: typing.Union[BridgeChatOut, bool] = True
class FactorioWeb(WebWorld):
@@ -92,7 +115,6 @@ class Factorio(World):
settings: typing.ClassVar[FactorioSettings]
trap_names: tuple[str] = ("Evolution", "Attack", "Teleport", "Grenade", "Cluster Grenade", "Artillery",
"Atomic Rocket", "Atomic Cliff Remover", "Inventory Spill")
want_progressives: dict[str, bool] = collections.defaultdict(lambda: False)
def __init__(self, world, player: int):
super(Factorio, self).__init__(world, player)
@@ -111,8 +133,6 @@ class Factorio(World):
self.options.max_tech_cost.value, self.options.min_tech_cost.value
self.tech_mix = self.options.tech_cost_mix.value
self.skip_silo = self.options.silo.value == Silo.option_spawn
self.want_progressives = collections.defaultdict(
lambda: self.options.progressive.want_progressives(self.random))
def create_regions(self):
player = self.player
@@ -181,6 +201,9 @@ class Factorio(World):
range(getattr(self.options,
f"{trap_name.lower().replace(' ', '_')}_traps")))
want_progressives = collections.defaultdict(lambda: self.options.progressive.
want_progressives(self.random))
cost_sorted_locations = sorted(self.science_locations, key=lambda location: location.name)
special_index = {"automation": 0,
"logistics": 1,
@@ -195,7 +218,7 @@ class Factorio(World):
for tech_name in base_tech_table:
if tech_name not in self.removed_technologies:
progressive_item_name = tech_to_progressive_lookup.get(tech_name, tech_name)
want_progressive = self.want_progressives[progressive_item_name]
want_progressive = want_progressives[progressive_item_name]
item_name = progressive_item_name if want_progressive else tech_name
tech_item = self.create_item(item_name)
index = special_index.get(tech_name, None)
@@ -210,12 +233,6 @@ class Factorio(World):
loc.place_locked_item(tech_item)
loc.revealed = True
def get_filler_item_name(self) -> str:
tech_name: str = self.random.choice(tuple(tech_table))
progressive_item_name: str = tech_to_progressive_lookup.get(tech_name, tech_name)
want_progressive: bool = self.want_progressives[progressive_item_name]
return progressive_item_name if want_progressive else tech_name
def set_rules(self):
player = self.player
shapes = get_shapes(self)

View File

@@ -1,26 +0,0 @@
import typing
import settings
class FactorioSettings(settings.Group):
class Executable(settings.UserFilePath):
is_exe = True
class ServerSettings(settings.OptionalUserFilePath):
"""
by default, no settings are loaded if this file does not exist. \
If this file does exist, then it will be used.
server_settings: "factorio\\\\data\\\\server-settings.json"
"""
class FilterItemSends(settings.Bool):
"""Whether to filter item send messages displayed in-game to only those that involve you."""
class BridgeChatOut(settings.Bool):
"""Whether to send chat messages from players on the Factorio server to Archipelago."""
executable: Executable = Executable("factorio/bin/x64/factorio")
server_settings: typing.Optional[ServerSettings] = None
filter_item_sends: typing.Union[FilterItemSends, bool] = False
bridge_chat_out: typing.Union[BridgeChatOut, bool] = True

View File

@@ -1,328 +0,0 @@
import logging
from collections import deque
from typing import TYPE_CHECKING
from NetUtils import ClientStatus
import worlds._bizhawk as bizhawk
from worlds._bizhawk.client import BizHawkClient
if TYPE_CHECKING:
from worlds._bizhawk.context import BizHawkClientContext
base_id = 7000
logger = logging.getLogger("Client")
rom_name_location = 0x07FFE3
locations_array_start = 0x200
locations_array_length = 0x100
items_obtained = 0x03
gp_location_low = 0x1C
gp_location_middle = 0x1D
gp_location_high = 0x1E
weapons_arrays_starts = [0x118, 0x158, 0x198, 0x1D8]
armors_arrays_starts = [0x11C, 0x15C, 0x19C, 0x1DC]
status_a_location = 0x102
status_b_location = 0x0FC
status_c_location = 0x0A3
key_items = ["Lute", "Crown", "Crystal", "Herb", "Key", "Tnt", "Adamant", "Slab", "Ruby", "Rod",
"Floater", "Chime", "Tail", "Cube", "Bottle", "Oxyale", "EarthOrb", "FireOrb", "WaterOrb", "AirOrb"]
consumables = ["Shard", "Tent", "Cabin", "House", "Heal", "Pure", "Soft"]
weapons = ["WoodenNunchucks", "SmallKnife", "WoodenRod", "Rapier", "IronHammer", "ShortSword", "HandAxe", "Scimitar",
"IronNunchucks", "LargeKnife", "IronStaff", "Sabre", "LongSword", "GreatAxe", "Falchon", "SilverKnife",
"SilverSword", "SilverHammer", "SilverAxe", "FlameSword", "IceSword", "DragonSword", "GiantSword",
"SunSword", "CoralSword", "WereSword", "RuneSword", "PowerRod", "LightAxe", "HealRod", "MageRod", "Defense",
"WizardRod", "Vorpal", "CatClaw", "ThorHammer", "BaneSword", "Katana", "Xcalber", "Masamune"]
armor = ["Cloth", "WoodenArmor", "ChainArmor", "IronArmor", "SteelArmor", "SilverArmor", "FlameArmor", "IceArmor",
"OpalArmor", "DragonArmor", "Copper", "Silver", "Gold", "Opal", "WhiteShirt", "BlackShirt", "WoodenShield",
"IronShield", "SilverShield", "FlameShield", "IceShield", "OpalShield", "AegisShield", "Buckler", "ProCape",
"Cap", "WoodenHelm", "IronHelm", "SilverHelm", "OpalHelm", "HealHelm", "Ribbon", "Gloves", "CopperGauntlets",
"IronGauntlets", "SilverGauntlets", "ZeusGauntlets", "PowerGauntlets", "OpalGauntlets", "ProRing"]
gold_items = ["Gold10", "Gold20", "Gold25", "Gold30", "Gold55", "Gold70", "Gold85", "Gold110", "Gold135", "Gold155",
"Gold160", "Gold180", "Gold240", "Gold255", "Gold260", "Gold295", "Gold300", "Gold315", "Gold330",
"Gold350", "Gold385", "Gold400", "Gold450", "Gold500", "Gold530", "Gold575", "Gold620", "Gold680",
"Gold750", "Gold795", "Gold880", "Gold1020", "Gold1250", "Gold1455", "Gold1520", "Gold1760", "Gold1975",
"Gold2000", "Gold2750", "Gold3400", "Gold4150", "Gold5000", "Gold5450", "Gold6400", "Gold6720",
"Gold7340", "Gold7690", "Gold7900", "Gold8135", "Gold9000", "Gold9300", "Gold9500", "Gold9900",
"Gold10000", "Gold12350", "Gold13000", "Gold13450", "Gold14050", "Gold14720", "Gold15000", "Gold17490",
"Gold18010", "Gold19990", "Gold20000", "Gold20010", "Gold26000", "Gold45000", "Gold65000"]
extended_consumables = ["FullCure", "Phoenix", "Blast", "Smoke",
"Refresh", "Flare", "Black", "Guard",
"Quick", "HighPotion", "Wizard", "Cloak"]
ext_consumables_lookup = {"FullCure": "Ext1", "Phoenix": "Ext2", "Blast": "Ext3", "Smoke": "Ext4",
"Refresh": "Ext1", "Flare": "Ext2", "Black": "Ext3", "Guard": "Ext4",
"Quick": "Ext1", "HighPotion": "Ext2", "Wizard": "Ext3", "Cloak": "Ext4"}
ext_consumables_locations = {"Ext1": 0x3C, "Ext2": 0x3D, "Ext3": 0x3E, "Ext4": 0x3F}
movement_items = ["Ship", "Bridge", "Canal", "Canoe"]
no_overworld_items = ["Sigil", "Mark"]
class FF1Client(BizHawkClient):
game = "Final Fantasy"
system = "NES"
weapons_queue: deque[int]
armor_queue: deque[int]
consumable_stack_amounts: dict[str, int] | None
def __init__(self) -> None:
self.wram = "RAM"
self.sram = "WRAM"
self.rom = "PRG ROM"
self.consumable_stack_amounts = None
self.weapons_queue = deque()
self.armor_queue = deque()
self.guard_character = 0x00
async def validate_rom(self, ctx: "BizHawkClientContext") -> bool:
try:
# Check ROM name/patch version
rom_name = ((await bizhawk.read(ctx.bizhawk_ctx, [(rom_name_location, 0x0D, self.rom)]))[0])
rom_name = rom_name.decode("ascii")
if rom_name != "FINAL FANTASY":
return False # Not a Final Fantasy 1 ROM
except bizhawk.RequestFailedError:
return False # Not able to get a response, say no for now
ctx.game = self.game
ctx.items_handling = 0b111
ctx.want_slot_data = True
# Resetting these in case of switching ROMs
self.consumable_stack_amounts = None
self.weapons_queue = deque()
self.armor_queue = deque()
return True
async def game_watcher(self, ctx: "BizHawkClientContext") -> None:
if ctx.server is None:
return
if ctx.slot is None:
return
try:
self.guard_character = await self.read_sram_value(ctx, status_a_location)
# If the first character's name starts with a 0 value, we're at the title screen/character creation.
# In that case, don't allow any read/writes.
# We do this by setting the guard to 1 because that's neither a valid character nor the initial value.
if self.guard_character == 0:
self.guard_character = 0x01
if self.consumable_stack_amounts is None:
self.consumable_stack_amounts = {}
self.consumable_stack_amounts["Shard"] = 1
other_consumable_amounts = await self.read_rom(ctx, 0x47400, 10)
self.consumable_stack_amounts["Tent"] = other_consumable_amounts[0] + 1
self.consumable_stack_amounts["Cabin"] = other_consumable_amounts[1] + 1
self.consumable_stack_amounts["House"] = other_consumable_amounts[2] + 1
self.consumable_stack_amounts["Heal"] = other_consumable_amounts[3] + 1
self.consumable_stack_amounts["Pure"] = other_consumable_amounts[4] + 1
self.consumable_stack_amounts["Soft"] = other_consumable_amounts[5] + 1
self.consumable_stack_amounts["Ext1"] = other_consumable_amounts[6] + 1
self.consumable_stack_amounts["Ext2"] = other_consumable_amounts[7] + 1
self.consumable_stack_amounts["Ext3"] = other_consumable_amounts[8] + 1
self.consumable_stack_amounts["Ext4"] = other_consumable_amounts[9] + 1
await self.location_check(ctx)
await self.received_items_check(ctx)
await self.process_weapons_queue(ctx)
await self.process_armor_queue(ctx)
except bizhawk.RequestFailedError:
# The connector didn't respond. Exit handler and return to main loop to reconnect
pass
async def location_check(self, ctx: "BizHawkClientContext"):
locations_data = await self.read_sram_values_guarded(ctx, locations_array_start, locations_array_length)
if locations_data is None:
return
locations_checked = []
if len(locations_data) > 0xFE and locations_data[0xFE] & 0x02 != 0 and not ctx.finished_game:
await ctx.send_msgs([
{"cmd": "StatusUpdate",
"status": ClientStatus.CLIENT_GOAL}
])
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
if locations_data[index] & flag != 0:
locations_checked.append(location)
found_locations = await ctx.check_locations(locations_checked)
for location in found_locations:
ctx.locations_checked.add(location)
location_name = ctx.location_names.lookup_in_game(location)
logger.info(
f'New Check: {location_name} ({len(ctx.locations_checked)}/'
f'{len(ctx.missing_locations) + len(ctx.checked_locations)})')
async def received_items_check(self, ctx: "BizHawkClientContext") -> None:
assert self.consumable_stack_amounts, "shouldn't call this function without reading consumable_stack_amounts"
write_list: list[tuple[int, list[int], str]] = []
items_received_count = await self.read_sram_value_guarded(ctx, items_obtained)
if items_received_count is None:
return
if items_received_count < len(ctx.items_received):
current_item = ctx.items_received[items_received_count]
current_item_id = current_item.item
current_item_name = ctx.item_names.lookup_in_game(current_item_id, ctx.game)
if current_item_name in key_items:
location = current_item_id - 0xE0
write_list.append((location, [1], self.sram))
elif current_item_name in movement_items:
location = current_item_id - 0x1E0
if current_item_name != "Canal":
write_list.append((location, [1], self.sram))
else:
write_list.append((location, [0], self.sram))
elif current_item_name in no_overworld_items:
if current_item_name == "Sigil":
location = 0x28
else:
location = 0x12
write_list.append((location, [1], self.sram))
elif current_item_name in gold_items:
gold_amount = int(current_item_name[4:])
current_gold_value = await self.read_sram_values_guarded(ctx, gp_location_low, 3)
if current_gold_value is None:
return
current_gold = int.from_bytes(current_gold_value, "little")
new_gold = min(gold_amount + current_gold, 999999)
lower_byte = new_gold % (2 ** 8)
middle_byte = (new_gold // (2 ** 8)) % (2 ** 8)
upper_byte = new_gold // (2 ** 16)
write_list.append((gp_location_low, [lower_byte], self.sram))
write_list.append((gp_location_middle, [middle_byte], self.sram))
write_list.append((gp_location_high, [upper_byte], self.sram))
elif current_item_name in consumables:
location = current_item_id - 0xE0
current_value = await self.read_sram_value_guarded(ctx, location)
if current_value is None:
return
amount_to_add = self.consumable_stack_amounts[current_item_name]
new_value = min(current_value + amount_to_add, 99)
write_list.append((location, [new_value], self.sram))
elif current_item_name in extended_consumables:
ext_name = ext_consumables_lookup[current_item_name]
location = ext_consumables_locations[ext_name]
current_value = await self.read_sram_value_guarded(ctx, location)
if current_value is None:
return
amount_to_add = self.consumable_stack_amounts[ext_name]
new_value = min(current_value + amount_to_add, 99)
write_list.append((location, [new_value], self.sram))
elif current_item_name in weapons:
self.weapons_queue.appendleft(current_item_id - 0x11B)
elif current_item_name in armor:
self.armor_queue.appendleft(current_item_id - 0x143)
write_list.append((items_obtained, [items_received_count + 1], self.sram))
write_successful = await self.write_sram_values_guarded(ctx, write_list)
if write_successful:
await bizhawk.display_message(ctx.bizhawk_ctx, f"Received {current_item_name}")
async def process_weapons_queue(self, ctx: "BizHawkClientContext"):
empty_slots = deque()
char1_slots = await self.read_sram_values_guarded(ctx, weapons_arrays_starts[0], 4)
char2_slots = await self.read_sram_values_guarded(ctx, weapons_arrays_starts[1], 4)
char3_slots = await self.read_sram_values_guarded(ctx, weapons_arrays_starts[2], 4)
char4_slots = await self.read_sram_values_guarded(ctx, weapons_arrays_starts[3], 4)
if char1_slots is None or char2_slots is None or char3_slots is None or char4_slots is None:
return
for i, slot in enumerate(char1_slots):
if slot == 0:
empty_slots.appendleft(weapons_arrays_starts[0] + i)
for i, slot in enumerate(char2_slots):
if slot == 0:
empty_slots.appendleft(weapons_arrays_starts[1] + i)
for i, slot in enumerate(char3_slots):
if slot == 0:
empty_slots.appendleft(weapons_arrays_starts[2] + i)
for i, slot in enumerate(char4_slots):
if slot == 0:
empty_slots.appendleft(weapons_arrays_starts[3] + i)
while len(empty_slots) > 0 and len(self.weapons_queue) > 0:
current_slot = empty_slots.pop()
current_weapon = self.weapons_queue.pop()
await self.write_sram_guarded(ctx, current_slot, current_weapon)
async def process_armor_queue(self, ctx: "BizHawkClientContext"):
empty_slots = deque()
char1_slots = await self.read_sram_values_guarded(ctx, armors_arrays_starts[0], 4)
char2_slots = await self.read_sram_values_guarded(ctx, armors_arrays_starts[1], 4)
char3_slots = await self.read_sram_values_guarded(ctx, armors_arrays_starts[2], 4)
char4_slots = await self.read_sram_values_guarded(ctx, armors_arrays_starts[3], 4)
if char1_slots is None or char2_slots is None or char3_slots is None or char4_slots is None:
return
for i, slot in enumerate(char1_slots):
if slot == 0:
empty_slots.appendleft(armors_arrays_starts[0] + i)
for i, slot in enumerate(char2_slots):
if slot == 0:
empty_slots.appendleft(armors_arrays_starts[1] + i)
for i, slot in enumerate(char3_slots):
if slot == 0:
empty_slots.appendleft(armors_arrays_starts[2] + i)
for i, slot in enumerate(char4_slots):
if slot == 0:
empty_slots.appendleft(armors_arrays_starts[3] + i)
while len(empty_slots) > 0 and len(self.armor_queue) > 0:
current_slot = empty_slots.pop()
current_armor = self.armor_queue.pop()
await self.write_sram_guarded(ctx, current_slot, current_armor)
async def read_sram_value(self, ctx: "BizHawkClientContext", location: int):
value = ((await bizhawk.read(ctx.bizhawk_ctx, [(location, 1, self.sram)]))[0])
return int.from_bytes(value, "little")
async def read_sram_values_guarded(self, ctx: "BizHawkClientContext", location: int, size: int):
value = await bizhawk.guarded_read(ctx.bizhawk_ctx,
[(location, size, self.sram)],
[(status_a_location, [self.guard_character], self.sram)])
if value is None:
return None
return value[0]
async def read_sram_value_guarded(self, ctx: "BizHawkClientContext", location: int):
value = await bizhawk.guarded_read(ctx.bizhawk_ctx,
[(location, 1, self.sram)],
[(status_a_location, [self.guard_character], self.sram)])
if value is None:
return None
return int.from_bytes(value[0], "little")
async def read_rom(self, ctx: "BizHawkClientContext", location: int, size: int):
return (await bizhawk.read(ctx.bizhawk_ctx, [(location, size, self.rom)]))[0]
async def write_sram_guarded(self, ctx: "BizHawkClientContext", location: int, value: int):
return await bizhawk.guarded_write(ctx.bizhawk_ctx,
[(location, [value], self.sram)],
[(status_a_location, [self.guard_character], self.sram)])
async def write_sram_values_guarded(self, ctx: "BizHawkClientContext", write_list):
return await bizhawk.guarded_write(ctx.bizhawk_ctx,
write_list,
[(status_a_location, [self.guard_character], self.sram)])

View File

@@ -1,5 +1,5 @@
import json
import pkgutil
from pathlib import Path
from typing import Dict, Set, NamedTuple, List
from BaseClasses import Item, ItemClassification
@@ -37,13 +37,15 @@ class FF1Items:
_item_table_lookup: Dict[str, ItemData] = {}
def _populate_item_table_from_data(self):
file = pkgutil.get_data(__name__, "data/items.json").decode("utf-8")
items = json.loads(file)
# Hardcode progression and categories for now
self._item_table = [ItemData(name, code, "FF1Item", ItemClassification.progression if name in
FF1_PROGRESSION_LIST else ItemClassification.useful if name in FF1_USEFUL_LIST else
ItemClassification.filler) for name, code in items.items()]
self._item_table_lookup = {item.name: item for item in self._item_table}
base_path = Path(__file__).parent
file_path = (base_path / "data/items.json").resolve()
with open(file_path) as file:
items = json.load(file)
# Hardcode progression and categories for now
self._item_table = [ItemData(name, code, "FF1Item", ItemClassification.progression if name in
FF1_PROGRESSION_LIST else ItemClassification.useful if name in FF1_USEFUL_LIST else
ItemClassification.filler) for name, code in items.items()]
self._item_table_lookup = {item.name: item for item in self._item_table}
def _get_item_table(self) -> List[ItemData]:
if not self._item_table or not self._item_table_lookup:

View File

@@ -1,5 +1,5 @@
import json
import pkgutil
from pathlib import Path
from typing import Dict, NamedTuple, List, Optional
from BaseClasses import Region, Location, MultiWorld
@@ -18,11 +18,13 @@ class FF1Locations:
_location_table_lookup: Dict[str, LocationData] = {}
def _populate_item_table_from_data(self):
file = pkgutil.get_data(__name__, "data/locations.json")
locations = json.loads(file)
# Hardcode progression and categories for now
self._location_table = [LocationData(name, code) for name, code in locations.items()]
self._location_table_lookup = {item.name: item for item in self._location_table}
base_path = Path(__file__).parent
file_path = (base_path / "data/locations.json").resolve()
with open(file_path) as file:
locations = json.load(file)
# Hardcode progression and categories for now
self._location_table = [LocationData(name, code) for name, code in locations.items()]
self._location_table_lookup = {item.name: item for item in self._location_table}
def _get_location_table(self) -> List[LocationData]:
if not self._location_table or not self._location_table_lookup:

View File

@@ -7,7 +7,6 @@ from .Items import ItemData, FF1Items, FF1_STARTER_ITEMS, FF1_PROGRESSION_LIST,
from .Locations import EventId, FF1Locations, generate_rule, CHAOS_TERMINATED_EVENT
from .Options import FF1Options
from ..AutoWorld import World, WebWorld
from .Client import FF1Client
class FF1Settings(settings.Group):

View File

@@ -22,6 +22,11 @@ All items can appear in other players worlds, including consumables, shards, wea
## What does another world's item look like in Final Fantasy
All local and remote items appear the same. Final Fantasy will say that you received an item, then the client log will
display what was found external to the in-game text box.
All local and remote items appear the same. Final Fantasy will say that you received an item, then BOTH the client log and the
emulator will display what was found external to the in-game text box.
## Unique Local Commands
The following commands are only available when using the FF1Client for the Final Fantasy Randomizer.
- `/nes` Shows the current status of the NES connection.
- `/toggle_msgs` Toggle displaying messages in EmuHawk

View File

@@ -2,10 +2,10 @@
## Required Software
- BizHawk: [BizHawk Releases from TASVideos](https://tasvideos.org/BizHawk/ReleaseHistory)
- Detailed installation instructions for BizHawk can be found at the above link.
- Windows users must run the prerequisite installer first, which can also be found at the above link.
- The built-in BizHawk client, which can be installed [here](https://github.com/ArchipelagoMW/Archipelago/releases)
- The FF1Client
- Bundled with Archipelago: [Archipelago Releases Page](https://github.com/ArchipelagoMW/Archipelago/releases)
- The BizHawk emulator. Versions 2.3.1 and higher are supported. Version 2.7 is recommended
- [BizHawk at TASVideos](https://tasvideos.org/BizHawk)
- Your legally obtained Final Fantasy (USA Edition) ROM file, probably named `Final Fantasy (USA).nes`. Neither
Archipelago.gg nor the Final Fantasy Randomizer Community can supply you with this.
@@ -13,7 +13,7 @@
1. Download and install the latest version of Archipelago.
1. On Windows, download Setup.Archipelago.<HighestVersion\>.exe and run it
2. Assign EmuHawk as your default program for launching `.nes` files.
2. Assign EmuHawk version 2.3.1 or higher as your default program for launching `.nes` files.
1. Extract your BizHawk folder to your Desktop, or somewhere you will remember. Below are optional additional steps
for loading ROMs more conveniently
1. Right-click on a ROM file and select **Open with...**
@@ -46,7 +46,7 @@ please refer to the [game agnostic setup guide](/tutorial/Archipelago/setup/en).
Once the Archipelago server has been hosted:
1. Navigate to your Archipelago install folder and run `ArchipelagoBizhawkClient.exe`
1. Navigate to your Archipelago install folder and run `ArchipelagoFF1Client.exe`
2. Notice the `/connect command` on the server hosting page (It should look like `/connect archipelago.gg:*****`
where ***** are numbers)
3. Type the connect command into the client OR add the port to the pre-populated address on the top bar (it should
@@ -54,11 +54,16 @@ Once the Archipelago server has been hosted:
### Running Your Game and Connecting to the Client Program
1. Open EmuHawk and load your ROM OR click your ROM file if it is already associated with the
1. Open EmuHawk 2.3.1 or higher and load your ROM OR click your ROM file if it is already associated with the
extension `*.nes`
2. Navigate to where you installed Archipelago, then to `data/lua`, and drag+drop the `connector_bizhawk_generic.lua`
script onto the main EmuHawk window. You can also instead open the Lua Console manually, click `Script``Open Script`,
and navigate to `connector_bizhawk_generic.lua` with the file picker.
2. Navigate to where you installed Archipelago, then to `data/lua`, and drag+drop the `connector_ff1.lua` script onto
the main EmuHawk window.
1. You could instead open the Lua Console manually, click `Script``Open Script`, and navigate to
`connector_ff1.lua` with the file picker.
2. If it gives a `NLua.Exceptions.LuaScriptException: .\socket.lua:13: module 'socket.core' not found:` exception
close your emulator entirely, restart it and re-run these steps
3. If it says `Must use a version of BizHawk 2.3.1 or higher`, double-check your BizHawk version by clicking **
Help** -> **About**
## Play the game

View File

@@ -20,11 +20,9 @@ It is generally recommended that you use a virtual environment to run python bas
3. Run the command `source venv/bin/activate` to activate the virtual environment.
4. If you want to exit the virtual environment, run the command `deactivate`.
## Steps to Run the Clients
1. Run the command `python3 Launcher.py`.
2. If your game doesn't have a patch file, just click the desired client in the right side column.
3. If your game does have a patch file, click the 'Open Patch' button and navigate to your patch file (the filename extension will look something like apsm, aplttp, apsmz3, etc.).
4. If the patching process needs a rom, but cannot find it, it will ask you to navigate to your legally obtained rom.
5. Your client should now be running and rom created (where applicable).
1. If your game doesn't have a patch file, run the command `python3 SNIClient.py`, changing the filename with the file of the client you want to run.
2. If your game does have a patch file, move the base rom to the Archipelago directory and run the command `python3 SNIClient.py 'patchfile'` with the filename extension for the patch file (apsm, aplttp, apsmz3, etc.) included and changing the filename with the file of the client you want to run.
3. Your client should now be running and rom created (where applicable).
## Additional Steps for SNES Games
1. If using RetroArch, the instructions to set up your emulator [here in the Link to the Past setup guide](https://archipelago.gg/tutorial/A%20Link%20to%20the%20Past/multiworld/en) also work on the macOS version of RetroArch.
2. Double click on the SNI tar.gz download to extract the files to an SNI directory. If it isn't already, rename this directory to SNI to make some steps easier.

View File

@@ -127,10 +127,6 @@ class Hylics2World(World):
tv = tvs.pop()
self.get_location(tv).place_locked_item(self.create_item(gesture))
def get_pre_fill_items(self) -> List["Item"]:
if self.options.gesture_shuffle:
return [self.create_item(gesture["name"]) for gesture in Items.gesture_item_table.values()]
return []
def fill_slot_data(self) -> Dict[str, Any]:
slot_data: Dict[str, Any] = {

View File

@@ -1,512 +0,0 @@
# Python standard libraries
from collections import defaultdict
from math import ceil
from typing import Any, ClassVar, Callable, Union, cast
# Archipelago imports
import settings
from worlds.AutoWorld import World, WebWorld
from worlds.LauncherComponents import components, Component, launch_subprocess, Type, icon_paths
from BaseClasses import (Item,
ItemClassification as ItemClass,
Tutorial,
CollectionState)
from Options import OptionGroup
# Jak imports
from . import options
from .game_id import jak1_id, jak1_name, jak1_max
from .items import (JakAndDaxterItem,
OrbAssoc,
item_table,
cell_item_table,
scout_item_table,
special_item_table,
move_item_table,
orb_item_table,
trap_item_table)
from .levels import level_table, level_table_with_global
from .locations import (JakAndDaxterLocation,
location_table,
cell_location_table,
scout_location_table,
special_location_table,
cache_location_table,
orb_location_table)
from .regions import create_regions
from .rules import (enforce_mp_absolute_limits,
enforce_mp_friendly_limits,
enforce_sp_limits,
set_orb_trade_rule)
from .locs import (cell_locations as cells,
scout_locations as scouts,
special_locations as specials,
orb_cache_locations as caches,
orb_locations as orbs)
from .regs.region_base import JakAndDaxterRegion
def launch_client():
from . import client
launch_subprocess(client.launch, name="JakAndDaxterClient")
components.append(Component("Jak and Daxter Client",
func=launch_client,
component_type=Type.CLIENT,
icon="precursor_orb"))
icon_paths["precursor_orb"] = f"ap:{__name__}/icons/precursor_orb.png"
class JakAndDaxterSettings(settings.Group):
class RootDirectory(settings.UserFolderPath):
"""Path to folder containing the ArchipelaGOAL mod executables (gk.exe and goalc.exe).
Ensure this path contains forward slashes (/) only. This setting only applies if
Auto Detect Root Directory is set to false."""
description = "ArchipelaGOAL Root Directory"
class AutoDetectRootDirectory(settings.Bool):
"""Attempt to find the OpenGOAL installation and the mod executables (gk.exe and goalc.exe)
automatically. If set to true, the ArchipelaGOAL Root Directory setting is ignored."""
description = "ArchipelaGOAL Auto Detect Root Directory"
class EnforceFriendlyOptions(settings.Bool):
"""Enforce friendly player options in both single and multiplayer seeds. Disabling this allows for
more disruptive and challenging options, but may impact seed generation. Use at your own risk!"""
description = "ArchipelaGOAL Enforce Friendly Options"
root_directory: RootDirectory = RootDirectory(
"%programfiles%/OpenGOAL-Launcher/features/jak1/mods/JakMods/archipelagoal")
# Don't ever change these type hints again.
auto_detect_root_directory: Union[AutoDetectRootDirectory, bool] = True
enforce_friendly_options: Union[EnforceFriendlyOptions, bool] = True
class JakAndDaxterWebWorld(WebWorld):
setup_en = Tutorial(
"Multiworld Setup Guide",
"A guide to setting up ArchipelaGOAL (Archipelago on OpenGOAL).",
"English",
"setup_en.md",
"setup/en",
["markustulliuscicero"]
)
tutorials = [setup_en]
bug_report_page = "https://github.com/ArchipelaGOAL/Archipelago/issues"
option_groups = [
OptionGroup("Orbsanity", [
options.EnableOrbsanity,
options.GlobalOrbsanityBundleSize,
options.PerLevelOrbsanityBundleSize,
]),
OptionGroup("Power Cell Counts", [
options.EnableOrderedCellCounts,
options.FireCanyonCellCount,
options.MountainPassCellCount,
options.LavaTubeCellCount,
]),
OptionGroup("Orb Trade Counts", [
options.CitizenOrbTradeAmount,
options.OracleOrbTradeAmount,
]),
OptionGroup("Traps", [
options.FillerPowerCellsReplacedWithTraps,
options.FillerOrbBundlesReplacedWithTraps,
options.TrapEffectDuration,
options.TrapWeights,
]),
]
class JakAndDaxterWorld(World):
"""
Jak and Daxter: The Precursor Legacy is a 2001 action platformer developed by Naughty Dog
for the PlayStation 2. The game follows the eponymous protagonists, a young boy named Jak
and his friend Daxter, who has been transformed into an ottsel. With the help of Samos
the Sage of Green Eco and his daughter Keira, the pair travel north in search of a cure for Daxter,
discovering artifacts created by an ancient race known as the Precursors along the way. When the
rogue sages Gol and Maia Acheron plan to flood the world with Dark Eco, they must stop their evil plan
and save the world.
"""
# ID, name, version
game = jak1_name
required_client_version = (0, 5, 0)
# Options
settings: ClassVar[JakAndDaxterSettings]
options_dataclass = options.JakAndDaxterOptions
options: options.JakAndDaxterOptions
# Web world
web = JakAndDaxterWebWorld()
# Stored as {ID: Name} pairs, these must now be swapped to {Name: ID} pairs.
# Remember, the game ID and various offsets for each item type have already been calculated.
item_name_to_id = {name: k for k, name in item_table.items()}
location_name_to_id = {name: k for k, name in location_table.items()}
item_name_groups = {
"Power Cells": set(cell_item_table.values()),
"Scout Flies": set(scout_item_table.values()),
"Specials": set(special_item_table.values()),
"Moves": set(move_item_table.values()),
"Precursor Orbs": set(orb_item_table.values()),
"Traps": set(trap_item_table.values()),
}
location_name_groups = {
"Power Cells": set(cell_location_table.values()),
"Power Cells - GR": set(cells.locGR_cellTable.values()),
"Power Cells - SV": set(cells.locSV_cellTable.values()),
"Power Cells - FJ": set(cells.locFJ_cellTable.values()),
"Power Cells - SB": set(cells.locSB_cellTable.values()),
"Power Cells - MI": set(cells.locMI_cellTable.values()),
"Power Cells - FC": set(cells.locFC_cellTable.values()),
"Power Cells - RV": set(cells.locRV_cellTable.values()),
"Power Cells - PB": set(cells.locPB_cellTable.values()),
"Power Cells - LPC": set(cells.locLPC_cellTable.values()),
"Power Cells - BS": set(cells.locBS_cellTable.values()),
"Power Cells - MP": set(cells.locMP_cellTable.values()),
"Power Cells - VC": set(cells.locVC_cellTable.values()),
"Power Cells - SC": set(cells.locSC_cellTable.values()),
"Power Cells - SM": set(cells.locSM_cellTable.values()),
"Power Cells - LT": set(cells.locLT_cellTable.values()),
"Power Cells - GMC": set(cells.locGMC_cellTable.values()),
"Scout Flies": set(scout_location_table.values()),
"Scout Flies - GR": set(scouts.locGR_scoutTable.values()),
"Scout Flies - SV": set(scouts.locSV_scoutTable.values()),
"Scout Flies - FJ": set(scouts.locFJ_scoutTable.values()),
"Scout Flies - SB": set(scouts.locSB_scoutTable.values()),
"Scout Flies - MI": set(scouts.locMI_scoutTable.values()),
"Scout Flies - FC": set(scouts.locFC_scoutTable.values()),
"Scout Flies - RV": set(scouts.locRV_scoutTable.values()),
"Scout Flies - PB": set(scouts.locPB_scoutTable.values()),
"Scout Flies - LPC": set(scouts.locLPC_scoutTable.values()),
"Scout Flies - BS": set(scouts.locBS_scoutTable.values()),
"Scout Flies - MP": set(scouts.locMP_scoutTable.values()),
"Scout Flies - VC": set(scouts.locVC_scoutTable.values()),
"Scout Flies - SC": set(scouts.locSC_scoutTable.values()),
"Scout Flies - SM": set(scouts.locSM_scoutTable.values()),
"Scout Flies - LT": set(scouts.locLT_scoutTable.values()),
"Scout Flies - GMC": set(scouts.locGMC_scoutTable.values()),
"Specials": set(special_location_table.values()),
"Orb Caches": set(cache_location_table.values()),
"Precursor Orbs": set(orb_location_table.values()),
"Precursor Orbs - GR": set(orbs.locGR_orbBundleTable.values()),
"Precursor Orbs - SV": set(orbs.locSV_orbBundleTable.values()),
"Precursor Orbs - FJ": set(orbs.locFJ_orbBundleTable.values()),
"Precursor Orbs - SB": set(orbs.locSB_orbBundleTable.values()),
"Precursor Orbs - MI": set(orbs.locMI_orbBundleTable.values()),
"Precursor Orbs - FC": set(orbs.locFC_orbBundleTable.values()),
"Precursor Orbs - RV": set(orbs.locRV_orbBundleTable.values()),
"Precursor Orbs - PB": set(orbs.locPB_orbBundleTable.values()),
"Precursor Orbs - LPC": set(orbs.locLPC_orbBundleTable.values()),
"Precursor Orbs - BS": set(orbs.locBS_orbBundleTable.values()),
"Precursor Orbs - MP": set(orbs.locMP_orbBundleTable.values()),
"Precursor Orbs - VC": set(orbs.locVC_orbBundleTable.values()),
"Precursor Orbs - SC": set(orbs.locSC_orbBundleTable.values()),
"Precursor Orbs - SM": set(orbs.locSM_orbBundleTable.values()),
"Precursor Orbs - LT": set(orbs.locLT_orbBundleTable.values()),
"Precursor Orbs - GMC": set(orbs.locGMC_orbBundleTable.values()),
"Trades": {location_table[cells.to_ap_id(k)] for k in
{11, 12, 31, 32, 33, 96, 97, 98, 99, 13, 14, 34, 35, 100, 101}},
"'Free 7 Scout Flies' Power Cells": set(cells.loc7SF_cellTable.values()),
}
# These functions and variables are Options-driven, keep them as instance variables here so that we don't clog up
# the seed generation routines with options checking. So we set these once, and then just use them as needed.
can_trade: Callable[[CollectionState, int, int | None], bool]
total_orbs: int = 2000
orb_bundle_item_name: str = ""
orb_bundle_size: int = 0
total_trade_orbs: int = 0
total_prog_orb_bundles: int = 0
total_trap_orb_bundles: int = 0
total_filler_orb_bundles: int = 0
total_power_cells: int = 101
total_prog_cells: int = 0
total_trap_cells: int = 0
total_filler_cells: int = 0
power_cell_thresholds: list[int]
power_cell_thresholds_minus_one: list[int]
trap_weights: tuple[list[str], list[int]]
# Store these dictionaries for speed improvements.
level_to_regions: dict[str, list[JakAndDaxterRegion]] # Contains all levels and regions.
level_to_orb_regions: dict[str, list[JakAndDaxterRegion]] # Contains only regions which contain orbs.
# Handles various options validation, rules enforcement, and caching of important information.
def generate_early(self) -> None:
# Initialize the level-region dictionary.
self.level_to_regions = defaultdict(list)
self.level_to_orb_regions = defaultdict(list)
# Cache the power cell threshold values for quicker reference.
self.power_cell_thresholds = [
self.options.fire_canyon_cell_count.value,
self.options.mountain_pass_cell_count.value,
self.options.lava_tube_cell_count.value,
100, # The 100 Power Cell Door.
]
# Order the thresholds ascending and set the options values to the new order.
if self.options.enable_ordered_cell_counts:
self.power_cell_thresholds.sort()
self.options.fire_canyon_cell_count.value = self.power_cell_thresholds[0]
self.options.mountain_pass_cell_count.value = self.power_cell_thresholds[1]
self.options.lava_tube_cell_count.value = self.power_cell_thresholds[2]
# We would have done this earlier, but we needed to sort the power cell thresholds first. Don't worry, we'll
# come back to them.
enforce_friendly_options = self.settings.enforce_friendly_options
if self.multiworld.players == 1:
# For singleplayer games, always enforce/clamp the cell counts to valid values.
enforce_sp_limits(self)
else:
if enforce_friendly_options:
# For multiplayer games, we have a host setting to make options fair/sane for other players.
# If this setting is enabled, enforce/clamp some friendly limitations on our options.
enforce_mp_friendly_limits(self)
else:
# Even if the setting is disabled, some values must be clamped to avoid generation errors.
enforce_mp_absolute_limits(self)
# That's right, set the collection of thresholds again. Don't just clamp the values without updating this list!
self.power_cell_thresholds = [
self.options.fire_canyon_cell_count.value,
self.options.mountain_pass_cell_count.value,
self.options.lava_tube_cell_count.value,
100, # The 100 Power Cell Door.
]
# Now that the threshold list is finalized, store this for the remove function.
self.power_cell_thresholds_minus_one = [x - 1 for x in self.power_cell_thresholds]
# Calculate the number of power cells needed for full region access, the number being replaced by traps,
# and the number of remaining filler.
if self.options.jak_completion_condition == options.CompletionCondition.option_open_100_cell_door:
self.total_prog_cells = 100
else:
self.total_prog_cells = max(self.power_cell_thresholds[:3])
non_prog_cells = self.total_power_cells - self.total_prog_cells
self.total_trap_cells = min(self.options.filler_power_cells_replaced_with_traps.value, non_prog_cells)
self.options.filler_power_cells_replaced_with_traps.value = self.total_trap_cells
self.total_filler_cells = non_prog_cells - self.total_trap_cells
# Cache the orb bundle size and item name for quicker reference.
if self.options.enable_orbsanity == options.EnableOrbsanity.option_per_level:
self.orb_bundle_size = self.options.level_orbsanity_bundle_size.value
self.orb_bundle_item_name = orb_item_table[self.orb_bundle_size]
elif self.options.enable_orbsanity == options.EnableOrbsanity.option_global:
self.orb_bundle_size = self.options.global_orbsanity_bundle_size.value
self.orb_bundle_item_name = orb_item_table[self.orb_bundle_size]
else:
self.orb_bundle_size = 0
self.orb_bundle_item_name = ""
# Calculate the number of orb bundles needed for trades, the number being replaced by traps,
# and the number of remaining filler. If Orbsanity is off, default values of 0 will prevail for all.
if self.orb_bundle_size > 0:
total_orb_bundles = self.total_orbs // self.orb_bundle_size
self.total_prog_orb_bundles = ceil(self.total_trade_orbs / self.orb_bundle_size)
non_prog_orb_bundles = total_orb_bundles - self.total_prog_orb_bundles
self.total_trap_orb_bundles = min(self.options.filler_orb_bundles_replaced_with_traps.value,
non_prog_orb_bundles)
self.options.filler_orb_bundles_replaced_with_traps.value = self.total_trap_orb_bundles
self.total_filler_orb_bundles = non_prog_orb_bundles - self.total_trap_orb_bundles
else:
self.options.filler_orb_bundles_replaced_with_traps.value = 0
self.trap_weights = self.options.trap_weights.weights_pair
# Options drive which trade rules to use, so they need to be setup before we create_regions.
set_orb_trade_rule(self)
# This will also set Locations, Location access rules, Region access rules, etc.
def create_regions(self) -> None:
create_regions(self)
# Don't forget to add the created regions to the multiworld!
for level in self.level_to_regions:
self.multiworld.regions.extend(self.level_to_regions[level])
# As a lazy measure, let's also fill level_to_orb_regions here.
# This should help speed up orbsanity calculations.
self.level_to_orb_regions[level] = [reg for reg in self.level_to_regions[level] if reg.orb_count > 0]
# from Utils import visualize_regions
# visualize_regions(self.multiworld.get_region("Menu", self.player), "jakanddaxter.puml")
def item_data_helper(self, item: int) -> list[tuple[int, ItemClass, OrbAssoc, int]]:
"""
Helper function to reuse some nasty if/else trees. This outputs a list of pairs of item count and class.
For instance, not all 101 power cells need to be marked progression if you only need 72 to beat the game.
So we will have 72 Progression Power Cells, and 29 Filler Power Cells.
"""
data: list[tuple[int, ItemClass, OrbAssoc, int]] = []
# Make N Power Cells. We only want AP's Progression Fill routine to handle the amount of cells we need
# to reach the furthest possible region. Even for early completion goals, all areas in the game must be
# reachable or generation will fail. TODO - Option-driven region creation would be an enormous refactor.
if item in range(jak1_id, jak1_id + scouts.fly_offset):
data.append((self.total_prog_cells, ItemClass.progression_skip_balancing, OrbAssoc.IS_POWER_CELL, 0))
data.append((self.total_filler_cells, ItemClass.filler, OrbAssoc.IS_POWER_CELL, 0))
# Make 7 Scout Flies per level.
elif item in range(jak1_id + scouts.fly_offset, jak1_id + specials.special_offset):
data.append((7, ItemClass.progression_skip_balancing, OrbAssoc.NEVER_UNLOCKS_ORBS, 0))
# Make only 1 of each Special Item.
elif item in range(jak1_id + specials.special_offset, jak1_id + caches.orb_cache_offset):
data.append((1, ItemClass.progression | ItemClass.useful, OrbAssoc.ALWAYS_UNLOCKS_ORBS, 0))
# Make only 1 of each Move Item.
elif item in range(jak1_id + caches.orb_cache_offset, jak1_id + orbs.orb_offset):
data.append((1, ItemClass.progression | ItemClass.useful, OrbAssoc.ALWAYS_UNLOCKS_ORBS, 0))
# Make N Precursor Orb bundles. Like Power Cells, only a fraction of these will be marked as Progression
# with the remainder as Filler, but they are still entirely fungible. See collect function for why these
# are OrbAssoc.NEVER_UNLOCKS_ORBS.
elif item in range(jak1_id + orbs.orb_offset, jak1_max - max(trap_item_table)):
data.append((self.total_prog_orb_bundles, ItemClass.progression_skip_balancing,
OrbAssoc.NEVER_UNLOCKS_ORBS, self.orb_bundle_size))
data.append((self.total_filler_orb_bundles, ItemClass.filler,
OrbAssoc.NEVER_UNLOCKS_ORBS, self.orb_bundle_size))
# We will manually create trap items as needed.
elif item in range(jak1_max - max(trap_item_table), jak1_max):
data.append((0, ItemClass.trap, OrbAssoc.NEVER_UNLOCKS_ORBS, 0))
# We will manually create filler items as needed.
elif item == jak1_max:
data.append((0, ItemClass.filler, OrbAssoc.NEVER_UNLOCKS_ORBS, 0))
# If we try to make items with ID's higher than we've defined, something has gone wrong.
else:
raise KeyError(f"Tried to fill item pool with unknown ID {item}.")
return data
def create_items(self) -> None:
items_made: int = 0
for item_name in self.item_name_to_id:
item_id = self.item_name_to_id[item_name]
# Handle Move Randomizer option.
# If it is OFF, put all moves in your starting inventory instead of the item pool,
# then fill the item pool with a corresponding amount of filler items.
if item_name in self.item_name_groups["Moves"] and not self.options.enable_move_randomizer:
self.multiworld.push_precollected(self.create_item(item_name))
self.multiworld.itempool.append(self.create_filler())
items_made += 1
continue
# Handle Orbsanity option.
# If it is OFF, don't add any orb bundles to the item pool, period.
# If it is ON, don't add any orb bundles that don't match the chosen option.
if (item_name in self.item_name_groups["Precursor Orbs"]
and (self.options.enable_orbsanity == options.EnableOrbsanity.option_off
or item_name != self.orb_bundle_item_name)):
continue
# Skip Traps for now.
if item_name in self.item_name_groups["Traps"]:
continue
# In almost every other scenario, do this. Not all items with the same name will have the same item class.
data = self.item_data_helper(item_id)
for (count, classification, orb_assoc, orb_amount) in data:
self.multiworld.itempool += [JakAndDaxterItem(item_name, classification, item_id,
self.player, orb_assoc, orb_amount)
for _ in range(count)]
items_made += count
# Handle Traps (for real).
# Manually fill the item pool with a weighted assortment of trap items, equal to the sum of
# total_trap_cells + total_trap_orb_bundles. Only do this if one or more traps have weights > 0.
names, weights = self.trap_weights
if sum(weights):
total_traps = self.total_trap_cells + self.total_trap_orb_bundles
trap_list = self.random.choices(names, weights=weights, k=total_traps)
self.multiworld.itempool += [self.create_item(trap_name) for trap_name in trap_list]
items_made += total_traps
# Handle Unfilled Locations.
# Add an amount of filler items equal to the number of locations yet to be filled.
# This is the final set of items we will add to the pool.
all_regions = self.multiworld.get_regions(self.player)
total_locations = sum(reg.location_count for reg in cast(list[JakAndDaxterRegion], all_regions))
total_filler = total_locations - items_made
self.multiworld.itempool += [self.create_filler() for _ in range(total_filler)]
def create_item(self, name: str) -> Item:
item_id = self.item_name_to_id[name]
# Use first tuple (will likely be the most important).
_, classification, orb_assoc, orb_amount = self.item_data_helper(item_id)[0]
return JakAndDaxterItem(name, classification, item_id, self.player, orb_assoc, orb_amount)
def get_filler_item_name(self) -> str:
return "Green Eco Pill"
def collect(self, state: CollectionState, item: JakAndDaxterItem) -> bool:
change = super().collect(state, item)
if change:
# Orbsanity as an option is no-factor to these conditions. Matching the item name implies Orbsanity is ON,
# so we don't need to check the option. When Orbsanity is OFF, there won't even be any orb bundle items
# to collect.
# Orb items do not intrinsically unlock anything that contains more Reachable Orbs, so they do not need to
# set the cache to stale. They just change how many orbs you have to trade with.
if item.orb_amount > 0:
state.prog_items[self.player]["Tradeable Orbs"] += self.orb_bundle_size # Give a bundle of Trade Orbs
# Power Cells DO unlock new regions that contain more Reachable Orbs - the connector levels and new
# hub levels - BUT they only do that when you have a number of them equal to one of the threshold values.
elif (item.orb_assoc == OrbAssoc.ALWAYS_UNLOCKS_ORBS
or (item.orb_assoc == OrbAssoc.IS_POWER_CELL
and state.count("Power Cell", self.player) in self.power_cell_thresholds)):
state.prog_items[self.player]["Reachable Orbs Fresh"] = False
# However, every other item that does not have an appropriate OrbAssoc that changes the CollectionState
# should NOT set the cache to stale, because they did not make it possible to reach more orb locations
# (level unlocks, region unlocks, etc.).
return change
def remove(self, state: CollectionState, item: JakAndDaxterItem) -> bool:
change = super().remove(state, item)
if change:
# Do the same thing we did in collect, except subtract trade orbs instead of add.
if item.orb_amount > 0:
state.prog_items[self.player]["Tradeable Orbs"] -= self.orb_bundle_size # Take a bundle of Trade Orbs
# Ditto Power Cells, but check thresholds - 1, because we potentially crossed the threshold in the opposite
# direction. E.g. we've removed the 20th power cell, our count is now 19, so we should stale the cache.
elif (item.orb_assoc == OrbAssoc.ALWAYS_UNLOCKS_ORBS
or (item.orb_assoc == OrbAssoc.IS_POWER_CELL
and state.count("Power Cell", self.player) in self.power_cell_thresholds_minus_one)):
state.prog_items[self.player]["Reachable Orbs Fresh"] = False
return change
def fill_slot_data(self) -> dict[str, Any]:
options_dict = self.options.as_dict("enable_move_randomizer",
"enable_orbsanity",
"global_orbsanity_bundle_size",
"level_orbsanity_bundle_size",
"fire_canyon_cell_count",
"mountain_pass_cell_count",
"lava_tube_cell_count",
"citizen_orb_trade_amount",
"oracle_orb_trade_amount",
"filler_power_cells_replaced_with_traps",
"filler_orb_bundles_replaced_with_traps",
"trap_effect_duration",
"trap_weights",
"jak_completion_condition",
"require_punch_for_klaww",
)
return options_dict

View File

@@ -1,489 +0,0 @@
import logging
import random
import struct
from typing import ByteString, Callable
import json
import pymem
from pymem import pattern
from pymem.exception import ProcessNotFound, ProcessError, MemoryReadError, WinAPIError
from dataclasses import dataclass
from ..locs import (orb_locations as orbs,
cell_locations as cells,
scout_locations as flies,
special_locations as specials,
orb_cache_locations as caches)
logger = logging.getLogger("MemoryReader")
# Some helpful constants.
sizeof_uint64 = 8
sizeof_uint32 = 4
sizeof_uint8 = 1
sizeof_float = 4
# *****************************************************************************
# **** This number must match (-> *ap-info-jak1* version) in ap-struct.gc! ****
# *****************************************************************************
expected_memory_version = 5
# IMPORTANT: OpenGOAL memory structures are particular about the alignment, in memory, of member elements according to
# their size in bits. The address for an N-bit field must be divisible by N. Use this class to define the memory offsets
# of important values in the struct. It will also do the byte alignment properly for you.
# See https://opengoal.dev/docs/reference/type_system/#arrays
@dataclass
class OffsetFactory:
current_offset: int = 0
def define(self, size: int, length: int = 1) -> int:
# If necessary, align current_offset to the current size first.
bytes_to_alignment = self.current_offset % size
if bytes_to_alignment != 0:
self.current_offset += (size - bytes_to_alignment)
# Increment current_offset so the next definition can be made.
offset_to_use = self.current_offset
self.current_offset += (size * length)
return offset_to_use
# Start defining important memory address offsets here. They must be in the same order, have the same sizes, and have
# the same lengths, as defined in `ap-info-jak1`.
offsets = OffsetFactory()
# Cell, Buzzer, and Special information.
next_cell_index_offset = offsets.define(sizeof_uint64)
next_buzzer_index_offset = offsets.define(sizeof_uint64)
next_special_index_offset = offsets.define(sizeof_uint64)
cells_checked_offset = offsets.define(sizeof_uint32, 101)
buzzers_checked_offset = offsets.define(sizeof_uint32, 112)
specials_checked_offset = offsets.define(sizeof_uint32, 32)
buzzers_received_offset = offsets.define(sizeof_uint8, 16)
specials_received_offset = offsets.define(sizeof_uint8, 32)
# Deathlink information.
death_count_offset = offsets.define(sizeof_uint32)
death_cause_offset = offsets.define(sizeof_uint8)
deathlink_enabled_offset = offsets.define(sizeof_uint8)
# Move Rando information.
next_orb_cache_index_offset = offsets.define(sizeof_uint64)
orb_caches_checked_offset = offsets.define(sizeof_uint32, 16)
moves_received_offset = offsets.define(sizeof_uint8, 16)
moverando_enabled_offset = offsets.define(sizeof_uint8)
# Orbsanity information.
orbsanity_option_offset = offsets.define(sizeof_uint8)
orbsanity_bundle_offset = offsets.define(sizeof_uint32)
collected_bundle_offset = offsets.define(sizeof_uint32, 17)
# Progression and Completion information.
fire_canyon_unlock_offset = offsets.define(sizeof_float)
mountain_pass_unlock_offset = offsets.define(sizeof_float)
lava_tube_unlock_offset = offsets.define(sizeof_float)
citizen_orb_amount_offset = offsets.define(sizeof_float)
oracle_orb_amount_offset = offsets.define(sizeof_float)
completion_goal_offset = offsets.define(sizeof_uint8)
completed_offset = offsets.define(sizeof_uint8)
# Text to display in the HUD (32 char max per string).
their_item_name_offset = offsets.define(sizeof_uint8, 32)
their_item_owner_offset = offsets.define(sizeof_uint8, 32)
my_item_name_offset = offsets.define(sizeof_uint8, 32)
my_item_finder_offset = offsets.define(sizeof_uint8, 32)
# Version of the memory struct, to cut down on mod/apworld version mismatches.
memory_version_offset = offsets.define(sizeof_uint32)
# Connection status to AP server (not the game!)
server_connection_offset = offsets.define(sizeof_uint8)
slot_name_offset = offsets.define(sizeof_uint8, 16)
slot_seed_offset = offsets.define(sizeof_uint8, 8)
# Trap information.
trap_duration_offset = offsets.define(sizeof_float)
# The End.
end_marker_offset = offsets.define(sizeof_uint8, 4)
# Can't believe this is easier to do in GOAL than Python but that's how it be sometimes.
def as_float(value: int) -> int:
return int(struct.unpack('f', value.to_bytes(sizeof_float, "little"))[0])
# "Jak" to be replaced by player name in the Client.
def autopsy(cause: int) -> str:
if cause in [1, 2, 3, 4]:
return random.choice(["Jak said goodnight.",
"Jak stepped into the light.",
"Jak gave Daxter his insect collection.",
"Jak did not follow Step 1."])
if cause == 5:
return "Jak fell into an endless pit."
if cause == 6:
return "Jak drowned in the spicy water."
if cause == 7:
return "Jak tried to tackle a Lurker Shark."
if cause == 8:
return "Jak hit 500 degrees."
if cause == 9:
return "Jak took a bath in a pool of dark eco."
if cause == 10:
return "Jak got bombarded with flaming 30-ton boulders."
if cause == 11:
return "Jak hit 800 degrees."
if cause == 12:
return "Jak ceased to be."
if cause == 13:
return "Jak got eaten by the dark eco plant."
if cause == 14:
return "Jak burned up."
if cause == 15:
return "Jak hit the ground hard."
if cause == 16:
return "Jak crashed the zoomer."
if cause == 17:
return "Jak got Flut Flut hurt."
if cause == 18:
return "Jak poisoned the whole darn catch."
if cause == 19:
return "Jak collided with too many obstacles."
return "Jak died."
class JakAndDaxterMemoryReader:
marker: ByteString
goal_address: int | None = None
connected: bool = False
initiated_connect: bool = False
# The memory reader just needs the game running.
gk_process: pymem.process = None
location_outbox: list[int] = []
outbox_index: int = 0
finished_game: bool = False
# Deathlink handling
deathlink_enabled: bool = False
send_deathlink: bool = False
cause_of_death: str = ""
death_count: int = 0
# Orbsanity handling
orbsanity_enabled: bool = False
orbs_paid: int = 0
# Game-related callbacks (inform the AP server of changes to game state)
inform_checked_location: Callable
inform_finished_game: Callable
inform_died: Callable
inform_toggled_deathlink: Callable
inform_traded_orbs: Callable
# Logging callbacks
# These will write to the provided logger, as well as the Client GUI with color markup.
log_error: Callable # Red
log_warn: Callable # Orange
log_success: Callable # Green
log_info: Callable # White (default)
def __init__(self,
location_check_callback: Callable,
finish_game_callback: Callable,
send_deathlink_callback: Callable,
toggle_deathlink_callback: Callable,
orb_trade_callback: Callable,
log_error_callback: Callable,
log_warn_callback: Callable,
log_success_callback: Callable,
log_info_callback: Callable,
marker: ByteString = b'UnLiStEdStRaTs_JaK1\x00'):
self.marker = marker
self.inform_checked_location = location_check_callback
self.inform_finished_game = finish_game_callback
self.inform_died = send_deathlink_callback
self.inform_toggled_deathlink = toggle_deathlink_callback
self.inform_traded_orbs = orb_trade_callback
self.log_error = log_error_callback
self.log_warn = log_warn_callback
self.log_success = log_success_callback
self.log_info = log_info_callback
async def main_tick(self):
if self.initiated_connect:
await self.connect()
self.initiated_connect = False
if self.connected:
try:
self.gk_process.read_bool(self.gk_process.base_address) # Ping to see if it's alive.
except (ProcessError, MemoryReadError, WinAPIError):
msg = (f"Error reading game memory! (Did the game crash?)\n"
f"Please close all open windows and reopen the Jak and Daxter Client "
f"from the Archipelago Launcher.\n"
f"If the game and compiler do not restart automatically, please follow these steps:\n"
f" Run the OpenGOAL Launcher, click Jak and Daxter > Features > Mods > ArchipelaGOAL.\n"
f" Then click Advanced > Play in Debug Mode.\n"
f" Then click Advanced > Open REPL.\n"
f" Then close and reopen the Jak and Daxter Client from the Archipelago Launcher.")
self.log_error(logger, msg)
self.connected = False
else:
return
if self.connected:
# Save some state variables temporarily.
old_deathlink_enabled = self.deathlink_enabled
# Read the memory address to check the state of the game.
self.read_memory()
# Checked Locations in game. Handle the entire outbox every tick until we're up to speed.
if len(self.location_outbox) > self.outbox_index:
self.inform_checked_location(self.location_outbox)
self.save_data()
self.outbox_index += 1
if self.finished_game:
self.inform_finished_game()
if old_deathlink_enabled != self.deathlink_enabled:
self.inform_toggled_deathlink()
logger.debug("Toggled DeathLink " + ("ON" if self.deathlink_enabled else "OFF"))
if self.send_deathlink:
self.inform_died()
if self.orbs_paid > 0:
self.inform_traded_orbs(self.orbs_paid)
self.orbs_paid = 0
async def connect(self):
try:
self.gk_process = pymem.Pymem("gk.exe") # The GOAL Kernel
logger.debug("Found the gk process: " + str(self.gk_process.process_id))
except ProcessNotFound:
self.log_error(logger, "Could not find the game process.")
self.connected = False
return
# If we don't find the marker in the first loaded module, we've failed.
modules = list(self.gk_process.list_modules())
marker_address = pattern.pattern_scan_module(self.gk_process.process_handle, modules[0], self.marker)
if marker_address:
# At this address is another address that contains the struct we're looking for: the game's state.
# From here we need to add the length in bytes for the marker and 4 bytes of padding,
# and the struct address is 8 bytes long (it's an uint64).
goal_pointer = marker_address + len(self.marker) + 4
self.goal_address = int.from_bytes(self.gk_process.read_bytes(goal_pointer, sizeof_uint64),
byteorder="little",
signed=False)
logger.debug("Found the archipelago memory address: " + str(self.goal_address))
await self.verify_memory_version()
else:
self.log_error(logger, "Could not find the Archipelago marker address!")
self.connected = False
async def verify_memory_version(self):
if self.goal_address is None:
self.log_error(logger, "Could not find the Archipelago memory address!")
self.connected = False
return
memory_version: int | None = None
try:
memory_version = self.read_goal_address(memory_version_offset, sizeof_uint32)
if memory_version == expected_memory_version:
self.log_success(logger, "The Memory Reader is ready!")
self.connected = True
else:
raise MemoryReadError(memory_version_offset, sizeof_uint32)
except (ProcessError, MemoryReadError, WinAPIError):
if memory_version is None:
msg = (f"Could not find a version number in the OpenGOAL memory structure!\n"
f" Expected Version: {str(expected_memory_version)}\n"
f" Found Version: {str(memory_version)}\n"
f"Please follow these steps:\n"
f" If the game is running, try entering '/memr connect' in the client.\n"
f" You should see 'The Memory Reader is ready!'\n"
f" If that did not work, or the game is not running, run the OpenGOAL Launcher.\n"
f" Click Jak and Daxter > Features > Mods > ArchipelaGOAL.\n"
f" Then click Advanced > Play in Debug Mode.\n"
f" Try entering '/memr connect' in the client again.")
else:
msg = (f"The OpenGOAL memory structure is incompatible with the current Archipelago client!\n"
f" Expected Version: {str(expected_memory_version)}\n"
f" Found Version: {str(memory_version)}\n"
f"Please follow these steps:\n"
f" Run the OpenGOAL Launcher, click Jak and Daxter > Features > Mods > ArchipelaGOAL.\n"
f" Click Update (if one is available).\n"
f" Click Advanced > Compile. When this is done, click Continue.\n"
f" Click Versions and verify the latest version is marked 'Active'.\n"
f" Close all launchers, games, clients, and console windows, then restart Archipelago.")
self.log_error(logger, msg)
self.connected = False
async def print_status(self):
proc_id = str(self.gk_process.process_id) if self.gk_process else "None"
last_loc = str(self.location_outbox[self.outbox_index - 1] if self.outbox_index else "None")
msg = (f"Memory Reader Status:\n"
f" Game process ID: {proc_id}\n"
f" Game state memory address: {str(self.goal_address)}\n"
f" Last location checked: {last_loc}")
await self.verify_memory_version()
self.log_info(logger, msg)
def read_memory(self) -> list[int]:
try:
# Need to grab these first and convert to floats, see below.
citizen_orb_amount = self.read_goal_address(citizen_orb_amount_offset, sizeof_float)
oracle_orb_amount = self.read_goal_address(oracle_orb_amount_offset, sizeof_float)
next_cell_index = self.read_goal_address(next_cell_index_offset, sizeof_uint64)
for k in range(0, next_cell_index):
next_cell = self.read_goal_address(cells_checked_offset + (k * sizeof_uint32), sizeof_uint32)
cell_ap_id = cells.to_ap_id(next_cell)
if cell_ap_id not in self.location_outbox:
self.location_outbox.append(cell_ap_id)
logger.debug("Checked power cell: " + str(next_cell))
# If orbsanity is ON and next_cell is one of the traders or oracles, then run a callback
# to add their amount to the DataStorage value holding our current orb trade total.
if next_cell in {11, 12, 31, 32, 33, 96, 97, 98, 99}:
citizen_orb_amount = as_float(citizen_orb_amount)
self.orbs_paid += citizen_orb_amount
logger.debug(f"Traded {citizen_orb_amount} orbs!")
if next_cell in {13, 14, 34, 35, 100, 101}:
oracle_orb_amount = as_float(oracle_orb_amount)
self.orbs_paid += oracle_orb_amount
logger.debug(f"Traded {oracle_orb_amount} orbs!")
next_buzzer_index = self.read_goal_address(next_buzzer_index_offset, sizeof_uint64)
for k in range(0, next_buzzer_index):
next_buzzer = self.read_goal_address(buzzers_checked_offset + (k * sizeof_uint32), sizeof_uint32)
buzzer_ap_id = flies.to_ap_id(next_buzzer)
if buzzer_ap_id not in self.location_outbox:
self.location_outbox.append(buzzer_ap_id)
logger.debug("Checked scout fly: " + str(next_buzzer))
next_special_index = self.read_goal_address(next_special_index_offset, sizeof_uint64)
for k in range(0, next_special_index):
next_special = self.read_goal_address(specials_checked_offset + (k * sizeof_uint32), sizeof_uint32)
special_ap_id = specials.to_ap_id(next_special)
if special_ap_id not in self.location_outbox:
self.location_outbox.append(special_ap_id)
logger.debug("Checked special: " + str(next_special))
death_count = self.read_goal_address(death_count_offset, sizeof_uint32)
death_cause = self.read_goal_address(death_cause_offset, sizeof_uint8)
if death_count > self.death_count:
self.cause_of_death = autopsy(death_cause) # The way he names his variables? Wack!
self.send_deathlink = True
self.death_count += 1
# Listen for any changes to this setting.
deathlink_flag = self.read_goal_address(deathlink_enabled_offset, sizeof_uint8)
self.deathlink_enabled = bool(deathlink_flag)
next_cache_index = self.read_goal_address(next_orb_cache_index_offset, sizeof_uint64)
for k in range(0, next_cache_index):
next_cache = self.read_goal_address(orb_caches_checked_offset + (k * sizeof_uint32), sizeof_uint32)
cache_ap_id = caches.to_ap_id(next_cache)
if cache_ap_id not in self.location_outbox:
self.location_outbox.append(cache_ap_id)
logger.debug("Checked orb cache: " + str(next_cache))
# Listen for any changes to this setting.
# moverando_flag = self.read_goal_address(moverando_enabled_offset, sizeof_uint8)
# self.moverando_enabled = bool(moverando_flag)
orbsanity_option = self.read_goal_address(orbsanity_option_offset, sizeof_uint8)
bundle_size = self.read_goal_address(orbsanity_bundle_offset, sizeof_uint32)
self.orbsanity_enabled = orbsanity_option > 0
# Per Level Orbsanity option. Only need to do this loop if we chose this setting.
if orbsanity_option == 1:
for level in range(0, 16):
collected_bundles = self.read_goal_address(collected_bundle_offset + (level * sizeof_uint32),
sizeof_uint32)
# Count up from the first bundle, by bundle size, until you reach the latest collected bundle.
# e.g. {25, 50, 75, 100, 125...}
if collected_bundles > 0:
for bundle in range(bundle_size,
bundle_size + collected_bundles, # Range max is non-inclusive.
bundle_size):
bundle_ap_id = orbs.to_ap_id(orbs.find_address(level, bundle, bundle_size))
if bundle_ap_id not in self.location_outbox:
self.location_outbox.append(bundle_ap_id)
logger.debug(f"Checked orb bundle: L{level} {bundle}")
# Global Orbsanity option. Index 16 refers to all orbs found regardless of level.
if orbsanity_option == 2:
collected_bundles = self.read_goal_address(collected_bundle_offset + (16 * sizeof_uint32),
sizeof_uint32)
if collected_bundles > 0:
for bundle in range(bundle_size,
bundle_size + collected_bundles, # Range max is non-inclusive.
bundle_size):
bundle_ap_id = orbs.to_ap_id(orbs.find_address(16, bundle, bundle_size))
if bundle_ap_id not in self.location_outbox:
self.location_outbox.append(bundle_ap_id)
logger.debug(f"Checked orb bundle: G {bundle}")
completed = self.read_goal_address(completed_offset, sizeof_uint8)
if completed > 0 and not self.finished_game:
self.finished_game = True
self.log_success(logger, "Congratulations! You finished the game!")
except (ProcessError, MemoryReadError, WinAPIError):
msg = (f"Error reading game memory! (Did the game crash?)\n"
f"Please close all open windows and reopen the Jak and Daxter Client "
f"from the Archipelago Launcher.\n"
f"If the game and compiler do not restart automatically, please follow these steps:\n"
f" Run the OpenGOAL Launcher, click Jak and Daxter > Features > Mods > ArchipelaGOAL.\n"
f" Then click Advanced > Play in Debug Mode.\n"
f" Then click Advanced > Open REPL.\n"
f" Then close and reopen the Jak and Daxter Client from the Archipelago Launcher.")
self.log_error(logger, msg)
self.connected = False
return self.location_outbox
def read_goal_address(self, offset: int, length: int) -> int:
return int.from_bytes(
self.gk_process.read_bytes(self.goal_address + offset, length),
byteorder="little",
signed=False)
def save_data(self):
with open("jakanddaxter_location_outbox.json", "w+") as f:
dump = {
"outbox_index": self.outbox_index,
"location_outbox": self.location_outbox
}
json.dump(dump, f, indent=4)
def load_data(self):
try:
with open("jakanddaxter_location_outbox.json", "r") as f:
load = json.load(f)
self.outbox_index = load["outbox_index"]
self.location_outbox = load["location_outbox"]
except FileNotFoundError:
pass

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