Compare commits

..

1 Commits

Author SHA1 Message Date
NewSoupVi
89ccce7805 Docs: Add deprioritized to AP API doc
Did this on my phone while in the bathroom :)
2025-08-23 22:31:57 +02:00
317 changed files with 19447 additions and 111789 deletions

View File

@@ -1,154 +0,0 @@
name: Build and Publish Docker Images
on:
push:
paths:
- "**"
- "!docs/**"
- "!deploy/**"
- "!setup.py"
- "!.gitignore"
- "!.github/workflows/**"
- ".github/workflows/docker.yml"
branches:
- "*"
tags:
- "v?[0-9]+.[0-9]+.[0-9]*"
workflow_dispatch:
env:
REGISTRY: ghcr.io
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
image-name: ${{ steps.image.outputs.name }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
package-name: ${{ steps.package.outputs.name }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set lowercase image name
id: image
run: |
echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT
- name: Set package name
id: package
run: |
echo "name=$(basename ${GITHUB_REPOSITORY,,})" >> $GITHUB_OUTPUT
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ steps.image.outputs.name }}
tags: |
type=ref,event=branch,enable={{is_not_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=nightly,enable={{is_default_branch}}
- name: Compute final tags
id: final-tags
run: |
readarray -t tags <<< "${{ steps.meta.outputs.tags }}"
if [[ "${{ github.ref_type }}" == "tag" ]]; then
tag="${{ github.ref_name }}"
if [[ "$tag" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
full_latest="${{ env.REGISTRY }}/${{ steps.image.outputs.name }}:latest"
# Check if latest is already in tags to avoid duplicates
if ! printf '%s\n' "${tags[@]}" | grep -q "^$full_latest$"; then
tags+=("$full_latest")
fi
fi
fi
# Set multiline output
echo "tags<<EOF" >> $GITHUB_OUTPUT
printf '%s\n' "${tags[@]}" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
build:
needs: prepare
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
matrix:
include:
- platform: amd64
runner: ubuntu-latest
suffix: amd64
cache-scope: amd64
- platform: arm64
runner: ubuntu-24.04-arm
suffix: arm64
cache-scope: arm64
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Compute suffixed tags
id: tags
run: |
readarray -t tags <<< "${{ needs.prepare.outputs.tags }}"
suffixed=()
for t in "${tags[@]}"; do
suffixed+=("$t-${{ matrix.suffix }}")
done
echo "tags=$(IFS=','; echo "${suffixed[*]}")" >> $GITHUB_OUTPUT
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: linux/${{ matrix.platform }}
push: true
tags: ${{ steps.tags.outputs.tags }}
labels: ${{ needs.prepare.outputs.labels }}
cache-from: type=gha,scope=${{ matrix.cache-scope }}
cache-to: type=gha,mode=max,scope=${{ matrix.cache-scope }}
provenance: false
manifest:
needs: [prepare, build]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push multi-arch manifest
run: |
readarray -t tag_array <<< "${{ needs.prepare.outputs.tags }}"
for tag in "${tag_array[@]}"; do
docker manifest create "$tag" \
"$tag-amd64" \
"$tag-arm64"
docker manifest push "$tag"
done

View File

@@ -12,6 +12,7 @@ env:
jobs:
labeler:
name: 'Apply content-based labels'
if: github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'synchronize'
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v5

View File

@@ -5,7 +5,7 @@ name: Release
on:
push:
tags:
- 'v?[0-9]+.[0-9]+.[0-9]*'
- '*.*.*'
env:
ENEMIZER_VERSION: 7.1

View File

@@ -41,13 +41,12 @@ jobs:
python:
- {version: '3.11.2'} # Change to '3.11' around 2026-06-10
- {version: '3.12'}
- {version: '3.13'}
include:
- python: {version: '3.11'} # old compat
os: windows-latest
- python: {version: '3.13'} # current
- python: {version: '3.12'} # current
os: windows-latest
- python: {version: '3.13'} # current
- python: {version: '3.12'} # current
os: macos-latest
steps:
@@ -75,7 +74,7 @@ jobs:
os:
- ubuntu-latest
python:
- {version: '3.13'} # current
- {version: '3.12'} # current
steps:
- uses: actions/checkout@v4

View File

@@ -1,24 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Build APWorld" type="PythonConfigurationType" factoryName="Python">
<module name="Archipelago" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="SCRIPT_NAME" value="$ContentRoot$/Launcher.py" />
<option name="PARAMETERS" value="\&quot;Build APWorlds\&quot;" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View File

@@ -261,7 +261,6 @@ class MultiWorld():
"local_items": set(item_link.get("local_items", [])),
"non_local_items": set(item_link.get("non_local_items", [])),
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
"skip_if_solo": item_link.get("skip_if_solo", False),
}
for _name, item_link in item_links.items():
@@ -285,8 +284,6 @@ class MultiWorld():
for group_name, item_link in item_links.items():
game = item_link["game"]
if item_link["skip_if_solo"] and len(item_link["players"]) == 1:
continue
group_id, group = self.add_group(group_name, game, set(item_link["players"]))
group["item_pool"] = item_link["item_pool"]
@@ -1346,7 +1343,8 @@ class Region:
for entrance in self.entrances: # BFS might be better here, trying DFS for now.
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
def add_locations(self, locations: Mapping[str, int | None], location_type: type[Location] | None = None) -> None:
def add_locations(self, locations: Dict[str, Optional[int]],
location_type: Optional[type[Location]] = None) -> None:
"""
Adds locations to the Region object, where location_type is your Location class and locations is a dict of
location names to address.
@@ -1434,8 +1432,8 @@ class Region:
entrance.connect(self)
return entrance
def add_exits(self, exits: Iterable[str] | Mapping[str, str | None],
rules: Mapping[str, Callable[[CollectionState], bool]] | None = None) -> List[Entrance]:
def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]],
rules: Dict[str, Callable[[CollectionState], bool]] = None) -> List[Entrance]:
"""
Connects current region to regions in exit dictionary. Passed region names must exist first.
@@ -1443,7 +1441,7 @@ class Region:
created entrances will be named "self.name -> connecting_region"
:param rules: rules for the exits from this region. format is {"connecting_region": rule}
"""
if not isinstance(exits, Mapping):
if not isinstance(exits, Dict):
exits = dict.fromkeys(exits)
return [
self.connect(
@@ -1857,9 +1855,6 @@ class Spoiler:
Utils.__version__, self.multiworld.seed))
outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm)
outfile.write('Players: %d\n' % self.multiworld.players)
if self.multiworld.players > 1:
loc_count = len([loc for loc in self.multiworld.get_locations() if not loc.is_event])
outfile.write('Total Location Count: %d\n' % loc_count)
outfile.write(f'Plando Options: {self.multiworld.plando_options}\n')
AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile)
@@ -1868,9 +1863,6 @@ class Spoiler:
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
outfile.write('Game: %s\n' % self.multiworld.game[player])
loc_count = len([loc for loc in self.multiworld.get_locations(player) if not loc.is_event])
outfile.write('Location Count: %d\n' % loc_count)
for f_option, option in self.multiworld.worlds[player].options_dataclass.type_hints.items():
write_option(f_option, option)

View File

@@ -99,6 +99,17 @@ class ClientCommandProcessor(CommandProcessor):
self.ctx.on_print_json({"data": parts, "cmd": "PrintJSON"})
return True
def get_current_datapackage(self) -> dict[str, typing.Any]:
"""
Return datapackage for current game if known.
:return: The datapackage for the currently registered game. If not found, an empty dictionary will be returned.
"""
if not self.ctx.game:
return {}
checksum = self.ctx.checksums[self.ctx.game]
return Utils.load_data_package_for_checksum(self.ctx.game, checksum)
def _cmd_missing(self, filter_text = "") -> bool:
"""List all missing location checks, from your local game state.
Can be given text, which will be used as filter."""
@@ -108,8 +119,8 @@ class ClientCommandProcessor(CommandProcessor):
count = 0
checked_count = 0
lookup = self.ctx.location_names[self.ctx.game]
for location_id, location in lookup.items():
lookup = self.get_current_datapackage().get("location_name_to_id", {})
for location, location_id in lookup.items():
if filter_text and filter_text not in location:
continue
if location_id < 0:
@@ -130,10 +141,11 @@ class ClientCommandProcessor(CommandProcessor):
self.output("No missing location checks found.")
return True
def output_datapackage_part(self, name: typing.Literal["Item Names", "Location Names"]) -> bool:
def output_datapackage_part(self, key: str, name: str) -> bool:
"""
Helper to digest a specific section of this game's datapackage.
:param key: The dictionary key in the datapackage.
:param name: Printed to the user as context for the part.
:return: Whether the process was successful.
@@ -142,20 +154,23 @@ class ClientCommandProcessor(CommandProcessor):
self.output(f"No game set, cannot determine {name}.")
return False
lookup = self.ctx.item_names if name == "Item Names" else self.ctx.location_names
lookup = lookup[self.ctx.game]
lookup = self.get_current_datapackage().get(key)
if lookup is None:
self.output("datapackage not yet loaded, try again")
return False
self.output(f"{name} for {self.ctx.game}")
for name in lookup.values():
self.output(name)
for key in lookup:
self.output(key)
return True
def _cmd_items(self) -> bool:
"""List all item names for the currently running game."""
return self.output_datapackage_part("Item Names")
return self.output_datapackage_part("item_name_to_id", "Item Names")
def _cmd_locations(self) -> bool:
"""List all location names for the currently running game."""
return self.output_datapackage_part("Location Names")
return self.output_datapackage_part("location_name_to_id", "Location Names")
def output_group_part(self, group_key: typing.Literal["item_name_groups", "location_name_groups"],
filter_key: str,
@@ -856,9 +871,9 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
server_url = urllib.parse.urlparse(address)
if server_url.username:
ctx.username = urllib.parse.unquote(server_url.username)
ctx.username = server_url.username
if server_url.password:
ctx.password = urllib.parse.unquote(server_url.password)
ctx.password = server_url.password
def reconnect_hint() -> str:
return ", type /connect to reconnect" if ctx.server_address else ""

View File

@@ -129,10 +129,6 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
for i, location in enumerate(placements))
for (i, location, unsafe) in swap_attempts:
placed_item = location.item
if item_to_place == placed_item:
# The number of allowed swaps is limited, so do not allow a swap of an item with a copy of
# itself.
continue
# Unplaceable items can sometimes be swapped infinitely. Limit the
# number of times we will swap an individual item to prevent this
swap_count = swapped_items[placed_item.player, placed_item.name, unsafe]
@@ -553,12 +549,10 @@ def distribute_items_restrictive(multiworld: MultiWorld,
if prioritylocations and regular_progression:
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
# deprioritized items are still not in the mix, so they need to be collected into state first.
# allow_partial should only be set if there is deprioritized progression to fall back on.
priority_retry_state = sweep_from_pool(multiworld.state, deprioritized_progression)
fill_restrictive(multiworld, priority_retry_state, prioritylocations, regular_progression,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority Retry", one_item_per_player=False,
allow_partial=bool(deprioritized_progression))
name="Priority Retry", one_item_per_player=False, allow_partial=True)
if prioritylocations and deprioritized_progression:
# There are no more regular progression items that can be placed on any priority locations.

View File

@@ -166,10 +166,19 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
f"A mix is also permitted.")
from worlds.AutoWorld import AutoWorldRegister
args.outputname = seed_name
args.sprite = dict.fromkeys(range(1, args.multi+1), None)
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
args.name = {}
from worlds.alttp.EntranceRandomizer import parse_arguments
erargs = parse_arguments(['--multi', str(args.multi)])
erargs.seed = seed
erargs.plando_options = args.plando
erargs.spoiler = args.spoiler
erargs.race = args.race
erargs.outputname = seed_name
erargs.outputpath = args.outputpath
erargs.skip_prog_balancing = args.skip_prog_balancing
erargs.skip_output = args.skip_output
erargs.spoiler_only = args.spoiler_only
erargs.name = {}
erargs.csv_output = args.csv_output
settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
@@ -196,7 +205,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
for player in range(1, args.multi + 1):
player_path_cache[player] = player_files.get(player, args.weights_file_path)
name_counter = Counter()
args.player_options = {}
erargs.player_options = {}
player = 1
while player <= args.multi:
@@ -209,21 +218,21 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
for k, v in vars(settingsObject).items():
if v is not None:
try:
getattr(args, k)[player] = v
getattr(erargs, k)[player] = v
except AttributeError:
setattr(args, k, {player: v})
setattr(erargs, k, {player: v})
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 args.name:
if player not in erargs.name:
if path == args.weights_file_path:
# weights file, so we need to make the name unique
args.name[player] = f"Player{player}"
erargs.name[player] = f"Player{player}"
else:
# use the filename
args.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
args.name[player] = handle_name(args.name[player], player, name_counter)
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
except Exception as e:
@@ -231,10 +240,10 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
else:
raise RuntimeError(f'No weights specified for player {player}')
if len(set(name.lower() for name in args.name.values())) != len(args.name):
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in args.name.values())}")
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
return args, seed
return erargs, seed
def read_weights_yamls(path) -> tuple[Any, ...]:
@@ -486,22 +495,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
if required_plando_options:
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
f"which is not enabled.")
games = requirements.get("game", {})
for game, version in games.items():
if game not in AutoWorldRegister.world_types:
continue
if not version:
raise Exception(f"Invalid version for game {game}: {version}.")
if isinstance(version, str):
version = {"min": version}
if "min" in version and tuplize_version(version["min"]) > AutoWorldRegister.world_types[game].world_version:
raise Exception(f"Settings reports required version of world \"{game}\" is at least {version['min']}, "
f"however world is of version "
f"{AutoWorldRegister.world_types[game].world_version.as_simple_string()}.")
if "max" in version and tuplize_version(version["max"]) < AutoWorldRegister.world_types[game].world_version:
raise Exception(f"Settings reports required version of world \"{game}\" is no later than {version['max']}, "
f"however world is of version "
f"{AutoWorldRegister.world_types[game].world_version.as_simple_string()}.")
ret = argparse.Namespace()
for option_key in Options.PerGameCommonOptions.type_hints:
if option_key in weights and option_key not in Options.CommonOptions.type_hints:

9
KH1Client.py Normal file
View File

@@ -0,0 +1,9 @@
if __name__ == '__main__':
import ModuleUpdate
ModuleUpdate.update()
import Utils
Utils.init_logging("KH1Client", exception_logger="Client")
from worlds.kh1.Client import launch
launch()

8
KH2Client.py Normal file
View File

@@ -0,0 +1,8 @@
import ModuleUpdate
import Utils
from worlds.kh2.Client import launch
ModuleUpdate.update()
if __name__ == '__main__':
Utils.init_logging("KH2Client", exception_logger="Client")
launch()

View File

@@ -484,7 +484,7 @@ def main(args: argparse.Namespace | dict | None = None):
if __name__ == '__main__':
init_logging('Launcher')
multiprocessing.freeze_support()
Utils.freeze_support()
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
parser = argparse.ArgumentParser(
description='Archipelago Launcher',

View File

@@ -412,10 +412,10 @@ class LinksAwakeningClient():
status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
item_id -= LABaseID
# The player name table only goes up to 101, so don't go past that
# The player name table only goes up to 100, so don't go past that
# Even if it didn't, the remote player _index_ byte is just a byte, so 255 max
if from_player > 101:
from_player = 101
if from_player > 100:
from_player = 100
next_index += 1
self.gameboy.write_memory(LAClientConstants.wLinkGiveItem, [

13
Main.py
View File

@@ -37,7 +37,7 @@ 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
multiworld.plando_options = args.plando_options
multiworld.game = args.game.copy()
multiworld.player_name = args.name.copy()
multiworld.sprite = args.sprite.copy()
@@ -54,17 +54,12 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
world_classes = AutoWorld.AutoWorldRegister.world_types.values()
version_count = max(len(cls.world_version.as_simple_string()) for cls in world_classes)
item_count = len(str(max(len(cls.item_names) for cls in world_classes)))
location_count = len(str(max(len(cls.location_names) for cls in world_classes)))
item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
if not cls.hidden and len(cls.item_names) > 0:
logger.info(f" {name:{longest_name}}: "
f"v{cls.world_version.as_simple_string():{version_count}} | "
f"Items: {len(cls.item_names):{item_count}} | "
logger.info(f" {name:{longest_name}}: Items: {len(cls.item_names):{item_count}} | "
f"Locations: {len(cls.location_names):{location_count}}")
del item_count, location_count

View File

@@ -32,7 +32,7 @@ if typing.TYPE_CHECKING:
import colorama
import websockets
from websockets.extensions.permessage_deflate import PerMessageDeflate, ServerPerMessageDeflateFactory
from websockets.extensions.permessage_deflate import PerMessageDeflate
try:
# ponyorm is a requirement for webhost, not default server, so may not be importable
from pony.orm.dbapiprovider import OperationalError
@@ -50,15 +50,6 @@ from BaseClasses import ItemClassification
min_client_version = Version(0, 5, 0)
colorama.just_fix_windows_console()
no_version = Version(0, 0, 0)
assert isinstance(no_version, tuple) # assert immutable
server_per_message_deflate_factory = ServerPerMessageDeflateFactory(
server_max_window_bits=11,
client_max_window_bits=11,
compress_settings={"memLevel": 4},
)
def remove_from_list(container, value):
try:
@@ -134,30 +125,8 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
class Client(Endpoint):
__slots__ = (
"version",
"auth",
"team",
"slot",
"send_index",
"tags",
"messageprocessor",
"ctx",
"remote_items",
"remote_start_inventory",
"no_items",
"no_locations",
"no_text",
)
version: Version
auth: bool
team: int | None
slot: int | None
send_index: int
tags: list[str]
messageprocessor: ClientMessageProcessor
ctx: weakref.ref[Context]
version = Version(0, 0, 0)
tags: typing.List[str]
remote_items: bool
remote_start_inventory: bool
no_items: bool
@@ -166,7 +135,6 @@ class Client(Endpoint):
def __init__(self, socket: "ServerConnection", ctx: Context) -> None:
super().__init__(socket)
self.version = no_version
self.auth = False
self.team = None
self.slot = None
@@ -174,11 +142,6 @@ class Client(Endpoint):
self.tags = []
self.messageprocessor = client_message_processor(ctx, self)
self.ctx = weakref.ref(ctx)
self.remote_items = False
self.remote_start_inventory = False
self.no_items = False
self.no_locations = False
self.no_text = False
@property
def items_handling(self):
@@ -1172,13 +1135,8 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
ctx.save()
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str],
status: HintStatus | None = None) -> typing.List[Hint]:
"""
Collect a new hint for a given item id or name, with a given status.
If status is None (which is the default value), an automatic status will be determined from the item's quality.
"""
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str], auto_status: HintStatus) \
-> typing.List[Hint]:
hints = []
slots: typing.Set[int] = {slot}
for group_id, group in ctx.groups.items():
@@ -1194,38 +1152,25 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
else:
found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
new_status = auto_status
if found:
status = HintStatus.HINT_FOUND
elif status is None:
if item_flags & ItemClassification.trap:
status = HintStatus.HINT_AVOID
else:
status = HintStatus.HINT_PRIORITY
hints.append(
Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, status)
)
new_status = HintStatus.HINT_FOUND
elif item_flags & ItemClassification.trap:
new_status = HintStatus.HINT_AVOID
hints.append(Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
item_flags, new_status))
return hints
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str,
status: HintStatus | None = HintStatus.HINT_UNSPECIFIED) -> typing.List[Hint]:
"""
Collect a new hint for a given location name, with a given status (defaults to "unspecified").
If None is passed for the status, then an automatic status will be determined from the item's quality.
"""
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str, auto_status: HintStatus) \
-> typing.List[Hint]:
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
return collect_hint_location_id(ctx, team, slot, seeked_location, status)
return collect_hint_location_id(ctx, team, slot, seeked_location, auto_status)
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int,
status: HintStatus | None = HintStatus.HINT_UNSPECIFIED) -> typing.List[Hint]:
"""
Collect a new hint for a given location id, with a given status (defaults to "unspecified").
If None is passed for the status, then an automatic status will be determined from the item's quality.
"""
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int, auto_status: HintStatus) \
-> typing.List[Hint]:
prev_hint = ctx.get_hint(team, slot, seeked_location)
if prev_hint:
return [prev_hint]
@@ -1235,16 +1180,13 @@ def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location
found = seeked_location in ctx.location_checks[team, slot]
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
new_status = auto_status
if found:
status = HintStatus.HINT_FOUND
elif status is None:
if item_flags & ItemClassification.trap:
status = HintStatus.HINT_AVOID
else:
status = HintStatus.HINT_PRIORITY
return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags, status)]
new_status = HintStatus.HINT_FOUND
elif item_flags & ItemClassification.trap:
new_status = HintStatus.HINT_AVOID
return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags,
new_status)]
return []
@@ -1358,8 +1300,7 @@ class CommandProcessor(metaclass=CommandMeta):
argname += "=" + parameter.default
argtext += argname
argtext += " "
doctext = '\n '.join(inspect.getdoc(method).split('\n'))
s += f"{self.marker}{command} {argtext}\n {doctext}\n"
s += f"{self.marker}{command} {argtext}\n {method.__doc__}\n"
return s
def _cmd_help(self):
@@ -1388,6 +1329,19 @@ class CommandProcessor(metaclass=CommandMeta):
class CommonCommandProcessor(CommandProcessor):
ctx: Context
def _cmd_countdown(self, seconds: str = "10") -> bool:
"""Start a countdown in seconds"""
try:
timer = int(seconds, 10)
except ValueError:
timer = 10
else:
if timer > 60 * 60:
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
async_start(countdown(self.ctx, timer))
return True
def _cmd_options(self):
"""List all current options. Warning: lists password."""
self.output("Current options:")
@@ -1656,6 +1610,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
points_available = get_client_points(self.ctx, self.client)
cost = self.ctx.get_hint_cost(self.client.slot)
auto_status = HintStatus.HINT_UNSPECIFIED if for_location else HintStatus.HINT_PRIORITY
if not input_text:
hints = {hint.re_check(self.ctx, self.client.team) for hint in
self.ctx.hints[self.client.team, self.client.slot]}
@@ -1681,9 +1636,9 @@ class ClientMessageProcessor(CommonCommandProcessor):
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
hints = []
elif not for_location:
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id)
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
else:
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id)
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
else:
game = self.ctx.games[self.client.slot]
@@ -1703,18 +1658,16 @@ class ClientMessageProcessor(CommonCommandProcessor):
hints = []
for item_name in self.ctx.item_name_groups[game][hint_name]:
if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name))
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name, auto_status))
elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
elif hint_name in self.ctx.location_name_groups[game]: # location group name
hints = []
for loc_name in self.ctx.location_name_groups[game][hint_name]:
if loc_name in self.ctx.location_names_for_game(game):
hints.extend(
collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name)
)
hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name, auto_status))
else: # location name
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
else:
self.output(response)
@@ -1992,7 +1945,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
target_item, target_player, flags = ctx.locations[client.slot][location]
if create_as_hint:
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location,
HintStatus.HINT_UNSPECIFIED))
locs.append(NetworkItem(target_item, location, target_player, flags))
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2, persist_even_if_found=True)
if locs and create_as_hint:
@@ -2007,16 +1961,6 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
if not locations:
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
"text": "CreateHints: No locations specified.", "original_cmd": cmd}])
return
try:
status = HintStatus(status)
except ValueError as err:
await ctx.send_msgs(client,
[{"cmd": "InvalidPacket", "type": "arguments",
"text": f"Unknown Status: {err}",
"original_cmd": cmd}])
return
hints = []
@@ -2284,19 +2228,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
self.output(f"Could not find player {player_name} to collect")
return False
def _cmd_countdown(self, seconds: str = "10") -> bool:
"""Start a countdown in seconds"""
try:
timer = int(seconds, 10)
except ValueError:
timer = 10
else:
if timer > 60 * 60:
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
async_start(countdown(self.ctx, timer))
return True
@mark_raw
def _cmd_release(self, player_name: str) -> bool:
"""Send out the remaining items from a player to their intended recipients."""
@@ -2418,9 +2349,9 @@ class ServerCommandProcessor(CommonCommandProcessor):
hints = []
for item_name_from_group in self.ctx.item_name_groups[game][item]:
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group, HintStatus.HINT_PRIORITY))
else: # item name or id
hints = collect_hints(self.ctx, team, slot, item)
hints = collect_hints(self.ctx, team, slot, item, HintStatus.HINT_PRIORITY)
if hints:
self.ctx.notify_hints(team, hints)
@@ -2454,14 +2385,17 @@ class ServerCommandProcessor(CommonCommandProcessor):
if usable:
if isinstance(location, int):
hints = collect_hint_location_id(self.ctx, team, slot, location)
hints = collect_hint_location_id(self.ctx, team, slot, location,
HintStatus.HINT_UNSPECIFIED)
elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]:
hints = []
for loc_name_from_group in self.ctx.location_name_groups[game][location]:
if loc_name_from_group in self.ctx.location_names_for_game(game):
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group))
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group,
HintStatus.HINT_UNSPECIFIED))
else:
hints = collect_hint_location_name(self.ctx, team, slot, location)
hints = collect_hint_location_name(self.ctx, team, slot, location,
HintStatus.HINT_UNSPECIFIED)
if hints:
self.ctx.notify_hints(team, hints)
else:
@@ -2676,13 +2610,7 @@ async def main(args: argparse.Namespace):
ssl_context = load_server_cert(args.cert, args.cert_key) if args.cert else None
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx),
host=ctx.host,
port=ctx.port,
ssl=ssl_context,
extensions=[server_per_message_deflate_factory],
)
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), host=ctx.host, port=ctx.port, ssl=ssl_context)
ip = args.host if args.host else Utils.get_public_ipv4()
logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port,
'No password' if not ctx.password else 'Password: %s' % ctx.password))

View File

@@ -174,8 +174,6 @@ decode = JSONDecoder(object_hook=_object_hook).decode
class Endpoint:
__slots__ = ("socket",)
socket: "ServerConnection"
def __init__(self, socket):

View File

@@ -1380,7 +1380,7 @@ class NonLocalItems(ItemSet):
class StartInventory(ItemDict):
"""Start with the specified amount of these items. Example: "Bomb: 1" """
"""Start with these items."""
verify_item_name = True
display_name = "Start Inventory"
rich_text_doc = True
@@ -1388,7 +1388,7 @@ class StartInventory(ItemDict):
class StartInventoryPool(StartInventory):
"""Start with the specified amount of these items and don't place them in the world. Example: "Bomb: 1"
"""Start with these items and don't place them in the world.
The game decides what the replacement items will be.
"""
@@ -1446,7 +1446,6 @@ class ItemLinks(OptionList):
Optional("local_items"): [And(str, len)],
Optional("non_local_items"): [And(str, len)],
Optional("link_replacement"): Or(None, bool),
Optional("skip_if_solo"): Or(None, bool),
}
])
@@ -1753,10 +1752,7 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
res = template.render(
option_groups=option_groups,
__version__=__version__,
game=game_name,
world_version=world.world_version.as_simple_string(),
yaml_dump=yaml_dump_scalar,
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
dictify_range=dictify_range,
cleandoc=cleandoc,
)

View File

@@ -20,6 +20,7 @@ Currently, the following games are supported:
* Meritous
* Super Metroid/Link to the Past combo randomizer (SMZ3)
* ChecksFinder
* ArchipIDLE
* Hollow Knight
* The Witness
* Sonic Adventure 2: Battle
@@ -80,8 +81,6 @@ Currently, the following games are supported:
* Super Mario Land 2: 6 Golden Coins
* shapez
* Paint
* Celeste (Open World)
* Choo-Choo Charles
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

11
Starcraft2Client.py Normal file
View File

@@ -0,0 +1,11 @@
from __future__ import annotations
import ModuleUpdate
ModuleUpdate.update()
from worlds.sc2.Client import launch
import Utils
if __name__ == "__main__":
Utils.init_logging("Starcraft2Client", exception_logger="Client")
launch()

View File

@@ -35,7 +35,7 @@ if typing.TYPE_CHECKING:
def tuplize_version(version: str) -> Version:
return Version(*(int(piece) for piece in version.split(".")))
return Version(*(int(piece, 10) for piece in version.split(".")))
class Version(typing.NamedTuple):
@@ -322,13 +322,11 @@ def get_options() -> Settings:
return get_settings()
def persistent_store(category: str, key: str, value: typing.Any, force_store: bool = False):
def persistent_store(category: str, key: str, value: typing.Any):
path = user_path("_persistent_storage.yaml")
storage = persistent_load()
if not force_store and category in storage and key in storage[category] and storage[category][key] == value:
return # no changes necessary
category_dict = storage.setdefault(category, {})
category_dict[key] = value
path = user_path("_persistent_storage.yaml")
with open(path, "wt") as f:
f.write(dump(storage, Dumper=Dumper))
@@ -720,22 +718,13 @@ def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bo
def get_input_text_from_response(text: str, command: str) -> typing.Optional[str]:
"""
Parses the response text from `get_intended_text` to find the suggested input and autocomplete the command in
arguments with it.
:param text: The response text from `get_intended_text`.
:param command: The command to which the input text should be added. Must contain the prefix used by the command
(`!` or `/`).
:return: The command with the suggested input text appended, or None if no suggestion was found.
"""
if "did you mean " in text:
for question in ("Didn't find something that closely matches",
"Too many close matches"):
if text.startswith(question):
name = get_text_between(text, "did you mean '",
"'? (")
return f"{command} {name}"
return f"!{command} {name}"
elif text.startswith("Missing: "):
return text.replace("Missing: ", "!hint_location ")
return None
@@ -951,15 +940,15 @@ class DeprecateDict(dict):
def _extend_freeze_support() -> None:
"""Extend multiprocessing.freeze_support() to also work on Non-Windows and without setting spawn method first."""
# original upstream issue: https://github.com/python/cpython/issues/76327
"""Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
# upstream issue: https://github.com/python/cpython/issues/76327
# code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26
import multiprocessing
import multiprocessing.spawn
def _freeze_support() -> None:
"""Minimal freeze_support. Only apply this if frozen."""
from subprocess import _args_from_interpreter_flags # noqa
from subprocess import _args_from_interpreter_flags
# Prevent `spawn` from trying to read `__main__` in from the main script
multiprocessing.process.ORIGINAL_DIR = None
@@ -986,23 +975,17 @@ def _extend_freeze_support() -> None:
multiprocessing.spawn.spawn_main(**kwargs)
sys.exit()
def _noop() -> None:
pass
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support if is_frozen() else _noop
if not is_windows and is_frozen():
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support
def freeze_support() -> None:
"""This now only calls multiprocessing.freeze_support since we are patching freeze_support on module load."""
"""This behaves like multiprocessing.freeze_support but also works on Non-Windows."""
import multiprocessing
deprecate("Use multiprocessing.freeze_support() instead")
_extend_freeze_support()
multiprocessing.freeze_support()
_extend_freeze_support()
def visualize_regions(root_region: Region, file_name: str, *,
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None:

View File

@@ -99,23 +99,16 @@ if __name__ == "__main__":
multiprocessing.set_start_method('spawn')
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.autolauncher import autohost, autogen, stop
from WebHostLib.options import create as create_options_files
try:
from WebHostLib.lttpsprites import update_sprites_lttp
update_sprites_lttp()
except Exception as e:
logging.exception(e)
logging.warning("Could not update LttP sprites.")
app = get_app()
from worlds import AutoWorldRegister
# Update to only valid WebHost worlds
invalid_worlds = {name for name, world in AutoWorldRegister.world_types.items()
if not hasattr(world.web, "tutorials")}
if invalid_worlds:
logging.error(f"Following worlds not loaded as they are invalid for WebHost: {invalid_worlds}")
AutoWorldRegister.world_types = {k: v for k, v in AutoWorldRegister.world_types.items() if k not in invalid_worlds}
create_options_files()
copy_tutorials_files_to_static()
if app.config["SELFLAUNCH"]:

View File

@@ -11,53 +11,6 @@ from WebHostLib.models import Room
from WebHostLib.tracker import TrackerData
class PlayerAlias(TypedDict):
team: int
player: int
alias: str | None
class PlayerItemsReceived(TypedDict):
team: int
player: int
items: list[NetworkItem]
class PlayerChecksDone(TypedDict):
team: int
player: int
locations: list[int]
class TeamTotalChecks(TypedDict):
team: int
checks_done: int
class PlayerHints(TypedDict):
team: int
player: int
hints: list[Hint]
class PlayerTimer(TypedDict):
team: int
player: int
time: datetime | None
class PlayerStatus(TypedDict):
team: int
player: int
status: ClientStatus
class PlayerLocationsTotal(TypedDict):
team: int
player: int
total_locations: int
@api_endpoints.route("/tracker/<suuid:tracker>")
@cache.memoize(timeout=60)
def tracker_data(tracker: UUID) -> dict[str, Any]:
@@ -76,77 +29,120 @@ def tracker_data(tracker: UUID) -> dict[str, Any]:
all_players: dict[int, list[int]] = tracker_data.get_all_players()
player_aliases: list[PlayerAlias] = []
class PlayerAlias(TypedDict):
player: int
name: str | None
player_aliases: list[dict[str, int | list[PlayerAlias]]] = []
"""Slot aliases of all players."""
for team, players in all_players.items():
team_player_aliases: list[PlayerAlias] = []
team_aliases = {"team": team, "players": team_player_aliases}
player_aliases.append(team_aliases)
for player in players:
player_aliases.append({"team": team, "player": player, "alias": tracker_data.get_player_alias(team, player)})
team_player_aliases.append({"player": player, "alias": tracker_data.get_player_alias(team, player)})
player_items_received: list[PlayerItemsReceived] = []
class PlayerItemsReceived(TypedDict):
player: int
items: list[NetworkItem]
player_items_received: list[dict[str, int | list[PlayerItemsReceived]]] = []
"""Items received by each player."""
for team, players in all_players.items():
player_received_items: list[PlayerItemsReceived] = []
team_items_received = {"team": team, "players": player_received_items}
player_items_received.append(team_items_received)
for player in players:
player_items_received.append(
{"team": team, "player": player, "items": tracker_data.get_player_received_items(team, player)})
player_received_items.append(
{"player": player, "items": tracker_data.get_player_received_items(team, player)})
player_checks_done: list[PlayerChecksDone] = []
class PlayerChecksDone(TypedDict):
player: int
locations: list[int]
player_checks_done: list[dict[str, int | list[PlayerChecksDone]]] = []
"""ID of all locations checked by each player."""
for team, players in all_players.items():
per_player_checks: list[PlayerChecksDone] = []
team_checks_done = {"team": team, "players": per_player_checks}
player_checks_done.append(team_checks_done)
for player in players:
player_checks_done.append(
{"team": team, "player": player, "locations": sorted(tracker_data.get_player_checked_locations(team, player))})
per_player_checks.append(
{"player": player, "locations": sorted(tracker_data.get_player_checked_locations(team, player))})
total_checks_done: list[TeamTotalChecks] = [
total_checks_done: list[dict[str, int]] = [
{"team": team, "checks_done": checks_done}
for team, checks_done in tracker_data.get_team_locations_checked_count().items()
]
"""Total number of locations checked for the entire multiworld per team."""
hints: list[PlayerHints] = []
class PlayerHints(TypedDict):
player: int
hints: list[Hint]
hints: list[dict[str, int | list[PlayerHints]]] = []
"""Hints that all players have used or received."""
for team, players in tracker_data.get_all_slots().items():
per_player_hints: list[PlayerHints] = []
team_hints = {"team": team, "players": per_player_hints}
hints.append(team_hints)
for player in players:
player_hints = sorted(tracker_data.get_player_hints(team, player))
hints.append({"team": team, "player": player, "hints": player_hints})
slot_info = tracker_data.get_slot_info(player)
per_player_hints.append({"player": player, "hints": player_hints})
slot_info = tracker_data.get_slot_info(team, player)
# this assumes groups are always after players
if slot_info.type != SlotType.group:
continue
for member in slot_info.group_members:
hints[member - 1]["hints"] += player_hints
team_hints[member]["hints"] += player_hints
activity_timers: list[PlayerTimer] = []
class PlayerTimer(TypedDict):
player: int
time: datetime | None
activity_timers: list[dict[str, int | list[PlayerTimer]]] = []
"""Time of last activity per player. Returned as RFC 1123 format and null if no connection has been made."""
for team, players in all_players.items():
player_timers: list[PlayerTimer] = []
team_timers = {"team": team, "players": player_timers}
activity_timers.append(team_timers)
for player in players:
activity_timers.append({"team": team, "player": player, "time": None})
player_timers.append({"player": player, "time": None})
for (team, player), timestamp in tracker_data._multisave.get("client_activity_timers", []):
for entry in activity_timers:
if entry["team"] == team and entry["player"] == player:
entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
break
client_activity_timers: tuple[tuple[int, int], float] = tracker_data._multisave.get("client_activity_timers", ())
for (team, player), timestamp in client_activity_timers:
# use index since we can rely on order
activity_timers[team]["player_timers"][player - 1]["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
connection_timers: list[PlayerTimer] = []
connection_timers: list[dict[str, int | list[PlayerTimer]]] = []
"""Time of last connection per player. Returned as RFC 1123 format and null if no connection has been made."""
for team, players in all_players.items():
player_timers: list[PlayerTimer] = []
team_connection_timers = {"team": team, "players": player_timers}
connection_timers.append(team_connection_timers)
for player in players:
connection_timers.append({"team": team, "player": player, "time": None})
player_timers.append({"player": player, "time": None})
for (team, player), timestamp in tracker_data._multisave.get("client_connection_timers", []):
# find the matching entry
for entry in connection_timers:
if entry["team"] == team and entry["player"] == player:
entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
break
client_connection_timers: tuple[tuple[int, int], float] = tracker_data._multisave.get(
"client_connection_timers", ())
for (team, player), timestamp in client_connection_timers:
connection_timers[team]["players"][player - 1]["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
player_status: list[PlayerStatus] = []
class PlayerStatus(TypedDict):
player: int
status: ClientStatus
player_status: list[dict[str, int | list[PlayerStatus]]] = []
"""The current client status for each player."""
for team, players in all_players.items():
player_statuses: list[PlayerStatus] = []
team_status = {"team": team, "players": player_statuses}
player_status.append(team_status)
for player in players:
player_status.append({"team": team, "player": player, "status": tracker_data.get_player_client_status(team, player)})
player_statuses.append({"player": player, "status": tracker_data.get_player_client_status(team, player)})
return {
**get_static_tracker_data(room),
"aliases": player_aliases,
"player_items_received": player_items_received,
"player_checks_done": player_checks_done,
@@ -155,87 +151,80 @@ def tracker_data(tracker: UUID) -> dict[str, Any]:
"activity_timers": activity_timers,
"connection_timers": connection_timers,
"player_status": player_status,
"datapackage": tracker_data._multidata["datapackage"],
}
class PlayerGroups(TypedDict):
slot: int
name: str
members: list[int]
class PlayerSlotData(TypedDict):
player: int
slot_data: dict[str, Any]
@api_endpoints.route("/static_tracker/<suuid:tracker>")
@cache.memoize(timeout=300)
def static_tracker_data(tracker: UUID) -> dict[str, Any]:
@cache.memoize()
def get_static_tracker_data(room: Room) -> dict[str, Any]:
"""
Outputs json data to <root_path>/api/static_tracker/<id of current session tracker>.
:param tracker: UUID of current session tracker.
:return: Static tracking data for all players in the room. Typing and docstrings describe the format of each value.
Builds and caches the static data for this active session tracker, so that it doesn't need to be recalculated.
"""
room: Room | None = Room.get(tracker=tracker)
if not room:
abort(404)
tracker_data = TrackerData(room)
all_players: dict[int, list[int]] = tracker_data.get_all_players()
groups: list[PlayerGroups] = []
class PlayerGroups(TypedDict):
slot: int
name: str
members: list[int]
groups: list[dict[str, int | list[PlayerGroups]]] = []
"""The Slot ID of groups and the IDs of the group's members."""
for team, players in tracker_data.get_all_slots().items():
groups_in_team: list[PlayerGroups] = []
team_groups = {"team": team, "groups": groups_in_team}
groups.append(team_groups)
for player in players:
slot_info = tracker_data.get_slot_info(player)
slot_info = tracker_data.get_slot_info(team, player)
if slot_info.type != SlotType.group or not slot_info.group_members:
continue
groups.append(
groups_in_team.append(
{
"slot": player,
"name": slot_info.name,
"members": list(slot_info.group_members),
})
break
class PlayerName(TypedDict):
player: int
name: str
player_locations_total: list[PlayerLocationsTotal] = []
player_names: list[dict[str, str | list[PlayerName]]] = []
"""Slot names of all players."""
for team, players in all_players.items():
per_team_player_names: list[PlayerName] = []
team_names = {"team": team, "players": per_team_player_names}
player_names.append(team_names)
for player in players:
player_locations_total.append(
{"team": team, "player": player, "total_locations": len(tracker_data.get_player_locations(player))})
per_team_player_names.append({"player": player, "name": tracker_data.get_player_name(team, player)})
class PlayerGame(TypedDict):
player: int
game: str
games: list[dict[str, int | list[PlayerGame]]] = []
"""The game each player is playing."""
for team, players in all_players.items():
player_games: list[PlayerGame] = []
team_games = {"team": team, "players": player_games}
games.append(team_games)
for player in players:
player_games.append({"player": player, "game": tracker_data.get_player_game(team, player)})
class PlayerSlotData(TypedDict):
player: int
slot_data: dict[str, Any]
slot_data: list[dict[str, int | list[PlayerSlotData]]] = []
"""Slot data for each player."""
for team, players in all_players.items():
player_slot_data: list[PlayerSlotData] = []
team_slot_data = {"team": team, "players": player_slot_data}
slot_data.append(team_slot_data)
for player in players:
player_slot_data.append({"player": player, "slot_data": tracker_data.get_slot_data(team, player)})
return {
"groups": groups,
"datapackage": tracker_data._multidata["datapackage"],
"player_locations_total": player_locations_total,
"slot_data": slot_data,
}
# It should be exceedingly rare that slot data is needed, so it's separated out.
@api_endpoints.route("/slot_data_tracker/<suuid:tracker>")
@cache.memoize(timeout=300)
def tracker_slot_data(tracker: UUID) -> list[PlayerSlotData]:
"""
Outputs json data to <root_path>/api/slot_data_tracker/<id of current session tracker>.
:param tracker: UUID of current session tracker.
:return: Slot data for all players in the room. Typing completely arbitrary per game.
"""
room: Room | None = Room.get(tracker=tracker)
if not room:
abort(404)
tracker_data = TrackerData(room)
all_players: dict[int, list[int]] = tracker_data.get_all_players()
slot_data: list[PlayerSlotData] = []
"""Slot data for each player."""
for team, players in all_players.items():
for player in players:
slot_data.append({"player": player, "slot_data": tracker_data.get_slot_data(player)})
break
return slot_data

View File

@@ -19,10 +19,7 @@ from pony.orm import commit, db_session, select
import Utils
from MultiServer import (
Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert,
server_per_message_deflate_factory,
)
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
from Utils import restricted_loads, cache_argsless
from .locker import Locker
from .models import Command, GameDataPackage, Room, db
@@ -100,7 +97,6 @@ class WebHostContext(Context):
self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext)
command.delete()
commit()
del commands
time.sleep(5)
@db_session
@@ -150,13 +146,13 @@ class WebHostContext(Context):
self.location_name_groups = static_location_name_groups
return self._load(multidata, game_data_packages, True)
@db_session
def init_save(self, enabled: bool = True):
self.saving = enabled
if self.saving:
with db_session:
savegame_data = Room.get(id=self.room_id).multisave
if savegame_data:
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
savegame_data = Room.get(id=self.room_id).multisave
if savegame_data:
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
self._start_async_saving(atexit_save=False)
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
@@ -286,12 +282,8 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
assert ctx.server is None
try:
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx),
ctx.host,
ctx.port,
ssl=get_ssl_context(),
extensions=[server_per_message_deflate_factory],
)
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=get_ssl_context())
await ctx.server
except OSError: # likely port in use
ctx.server = websockets.serve(
@@ -312,7 +304,6 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
with db_session:
room = Room.get(id=ctx.room_id)
room.last_port = port
del room
else:
ctx.logger.exception("Could not determine port. Likely hosting failure.")
with db_session:
@@ -331,7 +322,6 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
with db_session:
room = Room.get(id=room_id)
room.last_port = -1
del room
logger.exception(e)
raise
else:
@@ -343,12 +333,11 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup
ctx.exit_event.set() # make sure the saving thread stops at some point
# NOTE: async saving should probably be an async task and could be merged with shutdown_task
with db_session:
with (db_session):
# ensure the Room does not spin up again on its own, minute of safety buffer
room = Room.get(id=room_id)
room.last_activity = datetime.datetime.utcnow() - \
datetime.timedelta(minutes=1, seconds=room.timeout)
del room
logging.info(f"Shutting down room {room_id} on {name}.")
finally:
await asyncio.sleep(5)

View File

@@ -12,11 +12,12 @@ from flask import flash, redirect, render_template, request, session, url_for
from pony.orm import commit, db_session
from BaseClasses import get_seed, seeddigits
from Generate import PlandoOptions, handle_name, mystery_argparse
from Generate import PlandoOptions, handle_name
from Main import main as ERmain
from Utils import __version__, restricted_dumps
from WebHostLib import app
from settings import ServerOptions, GeneratorOptions
from worlds.alttp.EntranceRandomizer import parse_arguments
from .check import get_yaml_data, roll_options
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
from .upload import upload_zip_to_db
@@ -72,10 +73,6 @@ def generate(race=False):
return render_template("generate.html", race=race, version=__version__)
def format_exception(e: BaseException) -> str:
return f"{e.__class__.__name__}: {e}"
def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
results, gen_options = roll_options(options, set(meta["plando_options"]))
@@ -96,9 +93,7 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
except PicklingError as e:
from .autolauncher import handle_generation_failure
handle_generation_failure(e)
meta["error"] = format_exception(e)
details = json.dumps(meta, indent=4).strip()
return render_template("seedError.html", seed_error=meta["error"], details=details)
return render_template("seedError.html", seed_error=("PicklingError: " + str(e)))
commit()
@@ -110,9 +105,7 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
except BaseException as e:
from .autolauncher import handle_generation_failure
handle_generation_failure(e)
meta["error"] = format_exception(e)
details = json.dumps(meta, indent=4).strip()
return render_template("seedError.html", seed_error=meta["error"], details=details)
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e)))
return redirect(url_for("view_seed", seed=seed_id))
@@ -136,39 +129,36 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
args = mystery_argparse()
args.multi = playercount
args.seed = seed
args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
args.spoiler = meta["generator_options"].get("spoiler", 0)
args.race = race
args.outputname = seedname
args.outputpath = target.name
args.teams = 1
args.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
{"bosses", "items", "connections", "texts"}))
args.skip_prog_balancing = False
args.skip_output = False
args.spoiler_only = False
args.csv_output = False
args.sprite = dict.fromkeys(range(1, args.multi+1), None)
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
erargs = parse_arguments(['--multi', str(playercount)])
erargs.seed = seed
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
erargs.spoiler = meta["generator_options"].get("spoiler", 0)
erargs.race = race
erargs.outputname = seedname
erargs.outputpath = target.name
erargs.teams = 1
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
{"bosses", "items", "connections", "texts"}))
erargs.skip_prog_balancing = False
erargs.skip_output = False
erargs.spoiler_only = False
erargs.csv_output = False
name_counter = Counter()
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
for k, v in settings.items():
if v is not None:
if hasattr(args, k):
getattr(args, k)[player] = v
if hasattr(erargs, k):
getattr(erargs, k)[player] = v
else:
setattr(args, k, {player: v})
setattr(erargs, k, {player: v})
if not args.name[player]:
args.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
args.name[player] = handle_name(args.name[player], player, name_counter)
if len(set(args.name.values())) != len(args.name):
raise Exception(f"Names have to be unique. Names: {Counter(args.name.values())}")
ERmain(args, seed, baked_server_options=meta["server_options"])
if not erargs.name[player]:
erargs.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
if len(set(erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
ERmain(erargs, seed, baked_server_options=meta["server_options"])
return upload_to_db(target.name, sid, owner, race)
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
@@ -183,9 +173,9 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
if gen is not None:
gen.state = STATE_ERROR
meta = json.loads(gen.meta)
meta["error"] = ("Allowed time for Generation exceeded, " +
"please consider generating locally instead. " +
format_exception(e))
meta["error"] = (
"Allowed time for Generation exceeded, please consider generating locally instead. " +
e.__class__.__name__ + ": " + str(e))
gen.meta = json.dumps(meta)
commit()
except BaseException as e:
@@ -195,7 +185,7 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
if gen is not None:
gen.state = STATE_ERROR
meta = json.loads(gen.meta)
meta["error"] = format_exception(e)
meta["error"] = (e.__class__.__name__ + ": " + str(e))
gen.meta = json.dumps(meta)
commit()
raise
@@ -212,9 +202,7 @@ def wait_seed(seed: UUID):
if not generation:
return "Generation not found."
elif generation.state == STATE_ERROR:
meta = json.loads(generation.meta)
details = json.dumps(meta, indent=4).strip()
return render_template("seedError.html", seed_error=meta["error"], details=details)
return render_template("seedError.html", seed_error=generation.meta)
return render_template("waitSeed.html", seed_id=seed_id)

View File

@@ -3,10 +3,10 @@ import threading
import json
from Utils import local_path, user_path
from worlds.alttp.Rom import Sprite
def update_sprites_lttp():
from worlds.alttp.Rom import Sprite
from tkinter import Tk
from LttPAdjuster import get_image_for_sprite
from LttPAdjuster import BackgroundTaskProgress

View File

@@ -260,10 +260,7 @@ def host_room(room: UUID):
# indicate that the page should reload to get the assigned port
should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
if now - room.last_activity > datetime.timedelta(minutes=1):
# we only set last_activity if needed, otherwise parallel access on /room will cause an internal server error
# due to "pony.orm.core.OptimisticCheckError: Object Room was updated outside of current transaction"
with db_session:
room.last_activity = now # will trigger a spinup, if it's not already running
browser_tokens = "Mozilla", "Chrome", "Safari"
@@ -271,9 +268,9 @@ def host_room(room: UUID):
or "Discordbot" in request.user_agent.string
or not any(browser_token in request.user_agent.string for browser_token in browser_tokens))
def get_log(max_size: int = 0 if automated else 1024000) -> Tuple[str, int]:
def get_log(max_size: int = 0 if automated else 1024000) -> str:
if max_size == 0:
return "", 0
return ""
try:
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
raw_size = 0
@@ -284,9 +281,9 @@ def host_room(room: UUID):
break
raw_size += len(block)
fragments.append(block.decode("utf-8"))
return "".join(fragments), raw_size
return "".join(fragments)
except FileNotFoundError:
return "", 0
return ""
return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log)

View File

@@ -155,9 +155,7 @@ def generate_weighted_yaml(game: str):
options = {}
for key, val in request.form.items():
if val == "_ensure-empty-list":
options[key] = {}
elif "||" not in key:
if "||" not in key:
if len(str(val)) == 0:
continue
@@ -214,11 +212,8 @@ def generate_yaml(game: str):
if request.method == "POST":
options = {}
intent_generate = False
for key, val in request.form.items(multi=True):
if val == "_ensure-empty-list":
options[key] = []
elif options.get(key):
if key in options:
if not isinstance(options[key], list):
options[key] = [options[key]]
options[key].append(val)
@@ -231,7 +226,7 @@ def generate_yaml(game: str):
if key_parts[-1] == "qty":
if key_parts[0] not in options:
options[key_parts[0]] = {}
if val and val != "0":
if val != "0":
options[key_parts[0]][key_parts[1]] = int(val)
del options[key]

View File

@@ -1,7 +1,6 @@
flask>=3.1.1
werkzeug>=3.1.3
pony>=0.7.19; python_version <= '3.12'
pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13'
pony>=0.7.19
waitress>=3.0.2
Flask-Caching>=2.3.0
Flask-Compress>=1.17

View File

@@ -66,7 +66,7 @@ is to ensure items necessary to complete the game will be accessible to the play
rules allowing certain items to be placed in normally unreachable locations, provided the player has indicated they are
comfortable exploiting certain glitches in the game.
## I want to develop a game implementation for Archipelago. How do I do that?
## I want to add a game to the Archipelago randomizer. How do I do that?
The best way to get started is to take a look at our code on GitHub:
[Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
@@ -77,5 +77,4 @@ There, you will find examples of games in the `worlds` folder:
You may also find developer documentation in the `docs` folder:
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
If you have more questions regarding development of a game implementation, feel free to ask in the **#ap-world-dev**
channel on our Discord.
If you have more questions, feel free to ask in the **#ap-world-dev** channel on our Discord.

View File

@@ -1,43 +1,49 @@
let updateSection = (sectionName, fakeDOM) => {
document.getElementById(sectionName).innerHTML = fakeDOM.getElementById(sectionName).innerHTML;
}
window.addEventListener('load', () => {
// Reload tracker every 60 seconds (sync'd)
const url = window.location;
// Note: This synchronization code is adapted from code in trackerCommon.js
const targetSecond = parseInt(document.getElementById('player-tracker').getAttribute('data-second')) + 3;
console.log("Target second of refresh: " + targetSecond);
// Reload tracker every 15 seconds
const url = window.location;
setInterval(() => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
let getSleepTimeSeconds = () => {
// -40 % 60 is -40, which is absolutely wrong and should burn
var sleepSeconds = (((targetSecond - new Date().getSeconds()) % 60) + 60) % 60;
return sleepSeconds || 60;
};
// Create a fake DOM using the returned HTML
const domParser = new DOMParser();
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
let updateTracker = () => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
// Create a fake DOM using the returned HTML
const domParser = new DOMParser();
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
// Update dynamic sections
updateSection('player-info', fakeDOM);
updateSection('section-filler', fakeDOM);
updateSection('section-terran', fakeDOM);
updateSection('section-zerg', fakeDOM);
updateSection('section-protoss', fakeDOM);
updateSection('section-nova', fakeDOM);
updateSection('section-kerrigan', fakeDOM);
updateSection('section-keys', fakeDOM);
updateSection('section-locations', fakeDOM);
};
ajax.open('GET', url);
ajax.send();
updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000);
// Update item tracker
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
// Update only counters in the location-table
let counters = document.getElementsByClassName('counter');
const fakeCounters = fakeDOM.getElementsByClassName('counter');
for (let i = 0; i < counters.length; i++) {
counters[i].innerHTML = fakeCounters[i].innerHTML;
}
};
window.updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000);
ajax.open('GET', url);
ajax.send();
}, 15000)
// Collapsible advancement sections
const categories = document.getElementsByClassName("location-category");
for (let category of categories) {
let hide_id = category.id.split('_')[0];
if (hide_id === 'Total') {
continue;
}
category.addEventListener('click', function() {
// Toggle the advancement list
document.getElementById(hide_id).classList.toggle("hide");
// Change text of the header
const tab_header = document.getElementById(hide_id+'_header').children[0];
const orig_text = tab_header.innerHTML;
let new_text;
if (orig_text.includes("▼")) {
new_text = orig_text.replace("▼", "▲");
}
else {
new_text = orig_text.replace("▲", "▼");
}
tab_header.innerHTML = new_text;
});
}
});

View File

@@ -1,279 +1,160 @@
*{
margin: 0;
font-family: "JuraBook", monospace;
}
body{
--icon-size: 36px;
--item-class-padding: 4px;
}
a{
color: #1ae;
#player-tracker-wrapper{
margin: 0;
}
/* Section colours */
#player-info{
background-color: #37a;
}
.player-tracker{
max-width: 100%;
}
.tracker-section{
background-color: grey;
}
#terran-items{
background-color: #3a7;
}
#zerg-items{
background-color: #d94;
}
#protoss-items{
background-color: #37a;
}
#nova-items{
background-color: #777;
}
#kerrigan-items{
background-color: #a37;
}
#keys{
background-color: #aa2;
#tracker-table td {
vertical-align: top;
}
/* Sections */
.section-body{
display: flex;
flex-flow: row wrap;
justify-content: flex-start;
align-items: flex-start;
padding-bottom: 3px;
}
.section-body-2{
display: flex;
flex-direction: column;
}
.tracker-section:has(input.collapse-section[type=checkbox]:checked) .section-body,
.tracker-section:has(input.collapse-section[type=checkbox]:checked) .section-body-2{
display: none;
}
.section-title{
position: relative;
border-bottom: 3px solid black;
/* Prevent text selection */
user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
}
input[type="checkbox"]{
position: absolute;
cursor: pointer;
opacity: 0;
z-index: 1;
width: 100%;
height: 100%;
}
.section-title:hover h2{
text-shadow: 0 0 4px #ddd;
}
.f {
display: flex;
overflow: hidden;
.inventory-table-area{
border: 2px solid #000000;
border-radius: 4px;
padding: 3px 10px 3px 10px;
}
/* Acquire item filters */
.tracker-section img{
height: 100%;
width: var(--icon-size);
height: var(--icon-size);
background-color: black;
}
.unacquired, .lvl-0 .f{
filter: grayscale(100%) contrast(80%) brightness(42%) blur(0.5px);
}
.spacer{
width: var(--icon-size);
height: var(--icon-size);
.inventory-table-area:has(.inventory-table-terran) {
width: 690px;
background-color: #525494;
}
/* Item groups */
.item-class{
display: flex;
flex-flow: column;
justify-content: center;
padding: var(--item-class-padding);
}
.item-class-header{
display: flex;
flex-flow: row;
}
.item-class-upgrades{
/* Note: {display: flex; flex-flow: column wrap} */
/* just breaks on Firefox (width does not scale to content) */
display: grid;
grid-template-rows: repeat(4, auto);
grid-auto-flow: column;
.inventory-table-area:has(.inventory-table-zerg) {
width: 360px;
background-color: #9d60d2;
}
/* Subsections */
.section-toc{
display: flex;
flex-direction: row;
}
.toc-box{
position: relative;
padding-left: 15px;
padding-right: 15px;
}
.toc-box:hover{
text-shadow: 0 0 7px white;
}
.ss-header{
position: relative;
text-align: center;
writing-mode: sideways-lr;
user-select: none;
padding-top: 5px;
font-size: 115%;
}
.tracker-section:has(input.ss-1-toggle:checked) .ss-1{
display: none;
}
.tracker-section:has(input.ss-2-toggle:checked) .ss-2{
display: none;
}
.tracker-section:has(input.ss-3-toggle:checked) .ss-3{
display: none;
}
.tracker-section:has(input.ss-4-toggle:checked) .ss-4{
display: none;
}
.tracker-section:has(input.ss-5-toggle:checked) .ss-5{
display: none;
}
.tracker-section:has(input.ss-6-toggle:checked) .ss-6{
display: none;
}
.tracker-section:has(input.ss-7-toggle:checked) .ss-7{
display: none;
}
.tracker-section:has(input.ss-1-toggle:hover) .ss-1{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
}
.tracker-section:has(input.ss-2-toggle:hover) .ss-2{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
}
.tracker-section:has(input.ss-3-toggle:hover) .ss-3{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
}
.tracker-section:has(input.ss-4-toggle:hover) .ss-4{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
}
.tracker-section:has(input.ss-5-toggle:hover) .ss-5{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
}
.tracker-section:has(input.ss-6-toggle:hover) .ss-6{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
}
.tracker-section:has(input.ss-7-toggle:hover) .ss-7{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
.inventory-table-area:has(.inventory-table-protoss) {
width: 400px;
background-color: #d2b260;
}
/* Progressive items */
.progressive{
max-height: var(--icon-size);
display: contents;
#tracker-table .inventory-table td{
width: 40px;
height: 40px;
text-align: center;
vertical-align: middle;
}
.lvl-0 > :nth-child(2),
.lvl-0 > :nth-child(3),
.lvl-0 > :nth-child(4),
.lvl-0 > :nth-child(5){
display: none;
}
.lvl-1 > :nth-child(2),
.lvl-1 > :nth-child(3),
.lvl-1 > :nth-child(4),
.lvl-1 > :nth-child(5){
display: none;
}
.lvl-2 > :nth-child(1),
.lvl-2 > :nth-child(3),
.lvl-2 > :nth-child(4),
.lvl-2 > :nth-child(5){
display: none;
}
.lvl-3 > :nth-child(1),
.lvl-3 > :nth-child(2),
.lvl-3 > :nth-child(4),
.lvl-3 > :nth-child(5){
display: none;
}
.lvl-4 > :nth-child(1),
.lvl-4 > :nth-child(2),
.lvl-4 > :nth-child(3),
.lvl-4 > :nth-child(5){
display: none;
}
.lvl-5 > :nth-child(1),
.lvl-5 > :nth-child(2),
.lvl-5 > :nth-child(3),
.lvl-5 > :nth-child(4){
display: none;
.inventory-table td.title{
padding-top: 10px;
height: 20px;
font-family: "JuraBook", monospace;
font-size: 16px;
font-weight: bold;
}
/* Filler item counters */
.item-counter{
display: table;
text-align: center;
padding: var(--item-class-padding);
}
.item-count{
display: table-cell;
vertical-align: middle;
padding-left: 3px;
padding-right: 15px;
.inventory-table img{
height: 100%;
max-width: 40px;
max-height: 40px;
border: 1px solid #000000;
filter: grayscale(100%) contrast(75%) brightness(20%);
background-color: black;
}
/* Hidden items */
.hidden-class:not(:has(img.acquired)){
display: none;
}
.hidden-item:not(.acquired){
display:none;
.inventory-table img.acquired{
filter: none;
background-color: black;
}
/* Keys */
#keys ol, #keys ul{
columns: 3;
-webkit-columns: 3;
-moz-columns: 3;
}
#keys li{
padding-right: 15pt;
.inventory-table .tint-terran img.acquired {
filter: sepia(100%) saturate(300%) brightness(130%) hue-rotate(120deg)
}
/* Locations */
#section-locations{
padding-left: 5px;
}
@media only screen and (min-width: 120ch){
#section-locations ul{
columns: 2;
-webkit-columns: 2;
-moz-columns: 2;
}
}
#locations li.checked{
list-style-type: "✔ ";
.inventory-table .tint-protoss img.acquired {
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(180deg)
}
/* Allowing scrolling down a little further */
.bottom-padding{
min-height: 33vh;
}
.inventory-table .tint-level-1 img.acquired {
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg)
}
.inventory-table .tint-level-2 img.acquired {
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg) hue-rotate(120deg)
}
.inventory-table .tint-level-3 img.acquired {
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg) hue-rotate(240deg)
}
.inventory-table div.counted-item {
position: relative;
}
.inventory-table div.item-count {
width: 160px;
text-align: left;
color: black;
font-family: "JuraBook", monospace;
font-weight: bold;
}
#location-table{
border: 2px solid #000000;
border-radius: 4px;
background-color: #87b678;
padding: 10px 3px 3px;
font-family: "JuraBook", monospace;
font-size: 16px;
font-weight: bold;
cursor: default;
}
#location-table table{
width: 100%;
}
#location-table th{
vertical-align: middle;
text-align: left;
padding-right: 10px;
}
#location-table td{
padding-top: 2px;
padding-bottom: 2px;
line-height: 20px;
}
#location-table td.counter {
text-align: right;
font-size: 14px;
}
#location-table td.toggle-arrow {
text-align: right;
}
#location-table tr#Total-header {
font-weight: bold;
}
#location-table img{
height: 100%;
max-width: 30px;
max-height: 30px;
}
#location-table tbody.locations {
font-size: 16px;
}
#location-table td.location-name {
padding-left: 16px;
}
#location-table td:has(.location-column) {
vertical-align: top;
}
#location-table .location-column {
width: 100%;
height: 100%;
}
#location-table .location-column .spacer {
min-height: 24px;
}
.hide {
display: none;
}

File diff suppressed because it is too large Load Diff

View File

@@ -72,13 +72,3 @@ code{
padding-right: 0.25rem;
color: #000000;
}
code.grassy {
background-color: #b5e9a4;
border: 1px solid #2a6c2f;
white-space: preserve;
text-align: left;
display: block;
font-size: 14px;
line-height: 20px;
}

View File

@@ -13,7 +13,3 @@
min-height: 360px;
text-align: center;
}
h2, h4 {
color: #ffffff;
}

View File

@@ -98,7 +98,7 @@
<td>
{% if hint.finding_player == player %}
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b>
{% elif get_slot_info(hint.finding_player).type == 2 %}
{% elif get_slot_info(team, hint.finding_player).type == 2 %}
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
{% else %}
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.finding_player) }}">
@@ -109,7 +109,7 @@
<td>
{% if hint.receiving_player == player %}
<b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b>
{% elif get_slot_info(hint.receiving_player).type == 2 %}
{% elif get_slot_info(team, hint.receiving_player).type == 2 %}
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
{% else %}
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.receiving_player) }}">

View File

@@ -58,7 +58,8 @@
Open Log File...
</a>
</div>
{% set log, log_len = get_log() -%}
{% set log = get_log() -%}
{%- set log_len = log | length - 1 if log.endswith("…") else log | length -%}
<div id="logger" style="white-space: pre">{{ log }}</div>
<script>
let url = '{{ url_for('display_log', room = room.id) }}';

View File

@@ -45,15 +45,15 @@
{%- set current_sphere = loop.index %}
{%- for player, sphere_location_ids in sphere.items() %}
{%- set checked_locations = tracker_data.get_player_checked_locations(team, player) %}
{%- set finder_game = tracker_data.get_player_game(player) %}
{%- set player_location_data = tracker_data.get_player_locations(player) %}
{%- set finder_game = tracker_data.get_player_game(team, player) %}
{%- set player_location_data = tracker_data.get_player_locations(team, player) %}
{%- for location_id in sphere_location_ids.intersection(checked_locations) %}
<tr>
{%- set item_id, receiver, item_flags = player_location_data[location_id] %}
{%- set receiver_game = tracker_data.get_player_game(receiver) %}
{%- set receiver_game = tracker_data.get_player_game(team, receiver) %}
<td>{{ current_sphere }}</td>
<td>{{ tracker_data.get_player_name(player) }}</td>
<td>{{ tracker_data.get_player_name(receiver) }}</td>
<td>{{ tracker_data.get_player_name(team, player) }}</td>
<td>{{ tracker_data.get_player_name(team, receiver) }}</td>
<td>{{ tracker_data.item_id_to_name[receiver_game][item_id] }}</td>
<td>{{ tracker_data.location_id_to_name[finder_game][location_id] }}</td>
<td>{{ finder_game }}</td>

View File

@@ -22,14 +22,14 @@
-%}
<tr>
<td>
{% if get_slot_info(hint.finding_player).type == 2 %}
{% if get_slot_info(team, hint.finding_player).type == 2 %}
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
{% else %}
{{ player_names_with_alias[(team, hint.finding_player)] }}
{% endif %}
</td>
<td>
{% if get_slot_info(hint.receiving_player).type == 2 %}
{% if get_slot_info(team, hint.receiving_player).type == 2 %}
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
{% else %}
{{ player_names_with_alias[(team, hint.receiving_player)] }}

View File

@@ -134,7 +134,6 @@
{% macro OptionList(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="option-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
<div class="option-entry">
@@ -147,7 +146,6 @@
{% macro LocationSet(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="option-container">
{% for group_name in world.location_name_groups.keys()|sort %}
{% if group_name != "Everywhere" %}
@@ -171,7 +169,6 @@
{% macro ItemSet(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="option-container">
{% for group_name in world.item_name_groups.keys()|sort %}
{% if group_name != "Everything" %}
@@ -195,7 +192,6 @@
{% macro OptionSet(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="option-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
<div class="option-entry">

View File

@@ -4,20 +4,16 @@
{% block head %}
<title>Generation failed, please retry.</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/waitSeed.css') }}"/>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/waitSeed.css") }}"/>
{% endblock %}
{% block body %}
{% include 'header/oceanIslandHeader.html' %}
<div id="wait-seed-wrapper" class="grass-island">
<div id="wait-seed">
<h1>Generation Failed</h1>
<h2>Please try again!</h2>
<p>{{ seed_error }}</p>
<h4>More details:</h4>
<p>
<code class="grassy">{{ details }}</code>
</p>
<h1>Generation failed</h1>
<h2>please retry</h2>
{{ seed_error }}
</div>
</div>
{% endblock %}

View File

@@ -31,9 +31,6 @@
{% include 'header/oceanHeader.html' %}
<div id="games" class="markdown">
<h1>Currently Supported Games</h1>
<p>Below are the games that are currently included with the Archipelago software. To play a game that is not on
this page, please refer to the <a href="/tutorial/Archipelago/setup/en#playing-with-custom-worlds">playing with
custom worlds</a> section of the setup guide.</p>
<div class="js-only">
<label for="game-search">Search for your game below!</label><br />
<div class="page-controls">

File diff suppressed because it is too large Load Diff

View File

@@ -139,7 +139,6 @@
{% endmacro %}
{% macro OptionList(option_name, option) %}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="list-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
<div class="list-entry">
@@ -159,7 +158,6 @@
{% endmacro %}
{% macro LocationSet(option_name, option, world) %}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="set-container">
{% for group_name in world.location_name_groups.keys()|sort %}
{% if group_name != "Everywhere" %}
@@ -182,7 +180,6 @@
{% endmacro %}
{% macro ItemSet(option_name, option, world) %}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="set-container">
{% for group_name in world.item_name_groups.keys()|sort %}
{% if group_name != "Everything" %}
@@ -205,7 +202,6 @@
{% endmacro %}
{% macro OptionSet(option_name, option) %}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="set-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
<div class="set-entry">

File diff suppressed because it is too large Load Diff

View File

@@ -20,8 +20,6 @@ from worlds.tloz.Items import item_game_ids
from worlds.tloz.Locations import location_ids
from worlds.tloz import Items, Locations, Rom
from settings import get_settings
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_tloz.lua"
@@ -343,12 +341,13 @@ if __name__ == '__main__':
# Text Mode to use !hint and such with games that have no text entry
Utils.init_logging("ZeldaClient")
DISPLAY_MSGS = get_settings()["tloz_options"]["display_msgs"]
options = Utils.get_options()
DISPLAY_MSGS = options["tloz_options"]["display_msgs"]
async def run_game(romfile: str) -> None:
auto_start = typing.cast(typing.Union[bool, str],
get_settings()["tloz_options"].get("rom_start", True))
Utils.get_options()["tloz_options"].get("rom_start", True))
if auto_start is True:
import webbrowser
webbrowser.open(romfile)

View File

@@ -220,8 +220,6 @@
<MessageBoxLabel>:
theme_text_color: "Custom"
text_color: 1, 1, 1, 1
<MessageBox>:
height: self.content.texture_size[1] + 80
<ScrollBox>:
layout: layout
bar_width: "12dp"
@@ -235,3 +233,8 @@
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

@@ -33,10 +33,6 @@ description: {{ yaml_dump("Default %s Template" % game) }}
game: {{ yaml_dump(game) }}
requires:
version: {{ __version__ }} # Version of Archipelago required for this yaml to work as expected.
{%- if world_version != "0.0.0" %}
game:
{{ yaml_dump(game) }}: {{ world_version }} # Version of the world required for this yaml to work as expected.
{%- endif %}
{%- macro range_option(option) %}
# You can define additional values between the minimum and maximum values.

View File

@@ -0,0 +1,7 @@
author: Nintendo
data: null
game: A Link to the Past
min_format_version: 1
name: Link
format_version: 1
sprite_version: 1

2
data/sprites/remote/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -21,6 +21,9 @@
# Aquaria
/worlds/aquaria/ @tioui
# ArchipIDLE
/worlds/archipidle/ @LegendaryLinux
# Blasphemous
/worlds/blasphemous/ @TRPG0
@@ -39,15 +42,9 @@
# Celeste 64
/worlds/celeste64/ @PoryGone
# Celeste (Open World)
/worlds/celeste_open_world/ @PoryGone
# ChecksFinder
/worlds/checksfinder/ @SunCatMC
# Choo-Choo Charles
/worlds/cccharles/ @Yaranorgoth
# Civilization VI
/worlds/civ6/ @hesto2
@@ -72,9 +69,6 @@
# Faxanadu
/worlds/faxanadu/ @Daivuk
# Final Fantasy (1)
/worlds/ff1/ @Rosalie-A
# Final Fantasy Mystic Quest
/worlds/ffmq/ @Alchav @wildham0
@@ -244,6 +238,9 @@
# compatibility, these worlds may be deleted. If you are interested in stepping up as maintainer for
# any of these worlds, please review `/docs/world maintainer.md` documentation.
# Final Fantasy (1)
# /worlds/ff1/
# Ocarina of Time
# /worlds/oot/

View File

@@ -62,24 +62,6 @@ if possible.
* If your client appears in the Archipelago Launcher, you may define an icon for it that differentiates it from
other clients. The icon size is 48x48 pixels, but smaller or larger images will scale to that size.
### Launcher Integration
If you have a python client or want to utilize the integration features of the Archipelago Launcher (ex. Slot links in
webhost) you can define a Component to be a part of the Launcher. `LauncherComponents.components` can be appended to
with additional Components in order to automatically add them to the Launcher. Most Components only need a
`display_name` and `func`, but `supports_uri` and `game_name` can be defined to support launching by webhost links,
`icon` and `description` can be used to customize display in the Launcher UI, and `file_identifier` can be used to
launch by file.
Additionally, if you use `func` you have access to LauncherComponent.launch or launch_subprocess to run your
function as a subprocesses that can be utilized side by side other clients.
```py
def my_func(*args: str):
from .client import run_client
LauncherComponent.launch(run_client, name="My Client", args=args)
```
## World
The world is your game integration for the Archipelago generator, webhost, and multiworld server. It contains all the

View File

@@ -19,21 +19,7 @@ the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__i
## Metadata
Metadata about the apworld is defined in an `archipelago.json` file inside the zip archive.
The current format version has at minimum:
```json
{
"version": 6,
"compatible_version": 5,
"game": "Game Name"
}
```
with the following optional version fields using the format `"1.0.0"` to represent major.minor.build:
* `minimum_ap_version` and `maximum_ap_version` - which if present will each be compared against the current
Archipelago version respectively to filter those files from being loaded
* `world_version` - an arbitrary version for that world in order to only load the newest valid world.
An apworld without a world_version is always treated as older than one with a version
No metadata is specified yet.
## Extra Data

View File

@@ -352,14 +352,14 @@ direction_matching_group_lookup = {
Terrain matching or dungeon shuffle:
```python
def randomize_within_same_group(group: int) -> list[int]:
def randomize_within_same_group(group: int) -> List[int]:
return [group]
identity_group_lookup = bake_target_group_lookup(world, randomize_within_same_group)
```
Directional + area shuffle:
```python
def get_target_groups(group: int) -> list[int]:
def get_target_groups(group: int) -> List[int]:
# example group: LEFT | CAVE
# example result: [RIGHT | CAVE, DOOR | CAVE]
direction = group & Groups.DIRECTION_MASK

View File

@@ -79,7 +79,7 @@ Sent to clients when they connect to an Archipelago server.
| generator_version | [NetworkVersion](#NetworkVersion) | Object denoting the version of Archipelago which generated the multiworld. |
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. Example: `WebHost` |
| password | bool | Denoted whether a password is required to join this room. |
| permissions | dict\[str, [Permission](#Permission)\] | Mapping of permission name to [Permission](#Permission), keys are: "release", "collect" and "remaining". |
| permissions | dict\[str, [Permission](#Permission)\[int\]\] | Mapping of permission name to [Permission](#Permission), keys are: "release", "collect" and "remaining". |
| hint_cost | int | The percentage of total locations that need to be checked to receive a hint from the server. |
| location_check_points | int | The amount of hint points you receive per item/location check completed. |
| games | list\[str\] | List of games present in this multiworld. |
@@ -662,14 +662,13 @@ class SlotType(enum.IntFlag):
An object representing static information about a slot.
```python
from collections.abc import Sequence
from typing import NamedTuple
import typing
from NetUtils import SlotType
class NetworkSlot(NamedTuple):
class NetworkSlot(typing.NamedTuple):
name: str
game: str
type: SlotType
group_members: Sequence[int] = [] # only populated if type == group
group_members: typing.List[int] = [] # only populated if type == group
```
### Permission
@@ -687,8 +686,8 @@ class Permission(enum.IntEnum):
### Hint
An object representing a Hint.
```python
from typing import NamedTuple
class Hint(NamedTuple):
import typing
class Hint(typing.NamedTuple):
receiving_player: int
finding_player: int
location: int

View File

@@ -10,7 +10,7 @@ What you'll need:
* [Python 3.11.9 or newer](https://www.python.org/downloads/), not the Windows Store version
* On Windows, please consider only using the latest supported version in production environments since security
updates for older versions are not easily available.
* Python 3.13.x is currently the newest supported version
* Python 3.12.x is currently the newest supported version
* pip: included in downloads from python.org, separate in many Linux distributions
* Matching C compiler
* possibly optional, read operating system specific sections

View File

@@ -28,7 +28,7 @@ if it does not exist.
## Global Settings
All non-world-specific settings are defined directly in settings.py.
Each value needs to have a default. If the default should be `None`, annotate it using `T | None = None`.
Each value needs to have a default. If the default should be `None`, define it as `typing.Optional` and assign `None`.
To access a "global" config value, with correct typing, use one of
```python

View File

@@ -1,18 +0,0 @@
# Shared Cache
Archipelago maintains a shared folder of information that can be persisted for a machine and reused across Libraries.
It can be found at the User Cache Directory for appname `Archipelago` in the `Cache` subfolder
(ex. `%LOCALAPPDATA%/Archipelago/Cache`).
## Common Cache
The Common Cache `common.json` can be used to store any generic data that is expected to be shared across programs
for the same User.
* `uuid`: A UUID identifier used to identify clients as from the same user/machine, to be sent in the Connect packet
## Data Package Cache
The `datapackage` folder in the shared cache folder is used to store datapackages by game and checksum to be reused
in order to save network traffic. The expected structure is `datapackage/Game Name/checksum_value.json` with the
contents of each json file being the no-whitespace datapackage contents.

View File

@@ -15,10 +15,8 @@
* Prefer [format string literals](https://peps.python.org/pep-0498/) over string concatenation,
use single quotes inside them: `f"Like {dct['key']}"`
* Use type annotations where possible for function signatures and class members.
* Use type annotations where appropriate for local variables (e.g. `var: list[int] = []`, or when the
type is hard or impossible to deduce). Clear annotations help developers look up and validate API calls.
* Prefer new style type annotations for new code (e.g. `var: dict[str, str | int]` over
`var: Dict[str, Union[str, int]]`).
* Use type annotations where appropriate for local variables (e.g. `var: List[int] = []`, or when the
type is hard or impossible to deduce.) Clear annotations help developers look up and validate API calls.
* If a line ends with an open bracket/brace/parentheses, the matching closing bracket should be at the
beginning of a line at the same indentation as the beginning of the line with the open bracket.
```python
@@ -62,9 +60,3 @@
* Indent `case` inside `switch ` with 2 spaces.
* Use single quotes.
* Semicolons are required after every statement.
## KV
* Style should be defined in `.kv` as much as possible, only Python when unavailable.
* Should follow [our Python style](#python-code) where appropriate (quotation marks, indentation).
* When escaping a line break, add a space between code and backslash.

View File

@@ -82,10 +82,10 @@ overridden. For more information on what methods are available to your class, ch
#### Alternatives to WorldTestBase
Unit tests can also be created using
[unittest.TestCase](https://docs.python.org/3/library/unittest.html#unittest.TestCase) directly. These may be useful
for generating a multiworld under very specific constraints without using the generic world setup, or for testing
portions of your code that can be tested without relying on a multiworld to be created first.
Unit tests can also be created using [TestBase](/test/bases.py#L16) or
[unittest.TestCase](https://docs.python.org/3/library/unittest.html#unittest.TestCase) depending on your use case. These
may be useful for generating a multiworld under very specific constraints without using the generic world setup, or for
testing portions of your code that can be tested without relying on a multiworld to be created first.
#### Parametrization
@@ -102,7 +102,8 @@ for multiple inputs) the base test. Some important things to consider when attem
* Classes inheriting from `WorldTestBase`, including those created by the helpers in `test.param`, will run all
base tests by default, make sure the produced tests actually do what you aim for and do not waste a lot of
extra CPU time. Consider using `unittest.TestCase` directly or setting `WorldTestBase.run_default_tests` to False.
extra CPU time. Consider using `TestBase` or `unittest.TestCase` directly
or setting `WorldTestBase.run_default_tests` to False.
#### Performance Considerations

View File

@@ -18,8 +18,6 @@ Current endpoints:
- [`/room_status/<suuid:room_id>`](#roomstatus)
- Tracker API
- [`/tracker/<suuid:tracker>`](#tracker)
- [`/static_tracker/<suuid:tracker>`](#statictracker)
- [`/slot_data_tracker/<suuid:tracker>`](#slotdatatracker)
- User API
- [`/get_rooms`](#getrooms)
- [`/get_seeds`](#getseeds)
@@ -256,6 +254,8 @@ can either be viewed while on a room tracker page, or from the [room's endpoint]
<a name=tracker></a>
Will provide a dict of tracker data with the following keys:
- item_link groups and their players (`groups`)
- Each player's slot_data (`slot_data`)
- Each player's current alias (`aliases`)
- Will return the name if there is none
- A list of items each player has received as a NetworkItem (`player_items_received`)
@@ -265,55 +265,111 @@ Will provide a dict of tracker data with the following keys:
- The time of last activity of each player in RFC 1123 format (`activity_timers`)
- The time of last active connection of each player in RFC 1123 format (`connection_timers`)
- The current client status of each player (`player_status`)
- The datapackage hash for each player (`datapackage`)
- This hash can then be sent to the datapackage API to receive the appropriate datapackage as necessary
Example:
```json
{
"groups": [
{
"team": 0,
"groups": [
{
"slot": 5,
"name": "testGroup",
"members": [
1,
2
]
},
{
"slot": 6,
"name": "myCoolLink",
"members": [
3,
4
]
}
]
}
],
"slot_data": [
{
"team": 0,
"players": [
{
"player": 1,
"slot_data": {
"example_option": 1,
"other_option": 3
}
},
{
"player": 2,
"slot_data": {
"example_option": 1,
"other_option": 2
}
}
]
}
],
"aliases": [
{
"team": 0,
"player": 1,
"alias": "Incompetence"
},
{
"team": 0,
"player": 2,
"alias": "Slot_Name_2"
"players": [
{
"player": 1,
"alias": "Incompetence"
},
{
"player": 2,
"alias": "Slot_Name_2"
}
]
}
],
"player_items_received": [
{
"team": 0,
"player": 1,
"items": [
[1, 1, 1, 0],
[2, 2, 2, 1]
]
},
{
"team": 0,
"player": 2,
"items": [
[1, 1, 1, 2],
[2, 2, 2, 0]
"players": [
{
"player": 1,
"items": [
[1, 1, 1, 0],
[2, 2, 2, 1]
]
},
{
"player": 2,
"items": [
[1, 1, 1, 2],
[2, 2, 2, 0]
]
}
]
}
],
"player_checks_done": [
{
"team": 0,
"player": 1,
"locations": [
1,
2
]
},
{
"team": 0,
"player": 2,
"locations": [
1,
2
"players": [
{
"player": 1,
"locations": [
1,
2
]
},
{
"player": 2,
"locations": [
1,
2
]
}
]
}
],
@@ -326,132 +382,76 @@ Example:
"hints": [
{
"team": 0,
"player": 1,
"hints": [
[1, 2, 4, 6, 0, "", 4, 0]
"players": [
{
"player": 1,
"hints": [
[1, 2, 4, 6, 0, "", 4, 0]
]
},
{
"player": 2,
"hints": []
}
]
},
{
"team": 0,
"player": 2,
"hints": []
}
],
"activity_timers": [
{
"team": 0,
"player": 1,
"time": "Fri, 18 Apr 2025 20:35:45 GMT"
},
{
"team": 0,
"player": 2,
"time": "Fri, 18 Apr 2025 20:42:46 GMT"
"players": [
{
"player": 1,
"time": "Fri, 18 Apr 2025 20:35:45 GMT"
},
{
"player": 2,
"time": "Fri, 18 Apr 2025 20:42:46 GMT"
}
]
}
],
"connection_timers": [
{
"team": 0,
"player": 1,
"time": "Fri, 18 Apr 2025 20:38:25 GMT"
},
{
"team": 0,
"player": 2,
"time": "Fri, 18 Apr 2025 21:03:00 GMT"
"players": [
{
"player": 1,
"time": "Fri, 18 Apr 2025 20:38:25 GMT"
},
{
"player": 2,
"time": "Fri, 18 Apr 2025 21:03:00 GMT"
}
]
}
],
"player_status": [
{
"team": 0,
"player": 1,
"status": 0
},
{
"team": 0,
"player": 2,
"status": 0
}
]
}
```
### `/static_tracker/<suuid:tracker>`
<a name=statictracker></a>
Will provide a dict of static tracker data with the following keys:
- item_link groups and their players (`groups`)
- The datapackage hash for each game (`datapackage`)
- This hash can then be sent to the datapackage API to receive the appropriate datapackage as necessary
- The number of checks found vs. total checks available per player (`player_locations_total`)
- Same logic as the multitracker template: found = len(player_checks_done.locations) / total = player_locations_total.total_locations (all available checks).
Example:
```json
{
"groups": [
{
"slot": 5,
"name": "testGroup",
"members": [
1,
2
]
},
{
"slot": 6,
"name": "myCoolLink",
"members": [
3,
4
"players": [
{
"player": 1,
"status": 0
},
{
"player": 2,
"status": 0
}
]
}
],
"datapackage": {
"Archipelago": {
"checksum": "ac9141e9ad0318df2fa27da5f20c50a842afeecb",
"version": 0
},
"The Messenger": {
"checksum": "6991cbcda7316b65bcb072667f3ee4c4cae71c0b",
}
},
"player_locations_total": [
{
"player": 1,
"team" : 0,
"total_locations": 10
},
{
"player": 2,
"team" : 0,
"total_locations": 20
}
],
}
```
### `/slot_data_tracker/<suuid:tracker>`
<a name=slotdatatracker></a>
Will provide a list of each player's slot_data.
Example:
```json
[
{
"player": 1,
"slot_data": {
"example_option": 1,
"other_option": 3
}
},
{
"player": 2,
"slot_data": {
"example_option": 1,
"other_option": 2
"version": 0
}
}
]
}
```
## User Endpoints
@@ -554,4 +554,4 @@ Example:
"seed_id": "a528e34c-3b4f-42a9-9f8f-00a4fd40bacb"
}
]
```
```

View File

@@ -76,8 +76,8 @@ webhost:
* `game_info_languages` (optional) list of strings for defining the existing game info pages your game supports. The
documents must be prefixed with the same string as defined here. Default already has 'en'.
* `options_presets` (optional) `dict[str, dict[str, Any]]` where the keys are the names of the presets and the values
are the options to be set for that preset. The options are defined as a `dict[str, Any]` where the keys are the names
* `options_presets` (optional) `Dict[str, Dict[str, Any]]` where the keys are the names of the presets and the values
are the options to be set for that preset. The options are defined as a `Dict[str, Any]` where the keys are the names
of the options and the values are the values to be set for that option. These presets will be available for users to
select from on the game's options page.
@@ -753,7 +753,7 @@ from BaseClasses import CollectionState, MultiWorld
from worlds.AutoWorld import LogicMixin
class MyGameState(LogicMixin):
mygame_defeatable_enemies: dict[int, set[str]] # per player
mygame_defeatable_enemies: Dict[int, Set[str]] # per player
def init_mixin(self, multiworld: MultiWorld) -> None:
# Initialize per player with the corresponding "nothing" value, such as 0 or an empty set.
@@ -882,11 +882,11 @@ item/location pairs is unnecessary since the AP server already retains and freel
that request it. The most common usage of slot data is sending option results that the client needs to be aware of.
```python
def fill_slot_data(self) -> dict[str, Any]:
def fill_slot_data(self) -> Dict[str, Any]:
# In order for our game client to handle the generated seed correctly we need to know what the user selected
# for their difficulty and final boss HP.
# A dictionary returned from this method gets set as the slot_data and will be sent to the client after connecting.
# The options dataclass has a method to return a `dict[str, Any]` of each option name provided and the relevant
# The options dataclass has a method to return a `Dict[str, Any]` of each option name provided and the relevant
# option's value.
return self.options.as_dict("difficulty", "final_boss_hp")
```

View File

@@ -74,12 +74,13 @@ class EntranceLookup:
if entrance in self._expands_graph_cache:
return self._expands_graph_cache[entrance]
seen = {entrance.connected_region}
visited = set()
q: deque[Region] = deque()
q.append(entrance.connected_region)
while q:
region = q.popleft()
visited.add(region)
# check if the region itself is progression
if region in region.multiworld.indirect_connections:
@@ -102,8 +103,7 @@ class EntranceLookup:
and exit_ in self._usable_exits):
self._expands_graph_cache[entrance] = True
return True
elif exit_.connected_region and exit_.connected_region not in seen:
seen.add(exit_.connected_region)
elif exit_.connected_region and exit_.connected_region not in visited:
q.append(exit_.connected_region)
self._expands_graph_cache[entrance] = False

14
kvui.py
View File

@@ -720,11 +720,13 @@ class MessageBoxLabel(MDLabel):
class MessageBox(Popup):
def __init__(self, title, text, error=False, **kwargs):
label = MessageBoxLabel(text=text, padding=("6dp", "0dp"))
label = 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 MDNavigationItemBase(MDNavigationItem):
@@ -838,15 +840,15 @@ class GameManager(ThemedApp):
self.log_panels: typing.Dict[str, Widget] = {}
# keep track of last used command to autofill on click
self.last_autofillable_command = "!hint"
autofillable_commands = ("!hint_location", "!hint", "!getitem")
self.last_autofillable_command = "hint"
autofillable_commands = ("hint_location", "hint", "getitem")
original_say = ctx.on_user_say
def intercept_say(text):
text = original_say(text)
if text:
for command in autofillable_commands:
if text.startswith(command):
if text.startswith("!" + command):
self.last_autofillable_command = command
break
return text
@@ -1099,6 +1101,10 @@ class GameManager(ThemedApp):
hints = self.ctx.stored_data.get(f"_read_hints_{self.ctx.team}_{self.ctx.slot}", [])
self.hint_log.refresh_hints(hints)
# default F1 keybind, opens a settings menu, that seems to break the layout engine once closed
def open_settings(self, *largs):
pass
class LogtoUI(logging.Handler):
def __init__(self, on_log):

View File

@@ -22,7 +22,7 @@ SNI_VERSION = "v0.0.100" # change back to "latest" once tray icon issues are fi
# 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.4.0'
requirement = 'cx-Freeze==8.0.0'
try:
import pkg_resources
try:
@@ -65,6 +65,7 @@ from Cython.Build import cythonize
non_apworlds: set[str] = {
"A Link to the Past",
"Adventure",
"ArchipIDLE",
"Archipelago",
"Lufia II Ancient Cave",
"Meritous",
@@ -371,7 +372,6 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
os.makedirs(self.buildfolder / "Players" / "Templates", exist_ok=True)
from Options import generate_yaml_templates
from worlds.AutoWorld import AutoWorldRegister
from worlds.Files import APWorldContainer
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] = []
@@ -380,35 +380,13 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
if worldname not in non_apworlds:
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
world_directory = self.libfolder / "worlds" / file_name
if os.path.isfile(world_directory / "archipelago.json"):
manifest = json.load(open(world_directory / "archipelago.json"))
assert "game" in manifest, (
f"World directory {world_directory} has an archipelago.json manifest file, but it"
"does not define a \"game\"."
)
assert manifest["game"] == worldtype.game, (
f"World directory {world_directory} has an archipelago.json manifest file, but value of the"
f"\"game\" field ({manifest['game']} does not equal the World class's game ({worldtype.game})."
)
else:
manifest = {}
# this method creates an apworld that cannot be moved to a different OS or minor python version,
# which should be ok
zip_path = self.libfolder / "worlds" / (file_name + ".apworld")
apworld = APWorldContainer(str(zip_path))
apworld.minimum_ap_version = version_tuple
apworld.maximum_ap_version = version_tuple
apworld.game = worldtype.game
manifest.update(apworld.get_manifest())
apworld.manifest_path = f"{file_name}/archipelago.json"
with zipfile.ZipFile(zip_path, "x", zipfile.ZIP_DEFLATED,
with zipfile.ZipFile(self.libfolder / "worlds" / (file_name + ".apworld"), "x", zipfile.ZIP_DEFLATED,
compresslevel=9) as zf:
for path in world_directory.rglob("*.*"):
relative_path = os.path.join(*path.parts[path.parts.index("worlds")+1:])
if not relative_path.endswith("archipelago.json"):
zf.write(path, relative_path)
zf.writestr(apworld.manifest_path, json.dumps(manifest))
zf.write(path, relative_path)
folders_to_remove.append(file_name)
shutil.rmtree(world_directory)
shutil.copyfile("meta.yaml", self.buildfolder / "Players" / "Templates" / "meta.yaml")

3
test/TestBase.py Normal file
View File

@@ -0,0 +1,3 @@
from .bases import TestBase, WorldTestBase
from warnings import warn
warn("TestBase was renamed to bases", DeprecationWarning)

View File

@@ -9,7 +9,98 @@ from test.general import gen_steps
from worlds import AutoWorld
from worlds.AutoWorld import World, call_all
from BaseClasses import Location, MultiWorld, CollectionState, Item
from BaseClasses import Location, MultiWorld, CollectionState, ItemClassification, Item
from worlds.alttp.Items import item_factory
class TestBase(unittest.TestCase):
multiworld: MultiWorld
_state_cache = {}
def get_state(self, items):
if (self.multiworld, tuple(items)) in self._state_cache:
return self._state_cache[self.multiworld, tuple(items)]
state = CollectionState(self.multiworld)
for item in items:
item.classification = ItemClassification.progression
state.collect(item, prevent_sweep=True)
state.sweep_for_advancements()
state.update_reachable_regions(1)
self._state_cache[self.multiworld, tuple(items)] = state
return state
def get_path(self, state, region):
def flist_to_iter(node):
while node:
value, node = node
yield value
from itertools import zip_longest
reversed_path_as_flist = state.path.get(region, (region, None))
string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist))))
# Now we combine the flat string list into (region, exit) pairs
pathsiter = iter(string_path_flat)
pathpairs = zip_longest(pathsiter, pathsiter)
return list(pathpairs)
def run_location_tests(self, access_pool):
for i, (location, access, *item_pool) in enumerate(access_pool):
items = item_pool[0]
all_except = item_pool[1] if len(item_pool) > 1 else None
state = self._get_items(item_pool, all_except)
path = self.get_path(state, self.multiworld.get_location(location, 1).parent_region)
with self.subTest(msg="Reach Location", location=location, access=access, items=items,
all_except=all_except, path=path, entry=i):
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), access,
f"failed {self.multiworld.get_location(location, 1)} with: {item_pool}")
# check for partial solution
if not all_except and access: # we are not supposed to be able to reach location with partial inventory
for missing_item in item_pool[0]:
with self.subTest(msg="Location reachable without required item", location=location,
items=item_pool[0], missing_item=missing_item, entry=i):
state = self._get_items_partial(item_pool, missing_item)
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), False,
f"failed {self.multiworld.get_location(location, 1)}: succeeded with "
f"{missing_item} removed from: {item_pool}")
def run_entrance_tests(self, access_pool):
for i, (entrance, access, *item_pool) in enumerate(access_pool):
items = item_pool[0]
all_except = item_pool[1] if len(item_pool) > 1 else None
state = self._get_items(item_pool, all_except)
path = self.get_path(state, self.multiworld.get_entrance(entrance, 1).parent_region)
with self.subTest(msg="Reach Entrance", entrance=entrance, access=access, items=items,
all_except=all_except, path=path, entry=i):
self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), access)
# check for partial solution
if not all_except and access: # we are not supposed to be able to reach location with partial inventory
for missing_item in item_pool[0]:
with self.subTest(msg="Entrance reachable without required item", entrance=entrance,
items=item_pool[0], missing_item=missing_item, entry=i):
state = self._get_items_partial(item_pool, missing_item)
self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), False,
f"failed {self.multiworld.get_entrance(entrance, 1)} with: {item_pool}")
def _get_items(self, item_pool, all_except):
if all_except and len(all_except) > 0:
items = self.multiworld.itempool[:]
items = [item for item in items if
item.name not in all_except and not ("Bottle" in item.name and "AnyBottle" in all_except)]
items.extend(item_factory(item_pool[0], self.multiworld.worlds[1]))
else:
items = item_factory(item_pool[0], self.multiworld.worlds[1])
return self.get_state(items)
def _get_items_partial(self, item_pool, missing_item):
new_items = item_pool[0].copy()
new_items.remove(missing_item)
items = item_factory(new_items, self.multiworld.worlds[1])
return self.get_state(items)
class WorldTestBase(unittest.TestCase):

View File

@@ -1,227 +0,0 @@
#!/usr/bin/env python
# based on python-websockets compression benchmark (c) Aymeric Augustin and contributors
# https://github.com/python-websockets/websockets/blob/main/experiments/compression/benchmark.py
import collections
import time
import zlib
from typing import Iterable
REPEAT = 10
WB, ML = 12, 5 # defaults used as a reference
WBITS = range(9, 16)
MEMLEVELS = range(1, 10)
def benchmark(data: Iterable[bytes]) -> None:
size: dict[int, dict[int, float]] = collections.defaultdict(dict)
duration: dict[int, dict[int, float]] = collections.defaultdict(dict)
for wbits in WBITS:
for memLevel in MEMLEVELS:
encoder = zlib.compressobj(wbits=-wbits, memLevel=memLevel)
encoded = []
print(f"Compressing {REPEAT} times with {wbits=} and {memLevel=}")
t0 = time.perf_counter()
for _ in range(REPEAT):
for item in data:
# Taken from PerMessageDeflate.encode
item = encoder.compress(item) + encoder.flush(zlib.Z_SYNC_FLUSH)
if item.endswith(b"\x00\x00\xff\xff"):
item = item[:-4]
encoded.append(item)
t1 = time.perf_counter()
size[wbits][memLevel] = sum(len(item) for item in encoded) / REPEAT
duration[wbits][memLevel] = (t1 - t0) / REPEAT
raw_size = sum(len(item) for item in data)
print("=" * 79)
print("Compression ratio")
print("=" * 79)
print("\t".join(["wb \\ ml"] + [str(memLevel) for memLevel in MEMLEVELS]))
for wbits in WBITS:
print(
"\t".join(
[str(wbits)]
+ [
f"{100 * (1 - size[wbits][memLevel] / raw_size):.1f}%"
for memLevel in MEMLEVELS
]
)
)
print("=" * 79)
print()
print("=" * 79)
print("CPU time")
print("=" * 79)
print("\t".join(["wb \\ ml"] + [str(memLevel) for memLevel in MEMLEVELS]))
for wbits in WBITS:
print(
"\t".join(
[str(wbits)]
+ [
f"{1000 * duration[wbits][memLevel]:.1f}ms"
for memLevel in MEMLEVELS
]
)
)
print("=" * 79)
print()
print("=" * 79)
print(f"Size vs. {WB} \\ {ML}")
print("=" * 79)
print("\t".join(["wb \\ ml"] + [str(memLevel) for memLevel in MEMLEVELS]))
for wbits in WBITS:
print(
"\t".join(
[str(wbits)]
+ [
f"{100 * (size[wbits][memLevel] / size[WB][ML] - 1):.1f}%"
for memLevel in MEMLEVELS
]
)
)
print("=" * 79)
print()
print("=" * 79)
print(f"Time vs. {WB} \\ {ML}")
print("=" * 79)
print("\t".join(["wb \\ ml"] + [str(memLevel) for memLevel in MEMLEVELS]))
for wbits in WBITS:
print(
"\t".join(
[str(wbits)]
+ [
f"{100 * (duration[wbits][memLevel] / duration[WB][ML] - 1):.1f}%"
for memLevel in MEMLEVELS
]
)
)
print("=" * 79)
print()
def generate_data_package_corpus() -> list[bytes]:
# compared to default 12, 5:
# 11, 4 saves 16K RAM, gives +4.6% size, -5.0% time .. +1.1% time
# 10, 4 saves 20K RAM, gives +10.2% size, -3.8% time .. +0.6% time
# 11, 3 saves 20K RAM, gives +6.5% size, +14.2% time
# 10, 3 saves 24K RAM, gives +12.8% size, +0.5% time .. +6.9% time
# NOTE: time delta is highly unstable; time is ~100ms
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore")
from NetUtils import encode
from worlds import network_data_package
return [encode(network_data_package).encode("utf-8")]
def generate_solo_release_corpus() -> list[bytes]:
# compared to default 12, 5:
# 11, 4 saves 16K RAM, gives +0.9% size, +3.9% time
# 10, 4 saves 20K RAM, gives +1.4% size, +3.4% time
# 11, 3 saves 20K RAM, gives +1.8% size, +13.9% time
# 10, 3 saves 24K RAM, gives +2.1% size, +4.8% time
# NOTE: time delta is highly unstable; time is ~0.4ms
from random import Random
from MultiServer import json_format_send_event
from NetUtils import encode, NetworkItem
r = Random()
r.seed(0)
solo_release = []
solo_release_locations = [r.randint(1000, 1999) for _ in range(200)]
solo_release_items = sorted([r.randint(1000, 1999) for _ in range(200)]) # currently sorted by item
solo_player = 1
for location, item in zip(solo_release_locations, solo_release_items):
flags = r.choice((0, 0, 0, 0, 0, 0, 0, 1, 2, 3))
network_item = NetworkItem(item, location, solo_player, flags)
solo_release.append(json_format_send_event(network_item, solo_player))
solo_release.append({
"cmd": "ReceivedItems",
"index": 0,
"items": solo_release_items,
})
solo_release.append({
"cmd": "RoomUpdate",
"hint_points": 200,
"checked_locations": solo_release_locations,
})
return [encode(solo_release).encode("utf-8")]
def generate_gameplay_corpus() -> list[bytes]:
# compared to default 12, 5:
# 11, 4 saves 16K RAM, gives +13.6% size, +4.1% time
# 10, 4 saves 20K RAM, gives +22.3% size, +2.2% time
# 10, 3 saves 24K RAM, gives +26.2% size, +1.6% time
# NOTE: time delta is highly unstable; time is 4ms
from copy import copy
from random import Random
from MultiServer import json_format_send_event
from NetUtils import encode, NetworkItem
r = Random()
r.seed(0)
gameplay = []
observer = 1
hint_points = 0
index = 0
players = list(range(1, 10))
player_locations = {player: [r.randint(1000, 1999) for _ in range(200)] for player in players}
player_items = {player: [r.randint(1000, 1999) for _ in range(200)] for player in players}
player_receiver = {player: [r.randint(1, len(players)) for _ in range(200)] for player in players}
for i in range(0, len(player_locations[1])):
player_sequence = copy(players)
r.shuffle(player_sequence)
for finder in player_sequence:
flags = r.choice((0, 0, 0, 0, 0, 0, 0, 1, 2, 3))
receiver = player_receiver[finder][i]
item = player_items[finder][i]
location = player_locations[finder][i]
network_item = NetworkItem(item, location, receiver, flags)
gameplay.append(json_format_send_event(network_item, observer))
if finder == observer:
hint_points += 1
gameplay.append({
"cmd": "RoomUpdate",
"hint_points": hint_points,
"checked_locations": [location],
})
if receiver == observer:
gameplay.append({
"cmd": "ReceivedItems",
"index": index,
"items": [item],
})
index += 1
return [encode(gameplay).encode("utf-8")]
def main() -> None:
#corpus = generate_data_package_corpus()
#corpus = generate_solo_release_corpus()
#corpus = generate_gameplay_corpus()
corpus = generate_data_package_corpus() + generate_solo_release_corpus() + generate_gameplay_corpus()
benchmark(corpus)
print(f"raw size: {sum(len(data) for data in corpus)}")
if __name__ == "__main__":
main()

View File

@@ -1,5 +1,5 @@
from argparse import Namespace
from typing import Any, List, Optional, Tuple, Type
from typing import List, Optional, Tuple, Type, Union
from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region
from worlds import network_data_package
@@ -31,8 +31,8 @@ def setup_solo_multiworld(
return setup_multiworld(world_type, steps, seed)
def setup_multiworld(worlds: list[type[World]] | type[World], steps: tuple[str, ...] = gen_steps,
seed: int | None = None, options: dict[str, Any] | list[dict[str, Any]] = None) -> MultiWorld:
def setup_multiworld(worlds: Union[List[Type[World]], Type[World]], steps: Tuple[str, ...] = gen_steps,
seed: Optional[int] = None) -> MultiWorld:
"""
Creates a multiworld with a player for each provided world type, allowing duplicates, setting default options, and
calling the provided gen steps.
@@ -40,27 +40,20 @@ def setup_multiworld(worlds: list[type[World]] | type[World], steps: tuple[str,
:param worlds: Type/s of worlds to generate a multiworld for
:param steps: Gen steps that should be called before returning. Default calls through pre_fill
:param seed: The seed to be used when creating this multiworld
:param options: Options to set on each world. If just one dict of options is passed, it will be used for all worlds.
:return: The generated multiworld
"""
if not isinstance(worlds, list):
worlds = [worlds]
if options is None:
options = [{}] * len(worlds)
elif not isinstance(options, list):
options = [options] * len(worlds)
players = len(worlds)
multiworld = MultiWorld(players)
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)
args = Namespace()
for player, (world_type, option_overrides) in enumerate(zip(worlds, options), 1):
for player, world_type in enumerate(worlds, 1):
for key, option in world_type.options_dataclass.type_hints.items():
updated_options = getattr(args, key, {})
updated_options[player] = option.from_any(option_overrides.get(key, option.default))
updated_options[player] = option.from_any(option.default)
setattr(args, key, updated_options)
multiworld.set_options(args)
multiworld.state = CollectionState(multiworld)

View File

@@ -6,9 +6,9 @@ from Utils import get_intended_text, get_input_text_from_response
class TestClient(unittest.TestCase):
def test_autofill_hint_from_fuzzy_hint(self) -> None:
tests = (
("item", ["item1", "item2"]), # Multiple close matches
("itm", ["item1", "item21"]), # No close match, multiple option
("item", ["item1"]), # No close match, single option
("item", ["item1", "item2"]), # Multiple close matches
("itm", ["item1", "item21"]), # No close match, multiple option
("item", ["item1"]), # No close match, single option
("item", ["\"item\" 'item' (item)"]), # Testing different special characters
)
@@ -16,7 +16,7 @@ class TestClient(unittest.TestCase):
item_name, usable, response = get_intended_text(input_text, possible_answers)
self.assertFalse(usable, "This test must be updated, it seems get_fuzzy_results behavior changed")
hint_command = get_input_text_from_response(response, "!hint")
hint_command = get_input_text_from_response(response, "hint")
self.assertIsNotNone(hint_command,
"The response to fuzzy hints is no longer recognized by the hint autofill")
self.assertEqual(hint_command, f"!hint {item_name}",

View File

@@ -37,11 +37,10 @@ class TestImplemented(unittest.TestCase):
def test_slot_data(self):
"""Tests that if a world creates slot data, it's json serializable."""
# has an await for generate_output which isn't being called
excluded_games = ("Ocarina of Time",)
worlds_to_test = {game: world
for game, world in AutoWorldRegister.world_types.items() if game not in excluded_games}
for game_name, world_type in worlds_to_test.items():
for game_name, world_type in AutoWorldRegister.world_types.items():
# has an await for generate_output which isn't being called
if game_name in {"Ocarina of Time"}:
continue
multiworld = setup_solo_multiworld(world_type)
with self.subTest(game=game_name, seed=multiworld.seed):
distribute_items_restrictive(multiworld)

View File

@@ -150,7 +150,8 @@ class TestBase(unittest.TestCase):
"""Test that worlds don't modify the locality of items after duplicates are resolved"""
gen_steps = ("generate_early",)
additional_steps = ("create_regions", "create_items", "set_rules", "connect_entrances", "generate_basic", "pre_fill")
for game_name, world_type in AutoWorldRegister.world_types.items():
worlds_to_test = {game: world for game, world in AutoWorldRegister.world_types.items()}
for game_name, world_type in worlds_to_test.items():
with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type, gen_steps)
local_items = multiworld.worlds[1].options.local_items.value.copy()

View File

@@ -33,10 +33,7 @@ class TestBase(unittest.TestCase):
def test_location_creation_steps(self):
"""Tests that Regions and Locations aren't created after `create_items`."""
gen_steps = ("generate_early", "create_regions", "create_items")
excluded_games = ("Ocarina of Time", "Pokemon Red and Blue")
worlds_to_test = {game: world
for game, world in AutoWorldRegister.world_types.items() if game not in excluded_games}
for game_name, world_type in worlds_to_test.items():
for game_name, world_type in AutoWorldRegister.world_types.items():
with self.subTest("Game", game_name=game_name):
multiworld = setup_solo_multiworld(world_type, gen_steps)
region_count = len(multiworld.get_regions())
@@ -57,13 +54,13 @@ class TestBase(unittest.TestCase):
call_all(multiworld, "generate_basic")
self.assertEqual(region_count, len(multiworld.get_regions()),
f"{game_name} modified region count during generate_basic")
self.assertEqual(location_count, len(multiworld.get_locations()),
self.assertGreaterEqual(location_count, len(multiworld.get_locations()),
f"{game_name} modified locations count during generate_basic")
call_all(multiworld, "pre_fill")
self.assertEqual(region_count, len(multiworld.get_regions()),
f"{game_name} modified region count during pre_fill")
self.assertEqual(location_count, len(multiworld.get_locations()),
self.assertGreaterEqual(location_count, len(multiworld.get_locations()),
f"{game_name} modified locations count during pre_fill")
def test_location_group(self):

View File

@@ -93,13 +93,3 @@ class TestTracker(TestBase):
headers={"If-Modified-Since": "Wed, 21 Oct 2015 07:28:00"}, # missing timezone
)
self.assertEqual(response.status_code, 400)
def test_tracker_api(self) -> None:
"""Verify that tracker api gives a reply for the room."""
with self.app.test_request_context():
with self.client.open(url_for("api.tracker_data", tracker=self.tracker_uuid)) as response:
self.assertEqual(response.status_code, 200)
with self.client.open(url_for("api.static_tracker_data", tracker=self.tracker_uuid)) as response:
self.assertEqual(response.status_code, 200)
with self.client.open(url_for("api.tracker_slot_data", tracker=self.tracker_uuid)) as response:
self.assertEqual(response.status_code, 200)

View File

@@ -12,7 +12,7 @@ from typing import (Any, Callable, ClassVar, Dict, FrozenSet, Iterable, List, Ma
from Options import item_and_loc_options, ItemsAccessibility, OptionGroup, PerGameCommonOptions
from BaseClasses import CollectionState
from Utils import Version
from Utils import deprecate
if TYPE_CHECKING:
from BaseClasses import MultiWorld, Item, Location, Tutorial, Region, Entrance
@@ -22,10 +22,6 @@ if TYPE_CHECKING:
perf_logger = logging.getLogger("performance")
class InvalidItemError(KeyError):
pass
class AutoWorldRegister(type):
world_types: Dict[str, Type[World]] = {}
__file__: str
@@ -75,10 +71,6 @@ class AutoWorldRegister(type):
if "required_client_version" in base.__dict__:
dct["required_client_version"] = max(dct["required_client_version"],
base.__dict__["required_client_version"])
if "world_version" in dct:
if dct["world_version"] != Version(0, 0, 0):
raise RuntimeError(f"{name} is attempting to set 'world_version' from within the class. world_version "
f"can only be set from manifest.")
# construct class
new_class = super().__new__(mcs, name, bases, dct)
@@ -341,8 +333,6 @@ class World(metaclass=AutoWorldRegister):
"""If loaded from a .apworld, this is the Path to it."""
__file__: ClassVar[str]
"""path it was loaded from"""
world_version: ClassVar[Version] = Version(0, 0, 0)
"""Optional world version loaded from archipelago.json"""
def __init__(self, multiworld: "MultiWorld", player: int):
assert multiworld is not None

View File

@@ -8,8 +8,7 @@ import os
import threading
from io import BytesIO
from typing import (ClassVar, Dict, List, Literal, Tuple, Any, Optional, Union, BinaryIO, overload, Sequence,
TYPE_CHECKING)
from typing import ClassVar, Dict, List, Literal, Tuple, Any, Optional, Union, BinaryIO, overload, Sequence
import bsdiff4
@@ -17,9 +16,6 @@ semaphore = threading.Semaphore(os.cpu_count() or 4)
del threading
if TYPE_CHECKING:
from Utils import Version
class AutoPatchRegister(abc.ABCMeta):
patch_types: ClassVar[Dict[str, AutoPatchRegister]] = {}
@@ -69,7 +65,7 @@ class AutoPatchExtensionRegister(abc.ABCMeta):
return handler
container_version: int = 7
container_version: int = 6
def is_ap_player_container(game: str, data: bytes, player: int):
@@ -96,7 +92,7 @@ class APContainer:
version: ClassVar[int] = container_version
compression_level: ClassVar[int] = 9
compression_method: ClassVar[int] = zipfile.ZIP_DEFLATED
manifest_path: str = "archipelago.json"
path: Optional[str]
def __init__(self, path: Optional[str] = None):
@@ -120,7 +116,7 @@ class APContainer:
except Exception as e:
raise Exception(f"Manifest {manifest} did not convert to json.") from e
else:
opened_zipfile.writestr(self.manifest_path, manifest_str)
opened_zipfile.writestr("archipelago.json", manifest_str)
def read(self, file: Optional[Union[str, BinaryIO]] = None) -> None:
"""Read data into patch object. file can be file-like, such as an outer zip file's stream."""
@@ -141,18 +137,7 @@ class APContainer:
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]:
try:
assert self.manifest_path.endswith("archipelago.json"), "Filename should be archipelago.json"
manifest_info = opened_zipfile.getinfo(self.manifest_path)
except KeyError as e:
for info in opened_zipfile.infolist():
if info.filename.endswith("archipelago.json"):
manifest_info = info
self.manifest_path = info.filename
break
else:
raise e
with opened_zipfile.open(manifest_info, "r") as f:
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 "
@@ -167,33 +152,6 @@ class APContainer:
}
class APWorldContainer(APContainer):
"""A zipfile containing a world implementation."""
game: str | None = None
world_version: "Version | None" = None
minimum_ap_version: "Version | None" = None
maximum_ap_version: "Version | None" = None
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]:
from Utils import tuplize_version
manifest = super().read_contents(opened_zipfile)
self.game = manifest["game"]
for version_key in ("world_version", "minimum_ap_version", "maximum_ap_version"):
if version_key in manifest:
setattr(self, version_key, tuplize_version(manifest[version_key]))
return manifest
def get_manifest(self) -> Dict[str, Any]:
manifest = super().get_manifest()
manifest["game"] = self.game
manifest["compatible_version"] = 7
for version_key in ("world_version", "minimum_ap_version", "maximum_ap_version"):
version = getattr(self, version_key)
if version:
manifest[version_key] = version.as_simple_string()
return manifest
class APPlayerContainer(APContainer):
"""A zipfile containing at least archipelago.json meant for a player"""
game: ClassVar[Optional[str]] = None
@@ -290,8 +248,10 @@ class APProcedurePatch(APAutoPatchInterface):
manifest["compatible_version"] = 5
return manifest
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]:
manifest = super(APProcedurePatch, self).read_contents(opened_zipfile)
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
super(APProcedurePatch, self).read_contents(opened_zipfile)
with opened_zipfile.open("archipelago.json", "r") as f:
manifest = json.load(f)
if "procedure" not in manifest:
# support patching files made before moving to procedures
self.procedure = [("apply_bsdiff4", ["delta.bsdiff4"])]
@@ -300,7 +260,6 @@ class APProcedurePatch(APAutoPatchInterface):
for file in opened_zipfile.namelist():
if file not in ["archipelago.json"]:
self.files[file] = opened_zipfile.read(file)
return manifest
def write_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
super(APProcedurePatch, self).write_contents(opened_zipfile)

View File

@@ -5,7 +5,7 @@ import weakref
from enum import Enum, auto
from typing import Optional, Callable, List, Iterable, Tuple
from Utils import local_path, open_filename, is_frozen, is_kivy_running
from Utils import local_path, open_filename
class Type(Enum):
@@ -177,10 +177,11 @@ def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, path
if module_name == loaded_name:
found_already_loaded = True
break
if found_already_loaded and is_kivy_running():
raise Exception(f"Installed APWorld successfully, but '{module_name}' is already loaded, "
"so a Launcher restart is required to use the new installation.")
world_source = worlds.WorldSource(str(target), is_zip=True, relative=False)
if found_already_loaded:
raise Exception(f"Installed APWorld successfully, but '{module_name}' is already loaded,\n"
"so a Launcher restart is required to use the new installation.\n"
"If the Launcher is not open, no action needs to be taken.")
world_source = worlds.WorldSource(str(target), is_zip=True)
bisect.insort(worlds.world_sources, world_source)
world_source.load()
@@ -196,7 +197,7 @@ def install_apworld(apworld_path: str = "") -> None:
source, target = res
except Exception as e:
import Utils
Utils.messagebox("Notice", str(e), error=True)
Utils.messagebox(e.__class__.__name__, str(e), error=True)
logging.exception(e)
else:
import Utils
@@ -228,6 +229,8 @@ components: List[Component] = [
Component('Zelda 1 Client', 'Zelda1Client', file_identifier=SuffixIdentifier('.aptloz')),
# ChecksFinder
Component('ChecksFinder Client', 'ChecksFinderClient'),
# Starcraft 2
Component('Starcraft 2 Client', 'Starcraft2Client'),
# Zillion
Component('Zillion Client', 'ZillionClient',
file_identifier=SuffixIdentifier('.apzl')),
@@ -242,66 +245,3 @@ icon_paths = {
'icon': local_path('data', 'icon.png'),
'discord': local_path('data', 'discord-mark-blue.png'),
}
if not is_frozen():
def _build_apworlds(*launch_args: str):
import json
import os
import zipfile
from worlds import AutoWorldRegister
from worlds.Files import APWorldContainer
from Launcher import open_folder
import argparse
parser = argparse.ArgumentParser("Build script for APWorlds")
parser.add_argument("worlds", type=str, default=(), nargs="*", help="Names of APWorlds to build.")
args = parser.parse_args(launch_args)
if args.worlds:
games = [(game, AutoWorldRegister.world_types.get(game, None)) for game in args.worlds]
else:
games = [(worldname, worldtype) for worldname, worldtype in AutoWorldRegister.world_types.items()
if not worldtype.zip_path]
apworlds_folder = os.path.join("build", "apworlds")
os.makedirs(apworlds_folder, exist_ok=True)
for worldname, worldtype in games:
if not worldtype:
logging.error(f"Requested APWorld \"{worldname}\" does not exist.")
continue
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
world_directory = os.path.join("worlds", file_name)
if os.path.isfile(os.path.join(world_directory, "archipelago.json")):
manifest = json.load(open(os.path.join(world_directory, "archipelago.json")))
assert "game" in manifest, (
f"World directory {world_directory} has an archipelago.json manifest file, but it"
"does not define a \"game\"."
)
assert manifest["game"] == worldtype.game, (
f"World directory {world_directory} has an archipelago.json manifest file, but value of the"
f"\"game\" field ({manifest['game']} does not equal the World class's game ({worldtype.game})."
)
else:
manifest = {}
zip_path = os.path.join(apworlds_folder, file_name + ".apworld")
apworld = APWorldContainer(str(zip_path))
apworld.game = worldtype.game
manifest.update(apworld.get_manifest())
apworld.manifest_path = f"{file_name}/archipelago.json"
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED,
compresslevel=9) as zf:
for path in pathlib.Path(world_directory).rglob("*"):
relative_path = os.path.join(*path.parts[path.parts.index("worlds") + 1:])
if "__MACOSX" in relative_path or ".DS_STORE" in relative_path or "__pycache__" in relative_path:
continue
if not relative_path.endswith("archipelago.json"):
zf.write(path, relative_path)
zf.writestr(apworld.manifest_path, json.dumps(manifest))
open_folder(apworlds_folder)
components.append(Component('Build APWorlds', func=_build_apworlds, cli=True,
description="Build APWorlds from loose-file world folders."))

View File

@@ -7,11 +7,10 @@ import warnings
import zipimport
import time
import dataclasses
import json
from typing import List
from NetUtils import DataPackage
from Utils import local_path, user_path, Version, version_tuple, tuplize_version
from Utils import local_path, user_path
local_folder = os.path.dirname(__file__)
user_folder = user_path("worlds") if user_path() != local_path() else user_path("custom_worlds")
@@ -39,7 +38,6 @@ class WorldSource:
is_zip: bool = False
relative: bool = True # relative to regular world import folder
time_taken: float = -1.0
version: Version = Version(0, 0, 0)
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.path}, is_zip={self.is_zip}, relative={self.relative})"
@@ -104,93 +102,12 @@ for folder in (folder for folder in (user_folder, local_folder) if folder):
# import all submodules to trigger AutoWorldRegister
world_sources.sort()
apworlds: list[WorldSource] = []
for world_source in world_sources:
# load all loose files first:
if world_source.is_zip:
apworlds.append(world_source)
else:
world_source.load()
from .AutoWorld import AutoWorldRegister
for world_source in world_sources:
if not world_source.is_zip:
# look for manifest
manifest = {}
for dirpath, dirnames, filenames in os.walk(world_source.resolved_path):
for file in filenames:
if file.endswith("archipelago.json"):
manifest = json.load(open(os.path.join(dirpath, file), "r"))
break
if manifest:
break
game = manifest.get("game")
if game in AutoWorldRegister.world_types:
AutoWorldRegister.world_types[game].world_version = tuplize_version(manifest.get("world_version", "0.0.0"))
if apworlds:
# encapsulation for namespace / gc purposes
def load_apworlds() -> None:
global apworlds
from .Files import APWorldContainer, InvalidDataError
core_compatible: list[tuple[WorldSource, APWorldContainer]] = []
def fail_world(game_name: str, reason: str, add_as_failed_to_load: bool = True) -> None:
if add_as_failed_to_load:
failed_world_loads.append(game_name)
logging.warning(reason)
for apworld_source in apworlds:
apworld: APWorldContainer = APWorldContainer(apworld_source.resolved_path)
# populate metadata
try:
apworld.read()
except InvalidDataError as e:
if version_tuple < (0, 7, 0):
logging.error(
f"Invalid or missing manifest file for {apworld_source.resolved_path}. "
"This apworld will stop working with Archipelago 0.7.0."
)
logging.error(e)
else:
raise e
if apworld.minimum_ap_version and apworld.minimum_ap_version > version_tuple:
fail_world(apworld.game,
f"Did not load {apworld_source.path} "
f"as its minimum core version {apworld.minimum_ap_version} "
f"is higher than current core version {version_tuple}.")
elif apworld.maximum_ap_version and apworld.maximum_ap_version < version_tuple:
fail_world(apworld.game,
f"Did not load {apworld_source.path} "
f"as its maximum core version {apworld.maximum_ap_version} "
f"is lower than current core version {version_tuple}.")
else:
core_compatible.append((apworld_source, apworld))
# load highest version first
core_compatible.sort(
key=lambda element: element[1].world_version if element[1].world_version else Version(0, 0, 0),
reverse=True)
for apworld_source, apworld in core_compatible:
if apworld.game and apworld.game in AutoWorldRegister.world_types:
fail_world(apworld.game,
f"Did not load {apworld_source.path} "
f"as its game {apworld.game} is already loaded.",
add_as_failed_to_load=False)
else:
apworld_source.load()
if apworld.game in AutoWorldRegister.world_types:
# world could fail to load at this point
if apworld.world_version:
AutoWorldRegister.world_types[apworld.game].world_version = apworld.world_version
load_apworlds()
del load_apworlds
del apworlds
world_source.load()
# Build the data package for each game.
from .AutoWorld import AutoWorldRegister
network_data_package: DataPackage = {
"games": {world_name: world.get_data_package_data() for world_name, world in AutoWorldRegister.world_types.items()},
}

View File

@@ -19,7 +19,7 @@ class GameData:
"""
:param data:
"""
self.abilities: Dict[int, AbilityData] = {a.ability_id: AbilityData(self, a) for a in data.abilities if a.available}
self.abilities: Dict[int, AbilityData] = {}
self.units: Dict[int, UnitTypeData] = {u.unit_id: UnitTypeData(self, u) for u in data.units if u.available}
self.upgrades: Dict[int, UpgradeData] = {u.upgrade_id: UpgradeData(self, u) for u in data.upgrades}
# Cached UnitTypeIds so that conversion does not take long. This needs to be moved elsewhere if a new GameData object is created multiple times per game
@@ -40,7 +40,7 @@ class AbilityData:
self._proto = proto
# What happens if we comment this out? Should this not be commented out? What is its purpose?
# assert self.id != 0 # let the world burn
assert self.id != 0
def __repr__(self) -> str:
return f"AbilityData(name={self._proto.button_name})"

View File

@@ -623,23 +623,6 @@ class ParadeTrapWeight(Range):
default = 20
class DeathLinkAmnesty(Range):
"""Amount of forgiven deaths before sending a Death Link.
0 means that every death will send a Death Link."""
display_name = "Death Link Amnesty"
range_start = 0
range_end = 20
default = 0
class DWDeathLinkAmnesty(Range):
"""Amount of forgiven deaths before sending a Death Link during Death Wish levels."""
display_name = "Death Wish Amnesty"
range_start = 0
range_end = 30
default = 5
@dataclass
class AHITOptions(PerGameCommonOptions):
start_inventory_from_pool: StartInventoryPool
@@ -717,8 +700,6 @@ class AHITOptions(PerGameCommonOptions):
ParadeTrapWeight: ParadeTrapWeight
death_link: DeathLink
death_link_amnesty: DeathLinkAmnesty
dw_death_link_amnesty: DWDeathLinkAmnesty
ahit_option_groups: Dict[str, List[Any]] = {
@@ -788,6 +769,4 @@ slot_data_options: List[str] = [
"MaxPonCost",
"death_link",
"death_link_amnesty",
"dw_death_link_amnesty",
]

View File

@@ -0,0 +1,22 @@
import unittest
from argparse import Namespace
from BaseClasses import MultiWorld, CollectionState
from worlds import AutoWorldRegister
class LTTPTestBase(unittest.TestCase):
def world_setup(self):
from worlds.alttp.Options import Medallion
self.multiworld = MultiWorld(1)
self.multiworld.game[1] = "A Link to the Past"
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
self.world.options.turtle_rock_medallion.value = Medallion.option_quake

View File

@@ -1,113 +0,0 @@
import unittest
from argparse import Namespace
from BaseClasses import MultiWorld, CollectionState, ItemClassification
from worlds import AutoWorldRegister
from ..Items import item_factory
class TestBase(unittest.TestCase):
multiworld: MultiWorld
_state_cache = {}
def get_state(self, items):
if (self.multiworld, tuple(items)) in self._state_cache:
return self._state_cache[self.multiworld, tuple(items)]
state = CollectionState(self.multiworld)
for item in items:
item.classification = ItemClassification.progression
state.collect(item, prevent_sweep=True)
state.sweep_for_advancements()
state.update_reachable_regions(1)
self._state_cache[self.multiworld, tuple(items)] = state
return state
def get_path(self, state, region):
def flist_to_iter(node):
while node:
value, node = node
yield value
from itertools import zip_longest
reversed_path_as_flist = state.path.get(region, (region, None))
string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist))))
# Now we combine the flat string list into (region, exit) pairs
pathsiter = iter(string_path_flat)
pathpairs = zip_longest(pathsiter, pathsiter)
return list(pathpairs)
def run_location_tests(self, access_pool):
for i, (location, access, *item_pool) in enumerate(access_pool):
items = item_pool[0]
all_except = item_pool[1] if len(item_pool) > 1 else None
state = self._get_items(item_pool, all_except)
path = self.get_path(state, self.multiworld.get_location(location, 1).parent_region)
with self.subTest(msg="Reach Location", location=location, access=access, items=items,
all_except=all_except, path=path, entry=i):
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), access,
f"failed {self.multiworld.get_location(location, 1)} with: {item_pool}")
# check for partial solution
if not all_except and access: # we are not supposed to be able to reach location with partial inventory
for missing_item in item_pool[0]:
with self.subTest(msg="Location reachable without required item", location=location,
items=item_pool[0], missing_item=missing_item, entry=i):
state = self._get_items_partial(item_pool, missing_item)
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), False,
f"failed {self.multiworld.get_location(location, 1)}: succeeded with "
f"{missing_item} removed from: {item_pool}")
def run_entrance_tests(self, access_pool):
for i, (entrance, access, *item_pool) in enumerate(access_pool):
items = item_pool[0]
all_except = item_pool[1] if len(item_pool) > 1 else None
state = self._get_items(item_pool, all_except)
path = self.get_path(state, self.multiworld.get_entrance(entrance, 1).parent_region)
with self.subTest(msg="Reach Entrance", entrance=entrance, access=access, items=items,
all_except=all_except, path=path, entry=i):
self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), access)
# check for partial solution
if not all_except and access: # we are not supposed to be able to reach location with partial inventory
for missing_item in item_pool[0]:
with self.subTest(msg="Entrance reachable without required item", entrance=entrance,
items=item_pool[0], missing_item=missing_item, entry=i):
state = self._get_items_partial(item_pool, missing_item)
self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), False,
f"failed {self.multiworld.get_entrance(entrance, 1)} with: {item_pool}")
def _get_items(self, item_pool, all_except):
if all_except and len(all_except) > 0:
items = self.multiworld.itempool[:]
items = [item for item in items if
item.name not in all_except and not ("Bottle" in item.name and "AnyBottle" in all_except)]
items.extend(item_factory(item_pool[0], self.multiworld.worlds[1]))
else:
items = item_factory(item_pool[0], self.multiworld.worlds[1])
return self.get_state(items)
def _get_items_partial(self, item_pool, missing_item):
new_items = item_pool[0].copy()
new_items.remove(missing_item)
items = item_factory(new_items, self.multiworld.worlds[1])
return self.get_state(items)
class LTTPTestBase(unittest.TestCase):
def world_setup(self):
from worlds.alttp.Options import Medallion
self.multiworld = MultiWorld(1)
self.multiworld.game[1] = "A Link to the Past"
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
self.world.options.turtle_rock_medallion.value = Medallion.option_quake

View File

@@ -5,7 +5,7 @@ from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import item_factory
from worlds.alttp.Regions import create_regions
from worlds.alttp.Shops import create_shops
from worlds.alttp.test.bases import LTTPTestBase
from worlds.alttp.test import LTTPTestBase
class TestDungeon(LTTPTestBase):

View File

@@ -1,12 +1,13 @@
from ...Dungeons import get_dungeon_item_pool
from ...EntranceShuffle import link_inverted_entrances
from ...InvertedRegions import create_inverted_regions
from ...ItemPool import difficulties
from ...Items import item_factory
from ...Regions import mark_light_world_regions
from ...Shops import create_shops
from worlds.alttp.Dungeons import get_dungeon_item_pool
from worlds.alttp.EntranceShuffle import link_inverted_entrances
from worlds.alttp.InvertedRegions import create_inverted_regions
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import item_factory
from worlds.alttp.Regions import mark_light_world_regions
from worlds.alttp.Shops import create_shops
from test.bases import TestBase
from ..bases import LTTPTestBase, TestBase
from worlds.alttp.test import LTTPTestBase
class TestInverted(TestBase, LTTPTestBase):

View File

@@ -4,7 +4,7 @@ from worlds.alttp.EntranceShuffle import connect_entrance, Inverted_LW_Entrances
from worlds.alttp.InvertedRegions import create_inverted_regions
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Rules import set_inverted_big_bomb_rules
from worlds.alttp.test.bases import LTTPTestBase
from worlds.alttp.test import LTTPTestBase
class TestInvertedBombRules(LTTPTestBase):

View File

@@ -1,13 +1,14 @@
from ...Dungeons import get_dungeon_item_pool
from ...EntranceShuffle import link_inverted_entrances
from ...InvertedRegions import create_inverted_regions
from ...ItemPool import difficulties
from ...Items import item_factory
from ...Options import GlitchesRequired
from ...Regions import mark_light_world_regions
from ...Shops import create_shops
from worlds.alttp.Dungeons import get_dungeon_item_pool
from worlds.alttp.EntranceShuffle import link_inverted_entrances
from worlds.alttp.InvertedRegions import create_inverted_regions
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import item_factory
from worlds.alttp.Options import GlitchesRequired
from worlds.alttp.Regions import mark_light_world_regions
from worlds.alttp.Shops import create_shops
from test.bases import TestBase
from ..bases import LTTPTestBase, TestBase
from worlds.alttp.test import LTTPTestBase
class TestInvertedMinor(TestBase, LTTPTestBase):

View File

@@ -1,13 +1,14 @@
from ...Dungeons import get_dungeon_item_pool
from ...EntranceShuffle import link_inverted_entrances
from ...InvertedRegions import create_inverted_regions
from ...ItemPool import difficulties
from ...Items import item_factory
from ...Options import GlitchesRequired
from ...Regions import mark_light_world_regions
from ...Shops import create_shops
from worlds.alttp.Dungeons import get_dungeon_item_pool
from worlds.alttp.EntranceShuffle import link_inverted_entrances
from worlds.alttp.InvertedRegions import create_inverted_regions
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import item_factory
from worlds.alttp.Options import GlitchesRequired
from worlds.alttp.Regions import mark_light_world_regions
from worlds.alttp.Shops import create_shops
from test.bases import TestBase
from ..bases import LTTPTestBase, TestBase
from worlds.alttp.test import LTTPTestBase
class TestInvertedOWG(TestBase, LTTPTestBase):

View File

@@ -1,5 +1,5 @@
from ...ItemPool import difficulties
from ..bases import TestBase
from worlds.alttp.ItemPool import difficulties
from test.bases import TestBase
base_items = 41
extra_counts = (15, 15, 10, 5, 25)

View File

@@ -1,10 +1,11 @@
from ...Dungeons import get_dungeon_item_pool
from ...InvertedRegions import mark_dark_world_regions
from ...ItemPool import difficulties
from ...Items import item_factory
from ...Options import GlitchesRequired
from worlds.alttp.Dungeons import get_dungeon_item_pool
from worlds.alttp.InvertedRegions import mark_dark_world_regions
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import item_factory
from test.bases import TestBase
from worlds.alttp.Options import GlitchesRequired
from ..bases import LTTPTestBase, TestBase
from worlds.alttp.test import LTTPTestBase
class TestMinor(TestBase, LTTPTestBase):

View File

@@ -1,10 +1,11 @@
from ...Dungeons import get_dungeon_item_pool
from ...InvertedRegions import mark_dark_world_regions
from ...ItemPool import difficulties
from ...Items import item_factory
from ...Options import GlitchesRequired
from worlds.alttp.Dungeons import get_dungeon_item_pool
from worlds.alttp.InvertedRegions import mark_dark_world_regions
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import item_factory
from test.bases import TestBase
from worlds.alttp.Options import GlitchesRequired
from ..bases import LTTPTestBase, TestBase
from worlds.alttp.test import LTTPTestBase
class TestVanillaOWG(TestBase, LTTPTestBase):

View File

@@ -1,5 +1,5 @@
from ...Shops import shop_table
from ..bases import TestBase
from worlds.alttp.Shops import shop_table
from test.bases import TestBase
class TestSram(TestBase):

View File

@@ -1,10 +1,10 @@
from ...Dungeons import get_dungeon_item_pool
from ...InvertedRegions import mark_dark_world_regions
from ...ItemPool import difficulties
from ...Items import item_factory
from ...Options import GlitchesRequired
from ..bases import LTTPTestBase, TestBase
from worlds.alttp.Dungeons import get_dungeon_item_pool
from worlds.alttp.InvertedRegions import mark_dark_world_regions
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import item_factory
from test.bases import TestBase
from worlds.alttp.Options import GlitchesRequired
from worlds.alttp.test import LTTPTestBase
class TestVanilla(TestBase, LTTPTestBase):

View File

@@ -23,7 +23,7 @@ game you play will make sure that every game has its own save game.
Unzip the Aquaria randomizer release and copy all unzipped files in the Aquaria game folder. The unzipped files are:
- aquaria_randomizer.exe
- OpenAL32.dll
- randomizer_files (directory)
- override (directory)
- SDL2.dll
- usersettings.xml
- wrap_oal.dll
@@ -32,10 +32,7 @@ Unzip the Aquaria randomizer release and copy all unzipped files in the Aquaria
If there is a conflict between files in the original game folder and the unzipped files, you should overwrite
the original files with the ones from the unzipped randomizer.
There is multiple way to start the game. The easiest one is using the launcher. To do that, just run
the `aquaria_randomizer.exe` file.
You can also launch the randomizer using the command line interface (you can open the command line interface
Finally, to launch the randomizer, you must use the command line interface (you can open the command line interface
by typing `cmd` in the address bar of the Windows File Explorer). Here is the command line used to start the
randomizer:
@@ -52,17 +49,15 @@ aquaria_randomizer.exe --name YourName --server theServer:thePort --password th
### Linux when using the AppImage
If you use the AppImage, just copy it into the Aquaria game folder. You then have to make it executable. You
can do that from the command line by using:
can do that from command line by using:
```bash
chmod +x Aquaria_Randomizer-*.AppImage
```
or by using the Graphical file Explorer of your system (the permission can generally be set in the file properties).
or by using the Graphical Explorer of your system.
To launch the randomizer using the integrated launcher, just execute the AppImage file.
You can also use command line arguments to set the server and slot of your game:
To launch the randomizer, just launch in command line:
```bash
./Aquaria_Randomizer-*.AppImage --name YourName --server theServer:thePort
@@ -84,7 +79,7 @@ the original game will stop working. Copying the folder will guarantee that the
Untar the Aquaria randomizer release and copy all extracted files in the Aquaria game folder. The extracted files are:
- aquaria_randomizer
- randomizer_files (directory)
- override (directory)
- usersettings.xml
- cacert.pem
@@ -92,7 +87,7 @@ If there is a conflict between files in the original game folder and the extract
the original files with the ones from the extracted randomizer files.
Then, you should use your system package manager to install `liblua5`, `libogg`, `libvorbis`, `libopenal` and `libsdl2`.
On Debian base systems (like Ubuntu), you can use the following command:
On Debian base system (like Ubuntu), you can use the following command:
```bash
sudo apt install liblua5.1-0-dev libogg-dev libvorbis-dev libopenal-dev libsdl2-dev
@@ -102,9 +97,7 @@ Also, if there are certain `.so` files in the original Aquaria game folder (`lib
`libSDL-1.2.so.0` and `libstdc++.so.6`), you should remove them from the Aquaria Randomizer game folder. Those are
old libraries that will not work on the recent build of the randomizer.
To launch the randomizer using the integrated launcher, just execute the `aquaria_randomizer` file.
You can also use command line arguments to set the server and slot of your game:
To launch the randomizer, just launch in command line:
```bash
./aquaria_randomizer --name YourName --server theServer:thePort
@@ -122,20 +115,6 @@ sure that your executable has executable permission:
```bash
chmod +x aquaria_randomizer
```
### Steam deck
On the Steamdeck, go in desktop mode and follow the same procedure as the Linux Appimage.
### No sound on Linux/Steam deck
If your game play without problems, but with no sound, the game probably does not use the correct
driver for the sound system. To fix that, you can use `ALSOFT_DRIVERS=pulse` before your command
line to make it work. Something like this (depending on the way you launch the randomizer):
```bash
ALSOFT_DRIVERS=pulse ./Aquaria_Randomizer-*.AppImage --name YourName --server theServer:thePort
```
## Auto-Tracking

View File

@@ -2,12 +2,12 @@
## Logiciels nécessaires
- Une copie du jeu Aquaria non modifiée (disponible sur la majorité des sites de ventes de jeux vidéos en ligne)
- Une copie du jeu Aquaria non-modifiée (disponible sur la majorité des sites de ventes de jeux vidéos en ligne)
- Le client du Randomizer d'Aquaria [Aquaria randomizer](https://github.com/tioui/Aquaria_Randomizer/releases/latest)
## Logiciels optionnels
- De manière optionnelle, pour pouvoir envoyer des [commandes](/tutorial/Archipelago/commands/en) comme `!hint`: utilisez le client texte de [la version la plus récente d'Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest)
- De manière optionnel, pour pouvoir envoyer des [commandes](/tutorial/Archipelago/commands/en) comme `!hint`: utilisez le client texte de [la version la plus récente d'Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest)
- [Aquaria AP Tracker](https://github.com/palex00/aquaria-ap-tracker/releases/latest), pour utiliser avec [PopTracker](https://github.com/black-sliver/PopTracker/releases/latest)
## Procédures d'installation et d'exécution
@@ -25,7 +25,7 @@ Désarchiver le randomizer d'Aquaria et copier tous les fichiers de l'archive da
fichier d'archive devrait contenir les fichiers suivants:
- aquaria_randomizer.exe
- OpenAL32.dll
- randomizer_files (directory)
- override (directory)
- SDL2.dll
- usersettings.xml
- wrap_oal.dll
@@ -34,10 +34,7 @@ fichier d'archive devrait contenir les fichiers suivants:
S'il y a des conflits entre les fichiers de l'archive zip et les fichiers du jeu original, vous devez utiliser
les fichiers contenus dans l'archive zip.
Il y a plusieurs manières de lancer le randomizer. Le plus simple consiste à utiliser le lanceur intégré en
exécutant simplement le fichier `aquaria_randomizer.exe`.
Il est également possible de lancer le randomizer en utilisant la ligne de commande (vous pouvez ouvrir une interface de
Finalement, pour lancer le randomizer, vous devez utiliser la ligne de commande (vous pouvez ouvrir une interface de
ligne de commande, entrez l'adresse `cmd` dans la barre d'adresse de l'explorateur de fichier de Windows). Voici
la ligne de commande à utiliser pour lancer le randomizer:
@@ -60,12 +57,9 @@ le mettre exécutable. Vous pouvez mettre le fichier exécutable avec la command
chmod +x Aquaria_Randomizer-*.AppImage
```
ou bien en utilisant l'explorateur de fichier graphique de votre système (la permission d'exécution est
généralement dans les propriétés du fichier).
ou bien en utilisant l'explorateur graphique de votre système.
Pour lancer le randomizer en utilisant le lanceur intégré, seulement exécuter le fichier AppImage.
Vous pouvez également lancer le randomizer en spécifiant les informations de connexion dans les arguments de la ligne de commande:
Pour lancer le randomizer, utiliser la commande suivante:
```bash
./Aquaria_Randomizer-*.AppImage --name VotreNom --server LeServeur:LePort
@@ -89,7 +83,7 @@ avant de déposer le randomizer à l'intérieur permet de vous assurer de garder
Désarchiver le fichier tar et copier tous les fichiers qu'il contient dans le répertoire du jeu d'origine d'Aquaria. Les
fichiers extraient du fichier tar devraient être les suivants:
- aquaria_randomizer
- randomizer_files (directory)
- override (directory)
- usersettings.xml
- cacert.pem
@@ -108,10 +102,7 @@ Notez également que s'il y a des fichiers ".so" dans le répertoire d'Aquaria (
`libSDL-1.2.so.0` and `libstdc++.so.6`), vous devriez les retirer. Il s'agit de vieille version des librairies qui
ne sont plus fonctionnelles dans les systèmes modernes et qui pourrait empêcher le randomizer de fonctionner.
Pour lancer le randomizer en utilisant le lanceur intégré, seulement exécuter le fichier `aquaria_randomizer`.
Vous pouvez également lancer le randomizer en spécifiant les information de connexion dans les arguments de la
ligne de commande:
Pour lancer le randomizer, utiliser la commande suivante:
```bash
./aquaria_randomizer --name VotreNom --server LeServeur:LePort
@@ -129,21 +120,6 @@ pour vous assurer que votre fichier est exécutable:
```bash
chmod +x aquaria_randomizer
```
### Steam Deck
Pour installer le randomizer sur la Steam Deck, seulement suivre la procédure pour les fichiers AppImage
indiquée précédemment.
### Aucun son sur Linux/Steam Deck
Si le jeu fonctionne sans problème, mais qu'il n'y a aucun son, c'est probablement parce que le jeu
n'arrive pas à utiliser le bon pilote de son. Généralement, le problème est réglé en ajoutant la
variable d'environnement `ALSOFT_DRIVERS=pulse`. Voici un exemple (peut varier en fonction de la manière
que le randomizer est lancé):
```bash
ALSOFT_DRIVERS=pulse ./Aquaria_Randomizer-*.AppImage --name VotreNom --server LeServeur:LePort
```
## Tracking automatique

315
worlds/archipidle/Items.py Normal file
View File

@@ -0,0 +1,315 @@
item_table = (
'An Old GeoCities Profile',
'Very Funny Joke',
'Motivational Video',
'Staples Easy Button',
'One Million Dollars',
'Replica Master Sword',
'VHS Copy of Jurassic Park',
'32GB USB Drive',
'Pocket Protector',
'Leftover Parts from IKEA Furniture',
'Half-Empty Ink Cartridge for a Printer',
'Watch Battery',
'Towel',
'Scarf',
'2012 Magic the Gathering Core Set Starter Box',
'Poke\'mon Booster Pack',
'USB Speakers',
'Eco-Friendly Spork',
'Cheeseburger',
'Brand New Car',
'Hunting Knife',
'Zippo Lighter',
'Red Shirt',
'One-Up Mushroom',
'Nokia N-GAGE',
'2-Liter of Sprite',
'Free trial of the critically acclaimed MMORPG Final Fantasy XIV, including the entirety of A Realm Reborn and the award winning Heavensward and Stormblood expansions up to level 70 with no restrictions on playtime!',
'Can of Compressed Air',
'Striped Kitten',
'USB Power Adapter',
'Fortune Cookie',
'Nintendo Power Glove',
'The Lampshade of No Real Significance',
'Kneepads of Allure',
'Get Out of Jail Free Card',
'Box Set of Stargate SG-1 Season 4',
'The Missing Left Sock',
'Poster Tube',
'Electronic Picture Frame',
'Bottle of Shampoo',
'Your Mission, Should You Choose To Accept It',
'Fanny Pack',
'Robocop T-Shirt',
'Suspiciously Small Monocle',
'Table Saw',
'Cookies and Cream Milkshake',
'Deflated Accordion',
'Grandma\'s Homemade Pie',
'Invisible Lego on the Floor',
'Pitfall Trap',
'Flathead Screwdriver',
'Leftover Pizza',
'Voodoo Doll that Looks Like You',
'Pink Shoelaces',
'Half a Bottle of Scotch',
'Reminder Not to Forget Aginah',
'Medicine Ball',
'Yoga Mat',
'Chocolate Orange',
'Old Concert Tickets',
'The Pick of Destiny',
'McGuffin',
'Just a Regular McMuffin',
'34 Tacos',
'Duct Tape',
'Copy of Untitled Goose Game',
'Partially Used Bed Bath & Beyond Gift Card',
'Mostly Popped Bubble Wrap',
'Expired Driver\'s License',
'The Look, You Know the One',
'Transformers Lunch Box',
'MP3 Player',
'Dry Sharpie',
'Chalkboard Eraser',
'Overhead Projector',
'Physical Copy of the Japanese 1.0 Link to the Past',
'Collectable Action Figure',
'Box Set of The Lord of the Rings Books',
'Lite-Bright',
'Stories from the Good-Old-Days',
'Un-Reproducable Bug Reports',
'Autographed Copy of Shaq-Fu',
'Game-Winning Baseball',
'Portable Battery Bank',
'Blockbuster Membership Card',
'Offensive Bumper Sticker',
'Last Sunday\'s Crossword Puzzle',
'Rubik\'s Cube',
'Your First Grey Hair',
'Embarrassing Childhood Photo',
'Abandoned Sphere One Check',
'The Internet',
'Late-Night Cartoons',
'The Correct Usage of a Semicolon',
'Microsoft Windows 95 Resource Kit',
'Car-Phone',
'Walkman Radio',
'Relevant XKCD Comic',
'Razor Scooter',
'Set of Beyblades',
'Box of Pogs',
'Beanie-Baby Collection',
'Laser Tag Gun',
'Radio Controlled Car',
'Boogie Board',
'Air Jordans',
'Rubber Duckie',
'The Last Cookie in the Cookie Jar',
'Tin-Foil Hat',
'Button-Up Shirt',
'Designer Brand Bag',
'Trapper Keeper',
'Fake Moustache',
'Colored Pencils',
'Pair of 3D Glasses',
'Pair of Movie Tickets',
'Refrigerator Magnets',
'NASCAR Dinner Plates',
'The Final Boss',
'Unskippable Cutscenes',
'24 Rolls of Toilet Paper',
'Canned Soup',
'Warm Blanket',
'3D Printer',
'Jetpack',
'Hoverboard',
'Joycons with No Drift',
'Double Rainbow',
'Ping Pong Ball',
'Area 51 Arcade Cabinet',
'Elephant in the Room',
'The Pink Panther',
'Denim Shorts',
'Tennis Racket',
'Collection of Stuffed Animals',
'Old Cell Phone',
'Nintendo Virtual Boy',
'Box of 5.25 Inch Floppy Disks',
'Bag of Miscellaneous Wires',
'Garden Shovel',
'Leather Gloves',
'Knife of +9 VS Ogres',
'Old, Smelly Cheese',
'Linksys BEFSR41 Router',
'Ethernet Cables for a LAN Party',
'Mechanical Pencil',
'Book of Graph Paper',
'300 Sheets of Printer Paper',
'One AAA Battery',
'Box of Old Game Controllers',
'Sega Dreamcast',
'Mario\'s Overalls',
'Betamax Player',
'Stray Lego',
'Chocolate Chip Pancakes',
'Two Blueberry Muffins',
'Nintendo 64 Controller with a Perfect Thumbstick',
'Cuckoo Crossing the Road',
'One Eyed, One Horned, Flying Purple People-Eater',
'Love Potion Number Nine',
'Wireless Headphones',
'Festive Keychain',
'Bundle of Twisted Cables',
'Plank of Wood',
'Broken Ant Farm',
'Thirty-six American Dollars',
'Can of Shaving Cream',
'Blue Hair Dye',
'Mug Engraved with the AP Logo',
'Tube of Toothpaste',
'Album of Elevator Music',
'Headlight Fluid',
'Tickets to the Renaissance Faire',
'Bag of Golf Balls',
'Box of Packing Peanuts',
'Bottle of Peanut Butter',
'Breath of the Wild Cookbook',
'Stardew Valley Cookbook',
'Thirteen Angry Chickens',
'Bowl of Cereal',
'Rubber Snake',
'Stale Sunflower Seeds',
'Alarm Clock Without a Snooze Button',
'Wet Pineapple',
'Set of Scented Candles',
'Adorable Stuffed Animal',
'The Broodwitch',
'Old Photo Album',
'Trade Quest Item',
'Pair of Fancy Boots',
'Shoddy Pickaxe',
'Adventurer\'s Sword',
'Cute Puppy',
'Box of Matches',
'Set of Allen Wrenches',
'Glass of Water',
'Magic Shaggy Carpet',
'Macaroni and Cheese',
'Chocolate Chip Cookie Dough Ice Cream',
'Fresh Strawberries',
'Delicious Tacos',
'The Krabby Patty Recipe',
'Map to Waldo\'s Location',
'Stray Cat',
'Ham and Cheese Sandwich',
'DVD Player',
'Motorcycle Helmet',
'Fake Flowers',
'6-Pack of Sponges',
'Heated Pants',
'Empty Glass Bottle',
'Brown Paper Bag',
'Model Train Set',
'TV Remote',
'RC Car',
'Super Soaker 9000',
'Giant Sunglasses',
'World\'s Smallest Violin',
'Pile of Fresh Warm Laundry',
'Half-Empty Ice Cube Tray',
'Bob Ross Afro Wig',
'Empty Cardboard Box',
'Packet of Soy Sauce',
'Solutions to a Math Test',
'Pencil Eraser',
'The Great Pumpkin',
'Very Expensive Toaster',
'Pack of Colored Sharpies',
'Bag of Chocolate Chips',
'Grandma\'s Homemade Cookies',
'Collection of Bottle Caps',
'Pack of Playing Cards',
'Boom Box',
'Toy Sail Boat',
'Smooth Nail File',
'Colored Chalk',
'Missing Button',
'Rubber Band Ball',
'Joystick',
'Galaga Arcade Cabinet',
'Anime Mouse Pad',
'Orange and Yellow Glow Sticks',
'Odd Bookmark',
'Stray Dice',
'Tooth Picks',
'Dirty Dishes',
'Poke\'mon Card Game Rule Book (Gen 1)',
'Salt Shaker',
'Digital Thermometer',
'Infinite Improbability Drive',
'Fire Extinguisher',
'Beeping Smoke Alarm',
'Greasy Spatula',
'Progressive Auto Insurance',
'Mace Windu\'s Purple Lightsaber',
'An Old Fixer-Upper',
'Gamer Chair',
'Comfortable Reclining Chair',
'Shirt Covered in Dog Hair',
'Angry Praying Mantis',
'Card Games on Motorcycles',
'Trucker Hat',
'The DK Rap',
'Three Great Balls',
'Some Very Sus Behavior',
'Glass of Orange Juice',
'Turkey Bacon',
'Bald Barbie Doll',
'Developer Commentary',
'Subscription to Nintendo Power Magazine',
'DeLorean Time Machine',
'Unkillable Cockroach',
'Dungeons & Dragons Rulebook',
'Boxed Copy of Quest 64',
'James Bond\'s Gadget Wristwatch',
'Tube of Go-Gurt',
'Digital Watch',
'Laser Pointer',
'The Secret Cow Level',
'AOL Free Trial CD-ROM',
'E.T. for Atari 2600',
'Season 2 of Knight Rider',
'Spam E-Mails',
'Half-Life 3 Release Date',
'Source Code of Jurassic Park',
'Moldy Cheese',
'Comic Book Collection',
'Hardcover Copy of Scott Pilgrim VS the World',
'Old Gym Shorts',
'Very Cool Sunglasses',
'Your High School Yearbook Picture',
'Written Invitation to Prom',
'The Star Wars Holiday Special',
'Oil Change Coupon',
'Finger Guns',
'Box of Tabletop Games',
'Sock Puppets',
'The Dog of Wisdom',
'Surprised Chipmunk',
'Stonks',
'A Shrubbery',
'Roomba with a Knife',
'Wet Cat',
'The missing moderator, Frostwares',
'1,793 Crossbows',
'Holographic First Edition Charizard (Gen 1)',
'VR Headset',
'Archipelago 1.0 Release Date',
'Strand of Galadriel\'s Hair',
'Can of Meow-Mix',
'Shake-Weight',
'DVD Collection of Billy Mays Infomercials',
'Old CD Key',
)

View File

@@ -0,0 +1,28 @@
from BaseClasses import MultiWorld
from worlds.AutoWorld import LogicMixin
class ArchipIDLELogic(LogicMixin):
def _archipidle_location_is_accessible(self, player_id, items_required):
return sum(self.prog_items[player_id].values()) >= items_required
def set_rules(world: MultiWorld, player: int):
for i in range(16, 31):
world.get_location(f"IDLE item number {i}", player).access_rule = lambda \
state: state._archipidle_location_is_accessible(player, 4)
for i in range(31, 51):
world.get_location(f"IDLE item number {i}", player).access_rule = lambda \
state: state._archipidle_location_is_accessible(player, 10)
for i in range(51, 101):
world.get_location(f"IDLE item number {i}", player).access_rule = lambda \
state: state._archipidle_location_is_accessible(player, 20)
for i in range(101, 201):
world.get_location(f"IDLE item number {i}", player).access_rule = lambda \
state: state._archipidle_location_is_accessible(player, 40)
world.completion_condition[player] =\
lambda state: state.can_reach(world.get_location("IDLE item number 200", player), "Location", player)

View File

@@ -0,0 +1,128 @@
from BaseClasses import Item, MultiWorld, Region, Location, Entrance, Tutorial, ItemClassification
from worlds.AutoWorld import World, WebWorld
from datetime import datetime
from .Items import item_table
from .Rules import set_rules
class ArchipIDLEWebWorld(WebWorld):
theme = 'partyTime'
tutorials = [
Tutorial(
tutorial_name='Setup Guide',
description='A guide to playing Archipidle',
language='English',
file_name='guide_en.md',
link='guide/en',
authors=['Farrak Kilhn']
),
Tutorial(
tutorial_name='Guide d installation',
description='Un guide pour jouer à Archipidle',
language='Français',
file_name='guide_fr.md',
link='guide/fr',
authors=['TheLynk']
)
]
class ArchipIDLEWorld(World):
"""
An idle game which sends a check every thirty to sixty seconds, up to two hundred checks.
"""
game = "ArchipIDLE"
topology_present = False
hidden = (datetime.now().month != 4) # ArchipIDLE is only visible during April
web = ArchipIDLEWebWorld()
item_name_to_id = {}
start_id = 9000
for item in item_table:
item_name_to_id[item] = start_id
start_id += 1
location_name_to_id = {}
start_id = 9000
for i in range(1, 201):
location_name_to_id[f"IDLE item number {i}"] = start_id
start_id += 1
def set_rules(self):
set_rules(self.multiworld, self.player)
def create_item(self, name: str) -> Item:
return Item(name, ItemClassification.progression, self.item_name_to_id[name], self.player)
def create_items(self):
item_pool = [
ArchipIDLEItem(
item_table[0],
ItemClassification.progression,
self.item_name_to_id[item_table[0]],
self.player
)
]
for i in range(40):
item_pool.append(ArchipIDLEItem(
item_table[1],
ItemClassification.progression,
self.item_name_to_id[item_table[1]],
self.player
))
for i in range(40):
item_pool.append(ArchipIDLEItem(
item_table[2],
ItemClassification.filler,
self.item_name_to_id[item_table[2]],
self.player
))
item_table_copy = list(item_table[3:])
self.random.shuffle(item_table_copy)
for i in range(119):
item_pool.append(ArchipIDLEItem(
item_table_copy[i],
ItemClassification.progression if i < 9 else ItemClassification.filler,
self.item_name_to_id[item_table_copy[i]],
self.player
))
self.multiworld.itempool += item_pool
def create_regions(self):
self.multiworld.regions += [
create_region(self.multiworld, self.player, 'Menu', None, ['Entrance to IDLE Zone']),
create_region(self.multiworld, self.player, 'IDLE Zone', self.location_name_to_id)
]
# link up our region with the entrance we just made
self.multiworld.get_entrance('Entrance to IDLE Zone', self.player)\
.connect(self.multiworld.get_region('IDLE Zone', self.player))
def get_filler_item_name(self) -> str:
return self.multiworld.random.choice(item_table)
def create_region(world: MultiWorld, player: int, name: str, locations=None, exits=None):
region = Region(name, player, world)
if locations:
for location_name in locations.keys():
location = ArchipIDLELocation(player, location_name, locations[location_name], region)
region.locations.append(location)
if exits:
for _exit in exits:
region.exits.append(Entrance(player, _exit, region))
return region
class ArchipIDLEItem(Item):
game = "ArchipIDLE"
class ArchipIDLELocation(Location):
game: str = "ArchipIDLE"

View File

@@ -0,0 +1,13 @@
# ArchipIDLE
## What is this game?
ArchipIDLE was originally the 2022 Archipelago April Fools' Day joke. It is an idle game that sends a location check
on regular intervals. Updated annually with more items, gimmicks, and features, the game is visible
only during the month of April.
## Where is the options page?
The [player options page for this game](../player-options) contains all the options you need to configure
and export a config file.

View File

@@ -0,0 +1,12 @@
# ArchipIdle Setup Guide
## Joining a MultiWorld Game
1. Generate a `.yaml` file from the [ArchipIDLE Player Options Page](/games/ArchipIDLE/player-options)
2. Open the ArchipIDLE Client in your web browser by either:
- Navigate to the [ArchipIDLE Client](http://idle.multiworld.link)
- Download the client and run it locally from the
[ArchipIDLE GitHub Releases Page](https://github.com/ArchipelagoMW/archipidle/releases)
3. Enter the server address in the `Server Address` field and press enter
4. Enter your slot name when prompted. This should be the same as the `name` you entered on the
options page above, or the `name` field in your yaml file.
5. Click the "Begin!" button.

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