mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-07 15:13:52 -08:00
Compare commits
134 Commits
NewSoupVi-
...
NewSoupVi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5bb0f13a0 | ||
|
|
cbb1535242 | ||
|
|
643f61e7f4 | ||
|
|
6b91ffecf1 | ||
|
|
4f7f092b9b | ||
|
|
df3c6b7980 | ||
|
|
19839399e5 | ||
|
|
4847be98d2 | ||
|
|
3105320038 | ||
|
|
e8c8b0dbc5 | ||
|
|
c199775c48 | ||
|
|
d2bf7fdaf7 | ||
|
|
621ec274c3 | ||
|
|
7cd73e2710 | ||
|
|
708df4d1e2 | ||
|
|
914a534a3b | ||
|
|
11d18db452 | ||
|
|
00acfe63d4 | ||
|
|
2ac9ab5337 | ||
|
|
2569c9e531 | ||
|
|
946f227226 | ||
|
|
7ead8fdf49 | ||
|
|
f5f554cb3d | ||
|
|
3f2942c599 | ||
|
|
da519e7f73 | ||
|
|
0718ada682 | ||
|
|
f756919dd9 | ||
|
|
406b905dc8 | ||
|
|
91439e0fb0 | ||
|
|
03bd59bff6 | ||
|
|
cf02e1a1aa | ||
|
|
f6d696ea62 | ||
|
|
123acdef23 | ||
|
|
28c7a214dc | ||
|
|
bdae7cd42c | ||
|
|
fc404d0cf7 | ||
|
|
5ce71db048 | ||
|
|
aff98a5b78 | ||
|
|
30cedb13f3 | ||
|
|
0c1ecf7297 | ||
|
|
5390561b58 | ||
|
|
bb457b0f73 | ||
|
|
6276ccf415 | ||
|
|
d3588a057c | ||
|
|
30ce74d6d5 | ||
|
|
ff59b86335 | ||
|
|
e355d20063 | ||
|
|
28ea2444a4 | ||
|
|
e907980ff0 | ||
|
|
5a933a160a | ||
|
|
c7978bcc12 | ||
|
|
5c7a84748b | ||
|
|
8dc9719b99 | ||
|
|
60617c682e | ||
|
|
fd879408f3 | ||
|
|
8decde0370 | ||
|
|
adb5a7d632 | ||
|
|
f07fea2771 | ||
|
|
a2460b7fe7 | ||
|
|
f8f30f41b7 | ||
|
|
60070c2f1e | ||
|
|
3eb25a59dc | ||
|
|
1cbc5d6649 | ||
|
|
bdef410eb2 | ||
|
|
ec9145e61d | ||
|
|
a547c8dd7d | ||
|
|
7996fd8d19 | ||
|
|
7a652518a3 | ||
|
|
ae4426af08 | ||
|
|
91e97b68d4 | ||
|
|
6a08064a52 | ||
|
|
83cfb803a7 | ||
|
|
6d7abb3780 | ||
|
|
50f6cf04f6 | ||
|
|
b162095f89 | ||
|
|
33b485c0c3 | ||
|
|
4893ac3e51 | ||
|
|
76b0197462 | ||
|
|
6a63de2f0f | ||
|
|
e6fb7d9c6a | ||
|
|
0882c0fa97 | ||
|
|
f26fcc0eda | ||
|
|
50c9d056c9 | ||
|
|
5cec3f45f5 | ||
|
|
448f214cdb | ||
|
|
49f2d30587 | ||
|
|
897d5ab089 | ||
|
|
92ff0ddba8 | ||
|
|
1d2ad1f9c9 | ||
|
|
516ebc53ce | ||
|
|
a30b43821f | ||
|
|
d9955d624b | ||
|
|
5345937966 | ||
|
|
580370c3a0 | ||
|
|
c30a5b206e | ||
|
|
053f876e84 | ||
|
|
ab2097960d | ||
|
|
2f23dc72f9 | ||
|
|
f9083d9307 | ||
|
|
25baa57850 | ||
|
|
47b2242c3c | ||
|
|
6099869c59 | ||
|
|
1d861d1d06 | ||
|
|
d1624679ee | ||
|
|
12998bf6f4 | ||
|
|
24394561bd | ||
|
|
4ae87edf37 | ||
|
|
4525bae879 | ||
|
|
dc270303a9 | ||
|
|
a99da85a22 | ||
|
|
e256abfdfb | ||
|
|
fb9011da63 | ||
|
|
68187ba25f | ||
|
|
6c45c8d606 | ||
|
|
9e96cece56 | ||
|
|
1bd44e1e35 | ||
|
|
7badc3e745 | ||
|
|
3af1e92813 | ||
|
|
73718bbd61 | ||
|
|
8f2b4a961f | ||
|
|
9fdeecd996 | ||
|
|
174d89c81f | ||
|
|
71de33d7dd | ||
|
|
9c00eb91d6 | ||
|
|
597583577a | ||
|
|
4e085894d2 | ||
|
|
76a8b0d582 | ||
|
|
27e50aa81a | ||
|
|
aaaceebd91 | ||
|
|
1322ce866e | ||
|
|
78b529fc23 | ||
|
|
9aa0bf7245 | ||
|
|
287bb638a0 | ||
|
|
18ac9210cb |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -9,12 +9,14 @@ on:
|
||||
- 'setup.py'
|
||||
- 'requirements.txt'
|
||||
- '*.iss'
|
||||
- 'worlds/*/archipelago.json'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build.yml'
|
||||
- 'setup.py'
|
||||
- 'requirements.txt'
|
||||
- '*.iss'
|
||||
- 'worlds/*/archipelago.json'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
|
||||
154
.github/workflows/docker.yml
vendored
Normal file
154
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,154 @@
|
||||
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
|
||||
1
.github/workflows/label-pull-requests.yml
vendored
1
.github/workflows/label-pull-requests.yml
vendored
@@ -12,7 +12,6 @@ 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
|
||||
|
||||
24
.run/Build APWorld.run.xml
Normal file
24
.run/Build APWorld.run.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<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="\"Build APWorlds\"" />
|
||||
<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>
|
||||
@@ -261,6 +261,7 @@ 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():
|
||||
@@ -284,6 +285,8 @@ 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"]
|
||||
@@ -1343,8 +1346,7 @@ 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: Dict[str, Optional[int]],
|
||||
location_type: Optional[type[Location]] = None) -> None:
|
||||
def add_locations(self, locations: Mapping[str, int | None], location_type: type[Location] | None = 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.
|
||||
@@ -1432,8 +1434,8 @@ class Region:
|
||||
entrance.connect(self)
|
||||
return entrance
|
||||
|
||||
def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]],
|
||||
rules: Dict[str, Callable[[CollectionState], bool]] = None) -> List[Entrance]:
|
||||
def add_exits(self, exits: Iterable[str] | Mapping[str, str | None],
|
||||
rules: Mapping[str, Callable[[CollectionState], bool]] | None = None) -> List[Entrance]:
|
||||
"""
|
||||
Connects current region to regions in exit dictionary. Passed region names must exist first.
|
||||
|
||||
@@ -1441,7 +1443,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, Dict):
|
||||
if not isinstance(exits, Mapping):
|
||||
exits = dict.fromkeys(exits)
|
||||
return [
|
||||
self.connect(
|
||||
@@ -1855,6 +1857,9 @@ 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)
|
||||
|
||||
@@ -1863,6 +1868,9 @@ 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)
|
||||
|
||||
|
||||
@@ -856,9 +856,9 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
|
||||
|
||||
server_url = urllib.parse.urlparse(address)
|
||||
if server_url.username:
|
||||
ctx.username = server_url.username
|
||||
ctx.username = urllib.parse.unquote(server_url.username)
|
||||
if server_url.password:
|
||||
ctx.password = server_url.password
|
||||
ctx.password = urllib.parse.unquote(server_url.password)
|
||||
|
||||
def reconnect_hint() -> str:
|
||||
return ", type /connect to reconnect" if ctx.server_address else ""
|
||||
|
||||
4
Fill.py
4
Fill.py
@@ -129,6 +129,10 @@ 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]
|
||||
|
||||
21
Generate.py
21
Generate.py
@@ -23,7 +23,7 @@ from BaseClasses import seeddigits, get_seed, PlandoOptions
|
||||
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
|
||||
|
||||
|
||||
def mystery_argparse():
|
||||
def mystery_argparse(argv: list[str] | None = None):
|
||||
from settings import get_settings
|
||||
settings = get_settings()
|
||||
defaults = settings.generator
|
||||
@@ -57,7 +57,7 @@ def mystery_argparse():
|
||||
parser.add_argument("--spoiler_only", action="store_true",
|
||||
help="Skips generation assertion and multidata, outputting only a spoiler log. "
|
||||
"Intended for debugging and testing purposes.")
|
||||
args = parser.parse_args()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.skip_output and args.spoiler_only:
|
||||
parser.error("Cannot mix --skip_output and --spoiler_only")
|
||||
@@ -486,7 +486,22 @@ 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:
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
if __name__ == '__main__':
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
Utils.init_logging("KH1Client", exception_logger="Client")
|
||||
|
||||
from worlds.kh1.Client import launch
|
||||
launch()
|
||||
@@ -1,8 +0,0 @@
|
||||
import ModuleUpdate
|
||||
import Utils
|
||||
from worlds.kh2.Client import launch
|
||||
ModuleUpdate.update()
|
||||
|
||||
if __name__ == '__main__':
|
||||
Utils.init_logging("KH2Client", exception_logger="Client")
|
||||
launch()
|
||||
11
Main.py
11
Main.py
@@ -54,12 +54,17 @@ 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)
|
||||
|
||||
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())))
|
||||
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)))
|
||||
|
||||
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
||||
if not cls.hidden and len(cls.item_names) > 0:
|
||||
logger.info(f" {name:{longest_name}}: Items: {len(cls.item_names):{item_count}} | "
|
||||
logger.info(f" {name:{longest_name}}: "
|
||||
f"v{cls.world_version.as_simple_string():{version_count}} | "
|
||||
f"Items: {len(cls.item_names):{item_count}} | "
|
||||
f"Locations: {len(cls.location_names):{location_count}}")
|
||||
|
||||
del item_count, location_count
|
||||
|
||||
211
MultiServer.py
211
MultiServer.py
@@ -32,7 +32,7 @@ if typing.TYPE_CHECKING:
|
||||
|
||||
import colorama
|
||||
import websockets
|
||||
from websockets.extensions.permessage_deflate import PerMessageDeflate
|
||||
from websockets.extensions.permessage_deflate import PerMessageDeflate, ServerPerMessageDeflateFactory
|
||||
try:
|
||||
# ponyorm is a requirement for webhost, not default server, so may not be importable
|
||||
from pony.orm.dbapiprovider import OperationalError
|
||||
@@ -50,6 +50,15 @@ 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:
|
||||
@@ -125,8 +134,31 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
|
||||
|
||||
|
||||
class Client(Endpoint):
|
||||
version = Version(0, 0, 0)
|
||||
tags: typing.List[str]
|
||||
__slots__ = (
|
||||
"__weakref__",
|
||||
"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]
|
||||
remote_items: bool
|
||||
remote_start_inventory: bool
|
||||
no_items: bool
|
||||
@@ -135,6 +167,7 @@ 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
|
||||
@@ -142,6 +175,11 @@ 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):
|
||||
@@ -179,6 +217,7 @@ class Context:
|
||||
"release_mode": str,
|
||||
"remaining_mode": str,
|
||||
"collect_mode": str,
|
||||
"countdown_mode": str,
|
||||
"item_cheat": bool,
|
||||
"compatibility": int}
|
||||
# team -> slot id -> list of clients authenticated to slot.
|
||||
@@ -208,8 +247,8 @@ class Context:
|
||||
|
||||
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
||||
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
|
||||
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
|
||||
log_network: bool = False, logger: logging.Logger = logging.getLogger()):
|
||||
countdown_mode: str = "auto", remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0,
|
||||
compatibility: int = 2, log_network: bool = False, logger: logging.Logger = logging.getLogger()):
|
||||
self.logger = logger
|
||||
super(Context, self).__init__()
|
||||
self.slot_info = {}
|
||||
@@ -242,6 +281,7 @@ class Context:
|
||||
self.release_mode: str = release_mode
|
||||
self.remaining_mode: str = remaining_mode
|
||||
self.collect_mode: str = collect_mode
|
||||
self.countdown_mode: str = countdown_mode
|
||||
self.item_cheat = item_cheat
|
||||
self.exit_event = asyncio.Event()
|
||||
self.client_activity_timers: typing.Dict[
|
||||
@@ -627,6 +667,7 @@ class Context:
|
||||
"server_password": self.server_password, "password": self.password,
|
||||
"release_mode": self.release_mode,
|
||||
"remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode,
|
||||
"countdown_mode": self.countdown_mode,
|
||||
"item_cheat": self.item_cheat, "compatibility": self.compatibility}
|
||||
|
||||
}
|
||||
@@ -661,6 +702,7 @@ class Context:
|
||||
self.release_mode = savedata["game_options"]["release_mode"]
|
||||
self.remaining_mode = savedata["game_options"]["remaining_mode"]
|
||||
self.collect_mode = savedata["game_options"]["collect_mode"]
|
||||
self.countdown_mode = savedata["game_options"].get("countdown_mode", self.countdown_mode)
|
||||
self.item_cheat = savedata["game_options"]["item_cheat"]
|
||||
self.compatibility = savedata["game_options"]["compatibility"]
|
||||
|
||||
@@ -1135,8 +1177,13 @@ 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], auto_status: HintStatus) \
|
||||
-> typing.List[Hint]:
|
||||
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.
|
||||
"""
|
||||
|
||||
hints = []
|
||||
slots: typing.Set[int] = {slot}
|
||||
for group_id, group in ctx.groups.items():
|
||||
@@ -1152,25 +1199,39 @@ 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
|
||||
|
||||
hint_status = status # Assign again because we're in a for loop
|
||||
if found:
|
||||
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))
|
||||
hint_status = HintStatus.HINT_FOUND
|
||||
elif hint_status is None:
|
||||
if item_flags & ItemClassification.trap:
|
||||
hint_status = HintStatus.HINT_AVOID
|
||||
else:
|
||||
hint_status = HintStatus.HINT_PRIORITY
|
||||
|
||||
hints.append(
|
||||
Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, hint_status)
|
||||
)
|
||||
|
||||
return hints
|
||||
|
||||
|
||||
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str, auto_status: HintStatus) \
|
||||
-> typing.List[Hint]:
|
||||
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.
|
||||
"""
|
||||
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
|
||||
return collect_hint_location_id(ctx, team, slot, seeked_location, auto_status)
|
||||
return collect_hint_location_id(ctx, team, slot, seeked_location, status)
|
||||
|
||||
|
||||
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int, auto_status: HintStatus) \
|
||||
-> typing.List[Hint]:
|
||||
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.
|
||||
"""
|
||||
prev_hint = ctx.get_hint(team, slot, seeked_location)
|
||||
if prev_hint:
|
||||
return [prev_hint]
|
||||
@@ -1180,13 +1241,16 @@ 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:
|
||||
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)]
|
||||
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)]
|
||||
return []
|
||||
|
||||
|
||||
@@ -1300,7 +1364,8 @@ class CommandProcessor(metaclass=CommandMeta):
|
||||
argname += "=" + parameter.default
|
||||
argtext += argname
|
||||
argtext += " "
|
||||
s += f"{self.marker}{command} {argtext}\n {method.__doc__}\n"
|
||||
doctext = '\n '.join(inspect.getdoc(method).split('\n'))
|
||||
s += f"{self.marker}{command} {argtext}\n {doctext}\n"
|
||||
return s
|
||||
|
||||
def _cmd_help(self):
|
||||
@@ -1329,19 +1394,6 @@ 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:")
|
||||
@@ -1483,6 +1535,23 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
" You can ask the server admin for a /collect")
|
||||
return False
|
||||
|
||||
def _cmd_countdown(self, seconds: str = "10") -> bool:
|
||||
"""Start a countdown in seconds"""
|
||||
if self.ctx.countdown_mode == "disabled" or \
|
||||
self.ctx.countdown_mode == "auto" and len(self.ctx.player_names) >= 30:
|
||||
self.output("Sorry, client countdowns have been disabled on this server. You can ask the server admin for a /countdown")
|
||||
return False
|
||||
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_remaining(self) -> bool:
|
||||
"""List remaining items in your game, but not their location or recipient"""
|
||||
if self.ctx.remaining_mode == "enabled":
|
||||
@@ -1610,7 +1679,6 @@ 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]}
|
||||
@@ -1636,9 +1704,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, auto_status)
|
||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id)
|
||||
else:
|
||||
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
|
||||
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id)
|
||||
|
||||
else:
|
||||
game = self.ctx.games[self.client.slot]
|
||||
@@ -1658,16 +1726,18 @@ 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, auto_status))
|
||||
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name))
|
||||
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, auto_status)
|
||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
|
||||
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, auto_status))
|
||||
hints.extend(
|
||||
collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name)
|
||||
)
|
||||
else: # location name
|
||||
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
|
||||
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
|
||||
|
||||
else:
|
||||
self.output(response)
|
||||
@@ -1945,8 +2015,7 @@ 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,
|
||||
HintStatus.HINT_UNSPECIFIED))
|
||||
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
|
||||
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:
|
||||
@@ -2238,6 +2307,19 @@ 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."""
|
||||
@@ -2359,9 +2441,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, HintStatus.HINT_PRIORITY))
|
||||
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
|
||||
else: # item name or id
|
||||
hints = collect_hints(self.ctx, team, slot, item, HintStatus.HINT_PRIORITY)
|
||||
hints = collect_hints(self.ctx, team, slot, item)
|
||||
|
||||
if hints:
|
||||
self.ctx.notify_hints(team, hints)
|
||||
@@ -2395,17 +2477,14 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
|
||||
if usable:
|
||||
if isinstance(location, int):
|
||||
hints = collect_hint_location_id(self.ctx, team, slot, location,
|
||||
HintStatus.HINT_UNSPECIFIED)
|
||||
hints = collect_hint_location_id(self.ctx, team, slot, location)
|
||||
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,
|
||||
HintStatus.HINT_UNSPECIFIED))
|
||||
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group))
|
||||
else:
|
||||
hints = collect_hint_location_name(self.ctx, team, slot, location,
|
||||
HintStatus.HINT_UNSPECIFIED)
|
||||
hints = collect_hint_location_name(self.ctx, team, slot, location)
|
||||
if hints:
|
||||
self.ctx.notify_hints(team, hints)
|
||||
else:
|
||||
@@ -2433,6 +2512,11 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
elif value_type == str and option_name.endswith("password"):
|
||||
def value_type(input_text: str):
|
||||
return None if input_text.lower() in {"null", "none", '""', "''"} else input_text
|
||||
elif option_name == "countdown_mode":
|
||||
valid_values = {"enabled", "disabled", "auto"}
|
||||
if option_value.lower() not in valid_values:
|
||||
self.output(f"Unrecognized {option_name} value '{option_value}', known: {', '.join(valid_values)}")
|
||||
return False
|
||||
elif value_type == str and option_name.endswith("mode"):
|
||||
valid_values = {"goal", "enabled", "disabled"}
|
||||
valid_values.update(("auto", "auto_enabled") if option_name != "remaining_mode" else [])
|
||||
@@ -2520,6 +2604,13 @@ def parse_args() -> argparse.Namespace:
|
||||
goal: !collect can be used after goal completion
|
||||
auto-enabled: !collect is available and automatically triggered on goal completion
|
||||
''')
|
||||
parser.add_argument('--countdown_mode', default=defaults["countdown_mode"], nargs='?',
|
||||
choices=['enabled', 'disabled', "auto"], help='''\
|
||||
Select !countdown Accessibility. (default: %(default)s)
|
||||
enabled: !countdown is always available
|
||||
disabled: !countdown is never available
|
||||
auto: !countdown is available for rooms with less than 30 players
|
||||
''')
|
||||
parser.add_argument('--remaining_mode', default=defaults["remaining_mode"], nargs='?',
|
||||
choices=['enabled', 'disabled', "goal"], help='''\
|
||||
Select !remaining Accessibility. (default: %(default)s)
|
||||
@@ -2585,7 +2676,7 @@ async def main(args: argparse.Namespace):
|
||||
|
||||
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
|
||||
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,
|
||||
args.remaining_mode,
|
||||
args.countdown_mode, args.remaining_mode,
|
||||
args.auto_shutdown, args.compatibility, args.log_network)
|
||||
data_filename = args.multidata
|
||||
|
||||
@@ -2620,7 +2711,13 @@ 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)
|
||||
ctx.server = websockets.serve(
|
||||
functools.partial(server, ctx=ctx),
|
||||
host=ctx.host,
|
||||
port=ctx.port,
|
||||
ssl=ssl_context,
|
||||
extensions=[server_per_message_deflate_factory],
|
||||
)
|
||||
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))
|
||||
|
||||
@@ -174,6 +174,8 @@ decode = JSONDecoder(object_hook=_object_hook).decode
|
||||
|
||||
|
||||
class Endpoint:
|
||||
__slots__ = ("socket",)
|
||||
|
||||
socket: "ServerConnection"
|
||||
|
||||
def __init__(self, socket):
|
||||
|
||||
14
Options.py
14
Options.py
@@ -1380,7 +1380,7 @@ class NonLocalItems(ItemSet):
|
||||
|
||||
|
||||
class StartInventory(ItemDict):
|
||||
"""Start with these items."""
|
||||
"""Start with the specified amount of these items. Example: "Bomb: 1" """
|
||||
verify_item_name = True
|
||||
display_name = "Start Inventory"
|
||||
rich_text_doc = True
|
||||
@@ -1388,7 +1388,7 @@ class StartInventory(ItemDict):
|
||||
|
||||
|
||||
class StartInventoryPool(StartInventory):
|
||||
"""Start with these items and don't place them in the world.
|
||||
"""Start with the specified amount of these items and don't place them in the world. Example: "Bomb: 1"
|
||||
|
||||
The game decides what the replacement items will be.
|
||||
"""
|
||||
@@ -1446,6 +1446,7 @@ 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),
|
||||
}
|
||||
])
|
||||
|
||||
@@ -1473,8 +1474,10 @@ class ItemLinks(OptionList):
|
||||
super(ItemLinks, self).verify(world, player_name, plando_options)
|
||||
existing_links = set()
|
||||
for link in self.value:
|
||||
link["name"] = link["name"].strip()[:16].strip()
|
||||
if link["name"] in existing_links:
|
||||
raise Exception(f"You cannot have more than one link named {link['name']}.")
|
||||
raise Exception(f"Item link names are limited to their first 16 characters and must be unique. "
|
||||
f"You have more than one link named '{link['name']}'.")
|
||||
existing_links.add(link["name"])
|
||||
|
||||
pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world)
|
||||
@@ -1752,7 +1755,10 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
||||
|
||||
res = template.render(
|
||||
option_groups=option_groups,
|
||||
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
|
||||
__version__=__version__,
|
||||
game=game_name,
|
||||
world_version=world.world_version.as_simple_string(),
|
||||
yaml_dump=yaml_dump_scalar,
|
||||
dictify_range=dictify_range,
|
||||
cleandoc=cleandoc,
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ from json import loads, dumps
|
||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||
|
||||
import Utils
|
||||
from settings import Settings
|
||||
import settings
|
||||
from Utils import async_start
|
||||
from MultiServer import mark_raw
|
||||
if typing.TYPE_CHECKING:
|
||||
@@ -286,7 +286,7 @@ class SNESState(enum.IntEnum):
|
||||
|
||||
|
||||
def launch_sni() -> None:
|
||||
sni_path = Settings.sni_options.sni_path
|
||||
sni_path = settings.get_settings().sni_options.sni_path
|
||||
|
||||
if not os.path.isdir(sni_path):
|
||||
sni_path = Utils.local_path(sni_path)
|
||||
@@ -669,7 +669,7 @@ async def game_watcher(ctx: SNIContext) -> None:
|
||||
|
||||
|
||||
async def run_game(romfile: str) -> None:
|
||||
auto_start = Settings.sni_options.snes_rom_start
|
||||
auto_start = settings.get_settings().sni_options.snes_rom_start
|
||||
if auto_start is True:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
|
||||
59
Utils.py
59
Utils.py
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import json
|
||||
import typing
|
||||
import builtins
|
||||
@@ -35,7 +36,7 @@ if typing.TYPE_CHECKING:
|
||||
|
||||
|
||||
def tuplize_version(version: str) -> Version:
|
||||
return Version(*(int(piece, 10) for piece in version.split(".")))
|
||||
return Version(*(int(piece) for piece in version.split(".")))
|
||||
|
||||
|
||||
class Version(typing.NamedTuple):
|
||||
@@ -322,11 +323,13 @@ def get_options() -> Settings:
|
||||
return get_settings()
|
||||
|
||||
|
||||
def persistent_store(category: str, key: str, value: typing.Any):
|
||||
path = user_path("_persistent_storage.yaml")
|
||||
def persistent_store(category: str, key: str, value: typing.Any, force_store: bool = False):
|
||||
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))
|
||||
|
||||
@@ -475,7 +478,7 @@ class RestrictedUnpickler(pickle.Unpickler):
|
||||
mod = importlib.import_module(module)
|
||||
obj = getattr(mod, name)
|
||||
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection,
|
||||
self.options_module.PlandoText)):
|
||||
self.options_module.PlandoItem, self.options_module.PlandoText)):
|
||||
return obj
|
||||
# Forbid everything else.
|
||||
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
|
||||
@@ -718,13 +721,22 @@ 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
|
||||
@@ -1127,3 +1139,40 @@ def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]
|
||||
if isinstance(obj, str):
|
||||
return False
|
||||
return isinstance(obj, typing.Iterable)
|
||||
|
||||
|
||||
class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
|
||||
"""
|
||||
ThreadPoolExecutor that uses daemonic threads that do not keep the program alive.
|
||||
NOTE: use this with caution because killed threads will not properly clean up.
|
||||
"""
|
||||
|
||||
def _adjust_thread_count(self):
|
||||
# see upstream ThreadPoolExecutor for details
|
||||
import threading
|
||||
import weakref
|
||||
from concurrent.futures.thread import _worker
|
||||
|
||||
if self._idle_semaphore.acquire(timeout=0):
|
||||
return
|
||||
|
||||
def weakref_cb(_, q=self._work_queue):
|
||||
q.put(None)
|
||||
|
||||
num_threads = len(self._threads)
|
||||
if num_threads < self._max_workers:
|
||||
thread_name = f"{self._thread_name_prefix or self}_{num_threads}"
|
||||
t = threading.Thread(
|
||||
name=thread_name,
|
||||
target=_worker,
|
||||
args=(
|
||||
weakref.ref(self, weakref_cb),
|
||||
self._work_queue,
|
||||
self._initializer,
|
||||
self._initargs,
|
||||
),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
self._threads.add(t)
|
||||
# NOTE: don't add to _threads_queues so we don't block on shutdown
|
||||
|
||||
@@ -109,6 +109,13 @@ if __name__ == "__main__":
|
||||
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"]:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import base64
|
||||
import os
|
||||
import socket
|
||||
import typing
|
||||
import uuid
|
||||
|
||||
from flask import Flask
|
||||
@@ -61,20 +62,21 @@ cache = Cache()
|
||||
Compress(app)
|
||||
|
||||
|
||||
def to_python(value):
|
||||
def to_python(value: str) -> uuid.UUID:
|
||||
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
|
||||
|
||||
|
||||
def to_url(value):
|
||||
def to_url(value: uuid.UUID) -> str:
|
||||
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
||||
|
||||
|
||||
class B64UUIDConverter(BaseConverter):
|
||||
|
||||
def to_python(self, value):
|
||||
def to_python(self, value: str) -> uuid.UUID:
|
||||
return to_python(value)
|
||||
|
||||
def to_url(self, value):
|
||||
def to_url(self, value: typing.Any) -> str:
|
||||
assert isinstance(value, uuid.UUID)
|
||||
return to_url(value)
|
||||
|
||||
|
||||
@@ -84,7 +86,7 @@ app.jinja_env.filters["suuid"] = to_url
|
||||
app.jinja_env.filters["title_sorted"] = title_sorted
|
||||
|
||||
|
||||
def register():
|
||||
def register() -> None:
|
||||
"""Import submodules, triggering their registering on flask routing.
|
||||
Note: initializes worlds subsystem."""
|
||||
import importlib
|
||||
|
||||
@@ -11,6 +11,53 @@ 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]:
|
||||
@@ -29,122 +76,77 @@ def tracker_data(tracker: UUID) -> dict[str, Any]:
|
||||
|
||||
all_players: dict[int, list[int]] = tracker_data.get_all_players()
|
||||
|
||||
class PlayerAlias(TypedDict):
|
||||
player: int
|
||||
name: str | None
|
||||
|
||||
player_aliases: list[dict[str, int | list[PlayerAlias]]] = []
|
||||
player_aliases: 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:
|
||||
team_player_aliases.append({"player": player, "alias": tracker_data.get_player_alias(team, player)})
|
||||
player_aliases.append({"team": team, "player": player, "alias": tracker_data.get_player_alias(team, player)})
|
||||
|
||||
class PlayerItemsReceived(TypedDict):
|
||||
player: int
|
||||
items: list[NetworkItem]
|
||||
|
||||
player_items_received: list[dict[str, int | list[PlayerItemsReceived]]] = []
|
||||
player_items_received: 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_received_items.append(
|
||||
{"player": player, "items": tracker_data.get_player_received_items(team, player)})
|
||||
player_items_received.append(
|
||||
{"team": team, "player": player, "items": tracker_data.get_player_received_items(team, player)})
|
||||
|
||||
class PlayerChecksDone(TypedDict):
|
||||
player: int
|
||||
locations: list[int]
|
||||
|
||||
player_checks_done: list[dict[str, int | list[PlayerChecksDone]]] = []
|
||||
player_checks_done: 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:
|
||||
per_player_checks.append(
|
||||
{"player": player, "locations": sorted(tracker_data.get_player_checked_locations(team, player))})
|
||||
player_checks_done.append(
|
||||
{"team": team, "player": player, "locations": sorted(tracker_data.get_player_checked_locations(team, player))})
|
||||
|
||||
total_checks_done: list[dict[str, int]] = [
|
||||
total_checks_done: list[TeamTotalChecks] = [
|
||||
{"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."""
|
||||
|
||||
class PlayerHints(TypedDict):
|
||||
player: int
|
||||
hints: list[Hint]
|
||||
|
||||
hints: list[dict[str, int | list[PlayerHints]]] = []
|
||||
hints: 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))
|
||||
per_player_hints.append({"player": player, "hints": player_hints})
|
||||
slot_info = tracker_data.get_slot_info(team, player)
|
||||
hints.append({"team": team, "player": player, "hints": player_hints})
|
||||
slot_info = tracker_data.get_slot_info(player)
|
||||
# this assumes groups are always after players
|
||||
if slot_info.type != SlotType.group:
|
||||
continue
|
||||
for member in slot_info.group_members:
|
||||
team_hints[member]["hints"] += player_hints
|
||||
hints[member - 1]["hints"] += player_hints
|
||||
|
||||
class PlayerTimer(TypedDict):
|
||||
player: int
|
||||
time: datetime | None
|
||||
|
||||
activity_timers: list[dict[str, int | list[PlayerTimer]]] = []
|
||||
activity_timers: 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:
|
||||
player_timers.append({"player": player, "time": None})
|
||||
activity_timers.append({"team": team, "player": player, "time": None})
|
||||
|
||||
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
|
||||
# FIX: key is "players" (not "player_timers")
|
||||
activity_timers[team]["players"][player - 1]["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
|
||||
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
|
||||
|
||||
|
||||
connection_timers: list[dict[str, int | list[PlayerTimer]]] = []
|
||||
connection_timers: 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:
|
||||
player_timers.append({"player": player, "time": None})
|
||||
connection_timers.append({"team": team, "player": player, "time": None})
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
class PlayerStatus(TypedDict):
|
||||
player: int
|
||||
status: ClientStatus
|
||||
|
||||
player_status: list[dict[str, int | list[PlayerStatus]]] = []
|
||||
player_status: 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_statuses.append({"player": player, "status": tracker_data.get_player_client_status(team, player)})
|
||||
player_status.append({"team": team, "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,
|
||||
@@ -153,80 +155,87 @@ 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"],
|
||||
}
|
||||
|
||||
@cache.memoize()
|
||||
def get_static_tracker_data(room: Room) -> dict[str, Any]:
|
||||
"""
|
||||
Builds and caches the static data for this active session tracker, so that it doesn't need to be recalculated.
|
||||
"""
|
||||
|
||||
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]:
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
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()
|
||||
|
||||
class PlayerGroups(TypedDict):
|
||||
slot: int
|
||||
name: str
|
||||
members: list[int]
|
||||
|
||||
groups: list[dict[str, int | list[PlayerGroups]]] = []
|
||||
groups: 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(team, player)
|
||||
slot_info = tracker_data.get_slot_info(player)
|
||||
if slot_info.type != SlotType.group or not slot_info.group_members:
|
||||
continue
|
||||
groups_in_team.append(
|
||||
groups.append(
|
||||
{
|
||||
"slot": player,
|
||||
"name": slot_info.name,
|
||||
"members": list(slot_info.group_members),
|
||||
})
|
||||
class PlayerName(TypedDict):
|
||||
player: int
|
||||
name: str
|
||||
break
|
||||
|
||||
player_names: list[dict[str, str | list[PlayerName]]] = []
|
||||
"""Slot names of all players."""
|
||||
player_locations_total: list[PlayerLocationsTotal] = []
|
||||
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:
|
||||
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)})
|
||||
player_locations_total.append(
|
||||
{"team": team, "player": player, "total_locations": len(tracker_data.get_player_locations(player))})
|
||||
|
||||
return {
|
||||
"groups": groups,
|
||||
"slot_data": slot_data,
|
||||
"datapackage": tracker_data._multidata["datapackage"],
|
||||
"player_locations_total": player_locations_total,
|
||||
}
|
||||
|
||||
# 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
|
||||
|
||||
@@ -17,7 +17,7 @@ from .locker import Locker, AlreadyRunningException
|
||||
_stop_event = Event()
|
||||
|
||||
|
||||
def stop():
|
||||
def stop() -> None:
|
||||
"""Stops previously launched threads"""
|
||||
global _stop_event
|
||||
stop_event = _stop_event
|
||||
@@ -36,25 +36,39 @@ def handle_generation_failure(result: BaseException):
|
||||
logging.exception(e)
|
||||
|
||||
|
||||
def _mp_gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None) -> PrimaryKey | None:
|
||||
def _mp_gen_game(
|
||||
gen_options: dict,
|
||||
meta: dict[str, Any] | None = None,
|
||||
owner=None,
|
||||
sid=None,
|
||||
timeout: int|None = None,
|
||||
) -> PrimaryKey | None:
|
||||
from setproctitle import setproctitle
|
||||
|
||||
setproctitle(f"Generator ({sid})")
|
||||
res = gen_game(gen_options, meta=meta, owner=owner, sid=sid)
|
||||
setproctitle(f"Generator (idle)")
|
||||
return res
|
||||
try:
|
||||
return gen_game(gen_options, meta=meta, owner=owner, sid=sid, timeout=timeout)
|
||||
finally:
|
||||
setproctitle(f"Generator (idle)")
|
||||
|
||||
|
||||
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
|
||||
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation, timeout: int|None) -> None:
|
||||
try:
|
||||
meta = json.loads(generation.meta)
|
||||
options = restricted_loads(generation.options)
|
||||
logging.info(f"Generating {generation.id} for {len(options)} players")
|
||||
pool.apply_async(_mp_gen_game, (options,),
|
||||
{"meta": meta,
|
||||
"sid": generation.id,
|
||||
"owner": generation.owner},
|
||||
handle_generation_success, handle_generation_failure)
|
||||
pool.apply_async(
|
||||
_mp_gen_game,
|
||||
(options,),
|
||||
{
|
||||
"meta": meta,
|
||||
"sid": generation.id,
|
||||
"owner": generation.owner,
|
||||
"timeout": timeout,
|
||||
},
|
||||
handle_generation_success,
|
||||
handle_generation_failure,
|
||||
)
|
||||
except Exception as e:
|
||||
generation.state = STATE_ERROR
|
||||
commit()
|
||||
@@ -135,6 +149,7 @@ def autogen(config: dict):
|
||||
|
||||
with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator,
|
||||
initargs=(config,), maxtasksperchild=10) as generator_pool:
|
||||
job_time = config["JOB_TIME"]
|
||||
with db_session:
|
||||
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
|
||||
|
||||
@@ -145,7 +160,7 @@ def autogen(config: dict):
|
||||
if sid:
|
||||
generation.delete()
|
||||
else:
|
||||
launch_generator(generator_pool, generation)
|
||||
launch_generator(generator_pool, generation, timeout=job_time)
|
||||
|
||||
commit()
|
||||
select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
|
||||
@@ -157,7 +172,7 @@ def autogen(config: dict):
|
||||
generation for generation in Generation
|
||||
if generation.state == STATE_QUEUED).for_update()
|
||||
for generation in to_start:
|
||||
launch_generator(generator_pool, generation)
|
||||
launch_generator(generator_pool, generation, timeout=job_time)
|
||||
except AlreadyRunningException:
|
||||
logging.info("Autogen reports as already running, not starting another.")
|
||||
|
||||
|
||||
@@ -19,7 +19,10 @@ from pony.orm import commit, db_session, select
|
||||
|
||||
import Utils
|
||||
|
||||
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
|
||||
from MultiServer import (
|
||||
Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert,
|
||||
server_per_message_deflate_factory,
|
||||
)
|
||||
from Utils import restricted_loads, cache_argsless
|
||||
from .locker import Locker
|
||||
from .models import Command, GameDataPackage, Room, db
|
||||
@@ -97,6 +100,7 @@ class WebHostContext(Context):
|
||||
self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext)
|
||||
command.delete()
|
||||
commit()
|
||||
del commands
|
||||
time.sleep(5)
|
||||
|
||||
@db_session
|
||||
@@ -146,13 +150,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:
|
||||
savegame_data = Room.get(id=self.room_id).multisave
|
||||
if savegame_data:
|
||||
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
|
||||
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))
|
||||
self._start_async_saving(atexit_save=False)
|
||||
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
|
||||
|
||||
@@ -282,8 +286,12 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
assert ctx.server is None
|
||||
try:
|
||||
ctx.server = websockets.serve(
|
||||
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=get_ssl_context())
|
||||
|
||||
functools.partial(server, ctx=ctx),
|
||||
ctx.host,
|
||||
ctx.port,
|
||||
ssl=get_ssl_context(),
|
||||
extensions=[server_per_message_deflate_factory],
|
||||
)
|
||||
await ctx.server
|
||||
except OSError: # likely port in use
|
||||
ctx.server = websockets.serve(
|
||||
@@ -304,6 +312,7 @@ 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:
|
||||
@@ -322,6 +331,7 @@ 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:
|
||||
@@ -333,11 +343,12 @@ 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)
|
||||
|
||||
@@ -14,7 +14,7 @@ from pony.orm import commit, db_session
|
||||
from BaseClasses import get_seed, seeddigits
|
||||
from Generate import PlandoOptions, handle_name, mystery_argparse
|
||||
from Main import main as ERmain
|
||||
from Utils import __version__, restricted_dumps
|
||||
from Utils import __version__, restricted_dumps, DaemonThreadPoolExecutor
|
||||
from WebHostLib import app
|
||||
from settings import ServerOptions, GeneratorOptions
|
||||
from .check import get_yaml_data, roll_options
|
||||
@@ -33,6 +33,7 @@ def get_meta(options_source: dict, race: bool = False) -> dict[str, list[str] |
|
||||
"release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)),
|
||||
"remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)),
|
||||
"collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)),
|
||||
"countdown_mode": str(options_source.get("countdown_mode", ServerOptions.countdown_mode)),
|
||||
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
|
||||
"server_password": str(options_source.get("server_password", None)),
|
||||
}
|
||||
@@ -72,6 +73,10 @@ 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"]))
|
||||
|
||||
@@ -92,7 +97,9 @@ 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)
|
||||
return render_template("seedError.html", seed_error=("PicklingError: " + str(e)))
|
||||
meta["error"] = format_exception(e)
|
||||
details = json.dumps(meta, indent=4).strip()
|
||||
return render_template("seedError.html", seed_error=meta["error"], details=details)
|
||||
|
||||
commit()
|
||||
|
||||
@@ -100,16 +107,18 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
|
||||
else:
|
||||
try:
|
||||
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
|
||||
meta=meta, owner=session["_id"].int)
|
||||
meta=meta, owner=session["_id"].int, timeout=app.config["JOB_TIME"])
|
||||
except BaseException as e:
|
||||
from .autolauncher import handle_generation_failure
|
||||
handle_generation_failure(e)
|
||||
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(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 redirect(url_for("view_seed", seed=seed_id))
|
||||
|
||||
|
||||
def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None):
|
||||
def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None, timeout: int|None = None):
|
||||
if meta is None:
|
||||
meta = {}
|
||||
|
||||
@@ -128,7 +137,7 @@ 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 = mystery_argparse([]) # Just to set up the Namespace with defaults
|
||||
args.multi = playercount
|
||||
args.seed = seed
|
||||
args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
||||
@@ -163,11 +172,12 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
|
||||
ERmain(args, seed, baked_server_options=meta["server_options"])
|
||||
|
||||
return upload_to_db(target.name, sid, owner, race)
|
||||
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||
|
||||
thread_pool = DaemonThreadPoolExecutor(max_workers=1)
|
||||
thread = thread_pool.submit(task)
|
||||
|
||||
try:
|
||||
return thread.result(app.config["JOB_TIME"])
|
||||
return thread.result(timeout)
|
||||
except concurrent.futures.TimeoutError as e:
|
||||
if sid:
|
||||
with db_session:
|
||||
@@ -175,11 +185,14 @@ 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. " +
|
||||
e.__class__.__name__ + ": " + str(e))
|
||||
meta["error"] = ("Allowed time for Generation exceeded, " +
|
||||
"please consider generating locally instead. " +
|
||||
format_exception(e))
|
||||
gen.meta = json.dumps(meta)
|
||||
commit()
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
# don't update db, retry next time
|
||||
raise
|
||||
except BaseException as e:
|
||||
if sid:
|
||||
with db_session:
|
||||
@@ -187,10 +200,15 @@ 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"] = (e.__class__.__name__ + ": " + str(e))
|
||||
meta["error"] = format_exception(e)
|
||||
gen.meta = json.dumps(meta)
|
||||
commit()
|
||||
raise
|
||||
finally:
|
||||
# free resources claimed by thread pool, if possible
|
||||
# NOTE: Timeout depends on the process being killed at some point
|
||||
# since we can't actually cancel a running gen at the moment.
|
||||
thread_pool.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
|
||||
@app.route('/wait/<suuid:seed>')
|
||||
@@ -204,7 +222,9 @@ def wait_seed(seed: UUID):
|
||||
if not generation:
|
||||
return "Generation not found."
|
||||
elif generation.state == STATE_ERROR:
|
||||
return render_template("seedError.html", seed_error=generation.meta)
|
||||
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("waitSeed.html", seed_id=seed_id)
|
||||
|
||||
|
||||
|
||||
@@ -260,7 +260,10 @@ 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))
|
||||
with db_session:
|
||||
|
||||
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"
|
||||
room.last_activity = now # will trigger a spinup, if it's not already running
|
||||
|
||||
browser_tokens = "Mozilla", "Chrome", "Safari"
|
||||
@@ -268,9 +271,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) -> str:
|
||||
def get_log(max_size: int = 0 if automated else 1024000) -> Tuple[str, int]:
|
||||
if max_size == 0:
|
||||
return "…"
|
||||
return "…", 0
|
||||
try:
|
||||
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
|
||||
raw_size = 0
|
||||
@@ -281,9 +284,9 @@ def host_room(room: UUID):
|
||||
break
|
||||
raw_size += len(block)
|
||||
fragments.append(block.decode("utf-8"))
|
||||
return "".join(fragments)
|
||||
return "".join(fragments), raw_size
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
return "", 0
|
||||
|
||||
return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log)
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ def filter_rst_to_html(text: str) -> str:
|
||||
lines = text.splitlines()
|
||||
text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
|
||||
|
||||
return publish_parts(text, writer_name='html', settings=None, settings_overrides={
|
||||
return publish_parts(text, writer='html', settings=None, settings_overrides={
|
||||
'raw_enable': False,
|
||||
'file_insertion_enabled': False,
|
||||
'output_encoding': 'unicode'
|
||||
@@ -231,7 +231,7 @@ def generate_yaml(game: str):
|
||||
if key_parts[-1] == "qty":
|
||||
if key_parts[0] not in options:
|
||||
options[key_parts[0]] = {}
|
||||
if val != "0":
|
||||
if val and val != "0":
|
||||
options[key_parts[0]][key_parts[1]] = int(val)
|
||||
del options[key]
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ pony>=0.7.19; python_version <= '3.12'
|
||||
pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13'
|
||||
waitress>=3.0.2
|
||||
Flask-Caching>=2.3.0
|
||||
Flask-Compress>=1.17
|
||||
Flask-Compress>=1.17; python_version >= '3.12'
|
||||
Flask-Compress==1.18; python_version <= '3.11' # 3.11's pkg_resources can't resolve the new "backports.zstd" dependency
|
||||
Flask-Limiter>=3.12
|
||||
bokeh>=3.6.3
|
||||
markupsafe>=3.0.2
|
||||
setproctitle>=1.3.5
|
||||
mistune>=3.1.3
|
||||
docutils>=0.22.2
|
||||
|
||||
@@ -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 add a game to the Archipelago randomizer. How do I do that?
|
||||
## I want to develop a game implementation for Archipelago. 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,4 +77,5 @@ 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, feel free to ask in the **#ap-world-dev** channel on our Discord.
|
||||
If you have more questions regarding development of a game implementation, feel free to ask in the **#ap-world-dev**
|
||||
channel on our Discord.
|
||||
|
||||
@@ -72,3 +72,13 @@ 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;
|
||||
}
|
||||
|
||||
@@ -13,3 +13,7 @@
|
||||
min-height: 360px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h2, h4 {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
<td>
|
||||
{% if hint.finding_player == player %}
|
||||
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b>
|
||||
{% elif get_slot_info(team, hint.finding_player).type == 2 %}
|
||||
{% elif get_slot_info(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(team, hint.receiving_player).type == 2 %}
|
||||
{% elif get_slot_info(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) }}">
|
||||
|
||||
@@ -58,8 +58,7 @@
|
||||
Open Log File...
|
||||
</a>
|
||||
</div>
|
||||
{% set log = get_log() -%}
|
||||
{%- set log_len = log | length - 1 if log.endswith("…") else log | length -%}
|
||||
{% set log, log_len = get_log() -%}
|
||||
<div id="logger" style="white-space: pre">{{ log }}</div>
|
||||
<script>
|
||||
let url = '{{ url_for('display_log', room = room.id) }}';
|
||||
|
||||
@@ -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(team, player) %}
|
||||
{%- set player_location_data = tracker_data.get_player_locations(team, player) %}
|
||||
{%- set finder_game = tracker_data.get_player_game(player) %}
|
||||
{%- set player_location_data = tracker_data.get_player_locations(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(team, receiver) %}
|
||||
{%- set receiver_game = tracker_data.get_player_game(receiver) %}
|
||||
<td>{{ current_sphere }}</td>
|
||||
<td>{{ tracker_data.get_player_name(team, player) }}</td>
|
||||
<td>{{ tracker_data.get_player_name(team, receiver) }}</td>
|
||||
<td>{{ tracker_data.get_player_name(player) }}</td>
|
||||
<td>{{ tracker_data.get_player_name(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>
|
||||
|
||||
@@ -22,14 +22,14 @@
|
||||
-%}
|
||||
<tr>
|
||||
<td>
|
||||
{% if get_slot_info(team, hint.finding_player).type == 2 %}
|
||||
{% if get_slot_info(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(team, hint.receiving_player).type == 2 %}
|
||||
{% if get_slot_info(hint.receiving_player).type == 2 %}
|
||||
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
|
||||
{% else %}
|
||||
{{ player_names_with_alias[(team, hint.receiving_player)] }}
|
||||
|
||||
@@ -4,16 +4,20 @@
|
||||
|
||||
{% 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 retry</h2>
|
||||
{{ seed_error }}
|
||||
<h1>Generation Failed</h1>
|
||||
<h2>Please try again!</h2>
|
||||
<p>{{ seed_error }}</p>
|
||||
<h4>More details:</h4>
|
||||
<p>
|
||||
<code class="grassy">{{ details }}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
{% 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">
|
||||
|
||||
@@ -17,7 +17,6 @@ from .models import GameDataPackage, Room
|
||||
# Multisave is currently updated, at most, every minute.
|
||||
TRACKER_CACHE_TIMEOUT_IN_SECONDS = 60
|
||||
|
||||
_multidata_cache = {}
|
||||
_multiworld_trackers: Dict[str, Callable] = {}
|
||||
_player_trackers: Dict[str, Callable] = {}
|
||||
|
||||
@@ -85,27 +84,27 @@ class TrackerData:
|
||||
"""Retrieves the seed name."""
|
||||
return self._multidata["seed_name"]
|
||||
|
||||
def get_slot_data(self, team: int, player: int) -> Dict[str, Any]:
|
||||
def get_slot_data(self, player: int) -> Dict[str, Any]:
|
||||
"""Retrieves the slot data for a given player."""
|
||||
return self._multidata["slot_data"][player]
|
||||
|
||||
def get_slot_info(self, team: int, player: int) -> NetworkSlot:
|
||||
def get_slot_info(self, player: int) -> NetworkSlot:
|
||||
"""Retrieves the NetworkSlot data for a given player."""
|
||||
return self._multidata["slot_info"][player]
|
||||
|
||||
def get_player_name(self, team: int, player: int) -> str:
|
||||
def get_player_name(self, player: int) -> str:
|
||||
"""Retrieves the slot name for a given player."""
|
||||
return self.get_slot_info(team, player).name
|
||||
return self.get_slot_info(player).name
|
||||
|
||||
def get_player_game(self, team: int, player: int) -> str:
|
||||
def get_player_game(self, player: int) -> str:
|
||||
"""Retrieves the game for a given player."""
|
||||
return self.get_slot_info(team, player).game
|
||||
return self.get_slot_info(player).game
|
||||
|
||||
def get_player_locations(self, team: int, player: int) -> Dict[int, ItemMetadata]:
|
||||
def get_player_locations(self, player: int) -> Dict[int, ItemMetadata]:
|
||||
"""Retrieves all locations with their containing item's metadata for a given player."""
|
||||
return self._multidata["locations"][player]
|
||||
|
||||
def get_player_starting_inventory(self, team: int, player: int) -> List[int]:
|
||||
def get_player_starting_inventory(self, player: int) -> List[int]:
|
||||
"""Retrieves a list of all item codes a given slot starts with."""
|
||||
return self._multidata["precollected_items"][player]
|
||||
|
||||
@@ -116,7 +115,7 @@ class TrackerData:
|
||||
@_cache_results
|
||||
def get_player_missing_locations(self, team: int, player: int) -> Set[int]:
|
||||
"""Retrieves the set of all locations not marked complete by this player."""
|
||||
return set(self.get_player_locations(team, player)) - self.get_player_checked_locations(team, player)
|
||||
return set(self.get_player_locations(player)) - self.get_player_checked_locations(team, player)
|
||||
|
||||
def get_player_received_items(self, team: int, player: int) -> List[NetworkItem]:
|
||||
"""Returns all items received to this player in order of received."""
|
||||
@@ -126,7 +125,7 @@ class TrackerData:
|
||||
def get_player_inventory_counts(self, team: int, player: int) -> collections.Counter:
|
||||
"""Retrieves a dictionary of all items received by their id and their received count."""
|
||||
received_items = self.get_player_received_items(team, player)
|
||||
starting_items = self.get_player_starting_inventory(team, player)
|
||||
starting_items = self.get_player_starting_inventory(player)
|
||||
inventory = collections.Counter()
|
||||
for item in received_items:
|
||||
inventory[item.item] += 1
|
||||
@@ -179,7 +178,7 @@ class TrackerData:
|
||||
def get_team_locations_total_count(self) -> Dict[int, int]:
|
||||
"""Retrieves a dictionary of total player locations each team has."""
|
||||
return {
|
||||
team: sum(len(self.get_player_locations(team, player)) for player in players)
|
||||
team: sum(len(self.get_player_locations(player)) for player in players)
|
||||
for team, players in self.get_all_players().items()
|
||||
}
|
||||
|
||||
@@ -210,7 +209,7 @@ class TrackerData:
|
||||
return {
|
||||
0: [
|
||||
player for player, slot_info in self._multidata["slot_info"].items()
|
||||
if self.get_slot_info(0, player).type == SlotType.player
|
||||
if self.get_slot_info(player).type == SlotType.player
|
||||
]
|
||||
}
|
||||
|
||||
@@ -226,7 +225,7 @@ class TrackerData:
|
||||
def get_room_locations(self) -> Dict[TeamPlayer, Dict[int, ItemMetadata]]:
|
||||
"""Retrieves a dictionary of all locations and their associated item metadata per player."""
|
||||
return {
|
||||
(team, player): self.get_player_locations(team, player)
|
||||
(team, player): self.get_player_locations(player)
|
||||
for team, players in self.get_all_players().items() for player in players
|
||||
}
|
||||
|
||||
@@ -234,7 +233,7 @@ class TrackerData:
|
||||
def get_room_games(self) -> Dict[TeamPlayer, str]:
|
||||
"""Retrieves a dictionary of games for each player."""
|
||||
return {
|
||||
(team, player): self.get_player_game(team, player)
|
||||
(team, player): self.get_player_game(player)
|
||||
for team, players in self.get_all_slots().items() for player in players
|
||||
}
|
||||
|
||||
@@ -262,9 +261,9 @@ class TrackerData:
|
||||
for player in players:
|
||||
alias = self.get_player_alias(team, player)
|
||||
if alias:
|
||||
long_player_names[team, player] = f"{alias} ({self.get_player_name(team, player)})"
|
||||
long_player_names[team, player] = f"{alias} ({self.get_player_name(player)})"
|
||||
else:
|
||||
long_player_names[team, player] = self.get_player_name(team, player)
|
||||
long_player_names[team, player] = self.get_player_name(player)
|
||||
|
||||
return long_player_names
|
||||
|
||||
@@ -344,7 +343,7 @@ def get_timeout_and_player_tracker(room: Room, tracked_team: int, tracked_player
|
||||
tracker_data = TrackerData(room)
|
||||
|
||||
# Load and render the game-specific player tracker, or fallback to generic tracker if none exists.
|
||||
game_specific_tracker = _player_trackers.get(tracker_data.get_player_game(tracked_team, tracked_player), None)
|
||||
game_specific_tracker = _player_trackers.get(tracker_data.get_player_game(tracked_player), None)
|
||||
if game_specific_tracker and not generic:
|
||||
tracker = game_specific_tracker(tracker_data, tracked_team, tracked_player)
|
||||
else:
|
||||
@@ -409,10 +408,10 @@ def get_enabled_multiworld_trackers(room: Room) -> Dict[str, Callable]:
|
||||
|
||||
|
||||
def render_generic_tracker(tracker_data: TrackerData, team: int, player: int) -> str:
|
||||
game = tracker_data.get_player_game(team, player)
|
||||
game = tracker_data.get_player_game(player)
|
||||
|
||||
received_items_in_order = {}
|
||||
starting_inventory = tracker_data.get_player_starting_inventory(team, player)
|
||||
starting_inventory = tracker_data.get_player_starting_inventory(player)
|
||||
for index, item in enumerate(starting_inventory):
|
||||
received_items_in_order[item] = index
|
||||
for index, network_item in enumerate(tracker_data.get_player_received_items(team, player),
|
||||
@@ -428,7 +427,7 @@ def render_generic_tracker(tracker_data: TrackerData, team: int, player: int) ->
|
||||
player=player,
|
||||
player_name=tracker_data.get_room_long_player_names()[team, player],
|
||||
inventory=tracker_data.get_player_inventory_counts(team, player),
|
||||
locations=tracker_data.get_player_locations(team, player),
|
||||
locations=tracker_data.get_player_locations(player),
|
||||
checked_locations=tracker_data.get_player_checked_locations(team, player),
|
||||
received_items=received_items_in_order,
|
||||
saving_second=tracker_data.get_room_saving_second(),
|
||||
@@ -500,7 +499,7 @@ if "Factorio" in network_data_package["games"]:
|
||||
tracker_data.item_id_to_name["Factorio"][item_id]: count
|
||||
for item_id, count in tracker_data.get_player_inventory_counts(team, player).items()
|
||||
}) for team, players in tracker_data.get_all_players().items() for player in players
|
||||
if tracker_data.get_player_game(team, player) == "Factorio"
|
||||
if tracker_data.get_player_game(player) == "Factorio"
|
||||
}
|
||||
|
||||
return render_template(
|
||||
@@ -589,7 +588,7 @@ if "A Link to the Past" in network_data_package["games"]:
|
||||
|
||||
# Highlight 'bombs' if we received any bomb upgrades in bombless start.
|
||||
# In race mode, we'll just assume bombless start for simplicity.
|
||||
if tracker_data.get_slot_data(team, player).get("bombless_start", True):
|
||||
if tracker_data.get_slot_data(player).get("bombless_start", True):
|
||||
inventory["Bombs"] = sum(count for item, count in inventory.items() if item.startswith("Bomb Upgrade"))
|
||||
else:
|
||||
inventory["Bombs"] = 1
|
||||
@@ -605,7 +604,7 @@ if "A Link to the Past" in network_data_package["games"]:
|
||||
for code, count in tracker_data.get_player_inventory_counts(team, player).items()
|
||||
})
|
||||
for team, players in tracker_data.get_all_players().items()
|
||||
for player in players if tracker_data.get_slot_info(team, player).game == "A Link to the Past"
|
||||
for player in players if tracker_data.get_slot_info(player).game == "A Link to the Past"
|
||||
}
|
||||
|
||||
# Translate non-progression items to progression items for tracker simplicity.
|
||||
@@ -624,7 +623,7 @@ if "A Link to the Past" in network_data_package["games"]:
|
||||
for region_name in known_regions
|
||||
}
|
||||
for team, players in tracker_data.get_all_players().items()
|
||||
for player in players if tracker_data.get_slot_info(team, player).game == "A Link to the Past"
|
||||
for player in players if tracker_data.get_slot_info(player).game == "A Link to the Past"
|
||||
}
|
||||
|
||||
# Get a totals count.
|
||||
@@ -698,7 +697,7 @@ if "A Link to the Past" in network_data_package["games"]:
|
||||
team=team,
|
||||
player=player,
|
||||
inventory=inventory,
|
||||
player_name=tracker_data.get_player_name(team, player),
|
||||
player_name=tracker_data.get_player_name(player),
|
||||
regions=regions,
|
||||
known_regions=known_regions,
|
||||
)
|
||||
@@ -845,7 +844,7 @@ if "Ocarina of Time" in network_data_package["games"]:
|
||||
return full_name[len(area):]
|
||||
return full_name
|
||||
|
||||
locations = tracker_data.get_player_locations(team, player)
|
||||
locations = tracker_data.get_player_locations(player)
|
||||
checked_locations = tracker_data.get_player_checked_locations(team, player).intersection(set(locations))
|
||||
location_info = {}
|
||||
checks_done = {}
|
||||
@@ -907,7 +906,7 @@ if "Ocarina of Time" in network_data_package["games"]:
|
||||
player=player,
|
||||
team=team,
|
||||
room=tracker_data.room,
|
||||
player_name=tracker_data.get_player_name(team, player),
|
||||
player_name=tracker_data.get_player_name(player),
|
||||
icons=icons,
|
||||
acquired_items={lookup_any_item_id_to_name[id] for id, count in inventory.items() if count > 0},
|
||||
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
|
||||
@@ -954,57 +953,37 @@ if "Timespinner" in network_data_package["games"]:
|
||||
"Lab Glasses": "https://timespinnerwiki.com/mediawiki/images/4/4a/Lab_Glasses.png",
|
||||
"Eye Orb": "https://timespinnerwiki.com/mediawiki/images/a/a4/Eye_Orb.png",
|
||||
"Lab Coat": "https://timespinnerwiki.com/mediawiki/images/5/51/Lab_Coat.png",
|
||||
"Demon": "https://timespinnerwiki.com/mediawiki/images/f/f8/Familiar_Demon.png",
|
||||
"Demon": "https://timespinnerwiki.com/mediawiki/images/f/f8/Familiar_Demon.png",
|
||||
"Cube of Bodie": "https://timespinnerwiki.com/mediawiki/images/1/14/Menu_Icon_Stats.png"
|
||||
}
|
||||
|
||||
timespinner_location_ids = {
|
||||
"Present": [
|
||||
1337000, 1337001, 1337002, 1337003, 1337004, 1337005, 1337006, 1337007, 1337008, 1337009,
|
||||
1337010, 1337011, 1337012, 1337013, 1337014, 1337015, 1337016, 1337017, 1337018, 1337019,
|
||||
1337020, 1337021, 1337022, 1337023, 1337024, 1337025, 1337026, 1337027, 1337028, 1337029,
|
||||
1337030, 1337031, 1337032, 1337033, 1337034, 1337035, 1337036, 1337037, 1337038, 1337039,
|
||||
1337040, 1337041, 1337042, 1337043, 1337044, 1337045, 1337046, 1337047, 1337048, 1337049,
|
||||
1337050, 1337051, 1337052, 1337053, 1337054, 1337055, 1337056, 1337057, 1337058, 1337059,
|
||||
1337060, 1337061, 1337062, 1337063, 1337064, 1337065, 1337066, 1337067, 1337068, 1337069,
|
||||
1337070, 1337071, 1337072, 1337073, 1337074, 1337075, 1337076, 1337077, 1337078, 1337079,
|
||||
1337080, 1337081, 1337082, 1337083, 1337084, 1337085],
|
||||
"Past": [
|
||||
1337086, 1337087, 1337088, 1337089,
|
||||
1337090, 1337091, 1337092, 1337093, 1337094, 1337095, 1337096, 1337097, 1337098, 1337099,
|
||||
1337100, 1337101, 1337102, 1337103, 1337104, 1337105, 1337106, 1337107, 1337108, 1337109,
|
||||
1337110, 1337111, 1337112, 1337113, 1337114, 1337115, 1337116, 1337117, 1337118, 1337119,
|
||||
1337120, 1337121, 1337122, 1337123, 1337124, 1337125, 1337126, 1337127, 1337128, 1337129,
|
||||
1337130, 1337131, 1337132, 1337133, 1337134, 1337135, 1337136, 1337137, 1337138, 1337139,
|
||||
1337140, 1337141, 1337142, 1337143, 1337144, 1337145, 1337146, 1337147, 1337148, 1337149,
|
||||
1337150, 1337151, 1337152, 1337153, 1337154, 1337155,
|
||||
1337171, 1337172, 1337173, 1337174, 1337175],
|
||||
"Present": list(range(1337000, 1337085)),
|
||||
"Past": list(range(1337086, 1337175)),
|
||||
"Ancient Pyramid": [
|
||||
1337236,
|
||||
1337246, 1337247, 1337248, 1337249]
|
||||
}
|
||||
|
||||
slot_data = tracker_data.get_slot_data(team, player)
|
||||
slot_data = tracker_data.get_slot_data(player)
|
||||
if (slot_data["DownloadableItems"]):
|
||||
timespinner_location_ids["Present"] += [
|
||||
1337156, 1337157, 1337159,
|
||||
1337160, 1337161, 1337162, 1337163, 1337164, 1337165, 1337166, 1337167, 1337168, 1337169,
|
||||
1337170]
|
||||
timespinner_location_ids["Present"] += [1337156, 1337157] + list(range(1337159, 1337170))
|
||||
if (slot_data["Cantoran"]):
|
||||
timespinner_location_ids["Past"].append(1337176)
|
||||
if (slot_data["LoreChecks"]):
|
||||
timespinner_location_ids["Present"] += [
|
||||
1337177, 1337178, 1337179,
|
||||
1337180, 1337181, 1337182, 1337183, 1337184, 1337185, 1337186, 1337187]
|
||||
timespinner_location_ids["Past"] += [
|
||||
1337188, 1337189,
|
||||
1337190, 1337191, 1337192, 1337193, 1337194, 1337195, 1337196, 1337197, 1337198]
|
||||
timespinner_location_ids["Present"] += list(range(1337177, 1337187))
|
||||
timespinner_location_ids["Past"] += list(range(1337188, 1337198))
|
||||
if (slot_data["GyreArchives"]):
|
||||
timespinner_location_ids["Ancient Pyramid"] += [
|
||||
1337237, 1337238, 1337239,
|
||||
1337240, 1337241, 1337242, 1337243, 1337244, 1337245]
|
||||
timespinner_location_ids["Ancient Pyramid"] += list(range(1337237, 1337245))
|
||||
if (slot_data["PyramidStart"]):
|
||||
timespinner_location_ids["Ancient Pyramid"] += [
|
||||
1337233, 1337234, 1337235]
|
||||
if (slot_data["PureTorcher"]):
|
||||
timespinner_location_ids["Present"] += list(range(1337250, 1337352)) + list(range(1337422, 1337496)) + [1337506] + list(range(1337712, 1337779)) + [1337781, 1337782]
|
||||
timespinner_location_ids["Past"] += list(range(1337497, 1337505)) + list(range(1337507, 1337711)) + [1337780]
|
||||
timespinner_location_ids["Ancient Pyramid"] += list(range(1337369, 1337421))
|
||||
if (slot_data["GyreArchives"]):
|
||||
timespinner_location_ids["Ancient Pyramid"] += list(range(1337353, 1337368))
|
||||
|
||||
display_data = {}
|
||||
|
||||
@@ -1035,7 +1014,7 @@ if "Timespinner" in network_data_package["games"]:
|
||||
player=player,
|
||||
team=team,
|
||||
room=tracker_data.room,
|
||||
player_name=tracker_data.get_player_name(team, player),
|
||||
player_name=tracker_data.get_player_name(player),
|
||||
checks_done=checks_done,
|
||||
checks_in_area=checks_in_area,
|
||||
location_info=location_info,
|
||||
@@ -1144,7 +1123,7 @@ if "Super Metroid" in network_data_package["games"]:
|
||||
player=player,
|
||||
team=team,
|
||||
room=tracker_data.room,
|
||||
player_name=tracker_data.get_player_name(team, player),
|
||||
player_name=tracker_data.get_player_name(player),
|
||||
checks_done=checks_done,
|
||||
checks_in_area=checks_in_area,
|
||||
location_info=location_info,
|
||||
@@ -1194,7 +1173,7 @@ if "ChecksFinder" in network_data_package["games"]:
|
||||
|
||||
display_data = {}
|
||||
inventory = tracker_data.get_player_inventory_counts(team, player)
|
||||
locations = tracker_data.get_player_locations(team, player)
|
||||
locations = tracker_data.get_player_locations(player)
|
||||
|
||||
# Multi-items
|
||||
multi_items = {
|
||||
@@ -1236,7 +1215,7 @@ if "ChecksFinder" in network_data_package["games"]:
|
||||
player=player,
|
||||
team=team,
|
||||
room=tracker_data.room,
|
||||
player_name=tracker_data.get_player_name(team, player),
|
||||
player_name=tracker_data.get_player_name(player),
|
||||
checks_done=checks_done,
|
||||
checks_in_area=checks_in_area,
|
||||
location_info=location_info,
|
||||
@@ -1264,7 +1243,7 @@ if "Starcraft 2" in network_data_package["games"]:
|
||||
UPGRADE_RESEARCH_SPEED_ITEM_ID = 1807
|
||||
UPGRADE_RESEARCH_COST_ITEM_ID = 1808
|
||||
REDUCED_MAX_SUPPLY_ITEM_ID = 1850
|
||||
slot_data = tracker_data.get_slot_data(team, player)
|
||||
slot_data = tracker_data.get_slot_data(player)
|
||||
inventory: collections.Counter[int] = tracker_data.get_player_inventory_counts(team, player)
|
||||
item_id_to_name = tracker_data.item_id_to_name["Starcraft 2"]
|
||||
location_id_to_name = tracker_data.location_id_to_name["Starcraft 2"]
|
||||
@@ -1280,10 +1259,10 @@ if "Starcraft 2" in network_data_package["games"]:
|
||||
display_data["shield_regen_count"] = inventory.get(SHIELD_REGENERATION_ITEM_ID, 0)
|
||||
display_data["upgrade_speed_count"] = inventory.get(UPGRADE_RESEARCH_SPEED_ITEM_ID, 0)
|
||||
display_data["research_cost_count"] = inventory.get(UPGRADE_RESEARCH_COST_ITEM_ID, 0)
|
||||
|
||||
|
||||
# Locations
|
||||
have_nco_locations = False
|
||||
locations = tracker_data.get_player_locations(team, player)
|
||||
locations = tracker_data.get_player_locations(player)
|
||||
checked_locations = tracker_data.get_player_checked_locations(team, player)
|
||||
missions: dict[str, list[tuple[str, bool]]] = {}
|
||||
for location_id in locations:
|
||||
@@ -1438,7 +1417,7 @@ if "Starcraft 2" in network_data_package["games"]:
|
||||
# the maximum bundle contribution, not the sum
|
||||
inventory[upgrade_id] = bundle_amount
|
||||
|
||||
|
||||
|
||||
# Victory condition
|
||||
game_state = tracker_data.get_player_client_status(team, player)
|
||||
display_data["game_finished"] = game_state == ClientStatus.CLIENT_GOAL
|
||||
@@ -1456,7 +1435,7 @@ if "Starcraft 2" in network_data_package["games"]:
|
||||
player=player,
|
||||
team=team,
|
||||
room=tracker_data.room,
|
||||
player_name=tracker_data.get_player_name(team, player),
|
||||
player_name=tracker_data.get_player_name(player),
|
||||
missions=missions,
|
||||
locations=locations,
|
||||
checked_locations=checked_locations,
|
||||
|
||||
@@ -33,6 +33,10 @@ 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.
|
||||
|
||||
@@ -72,6 +72,9 @@
|
||||
# Faxanadu
|
||||
/worlds/faxanadu/ @Daivuk
|
||||
|
||||
# Final Fantasy (1)
|
||||
/worlds/ff1/ @Rosalie-A
|
||||
|
||||
# Final Fantasy Mystic Quest
|
||||
/worlds/ffmq/ @Alchav @wildham0
|
||||
|
||||
@@ -241,9 +244,6 @@
|
||||
# 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/
|
||||
|
||||
|
||||
@@ -1,26 +1,83 @@
|
||||
# apworld Specification
|
||||
# APWorld Specification
|
||||
|
||||
Archipelago depends on worlds to provide game-specific details like items, locations and output generation.
|
||||
Those are located in the `worlds/` folder (source) or `<install dir>/lib/worlds/` (when installed).
|
||||
These are called "APWorlds".
|
||||
They are located in the `worlds/` folder (source) or `<install dir>/lib/worlds/` (when installed).
|
||||
See [world api.md](world%20api.md) for details.
|
||||
APWorlds can either be a folder, or they can be packaged as an .apworld file.
|
||||
|
||||
apworld provides a way to package and ship a world that is not part of the main distribution by placing a `*.apworld`
|
||||
file into the worlds folder.
|
||||
## .apworld File Format
|
||||
|
||||
**Warning:** apworlds have to be all lower case, otherwise they raise a bogus Exception when trying to import in frozen python 3.10+!
|
||||
The `.apworld` file format provides a way to package and ship an APWorld that is not part of the main distribution
|
||||
by placing a `*.apworld` file into the worlds folder.
|
||||
|
||||
|
||||
## File Format
|
||||
|
||||
apworld files are zip archives, all lower case, with the file ending `.apworld`.
|
||||
`.apworld` files are zip archives, all lower case, with the file ending `.apworld`.
|
||||
The zip has to contain a folder with the same name as the zip, case-sensitive, that contains what would normally be in
|
||||
the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__init__.py`.
|
||||
|
||||
**Warning:** `.apworld` files have to be all lower case,
|
||||
otherwise they raise a bogus Exception when trying to import in frozen python 3.10+!
|
||||
|
||||
## Metadata
|
||||
|
||||
No metadata is specified yet.
|
||||
Metadata about the APWorld is defined in an `archipelago.json` file.
|
||||
|
||||
If the APWorld is a folder, the only required field is "game":
|
||||
```json
|
||||
{
|
||||
"game": "Game Name"
|
||||
}
|
||||
```
|
||||
|
||||
There are also the following optional fields:
|
||||
* `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
|
||||
(**Must** use exactly the format `"major.minor.build"`, e.g. `1.0.0`)
|
||||
* `authors` - a list of authors, to eventually be displayed in various user-facing places such as WebHost and
|
||||
package managers. Should always be a list of strings.
|
||||
|
||||
If the APWorld is packaged as an `.apworld` zip file, it also needs to have `version` and `compatible_version`,
|
||||
which refer to the version of the APContainer packaging scheme defined in [Files.py](../worlds/Files.py).
|
||||
These get automatically added to the `archipelago.json` of an .apworld if it is packaged using the
|
||||
["Build apworlds" launcher component](#build-apworlds-launcher-component),
|
||||
which is the correct way to package your `.apworld` as a world developer. Do not write these fields yourself.
|
||||
|
||||
### "Build apworlds" Launcher Component
|
||||
|
||||
In the Archipelago Launcher, there is a "Build apworlds" component that will package all world folders to `.apworld`,
|
||||
and add `archipelago.json` manifest files to them.
|
||||
These .apworld files will be output to `build/apworlds` (relative to the Archipelago root directory).
|
||||
The `archipelago.json` file in each .apworld will automatically include the appropriate
|
||||
`version` and `compatible_version`.
|
||||
|
||||
If a world folder has an `archipelago.json` in its root, any fields it contains will be carried over.
|
||||
So, a world folder with an `archipelago.json` that looks like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"game": "Game Name",
|
||||
"minimum_ap_version": "0.6.4",
|
||||
"world_version": "2.1.4",
|
||||
"authors": ["NewSoupVi"]
|
||||
}
|
||||
```
|
||||
|
||||
will be packaged into an `.apworld` with a manifest file inside of it that looks like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"minimum_ap_version": "0.6.4",
|
||||
"world_version": "2.1.4",
|
||||
"authors": ["NewSoupVi"],
|
||||
"version": 7,
|
||||
"compatible_version": 7,
|
||||
"game": "Game Name"
|
||||
}
|
||||
```
|
||||
|
||||
This is the recommended workflow for packaging your world to an `.apworld`.
|
||||
|
||||
## Extra Data
|
||||
|
||||
@@ -29,7 +86,7 @@ The zip can contain arbitrary files in addition what was specified above.
|
||||
|
||||
## Caveats
|
||||
|
||||
Imports from other files inside the apworld have to use relative imports. e.g. `from .options import MyGameOptions`
|
||||
Imports from other files inside the APWorld have to use relative imports. e.g. `from .options import MyGameOptions`
|
||||
|
||||
Imports from AP base have to use absolute imports, e.g. `from Options import Toggle` or
|
||||
`from worlds.AutoWorld import World`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)\[int\]\] | Mapping of permission name to [Permission](#Permission), keys are: "release", "collect" and "remaining". |
|
||||
| permissions | dict\[str, [Permission](#Permission)\] | 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,13 +662,14 @@ class SlotType(enum.IntFlag):
|
||||
An object representing static information about a slot.
|
||||
|
||||
```python
|
||||
import typing
|
||||
from collections.abc import Sequence
|
||||
from typing import NamedTuple
|
||||
from NetUtils import SlotType
|
||||
class NetworkSlot(typing.NamedTuple):
|
||||
class NetworkSlot(NamedTuple):
|
||||
name: str
|
||||
game: str
|
||||
type: SlotType
|
||||
group_members: typing.List[int] = [] # only populated if type == group
|
||||
group_members: Sequence[int] = [] # only populated if type == group
|
||||
```
|
||||
|
||||
### Permission
|
||||
@@ -686,8 +687,8 @@ class Permission(enum.IntEnum):
|
||||
### Hint
|
||||
An object representing a Hint.
|
||||
```python
|
||||
import typing
|
||||
class Hint(typing.NamedTuple):
|
||||
from typing import NamedTuple
|
||||
class Hint(NamedTuple):
|
||||
receiving_player: int
|
||||
finding_player: int
|
||||
location: int
|
||||
|
||||
@@ -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`, define it as `typing.Optional` and assign `None`.
|
||||
Each value needs to have a default. If the default should be `None`, annotate it using `T | None = None`.
|
||||
|
||||
To access a "global" config value, with correct typing, use one of
|
||||
```python
|
||||
|
||||
@@ -15,8 +15,10 @@
|
||||
* 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.
|
||||
* 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]]`).
|
||||
* 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
|
||||
@@ -60,3 +62,9 @@
|
||||
* 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.
|
||||
|
||||
@@ -18,6 +18,8 @@ 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)
|
||||
@@ -254,8 +256,6 @@ 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,111 +265,55 @@ 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,
|
||||
"players": [
|
||||
{
|
||||
"player": 1,
|
||||
"alias": "Incompetence"
|
||||
},
|
||||
{
|
||||
"player": 2,
|
||||
"alias": "Slot_Name_2"
|
||||
}
|
||||
]
|
||||
"player": 1,
|
||||
"alias": "Incompetence"
|
||||
},
|
||||
{
|
||||
"team": 0,
|
||||
"player": 2,
|
||||
"alias": "Slot_Name_2"
|
||||
}
|
||||
],
|
||||
"player_items_received": [
|
||||
{
|
||||
"team": 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": 1,
|
||||
"items": [
|
||||
[1, 1, 1, 0],
|
||||
[2, 2, 2, 1]
|
||||
]
|
||||
},
|
||||
{
|
||||
"team": 0,
|
||||
"player": 2,
|
||||
"items": [
|
||||
[1, 1, 1, 2],
|
||||
[2, 2, 2, 0]
|
||||
]
|
||||
}
|
||||
],
|
||||
"player_checks_done": [
|
||||
{
|
||||
"team": 0,
|
||||
"players": [
|
||||
{
|
||||
"player": 1,
|
||||
"locations": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"player": 2,
|
||||
"locations": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
}
|
||||
"player": 1,
|
||||
"locations": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"team": 0,
|
||||
"player": 2,
|
||||
"locations": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -382,76 +326,132 @@ Example:
|
||||
"hints": [
|
||||
{
|
||||
"team": 0,
|
||||
"players": [
|
||||
{
|
||||
"player": 1,
|
||||
"hints": [
|
||||
[1, 2, 4, 6, 0, "", 4, 0]
|
||||
]
|
||||
},
|
||||
{
|
||||
"player": 2,
|
||||
"hints": []
|
||||
}
|
||||
"player": 1,
|
||||
"hints": [
|
||||
[1, 2, 4, 6, 0, "", 4, 0]
|
||||
]
|
||||
},
|
||||
{
|
||||
"team": 0,
|
||||
"player": 2,
|
||||
"hints": []
|
||||
}
|
||||
],
|
||||
"activity_timers": [
|
||||
{
|
||||
"team": 0,
|
||||
"players": [
|
||||
{
|
||||
"player": 1,
|
||||
"time": "Fri, 18 Apr 2025 20:35:45 GMT"
|
||||
},
|
||||
{
|
||||
"player": 2,
|
||||
"time": "Fri, 18 Apr 2025 20:42:46 GMT"
|
||||
}
|
||||
]
|
||||
"player": 1,
|
||||
"time": "Fri, 18 Apr 2025 20:35:45 GMT"
|
||||
},
|
||||
{
|
||||
"team": 0,
|
||||
"player": 2,
|
||||
"time": "Fri, 18 Apr 2025 20:42:46 GMT"
|
||||
}
|
||||
],
|
||||
"connection_timers": [
|
||||
{
|
||||
"team": 0,
|
||||
"players": [
|
||||
{
|
||||
"player": 1,
|
||||
"time": "Fri, 18 Apr 2025 20:38:25 GMT"
|
||||
},
|
||||
{
|
||||
"player": 2,
|
||||
"time": "Fri, 18 Apr 2025 21:03:00 GMT"
|
||||
}
|
||||
]
|
||||
"player": 1,
|
||||
"time": "Fri, 18 Apr 2025 20:38:25 GMT"
|
||||
},
|
||||
{
|
||||
"team": 0,
|
||||
"player": 2,
|
||||
"time": "Fri, 18 Apr 2025 21:03:00 GMT"
|
||||
}
|
||||
],
|
||||
"player_status": [
|
||||
{
|
||||
"team": 0,
|
||||
"players": [
|
||||
{
|
||||
"player": 1,
|
||||
"status": 0
|
||||
},
|
||||
{
|
||||
"player": 2,
|
||||
"status": 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
|
||||
]
|
||||
}
|
||||
],
|
||||
"datapackage": {
|
||||
"Archipelago": {
|
||||
"checksum": "ac9141e9ad0318df2fa27da5f20c50a842afeecb",
|
||||
"version": 0
|
||||
},
|
||||
"The Messenger": {
|
||||
"checksum": "6991cbcda7316b65bcb072667f3ee4c4cae71c0b",
|
||||
"version": 0
|
||||
}
|
||||
},
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## User Endpoints
|
||||
@@ -554,4 +554,4 @@ Example:
|
||||
"seed_id": "a528e34c-3b4f-42a9-9f8f-00a4fd40bacb"
|
||||
}
|
||||
]
|
||||
```
|
||||
```
|
||||
@@ -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")
|
||||
```
|
||||
|
||||
@@ -180,8 +180,8 @@ Root: HKCR; Subkey: "{#MyAppName}mm2patch\shell\open\command"; ValueData: """{a
|
||||
|
||||
Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\shell\open\command"; ValueData: """{app}\ArchipelagoLinksAwakeningClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".aptloz"; ValueData: "{#MyAppName}tlozpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}tlozpatch"; ValueData: "Archipelago The Legend of Zelda Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
|
||||
21
kvui.py
21
kvui.py
@@ -34,6 +34,17 @@ from kivy.config import Config
|
||||
Config.set("input", "mouse", "mouse,disable_multitouch")
|
||||
Config.set("kivy", "exit_on_escape", "0")
|
||||
Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers
|
||||
|
||||
# Workaround for an issue where importing kivy.core.window before loading sounds
|
||||
# will hang the whole application on Linux once the first sound is loaded.
|
||||
# kivymd imports kivy.core.window, so we have to do this before the first kivymd import.
|
||||
# No longer necessary when we switch to kivy 3.0.0, which fixes this issue.
|
||||
from kivy.core.audio import SoundLoader
|
||||
for classobj in SoundLoader._classes:
|
||||
# The least invasive way to force a SoundLoader class to load its audio engine seems to be calling
|
||||
# .extensions(), which e.g. in audio_sdl2.pyx then calls a function called "mix_init()"
|
||||
classobj.extensions()
|
||||
|
||||
from kivymd.uix.divider import MDDivider
|
||||
from kivy.core.window import Window
|
||||
from kivy.core.clipboard import Clipboard
|
||||
@@ -838,15 +849,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,10 +1110,6 @@ 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):
|
||||
|
||||
16
ruff.toml
Normal file
16
ruff.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
line-length = 120
|
||||
indent-width = 4
|
||||
target-version = "py311"
|
||||
|
||||
[lint]
|
||||
select = ["B", "C", "E", "F", "W", "I", "N", "Q", "UP", "RET", "RSE", "RUF", "ISC", "PLC", "PLE", "PLW", "T20", "PERF"]
|
||||
ignore = [
|
||||
"B011", # In AP, the use of assert False is essential because we optimise out these statements for release builds.
|
||||
"C901", # Author disagrees with limiting branch complexity
|
||||
"N818", # Author agrees with this rule, but Core AP violates this and changing it would be a hassle.
|
||||
"PLC0415", # In AP, we consider local imports totally fine & necessary
|
||||
"PLC1802", # Author agrees with this rule, but it literally changes the functionality of the code, which is unsafe.
|
||||
"PLC1901", # This is just not equivalent
|
||||
"PLE1141", # Gives false positives when the dict keys are tuples, but does not mention this in the suggested fix.
|
||||
"UP015", # Explicit is better than implicit, so we'd prefer to keep "r" in open() calls.
|
||||
]
|
||||
12
settings.py
12
settings.py
@@ -579,6 +579,17 @@ class ServerOptions(Group):
|
||||
"goal" -> Client can ask for remaining items after goal completion
|
||||
"""
|
||||
|
||||
class CountdownMode(str):
|
||||
"""
|
||||
Countdown modes
|
||||
Determines whether or not a player can initiate a countdown with !countdown
|
||||
Note that /countdown is always available to the host.
|
||||
|
||||
"enabled" -> Client can always initiate a countdown with !countdown.
|
||||
"disabled" -> Client can never initiate a countdown with !countdown.
|
||||
"auto" -> !countdown will be available for any room with less than 30 slots.
|
||||
"""
|
||||
|
||||
class AutoShutdown(int):
|
||||
"""Automatically shut down the server after this many seconds without new location checks, 0 to keep running"""
|
||||
|
||||
@@ -613,6 +624,7 @@ class ServerOptions(Group):
|
||||
release_mode: ReleaseMode = ReleaseMode("auto")
|
||||
collect_mode: CollectMode = CollectMode("auto")
|
||||
remaining_mode: RemainingMode = RemainingMode("goal")
|
||||
countdown_mode: CountdownMode = CountdownMode("auto")
|
||||
auto_shutdown: AutoShutdown = AutoShutdown(0)
|
||||
compatibility: Compatibility = Compatibility(2)
|
||||
log_network: LogNetwork = LogNetwork(0)
|
||||
|
||||
41
setup.py
41
setup.py
@@ -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.0.0'
|
||||
requirement = 'cx-Freeze==8.4.0'
|
||||
try:
|
||||
import pkg_resources
|
||||
try:
|
||||
@@ -146,7 +146,16 @@ def download_SNI() -> None:
|
||||
|
||||
signtool: str | None = None
|
||||
try:
|
||||
with urllib.request.urlopen('http://192.168.206.4:12345/connector/status') as response:
|
||||
import socket
|
||||
|
||||
sign_host, sign_port = "192.168.206.4", 12345
|
||||
# check if the sign_host is on a local network
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect((sign_host, sign_port))
|
||||
if s.getsockname()[0].rsplit(".", 1)[0] != sign_host.rsplit(".", 1)[0]:
|
||||
raise ConnectionError() # would go through default route
|
||||
# configure signtool
|
||||
with urllib.request.urlopen(f"http://{sign_host}:{sign_port}/connector/status") as response:
|
||||
html = response.read()
|
||||
if b"status=OK\n" in html:
|
||||
signtool = (r'signtool sign /sha1 6df76fe776b82869a5693ddcb1b04589cffa6faf /fd sha256 /td sha256 '
|
||||
@@ -371,6 +380,7 @@ 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] = []
|
||||
@@ -379,13 +389,36 @@ 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"):
|
||||
with open(os.path.join(world_directory, "archipelago.json"), mode="r", encoding="utf-8") as manifest_file:
|
||||
manifest = json.load(manifest_file)
|
||||
|
||||
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
|
||||
with zipfile.ZipFile(self.libfolder / "worlds" / (file_name + ".apworld"), "x", zipfile.ZIP_DEFLATED,
|
||||
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,
|
||||
compresslevel=9) as zf:
|
||||
for path in world_directory.rglob("*.*"):
|
||||
relative_path = os.path.join(*path.parts[path.parts.index("worlds")+1:])
|
||||
zf.write(path, relative_path)
|
||||
if not relative_path.endswith("archipelago.json"):
|
||||
zf.write(path, relative_path)
|
||||
zf.writestr(apworld.manifest_path, json.dumps(manifest))
|
||||
folders_to_remove.append(file_name)
|
||||
shutil.rmtree(world_directory)
|
||||
shutil.copyfile("meta.yaml", self.buildfolder / "Players" / "Templates" / "meta.yaml")
|
||||
|
||||
0
test/benchmark/compression/__init__.py
Normal file
0
test/benchmark/compression/__init__.py
Normal file
227
test/benchmark/compression/benchmark.py
Normal file
227
test/benchmark/compression/benchmark.py
Normal file
@@ -0,0 +1,227 @@
|
||||
#!/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()
|
||||
@@ -1,4 +1,12 @@
|
||||
def run_locations_benchmark():
|
||||
def run_locations_benchmark(freeze_gc: bool = True) -> None:
|
||||
"""
|
||||
Run a benchmark of location access rule performance against an empty_state and an all_state.
|
||||
|
||||
:param freeze_gc: Whether to freeze gc before benchmarking and unfreeze gc afterward. Freezing gc moves all objects
|
||||
tracked by the garbage collector to a permanent generation, ignoring them in all future collections. Freezing
|
||||
greatly reduces the duration of running gc.collect() within benchmarks, which otherwise often takes much longer
|
||||
than running all iterations for the location rule being benchmarked.
|
||||
"""
|
||||
import argparse
|
||||
import logging
|
||||
import gc
|
||||
@@ -34,6 +42,8 @@ def run_locations_benchmark():
|
||||
return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top))
|
||||
|
||||
def location_test(self, test_location: Location, state: CollectionState, state_name: str) -> float:
|
||||
if freeze_gc:
|
||||
gc.freeze()
|
||||
with TimeIt(f"{test_location.game} {self.rule_iterations} "
|
||||
f"runs of {test_location}.access_rule({state_name})", logger) as t:
|
||||
for _ in range(self.rule_iterations):
|
||||
@@ -41,6 +51,8 @@ def run_locations_benchmark():
|
||||
# if time is taken to disentangle complex ref chains,
|
||||
# this time should be attributed to the rule.
|
||||
gc.collect()
|
||||
if freeze_gc:
|
||||
gc.unfreeze()
|
||||
return t.dif
|
||||
|
||||
def main(self):
|
||||
@@ -64,9 +76,13 @@ def run_locations_benchmark():
|
||||
|
||||
gc.collect()
|
||||
for step in self.gen_steps:
|
||||
if freeze_gc:
|
||||
gc.freeze()
|
||||
with TimeIt(f"{game} step {step}", logger):
|
||||
call_all(multiworld, step)
|
||||
gc.collect()
|
||||
if freeze_gc:
|
||||
gc.unfreeze()
|
||||
|
||||
locations = sorted(multiworld.get_unfilled_locations())
|
||||
if not locations:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from argparse import Namespace
|
||||
from typing import List, Optional, Tuple, Type, Union
|
||||
from typing import Any, List, Optional, Tuple, Type
|
||||
|
||||
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: Union[List[Type[World]], Type[World]], steps: Tuple[str, ...] = gen_steps,
|
||||
seed: Optional[int] = None) -> MultiWorld:
|
||||
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:
|
||||
"""
|
||||
Creates a multiworld with a player for each provided world type, allowing duplicates, setting default options, and
|
||||
calling the provided gen steps.
|
||||
@@ -40,20 +40,27 @@ def setup_multiworld(worlds: Union[List[Type[World]], Type[World]], steps: Tuple
|
||||
: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 in enumerate(worlds, 1):
|
||||
for player, (world_type, option_overrides) in enumerate(zip(worlds, options), 1):
|
||||
for key, option in world_type.options_dataclass.type_hints.items():
|
||||
updated_options = getattr(args, key, {})
|
||||
updated_options[player] = option.from_any(option.default)
|
||||
updated_options[player] = option.from_any(option_overrides.get(key, option.default))
|
||||
setattr(args, key, updated_options)
|
||||
multiworld.set_options(args)
|
||||
multiworld.state = CollectionState(multiworld)
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import unittest
|
||||
|
||||
from BaseClasses import PlandoOptions
|
||||
from Options import ItemLinks, Choice
|
||||
from Options import Choice, ItemLinks, PlandoConnections, PlandoItems, PlandoTexts
|
||||
from Utils import restricted_dumps
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
@@ -72,8 +72,8 @@ class TestOptions(unittest.TestCase):
|
||||
for link in item_links.values():
|
||||
self.assertEqual(link.value[0], item_link_group[0])
|
||||
|
||||
def test_pickle_dumps(self):
|
||||
"""Test options can be pickled into database for WebHost generation"""
|
||||
def test_pickle_dumps_default(self):
|
||||
"""Test that default option values can be pickled into database for WebHost generation"""
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
if not world_type.hidden:
|
||||
for option_key, option in world_type.options_dataclass.type_hints.items():
|
||||
@@ -81,3 +81,23 @@ class TestOptions(unittest.TestCase):
|
||||
restricted_dumps(option.from_any(option.default))
|
||||
if issubclass(option, Choice) and option.default in option.name_lookup:
|
||||
restricted_dumps(option.from_text(option.name_lookup[option.default]))
|
||||
|
||||
def test_pickle_dumps_plando(self):
|
||||
"""Test that plando options using containers of a custom type can be pickled"""
|
||||
# The base PlandoConnections class can't be instantiated directly, create a subclass and then cast it
|
||||
class TestPlandoConnections(PlandoConnections):
|
||||
entrances = {"An Entrance"}
|
||||
exits = {"An Exit"}
|
||||
plando_connection_value = PlandoConnections(
|
||||
TestPlandoConnections.from_any([{"entrance": "An Entrance", "exit": "An Exit"}])
|
||||
)
|
||||
|
||||
plando_values = {
|
||||
"PlandoConnections": plando_connection_value,
|
||||
"PlandoItems": PlandoItems.from_any([{"item": "Something", "location": "Somewhere"}]),
|
||||
"PlandoTexts": PlandoTexts.from_any([{"text": "Some text.", "at": "text_box"}]),
|
||||
}
|
||||
|
||||
for option_key, value in plando_values.items():
|
||||
with self.subTest(option=option_key):
|
||||
restricted_dumps(value)
|
||||
|
||||
102
test/general/test_world_manifest.py
Normal file
102
test/general/test_world_manifest.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Check world sources' manifest files"""
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import test
|
||||
from Utils import home_path, local_path
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from ..param import classvar_matrix
|
||||
|
||||
|
||||
test_path = Path(test.__file__).parent
|
||||
worlds_paths = [
|
||||
Path(local_path("worlds")),
|
||||
Path(local_path("custom_worlds")),
|
||||
Path(home_path("worlds")),
|
||||
Path(home_path("custom_worlds")),
|
||||
]
|
||||
|
||||
# Only check source folders for now. Zip validation should probably be in the loader and/or installer.
|
||||
source_world_names = [
|
||||
k
|
||||
for k, v in AutoWorldRegister.world_types.items()
|
||||
if not v.zip_path and not Path(v.__file__).is_relative_to(test_path)
|
||||
]
|
||||
|
||||
|
||||
def get_source_world_manifest_path(game: str) -> Path | None:
|
||||
"""Get path of archipelago.json in the world's root folder from game name."""
|
||||
# TODO: add a feature to AutoWorld that makes this less annoying
|
||||
world_type = AutoWorldRegister.world_types[game]
|
||||
world_type_path = Path(world_type.__file__)
|
||||
for worlds_path in worlds_paths:
|
||||
if world_type_path.is_relative_to(worlds_path):
|
||||
world_root = worlds_path / world_type_path.relative_to(worlds_path).parents[0]
|
||||
manifest_path = world_root / "archipelago.json"
|
||||
return manifest_path if manifest_path.exists() else None
|
||||
assert False, f"{world_type_path} not found in any worlds path"
|
||||
|
||||
|
||||
# TODO: remove the filter once manifests are mandatory.
|
||||
@classvar_matrix(game=filter(get_source_world_manifest_path, source_world_names))
|
||||
class TestWorldManifest(unittest.TestCase):
|
||||
game: ClassVar[str]
|
||||
manifest: ClassVar[dict[str, Any]]
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
world_type = AutoWorldRegister.world_types[cls.game]
|
||||
assert world_type.game == cls.game
|
||||
manifest_path = get_source_world_manifest_path(cls.game)
|
||||
assert manifest_path # make mypy happy
|
||||
with manifest_path.open("r", encoding="utf-8") as f:
|
||||
cls.manifest = json.load(f)
|
||||
|
||||
def test_game(self) -> None:
|
||||
"""Test that 'game' will be correctly defined when generating APWorld manifest from source."""
|
||||
self.assertIn(
|
||||
"game",
|
||||
self.manifest,
|
||||
f"archipelago.json manifest exists for {self.game} but does not contain 'game'",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.manifest["game"],
|
||||
self.game,
|
||||
f"archipelago.json manifest for {self.game} specifies wrong game '{self.manifest['game']}'",
|
||||
)
|
||||
|
||||
def test_world_version(self) -> None:
|
||||
"""Test that world_version matches the requirements in apworld specification.md"""
|
||||
if "world_version" in self.manifest:
|
||||
world_version: str = self.manifest["world_version"]
|
||||
self.assertIsInstance(
|
||||
world_version,
|
||||
str,
|
||||
f"world_version in archipelago.json for '{self.game}' has to be string if provided.",
|
||||
)
|
||||
parts = world_version.split(".")
|
||||
self.assertEqual(
|
||||
len(parts),
|
||||
3,
|
||||
f"world_version in archipelago.json for '{self.game}' has to be in the form of 'major.minor.build'.",
|
||||
)
|
||||
for part in parts:
|
||||
self.assertTrue(
|
||||
part.isdigit(),
|
||||
f"world_version in archipelago.json for '{self.game}' may only contain numbers.",
|
||||
)
|
||||
|
||||
def test_no_container_version(self) -> None:
|
||||
self.assertNotIn(
|
||||
"version",
|
||||
self.manifest,
|
||||
f"archipelago.json for '{self.game}' must not define 'version', see apworld specification.md.",
|
||||
)
|
||||
self.assertNotIn(
|
||||
"compatible_version",
|
||||
self.manifest,
|
||||
f"archipelago.json for '{self.game}' must not define 'compatible_version', see apworld specification.md.",
|
||||
)
|
||||
@@ -3,6 +3,7 @@
|
||||
# Run with `python test/hosting` instead,
|
||||
import logging
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from time import sleep
|
||||
from typing import Any
|
||||
@@ -11,7 +12,7 @@ from test.hosting.client import Client
|
||||
from test.hosting.generate import generate_local
|
||||
from test.hosting.serve import ServeGame, LocalServeGame, WebHostServeGame
|
||||
from test.hosting.webhost import (create_room, get_app, get_multidata_for_room, set_multidata_for_room, start_room,
|
||||
stop_autohost, upload_multidata)
|
||||
stop_autogen, stop_autohost, upload_multidata, generate_remote)
|
||||
from test.hosting.world import copy as copy_world, delete as delete_world
|
||||
|
||||
failure = False
|
||||
@@ -56,35 +57,62 @@ else:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
warnings.simplefilter("ignore", ResourceWarning)
|
||||
warnings.simplefilter("ignore", UserWarning)
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
|
||||
spacer = '=' * 80
|
||||
|
||||
with TemporaryDirectory() as tempdir:
|
||||
empty_file = str(Path(tempdir) / "empty")
|
||||
open(empty_file, "w").close()
|
||||
sys.argv += ["--config_override", empty_file] # tests #5541
|
||||
multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]]
|
||||
p1_games = []
|
||||
data_paths = []
|
||||
rooms = []
|
||||
p1_games: list[str] = []
|
||||
data_paths: list[Path | None] = []
|
||||
rooms: list[str] = []
|
||||
multidata: Path | None
|
||||
|
||||
copy_world("VVVVVV", "Temp World")
|
||||
try:
|
||||
for n, games in enumerate(multis, 1):
|
||||
print(f"Generating [{n}] {', '.join(games)}")
|
||||
print(f"Generating [{n}] {', '.join(games)} offline")
|
||||
multidata = generate_local(games, tempdir)
|
||||
print(f"Generated [{n}] {', '.join(games)} as {multidata}\n")
|
||||
p1_games.append(games[0])
|
||||
data_paths.append(multidata)
|
||||
p1_games.append(games[0])
|
||||
finally:
|
||||
delete_world("Temp World")
|
||||
|
||||
webapp = get_app(tempdir)
|
||||
webhost_client = webapp.test_client()
|
||||
|
||||
for n, multidata in enumerate(data_paths, 1):
|
||||
assert multidata
|
||||
seed = upload_multidata(webhost_client, multidata)
|
||||
print(f"Uploaded [{n}] {multidata} as {seed}\n")
|
||||
room = create_room(webhost_client, seed)
|
||||
print(f"Uploaded [{n}] {multidata} as {room}\n")
|
||||
print(f"Started [{n}] {seed} as {room}\n")
|
||||
rooms.append(room)
|
||||
|
||||
# Generate 1 extra game on WebHost
|
||||
from WebHostLib.autolauncher import autogen
|
||||
for n, games in enumerate(multis[:1], len(multis) + 1):
|
||||
multis.append(games)
|
||||
try:
|
||||
print(f"Generating [{n}] {', '.join(games)} online")
|
||||
autogen(webapp.config)
|
||||
sleep(5) # until we have lazy loading of worlds, wait here for the process to start up
|
||||
seed = generate_remote(webhost_client, games)
|
||||
print(f"Generated [{n}] {', '.join(games)} as {seed}\n")
|
||||
finally:
|
||||
stop_autogen()
|
||||
data_paths.append(None) # WebHost-only
|
||||
room = create_room(webhost_client, seed)
|
||||
print(f"Started [{n}] {seed} as {room}\n")
|
||||
rooms.append(room)
|
||||
|
||||
print("Starting autohost")
|
||||
@@ -96,31 +124,10 @@ if __name__ == "__main__":
|
||||
for n, (multidata, room, game, multi_games) in enumerate(zip(data_paths, rooms, p1_games, multis), 1):
|
||||
involved_games = {"Archipelago"} | set(multi_games)
|
||||
for collected_items in range(3):
|
||||
print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected")
|
||||
with LocalServeGame(multidata) as host:
|
||||
with Client(host.address, game, "Player1") as client:
|
||||
local_data_packages = client.games_packages
|
||||
local_collected_items = len(client.checked_locations)
|
||||
if collected_items < 2: # Don't collect anything on the last iteration
|
||||
client.collect_any()
|
||||
# TODO: Ctrl+C test here as well
|
||||
|
||||
for game_name in sorted(involved_games):
|
||||
expect_true(game_name in local_data_packages,
|
||||
f"{game_name} missing from MultiServer datap ackage")
|
||||
expect_true("item_name_groups" not in local_data_packages.get(game_name, {}),
|
||||
f"item_name_groups are not supposed to be in MultiServer data for {game_name}")
|
||||
expect_true("location_name_groups" not in local_data_packages.get(game_name, {}),
|
||||
f"location_name_groups are not supposed to be in MultiServer data for {game_name}")
|
||||
for game_name in local_data_packages:
|
||||
expect_true(game_name in involved_games,
|
||||
f"Received unexpected extra data package for {game_name} from MultiServer")
|
||||
assert_equal(local_collected_items, collected_items,
|
||||
"MultiServer did not load or save correctly")
|
||||
|
||||
print(f"\nTesting [{n}] {game} in {multidata} on customserver with {collected_items} items collected")
|
||||
prev_host_adr: str
|
||||
with WebHostServeGame(webhost_client, room) as host:
|
||||
sleep(.1) # wait for the server to fully start before doing anything
|
||||
prev_host_adr = host.address
|
||||
with Client(host.address, game, "Player1") as client:
|
||||
web_data_packages = client.games_packages
|
||||
@@ -134,6 +141,7 @@ if __name__ == "__main__":
|
||||
autohost(webapp.config) # this will spin the room right up again
|
||||
sleep(1) # make log less annoying
|
||||
# if saving failed, the next iteration will fail below
|
||||
sleep(2) # work around issue #5571
|
||||
|
||||
# verify server shut down
|
||||
try:
|
||||
@@ -156,6 +164,31 @@ if __name__ == "__main__":
|
||||
"customserver did not load or save correctly during/after "
|
||||
+ ("Ctrl+C" if collected_items == 2 else "/exit"))
|
||||
|
||||
if not multidata:
|
||||
continue # games rolled on WebHost can not be tested against MultiServer
|
||||
|
||||
print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected")
|
||||
with LocalServeGame(multidata) as host:
|
||||
with Client(host.address, game, "Player1") as client:
|
||||
local_data_packages = client.games_packages
|
||||
local_collected_items = len(client.checked_locations)
|
||||
if collected_items < 2: # Don't collect anything on the last iteration
|
||||
client.collect_any()
|
||||
# TODO: Ctrl+C test here as well
|
||||
|
||||
for game_name in sorted(involved_games):
|
||||
expect_true(game_name in local_data_packages,
|
||||
f"{game_name} missing from MultiServer datapackage")
|
||||
expect_true("item_name_groups" not in local_data_packages.get(game_name, {}),
|
||||
f"item_name_groups are not supposed to be in MultiServer data for {game_name}")
|
||||
expect_true("location_name_groups" not in local_data_packages.get(game_name, {}),
|
||||
f"location_name_groups are not supposed to be in MultiServer data for {game_name}")
|
||||
for game_name in local_data_packages:
|
||||
expect_true(game_name in involved_games,
|
||||
f"Received unexpected extra data package for {game_name} from MultiServer")
|
||||
assert_equal(local_collected_items, collected_items,
|
||||
"MultiServer did not load or save correctly")
|
||||
|
||||
# compare customserver to MultiServer
|
||||
expect_equal(local_data_packages, web_data_packages,
|
||||
"customserver datapackage differs from MultiServer")
|
||||
@@ -176,10 +209,12 @@ if __name__ == "__main__":
|
||||
print(f"Restoring multidata for {room}")
|
||||
set_multidata_for_room(webhost_client, room, old_data)
|
||||
with WebHostServeGame(webhost_client, room) as host:
|
||||
sleep(.1) # wait for the server to fully start before doing anything
|
||||
with Client(host.address, game, "Player1") as client:
|
||||
assert_equal(len(client.checked_locations), 2,
|
||||
"Save was destroyed during exception in customserver")
|
||||
print("Save file is not busted 🥳")
|
||||
sleep(2) # work around issue #5571
|
||||
|
||||
finally:
|
||||
print("Stopping autohost")
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional, cast
|
||||
from typing import TYPE_CHECKING, Iterable, Optional, cast
|
||||
|
||||
from WebHostLib import to_python
|
||||
|
||||
@@ -10,6 +14,7 @@ if TYPE_CHECKING:
|
||||
|
||||
__all__ = [
|
||||
"get_app",
|
||||
"generate_remote",
|
||||
"upload_multidata",
|
||||
"create_room",
|
||||
"start_room",
|
||||
@@ -17,6 +22,7 @@ __all__ = [
|
||||
"set_room_timeout",
|
||||
"get_multidata_for_room",
|
||||
"set_multidata_for_room",
|
||||
"stop_autogen",
|
||||
"stop_autohost",
|
||||
]
|
||||
|
||||
@@ -33,10 +39,43 @@ def get_app(tempdir: str) -> "Flask":
|
||||
"TESTING": True,
|
||||
"HOST_ADDRESS": "localhost",
|
||||
"HOSTERS": 1,
|
||||
"GENERATORS": 1,
|
||||
"JOB_THRESHOLD": 1,
|
||||
})
|
||||
return get_app()
|
||||
|
||||
|
||||
def generate_remote(app_client: "FlaskClient", games: Iterable[str]) -> str:
|
||||
data = io.BytesIO()
|
||||
with zipfile.ZipFile(data, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
|
||||
for n, game in enumerate(games, 1):
|
||||
name = f"{n}.yaml"
|
||||
zip_file.writestr(name, json.dumps({
|
||||
"name": f"Player{n}",
|
||||
"game": game,
|
||||
game: {},
|
||||
"description": f"generate_remote slot {n} ('Player{n}'): {game}",
|
||||
}))
|
||||
data.seek(0)
|
||||
response = app_client.post("/generate", content_type="multipart/form-data", data={
|
||||
"file": (data, "yamls.zip"),
|
||||
})
|
||||
assert response.status_code < 400, f"Starting gen failed: status {response.status_code}"
|
||||
assert "Location" in response.headers, f"Starting gen failed: no redirect"
|
||||
location = response.headers["Location"]
|
||||
assert isinstance(location, str)
|
||||
assert location.startswith("/wait/"), f"Starting WebHost gen failed: unexpected redirect to {location}"
|
||||
for attempt in range(10):
|
||||
response = app_client.get(location)
|
||||
if "Location" in response.headers:
|
||||
location = response.headers["Location"]
|
||||
assert isinstance(location, str)
|
||||
assert location.startswith("/seed/"), f"Finishing WebHost gen failed: unexpected redirect to {location}"
|
||||
return location[6:]
|
||||
time.sleep(1)
|
||||
raise TimeoutError("WebHost gen did not finish")
|
||||
|
||||
|
||||
def upload_multidata(app_client: "FlaskClient", multidata: Path) -> str:
|
||||
response = app_client.post("/uploads", data={
|
||||
"file": multidata.open("rb"),
|
||||
@@ -188,7 +227,7 @@ def set_multidata_for_room(webhost_client: "FlaskClient", room_id: str, data: by
|
||||
room.seed.multidata = data
|
||||
|
||||
|
||||
def stop_autohost(graceful: bool = True) -> None:
|
||||
def _stop_webhost_mp(name_filter: str, graceful: bool = True) -> None:
|
||||
import os
|
||||
import signal
|
||||
|
||||
@@ -198,13 +237,30 @@ def stop_autohost(graceful: bool = True) -> None:
|
||||
|
||||
stop()
|
||||
proc: multiprocessing.process.BaseProcess
|
||||
for proc in filter(lambda child: child.name.startswith("MultiHoster"), multiprocessing.active_children()):
|
||||
for proc in filter(lambda child: child.name.startswith(name_filter), multiprocessing.active_children()):
|
||||
# FIXME: graceful currently does not work on Windows because the signals are not properly emulated
|
||||
# and ungraceful may not save the game
|
||||
if proc.pid == os.getpid():
|
||||
continue
|
||||
if graceful and proc.pid:
|
||||
os.kill(proc.pid, getattr(signal, "CTRL_C_EVENT", signal.SIGINT))
|
||||
else:
|
||||
proc.kill()
|
||||
try:
|
||||
proc.join(30)
|
||||
try:
|
||||
proc.join(30)
|
||||
except TimeoutError:
|
||||
raise
|
||||
except KeyboardInterrupt:
|
||||
# on Windows, the MP exception may be forwarded to the host, so ignore once and retry
|
||||
proc.join(30)
|
||||
except TimeoutError:
|
||||
proc.kill()
|
||||
proc.join()
|
||||
|
||||
def stop_autogen(graceful: bool = True) -> None:
|
||||
# FIXME: this name filter is jank, but there seems to be no way to add a custom prefix for a Pool
|
||||
_stop_webhost_mp("SpawnPoolWorker-", graceful)
|
||||
|
||||
def stop_autohost(graceful: bool = True) -> None:
|
||||
_stop_webhost_mp("MultiHoster", graceful)
|
||||
|
||||
@@ -11,7 +11,7 @@ _new_worlds: dict[str, str] = {}
|
||||
|
||||
def copy(src: str, dst: str) -> None:
|
||||
from Utils import get_file_safe_name
|
||||
from worlds import AutoWorldRegister
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
assert dst not in _new_worlds, "World already created"
|
||||
if '"' in dst or "\\" in dst: # easier to reject than to escape
|
||||
|
||||
14
test/utils/test_daemon_thread_pool.py
Normal file
14
test/utils/test_daemon_thread_pool.py
Normal file
@@ -0,0 +1,14 @@
|
||||
import unittest
|
||||
|
||||
from Utils import DaemonThreadPoolExecutor
|
||||
|
||||
|
||||
class DaemonThreadPoolExecutorTest(unittest.TestCase):
|
||||
def test_is_daemon(self) -> None:
|
||||
def run() -> None:
|
||||
pass
|
||||
|
||||
with DaemonThreadPoolExecutor(1) as executor:
|
||||
executor.submit(run)
|
||||
|
||||
self.assertTrue(next(iter(executor._threads)).daemon)
|
||||
@@ -93,3 +93,13 @@ 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)
|
||||
|
||||
@@ -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 deprecate
|
||||
from Utils import Version
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from BaseClasses import MultiWorld, Item, Location, Tutorial, Region, Entrance
|
||||
@@ -75,6 +75,10 @@ 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)
|
||||
@@ -337,6 +341,8 @@ 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
|
||||
|
||||
@@ -8,7 +8,8 @@ import os
|
||||
import threading
|
||||
from io import BytesIO
|
||||
|
||||
from typing import ClassVar, Dict, List, Literal, Tuple, Any, Optional, Union, BinaryIO, overload, Sequence
|
||||
from typing import (ClassVar, Dict, List, Literal, Tuple, Any, Optional, Union, BinaryIO, overload, Sequence,
|
||||
TYPE_CHECKING)
|
||||
|
||||
import bsdiff4
|
||||
|
||||
@@ -16,6 +17,9 @@ 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]] = {}
|
||||
@@ -65,7 +69,7 @@ class AutoPatchExtensionRegister(abc.ABCMeta):
|
||||
return handler
|
||||
|
||||
|
||||
container_version: int = 6
|
||||
container_version: int = 7
|
||||
|
||||
|
||||
def is_ap_player_container(game: str, data: bytes, player: int):
|
||||
@@ -92,7 +96,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):
|
||||
@@ -116,7 +120,7 @@ class APContainer:
|
||||
except Exception as e:
|
||||
raise Exception(f"Manifest {manifest} did not convert to json.") from e
|
||||
else:
|
||||
opened_zipfile.writestr("archipelago.json", manifest_str)
|
||||
opened_zipfile.writestr(self.manifest_path, 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."""
|
||||
@@ -137,7 +141,18 @@ 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]:
|
||||
with opened_zipfile.open("archipelago.json", "r") as f:
|
||||
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:
|
||||
manifest = json.load(f)
|
||||
if manifest["compatible_version"] > self.version:
|
||||
raise Exception(f"File (version: {manifest['compatible_version']}) too new "
|
||||
@@ -152,6 +167,33 @@ 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
|
||||
@@ -248,10 +290,8 @@ class APProcedurePatch(APAutoPatchInterface):
|
||||
manifest["compatible_version"] = 5
|
||||
return manifest
|
||||
|
||||
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)
|
||||
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]:
|
||||
manifest = super(APProcedurePatch, self).read_contents(opened_zipfile)
|
||||
if "procedure" not in manifest:
|
||||
# support patching files made before moving to procedures
|
||||
self.procedure = [("apply_bsdiff4", ["delta.bsdiff4"])]
|
||||
@@ -260,6 +300,7 @@ 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)
|
||||
|
||||
@@ -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
|
||||
from Utils import local_path, open_filename, is_frozen, is_kivy_running
|
||||
|
||||
|
||||
class Type(Enum):
|
||||
@@ -177,11 +177,10 @@ 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:
|
||||
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)
|
||||
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)
|
||||
bisect.insort(worlds.world_sources, world_source)
|
||||
world_source.load()
|
||||
|
||||
@@ -197,7 +196,7 @@ def install_apworld(apworld_path: str = "") -> None:
|
||||
source, target = res
|
||||
except Exception as e:
|
||||
import Utils
|
||||
Utils.messagebox(e.__class__.__name__, str(e), error=True)
|
||||
Utils.messagebox("Notice", str(e), error=True)
|
||||
logging.exception(e)
|
||||
else:
|
||||
import Utils
|
||||
@@ -218,8 +217,6 @@ components: List[Component] = [
|
||||
description="Install an APWorld to play games not included with Archipelago by default."),
|
||||
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient,
|
||||
description="Connect to a multiworld using the text client."),
|
||||
Component('Links Awakening DX Client', 'LinksAwakeningClient',
|
||||
file_identifier=SuffixIdentifier('.apladx')),
|
||||
Component('LttP Adjuster', 'LttPAdjuster'),
|
||||
# Ocarina of Time
|
||||
Component('OoT Client', 'OoTClient',
|
||||
@@ -243,3 +240,67 @@ 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")):
|
||||
with open(os.path.join(world_directory, "archipelago.json"), mode="r", encoding="utf-8") as manifest_file:
|
||||
manifest = json.load(manifest_file)
|
||||
|
||||
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."))
|
||||
|
||||
@@ -7,10 +7,11 @@ 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
|
||||
from Utils import local_path, user_path, Version, version_tuple, tuplize_version
|
||||
|
||||
local_folder = os.path.dirname(__file__)
|
||||
user_folder = user_path("worlds") if user_path() != local_path() else user_path("custom_worlds")
|
||||
@@ -38,6 +39,7 @@ 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})"
|
||||
@@ -102,12 +104,94 @@ 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:
|
||||
world_source.load()
|
||||
# load all loose files first:
|
||||
if world_source.is_zip:
|
||||
apworlds.append(world_source)
|
||||
else:
|
||||
world_source.load()
|
||||
|
||||
|
||||
# Build the data package for each game.
|
||||
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"):
|
||||
with open(os.path.join(dirpath, file), mode="r", encoding="utf-8") as manifest_file:
|
||||
manifest = json.load(manifest_file)
|
||||
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
|
||||
|
||||
# Build the data package for each game.
|
||||
network_data_package: DataPackage = {
|
||||
"games": {world_name: world.get_data_package_data() for world_name, world in AutoWorldRegister.world_types.items()},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import Utils
|
||||
import websockets
|
||||
import functools
|
||||
@@ -208,6 +210,9 @@ async def proxy(websocket, path: str = "/", ctx: AHITContext = None):
|
||||
if not ctx.is_proxy_connected():
|
||||
break
|
||||
|
||||
if msg["cmd"] == "Bounce" and msg.get("tags") == ["DeathLink"] and "data" in msg:
|
||||
msg["data"]["time"] = time.time()
|
||||
|
||||
await ctx.send_msgs([msg])
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -243,7 +243,7 @@ guaranteed_first_acts = [
|
||||
"Time Rift - Mafia of Cooks",
|
||||
"Time Rift - Dead Bird Studio",
|
||||
"Time Rift - Sleepy Subcon",
|
||||
"Time Rift - Alpine Skyline"
|
||||
"Time Rift - Alpine Skyline",
|
||||
"Time Rift - Tour",
|
||||
"Time Rift - Rumbi Factory",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
- override (directory)
|
||||
- randomizer_files (directory)
|
||||
- SDL2.dll
|
||||
- usersettings.xml
|
||||
- wrap_oal.dll
|
||||
@@ -32,7 +32,10 @@ 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.
|
||||
|
||||
Finally, to launch the randomizer, you must use the command line interface (you can open the command line interface
|
||||
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
|
||||
by typing `cmd` in the address bar of the Windows File Explorer). Here is the command line used to start the
|
||||
randomizer:
|
||||
|
||||
@@ -49,15 +52,17 @@ 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 command line by using:
|
||||
can do that from the command line by using:
|
||||
|
||||
```bash
|
||||
chmod +x Aquaria_Randomizer-*.AppImage
|
||||
```
|
||||
|
||||
or by using the Graphical Explorer of your system.
|
||||
or by using the Graphical file Explorer of your system (the permission can generally be set in the file properties).
|
||||
|
||||
To launch the randomizer, just launch in command line:
|
||||
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:
|
||||
|
||||
```bash
|
||||
./Aquaria_Randomizer-*.AppImage --name YourName --server theServer:thePort
|
||||
@@ -79,7 +84,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
|
||||
- override (directory)
|
||||
- randomizer_files (directory)
|
||||
- usersettings.xml
|
||||
- cacert.pem
|
||||
|
||||
@@ -87,7 +92,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 system (like Ubuntu), you can use the following command:
|
||||
On Debian base systems (like Ubuntu), you can use the following command:
|
||||
|
||||
```bash
|
||||
sudo apt install liblua5.1-0-dev libogg-dev libvorbis-dev libopenal-dev libsdl2-dev
|
||||
@@ -97,7 +102,9 @@ 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, just launch in command line:
|
||||
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:
|
||||
|
||||
```bash
|
||||
./aquaria_randomizer --name YourName --server theServer:thePort
|
||||
@@ -115,6 +122,20 @@ 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
|
||||
|
||||
|
||||
@@ -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 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)
|
||||
- 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)
|
||||
- [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
|
||||
- override (directory)
|
||||
- randomizer_files (directory)
|
||||
- SDL2.dll
|
||||
- usersettings.xml
|
||||
- wrap_oal.dll
|
||||
@@ -34,7 +34,10 @@ 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.
|
||||
|
||||
Finalement, pour lancer le randomizer, vous devez utiliser la ligne de commande (vous pouvez ouvrir une interface de
|
||||
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
|
||||
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:
|
||||
|
||||
@@ -57,9 +60,12 @@ 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 graphique de votre système.
|
||||
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).
|
||||
|
||||
Pour lancer le randomizer, utiliser la commande suivante:
|
||||
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:
|
||||
|
||||
```bash
|
||||
./Aquaria_Randomizer-*.AppImage --name VotreNom --server LeServeur:LePort
|
||||
@@ -83,7 +89,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
|
||||
- override (directory)
|
||||
- randomizer_files (directory)
|
||||
- usersettings.xml
|
||||
- cacert.pem
|
||||
|
||||
@@ -102,7 +108,10 @@ 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, utiliser la commande suivante:
|
||||
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:
|
||||
|
||||
```bash
|
||||
./aquaria_randomizer --name VotreNom --server LeServeur:LePort
|
||||
@@ -120,6 +129,21 @@ 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
|
||||
|
||||
|
||||
6
worlds/bumpstik/archipelago.json
Normal file
6
worlds/bumpstik/archipelago.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"game": "Bumper Stickers",
|
||||
"authors": ["KewlioMZX"],
|
||||
"world_version": "1.0.0",
|
||||
"minimum_ap_version": "0.6.4"
|
||||
}
|
||||
@@ -232,11 +232,9 @@ def create_regions(world: MultiWorld, options: CCCharlesOptions, player: int) ->
|
||||
# Connect the Regions by named Entrances that must have access Rules
|
||||
menu_region.connect(start_camp_region)
|
||||
menu_region.connect(tony_tiddle_mission_region)
|
||||
menu_region.connect(barn_region)
|
||||
tony_tiddle_mission_region.connect(barn_region, "Barn Door")
|
||||
menu_region.connect(barn_region, "Barn Door")
|
||||
menu_region.connect(candice_mission_region)
|
||||
menu_region.connect(tutorial_house_region)
|
||||
candice_mission_region.connect(tutorial_house_region, "Tutorial House Door")
|
||||
menu_region.connect(tutorial_house_region, "Tutorial House Door")
|
||||
menu_region.connect(swamp_edges_region)
|
||||
menu_region.connect(swamp_mission_region)
|
||||
menu_region.connect(junkyard_area_region)
|
||||
@@ -244,7 +242,6 @@ def create_regions(world: MultiWorld, options: CCCharlesOptions, player: int) ->
|
||||
menu_region.connect(junkyard_shed_region)
|
||||
menu_region.connect(military_base_region)
|
||||
menu_region.connect(south_mine_outside_region)
|
||||
menu_region.connect(south_mine_inside_region)
|
||||
south_mine_outside_region.connect(south_mine_inside_region, "South Mine Gate")
|
||||
menu_region.connect(middle_station_region)
|
||||
menu_region.connect(canyon_region)
|
||||
@@ -258,13 +255,11 @@ def create_regions(world: MultiWorld, options: CCCharlesOptions, player: int) ->
|
||||
menu_region.connect(lost_stairs_region)
|
||||
menu_region.connect(east_house_region)
|
||||
menu_region.connect(rockets_testing_ground_region)
|
||||
menu_region.connect(rockets_testing_bunker_region)
|
||||
rockets_testing_ground_region.connect(rockets_testing_bunker_region, "Stuck Bunker Door")
|
||||
menu_region.connect(workshop_region)
|
||||
menu_region.connect(east_tower_region)
|
||||
menu_region.connect(lighthouse_region)
|
||||
menu_region.connect(north_mine_outside_region)
|
||||
menu_region.connect(north_mine_inside_region)
|
||||
north_mine_outside_region.connect(north_mine_inside_region, "North Mine Gate")
|
||||
menu_region.connect(wood_bridge_region)
|
||||
menu_region.connect(museum_region)
|
||||
@@ -278,11 +273,9 @@ def create_regions(world: MultiWorld, options: CCCharlesOptions, player: int) ->
|
||||
menu_region.connect(north_beach_region)
|
||||
menu_region.connect(mine_shaft_region)
|
||||
menu_region.connect(mob_camp_region)
|
||||
menu_region.connect(mob_camp_locked_room_region)
|
||||
mob_camp_region.connect(mob_camp_locked_room_region, "Mob Camp Locked Door")
|
||||
menu_region.connect(mine_elevator_exit_region)
|
||||
menu_region.connect(mountain_ruin_outside_region)
|
||||
menu_region.connect(mountain_ruin_inside_region)
|
||||
mountain_ruin_outside_region.connect(mountain_ruin_inside_region, "Mountain Ruin Gate")
|
||||
menu_region.connect(prism_temple_region)
|
||||
menu_region.connect(pickle_val_region)
|
||||
|
||||
27
worlds/celeste64/LICENSE
Normal file
27
worlds/celeste64/LICENSE
Normal file
@@ -0,0 +1,27 @@
|
||||
Modified MIT License
|
||||
|
||||
Copyright (c) 2025 PoryGone
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, and/or distribute copies of the Software,
|
||||
and to permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
No copy or substantial portion of the Software shall be sublicensed or relicensed
|
||||
without the express written permission of the copyright holder(s)
|
||||
|
||||
No copy or substantial portion of the Software shall be sold without the express
|
||||
written permission of the copyright holder(s)
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
6
worlds/celeste64/archipelago.json
Normal file
6
worlds/celeste64/archipelago.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"game": "Celeste 64",
|
||||
"authors": [ "PoryGone" ],
|
||||
"minimum_ap_version": "0.6.3",
|
||||
"world_version": "1.3.1"
|
||||
}
|
||||
27
worlds/celeste_open_world/LICENSE
Normal file
27
worlds/celeste_open_world/LICENSE
Normal file
@@ -0,0 +1,27 @@
|
||||
Modified MIT License
|
||||
|
||||
Copyright (c) 2025 PoryGone
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, and/or distribute copies of the Software,
|
||||
and to permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
No copy or substantial portion of the Software shall be sublicensed or relicensed
|
||||
without the express written permission of the copyright holder(s)
|
||||
|
||||
No copy or substantial portion of the Software shall be sold without the express
|
||||
written permission of the copyright holder(s)
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
6
worlds/celeste_open_world/archipelago.json
Normal file
6
worlds/celeste_open_world/archipelago.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"game": "Celeste (Open World)",
|
||||
"authors": [ "PoryGone" ],
|
||||
"minimum_ap_version": "0.6.3",
|
||||
"world_version": "1.0.5"
|
||||
}
|
||||
@@ -20,6 +20,7 @@ class CivVIBoostData:
|
||||
Prereq: List[str]
|
||||
PrereqRequiredCount: int
|
||||
Classification: str
|
||||
EraRequired: bool = False
|
||||
|
||||
|
||||
class GoodyHutRewardData(TypedDict):
|
||||
|
||||
@@ -150,7 +150,10 @@ def generate_era_location_table() -> Dict[str, Dict[str, CivVILocationData]]:
|
||||
location = CivVILocationData(
|
||||
boost.Type, 0, 0, id_base, boost.EraType, CivVICheckType.BOOST
|
||||
)
|
||||
era_locations["ERA_ANCIENT"][boost.Type] = location
|
||||
# If EraRequired is True, place the boost in its actual era
|
||||
# Otherwise, place it in ERA_ANCIENT for early access
|
||||
target_era = boost.EraType if boost.EraRequired else "ERA_ANCIENT"
|
||||
era_locations[target_era][boost.Type] = location
|
||||
id_base += 1
|
||||
|
||||
return era_locations
|
||||
|
||||
@@ -210,8 +210,8 @@ boosts: List[CivVIBoostData] = [
|
||||
CivVIBoostData(
|
||||
"BOOST_TECH_SQUARE_RIGGING",
|
||||
"ERA_RENAISSANCE",
|
||||
["TECH_GUNPOWDER"],
|
||||
1,
|
||||
["TECH_GUNPOWDER", "TECH_MILITARY_ENGINEERING", "TECH_MINING"],
|
||||
3,
|
||||
"DEFAULT",
|
||||
),
|
||||
CivVIBoostData(
|
||||
@@ -252,15 +252,15 @@ boosts: List[CivVIBoostData] = [
|
||||
CivVIBoostData(
|
||||
"BOOST_TECH_BALLISTICS",
|
||||
"ERA_INDUSTRIAL",
|
||||
["TECH_SIEGE_TACTICS", "TECH_MILITARY_ENGINEERING"],
|
||||
2,
|
||||
["TECH_SIEGE_TACTICS", "TECH_MILITARY_ENGINEERING", "TECH_BRONZE_WORKING"],
|
||||
3,
|
||||
"DEFAULT",
|
||||
),
|
||||
CivVIBoostData(
|
||||
"BOOST_TECH_MILITARY_SCIENCE",
|
||||
"ERA_INDUSTRIAL",
|
||||
["TECH_STIRRUPS"],
|
||||
1,
|
||||
["TECH_BRONZE_WORKING", "TECH_STIRRUPS", "TECH_MINING"],
|
||||
3,
|
||||
"DEFAULT",
|
||||
),
|
||||
CivVIBoostData(
|
||||
@@ -301,8 +301,8 @@ boosts: List[CivVIBoostData] = [
|
||||
CivVIBoostData(
|
||||
"BOOST_TECH_REPLACEABLE_PARTS",
|
||||
"ERA_MODERN",
|
||||
["TECH_MILITARY_SCIENCE"],
|
||||
1,
|
||||
["TECH_MILITARY_SCIENCE", "TECH_MINING"],
|
||||
2,
|
||||
"DEFAULT",
|
||||
),
|
||||
CivVIBoostData(
|
||||
@@ -343,8 +343,8 @@ boosts: List[CivVIBoostData] = [
|
||||
CivVIBoostData(
|
||||
"BOOST_TECH_ADVANCED_FLIGHT",
|
||||
"ERA_ATOMIC",
|
||||
["TECH_FLIGHT"],
|
||||
1,
|
||||
["TECH_FLIGHT", "TECH_REFINING", "TECH_MINING"],
|
||||
3,
|
||||
"DEFAULT",
|
||||
),
|
||||
CivVIBoostData(
|
||||
@@ -436,8 +436,8 @@ boosts: List[CivVIBoostData] = [
|
||||
CivVIBoostData(
|
||||
"BOOST_TECH_COMPOSITES",
|
||||
"ERA_INFORMATION",
|
||||
["TECH_COMBUSTION"],
|
||||
1,
|
||||
["TECH_COMBUSTION", "TECH_REFINING", "TECH_MINING"],
|
||||
3,
|
||||
"DEFAULT",
|
||||
),
|
||||
CivVIBoostData(
|
||||
@@ -470,7 +470,7 @@ boosts: List[CivVIBoostData] = [
|
||||
"TECH_ELECTRICITY",
|
||||
"TECH_NUCLEAR_FISSION",
|
||||
],
|
||||
1,
|
||||
4,
|
||||
"DEFAULT",
|
||||
),
|
||||
CivVIBoostData(
|
||||
@@ -651,10 +651,11 @@ boosts: List[CivVIBoostData] = [
|
||||
),
|
||||
CivVIBoostData(
|
||||
"BOOST_CIVIC_FEUDALISM",
|
||||
"ERA_MEDIEVAL",
|
||||
"ERA_CLASSICAL",
|
||||
[],
|
||||
0,
|
||||
"DEFAULT",
|
||||
True,
|
||||
),
|
||||
CivVIBoostData(
|
||||
"BOOST_CIVIC_CIVIL_SERVICE",
|
||||
@@ -662,6 +663,7 @@ boosts: List[CivVIBoostData] = [
|
||||
[],
|
||||
0,
|
||||
"DEFAULT",
|
||||
True,
|
||||
),
|
||||
CivVIBoostData(
|
||||
"BOOST_CIVIC_MERCENARIES",
|
||||
@@ -790,6 +792,7 @@ boosts: List[CivVIBoostData] = [
|
||||
[],
|
||||
0,
|
||||
"DEFAULT",
|
||||
True
|
||||
),
|
||||
CivVIBoostData(
|
||||
"BOOST_CIVIC_CONSERVATION",
|
||||
@@ -885,6 +888,7 @@ boosts: List[CivVIBoostData] = [
|
||||
["TECH_ROCKETRY"],
|
||||
1,
|
||||
"DEFAULT",
|
||||
True
|
||||
),
|
||||
CivVIBoostData(
|
||||
"BOOST_CIVIC_GLOBALIZATION",
|
||||
|
||||
@@ -14,25 +14,27 @@ The following are required in order to play Civ VI in Archipelago:
|
||||
|
||||
- A copy of the game `Civilization VI` including the two expansions `Rise & Fall` and `Gathering Storm` (both the Steam and Epic version should work).
|
||||
|
||||
## Enabling the tuner
|
||||
|
||||
In the main menu, navigate to the "Game Options" page. On the "Game" menu, make sure that "Tuner (disables achievements)" is enabled.
|
||||
|
||||
## Mod Installation
|
||||
|
||||
1. Download and unzip the latest release of the mod from [GitHub](https://github.com/hesto2/civilization_archipelago_mod/releases/latest).
|
||||
|
||||
2. Copy the folder containing the mod files to your Civ VI mods folder. On Windows, this is usually located at `C:\Users\YOUR_USER\Documents\My Games\Sid Meier's Civilization VI\Mods`. If you use OneDrive, check if the folder is instead located in your OneDrive file structure, and use that path when relevant in future steps.
|
||||
|
||||
3. After the Archipelago host generates a game, you should be given a `.apcivvi` file. Associate the file with the Archipelago Launcher and double click it.
|
||||
3. After the Archipelago host generates a game, you should be given a `.apcivvi` file. You can open it as a zip file, you can do this by either right clicking it and opening it with a program that handles zip files (if you associate that file with the program it will open it with that program in the future by double clicking it), or by right clicking and renaming the file extension from `apcivvi` to `zip` (only works if you are displaying file extensions). You can also associate the file with the Archipelago Launcher and double click it and it will create a folder with the mod files inside of it.
|
||||
|
||||
4. Copy the contents of the new folder it generates (it will have the same name as the `.apcivvi` file) into your Civilization VI Archipelago Mod folder. If double clicking the `.apcivvi` file doesn't generate a folder, you can instead open it as a zip file. You can do this by either right clicking it and opening it with a program that handles zip files, or by right clicking and renaming the file extension from `apcivvi` to `zip`.
|
||||
4. Copy the contents of the zip file or folder it generated (the name of the folder should be the same as the apcivvi file) into your Civilization VI Archipelago Mod folder (there should be five files placed there from the `.apcivvi` file, overwrite if asked).
|
||||
|
||||
5. Place the files generated from the `.apcivvi` in your archipelago mod folder (there should be five files placed there from the apcivvi file, overwrite if asked). Your mod path should look something like `C:\Users\YOUR_USER\Documents\My Games\Sid Meier's Civilization VI\Mods\civilization_archipelago_mod`.
|
||||
5. Your mod path should look something like `C:\Users\YOUR_USER\Documents\My Games\Sid Meier's Civilization VI\Mods\civilization_archipelago_mod`. If everything was done correctly you can now connect to the game.
|
||||
|
||||
## Configuring your game
|
||||
## Connecting to a game
|
||||
|
||||
Make sure you enable the mod in the main title under Additional Content > Mods. When configuring your game, make sure to start the game in the Ancient Era and leave all settings related to starting technologies and civics as the defaults. Other than that, configure difficulty, AI, etc. as you normally would.
|
||||
1. In the main menu, navigate to the "Game Options" page. On the "Game" menu, make sure that "Tuner (disables achievements)" is enabled.
|
||||
|
||||
2. In the main menu, navigate to the "Additional Content" page, then go to "Mods" and make sure the Archipelago mod is enabled.
|
||||
|
||||
3. When starting the game make sure you are on the Gathering Storm ruleset in a Single Player game. Additionally you must start in the ancient era, other settings and game modes can be customised to your own liking. An important thing to note is that settings preset saves the mod list from when you created it, so if you want to use a setting preset with this you must create it after installing the Archipelago mod.
|
||||
|
||||
4. To connect to the room open the Archipelago Launcher, from within the launcher open the Civ6 client and connect to the room. Once connected to the room enter your slot name and if everything went right you should now be connected.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -51,3 +53,8 @@ Make sure you enable the mod in the main title under Additional Content > Mods.
|
||||
- If you still have any errors make sure the two expansions Rise & Fall and Gathering Storm are active in the mod selector (all the official DLC works without issues but Rise & Fall and Gathering Storm are required for the mod).
|
||||
|
||||
- If boostsanity is enabled and those items are not being sent out but regular techs are, make sure you placed the files from your new room in the mod folder.
|
||||
|
||||
- If you are neither receiving or sending items, make sure you have the correct client open. The client should be the Civ6 and NOT the Text Client.
|
||||
|
||||
- This should be compatible with a lot of other mods, but if you are having issues try disabling all mods other than the Archipelago mod and see if the problem still persists.
|
||||
|
||||
|
||||
@@ -105,3 +105,78 @@ class TestBoostsanityExcluded(CivVITestBase):
|
||||
if "BOOST" in location.name:
|
||||
found_locations += 1
|
||||
self.assertEqual(found_locations, 0)
|
||||
|
||||
|
||||
class TestBoostsanityEraRequired(CivVITestBase):
|
||||
options = {
|
||||
"boostsanity": "true",
|
||||
"progression_style": "none",
|
||||
"shuffle_goody_hut_rewards": "false",
|
||||
}
|
||||
|
||||
def test_era_required_boosts_not_accessible_early(self) -> None:
|
||||
# BOOST_CIVIC_FEUDALISM has EraRequired=True and ERA_CLASSICAL
|
||||
# It should NOT be accessible in Ancient era
|
||||
self.assertFalse(self.can_reach_location("BOOST_CIVIC_FEUDALISM"))
|
||||
|
||||
# BOOST_CIVIC_URBANIZATION has EraRequired=True and ERA_INDUSTRIAL
|
||||
# It should NOT be accessible in Ancient era
|
||||
self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
|
||||
|
||||
# BOOST_CIVIC_SPACE_RACE has EraRequired=True and ERA_ATOMIC
|
||||
# It should NOT be accessible in Ancient era
|
||||
self.assertFalse(self.can_reach_location("BOOST_CIVIC_SPACE_RACE"))
|
||||
|
||||
# Regular boosts without EraRequired should be accessible
|
||||
self.assertTrue(self.can_reach_location("BOOST_TECH_SAILING"))
|
||||
self.assertTrue(self.can_reach_location("BOOST_CIVIC_MILITARY_TRADITION"))
|
||||
|
||||
def test_era_required_boosts_accessible_in_correct_era(self) -> None:
|
||||
# Collect items to reach Classical era
|
||||
self.collect_by_name(["Mining", "Bronze Working", "Astrology", "Writing",
|
||||
"Irrigation", "Sailing", "Animal Husbandry",
|
||||
"State Workforce", "Foreign Trade"])
|
||||
|
||||
# BOOST_CIVIC_FEUDALISM should now be accessible in Classical era
|
||||
self.assertTrue(self.can_reach_location("BOOST_CIVIC_FEUDALISM"))
|
||||
|
||||
# BOOST_CIVIC_URBANIZATION still not accessible (requires Industrial)
|
||||
self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
|
||||
|
||||
# Collect more items to reach Industrial era
|
||||
self.collect_all_but(["TECH_ROCKETRY"])
|
||||
|
||||
# Now BOOST_CIVIC_URBANIZATION should be accessible
|
||||
self.assertTrue(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
|
||||
|
||||
|
||||
class TestBoostsanityEraRequiredWithProgression(CivVITestBase):
|
||||
options = {
|
||||
"boostsanity": "true",
|
||||
"progression_style": "eras_and_districts",
|
||||
"shuffle_goody_hut_rewards": "false",
|
||||
}
|
||||
|
||||
def test_era_required_with_progressive_eras(self) -> None:
|
||||
# Collect all items except Progressive Era
|
||||
self.collect_all_but(["Progressive Era"])
|
||||
|
||||
# Even with all other items, era-required boosts should not be accessible
|
||||
self.assertFalse(self.can_reach_location("BOOST_CIVIC_FEUDALISM"))
|
||||
self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
|
||||
|
||||
# Collect enough Progressive Era items to reach Classical (needs 2)
|
||||
self.collect(self.get_item_by_name("Progressive Era"))
|
||||
self.collect(self.get_item_by_name("Progressive Era"))
|
||||
|
||||
# BOOST_CIVIC_FEUDALISM should now be accessible
|
||||
self.assertTrue(self.can_reach_location("BOOST_CIVIC_FEUDALISM"))
|
||||
|
||||
# But BOOST_CIVIC_URBANIZATION still requires Industrial era (needs 5 total)
|
||||
self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
|
||||
|
||||
# Collect 3 more Progressive Era items to reach Industrial
|
||||
self.collect_by_name(["Progressive Era", "Progressive Era", "Progressive Era"])
|
||||
|
||||
# Now BOOST_CIVIC_URBANIZATION should be accessible
|
||||
self.assertTrue(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
|
||||
|
||||
27
worlds/dkc3/LICENSE
Normal file
27
worlds/dkc3/LICENSE
Normal file
@@ -0,0 +1,27 @@
|
||||
Modified MIT License
|
||||
|
||||
Copyright (c) 2025 PoryGone
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, and/or distribute copies of the Software,
|
||||
and to permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
No copy or substantial portion of the Software shall be sublicensed or relicensed
|
||||
without the express written permission of the copyright holder(s)
|
||||
|
||||
No copy or substantial portion of the Software shall be sold without the express
|
||||
written permission of the copyright holder(s)
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
6
worlds/dkc3/archipelago.json
Normal file
6
worlds/dkc3/archipelago.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"game": "Donkey Kong Country 3",
|
||||
"authors": [ "PoryGone" ],
|
||||
"minimum_ap_version": "0.6.3",
|
||||
"world_version": "1.1.0"
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import csv
|
||||
import enum
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from functools import reduce
|
||||
from random import Random
|
||||
from typing import Dict, List, Set
|
||||
|
||||
@@ -61,7 +62,7 @@ def load_item_csv():
|
||||
item_reader = csv.DictReader(file)
|
||||
for item in item_reader:
|
||||
id = int(item["id"]) if item["id"] else None
|
||||
classification = ItemClassification[item["classification"]]
|
||||
classification = reduce((lambda a, b: a | b), {ItemClassification[str_classification] for str_classification in item["classification"].split(",")})
|
||||
groups = {Group[group] for group in item["groups"].split(",") if group}
|
||||
items.append(ItemData(id, item["name"], classification, groups))
|
||||
return items
|
||||
|
||||
@@ -22,7 +22,7 @@ id,name,classification,groups
|
||||
20,Wall Jump Pack,progression,"DLC,Freemium"
|
||||
21,Health Bar Pack,useful,"DLC,Freemium"
|
||||
22,Parallax Pack,filler,"DLC,Freemium"
|
||||
23,Harmless Plants Pack,progression,"DLC,Freemium"
|
||||
23,Harmless Plants Pack,"progression,trap","DLC,Freemium"
|
||||
24,Death of Comedy Pack,progression,"DLC,Freemium"
|
||||
25,Canadian Dialog Pack,filler,"DLC,Freemium"
|
||||
26,DLC NPC Pack,progression,"DLC,Freemium"
|
||||
|
||||
|
@@ -59,6 +59,19 @@ class FactorioCommandProcessor(ClientCommandProcessor):
|
||||
def _cmd_toggle_chat(self):
|
||||
"""Toggle sending of chat messages from players on the Factorio server to Archipelago."""
|
||||
self.ctx.toggle_bridge_chat_out()
|
||||
|
||||
def _cmd_rcon_reconnect(self) -> bool:
|
||||
"""Reconnect the RCON client if its disconnected."""
|
||||
try:
|
||||
result = self.ctx.rcon_client.send_command("/help")
|
||||
if result:
|
||||
self.output("RCON Client already connected.")
|
||||
return True
|
||||
except factorio_rcon.RCONNetworkError:
|
||||
self.ctx.rcon_client = factorio_rcon.RCONClient("localhost", self.ctx.rcon_port, self.ctx.rcon_password, timeout=5)
|
||||
self.output("RCON Client successfully reconnected.")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class FactorioContext(CommonContext):
|
||||
@@ -242,7 +255,13 @@ async def game_watcher(ctx: FactorioContext):
|
||||
if ctx.rcon_client and time.perf_counter() > next_bridge:
|
||||
next_bridge = time.perf_counter() + 1
|
||||
ctx.awaiting_bridge = False
|
||||
data = json.loads(ctx.rcon_client.send_command("/ap-sync"))
|
||||
try:
|
||||
data = json.loads(ctx.rcon_client.send_command("/ap-sync"))
|
||||
except factorio_rcon.RCONNotConnected:
|
||||
continue
|
||||
except factorio_rcon.RCONNetworkError:
|
||||
bridge_logger.warning("RCON Client has unexpectedly lost connection. Please issue /rcon_reconnect.")
|
||||
continue
|
||||
if not ctx.auth:
|
||||
pass # auth failed, wait for new attempt
|
||||
elif data["slot_name"] != ctx.auth:
|
||||
@@ -294,9 +313,13 @@ async def game_watcher(ctx: FactorioContext):
|
||||
"cmd": "Set", "key": ctx.energylink_key, "operations":
|
||||
[{"operation": "add", "value": value}]
|
||||
}]))
|
||||
ctx.rcon_client.send_command(
|
||||
f"/ap-energylink -{value}")
|
||||
logger.debug(f"EnergyLink: Sent {format_SI_prefix(value)}J")
|
||||
try:
|
||||
ctx.rcon_client.send_command(
|
||||
f"/ap-energylink -{value}")
|
||||
except factorio_rcon.RCONNetworkError:
|
||||
bridge_logger.warning("RCON Client has unexpectedly lost connection. Please issue /rcon_reconnect.")
|
||||
else:
|
||||
logger.debug(f"EnergyLink: Sent {format_SI_prefix(value)}J")
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ logger = logging.getLogger("Client")
|
||||
|
||||
|
||||
rom_name_location = 0x07FFE3
|
||||
player_name_location = 0x07BCC0
|
||||
locations_array_start = 0x200
|
||||
locations_array_length = 0x100
|
||||
items_obtained = 0x03
|
||||
@@ -111,6 +112,12 @@ class FF1Client(BizHawkClient):
|
||||
|
||||
return True
|
||||
|
||||
async def set_auth(self, ctx: "BizHawkClientContext") -> None:
|
||||
auth_raw = (await bizhawk.read(
|
||||
ctx.bizhawk_ctx,
|
||||
[(player_name_location, 0x40, self.rom)]))[0]
|
||||
ctx.auth = str(auth_raw, "utf-8").replace("\x00", "").strip()
|
||||
|
||||
async def game_watcher(self, ctx: "BizHawkClientContext") -> None:
|
||||
if ctx.server is None:
|
||||
return
|
||||
@@ -204,7 +211,7 @@ class FF1Client(BizHawkClient):
|
||||
write_list.append((location, [0], self.sram))
|
||||
elif current_item_name in no_overworld_items:
|
||||
if current_item_name == "Sigil":
|
||||
location = 0x28
|
||||
location = 0x2B
|
||||
else:
|
||||
location = 0x12
|
||||
write_list.append((location, [1], self.sram))
|
||||
|
||||
@@ -253,5 +253,17 @@
|
||||
"CubeBot": 529,
|
||||
"Sarda": 525,
|
||||
"Fairy": 531,
|
||||
"Lefein": 527
|
||||
"Lefein": 527,
|
||||
"DeepDungeon32B_Chest144": 401,
|
||||
"DeepDungeon30B_Chest145": 402,
|
||||
"DeepDungeon29B_Chest146": 403,
|
||||
"DeepDungeon29B_Chest147": 404,
|
||||
"DeepDungeon40B_Chest186": 443,
|
||||
"DeepDungeon38B_Chest188": 445,
|
||||
"DeepDungeon36B_Chest189": 446,
|
||||
"DeepDungeon33B_Chest190": 447,
|
||||
"DeepDungeon40B_Chest191": 448,
|
||||
"DeepDungeon41B_Chest192": 449,
|
||||
"DeepDungeon34B_Chest193": 450,
|
||||
"DeepDungeon39B_Chest194": 451
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import NamedTuple, Union
|
||||
from typing_extensions import deprecated
|
||||
import logging
|
||||
|
||||
from BaseClasses import Item, Tutorial, ItemClassification
|
||||
@@ -49,7 +50,8 @@ class GenericWorld(World):
|
||||
return Item(name, ItemClassification.filler, -1, self.player)
|
||||
raise InvalidItemError(name)
|
||||
|
||||
|
||||
@deprecated("worlds.generic.PlandoItem is deprecated and will be removed in the next version. "
|
||||
"Use Options.PlandoItem(s) instead.")
|
||||
class PlandoItem(NamedTuple):
|
||||
item: str
|
||||
location: str
|
||||
|
||||
@@ -81,7 +81,8 @@ are `description`, `name`, `game`, `requires`, and the name of the games you wan
|
||||
* `requires` details different requirements from the generator for the YAML to work as you expect it to. Generally this
|
||||
is good for detailing the version of Archipelago this YAML was prepared for. If it is rolled on an older version,
|
||||
options may be missing and as such it will not work as expected. If any plando is used in the file then requiring it
|
||||
here to ensure it will be used is good practice.
|
||||
here to ensure it will be used is good practice. Specific versions of custom worlds can also be required, ensuring
|
||||
that the generator is using a compatible version.
|
||||
|
||||
## Game Options
|
||||
|
||||
@@ -165,7 +166,9 @@ game:
|
||||
A Link to the Past: 10
|
||||
Timespinner: 10
|
||||
requires:
|
||||
version: 0.4.1
|
||||
version: 0.6.4
|
||||
game:
|
||||
A Link to the Past: 0.6.4
|
||||
A Link to the Past:
|
||||
accessibility: minimal
|
||||
progression_balancing: 50
|
||||
@@ -214,12 +217,13 @@ Timespinner:
|
||||
progression_balancing: 50
|
||||
item_links: # Share part of your item pool with other players.
|
||||
- name: TSAll
|
||||
item_pool:
|
||||
item_pool:
|
||||
- Everything
|
||||
local_items:
|
||||
- Twin Pyramid Key
|
||||
- Timespinner Wheel
|
||||
replacement_item: null
|
||||
skip_if_solo: true
|
||||
```
|
||||
|
||||
#### This is a fully functional yaml file that will do all the following things:
|
||||
@@ -228,7 +232,7 @@ Timespinner:
|
||||
* `name` is `Example Player` and this will be used in the server console when sending and receiving items.
|
||||
* `game` has an equal chance of being either `A Link to the Past` or `Timespinner` with a 10/20 chance for each. This is
|
||||
because each game has a weight of 10 and the total of all weights is 20.
|
||||
* `requires` is set to required release version 0.3.2 or higher.
|
||||
* `requires` is set to require Archipelago release version 0.6.4 or higher, as well as A Link to the Past version 0.6.4.
|
||||
* `accessibility` for both games is set to `minimal` which will set this seed to beatable only, so some locations and
|
||||
items may be completely inaccessible but the seed will still be completable.
|
||||
* `progression_balancing` for both games is set to 50, the default value, meaning we will likely receive important items
|
||||
@@ -262,7 +266,7 @@ Timespinner:
|
||||
* For `Timespinner` all players in the `TSAll` item link group will share their entire item pool and the `Twin Pyramid
|
||||
Key` and `Timespinner Wheel` will be forced among the worlds of those in the group. The `null` replacement item
|
||||
will, instead of forcing a specific chosen item, allow the generator to randomly pick a filler item to replace the
|
||||
player items.
|
||||
player items. This item link will only be created if there are at least two players in the group.
|
||||
* `triggers` allows us to define a trigger such that if our `smallkey_shuffle` option happens to roll the `any_world`
|
||||
result it will also ensure that `bigkey_shuffle`, `map_shuffle`, and `compass_shuffle` are also forced to the
|
||||
`any_world` result. More information on triggers can be found in the
|
||||
|
||||
@@ -136,6 +136,27 @@ are rolling locally, ensure this file is edited to your liking **before** rollin
|
||||
when running the Archipelago Installation software. If you have changed settings in this file, and would like to retain
|
||||
them, you may rename the file to `options.yaml`.
|
||||
|
||||
### Playing with custom worlds
|
||||
|
||||
If you are generating locally, you can play with worlds that are not included in the Archipelago installation.
|
||||
These worlds are packaged as `.apworld` files. To add a world to your installation, click the "Install APWorld" button
|
||||
in the launcher and select the `.apworld` file you wish to install. Alternatively, you can drag the `.apworld` file
|
||||
onto the launcher or double-click the file itself (if on Windows). Once the world is installed, it will function like
|
||||
the worlds that are already packaged with Archipelago. Also note that while generation with custom worlds must be done
|
||||
locally, these games can then be uploaded to the website for hosting and played as normal.
|
||||
|
||||
We strongly recommend that you ensure the source of the `.apworld` is safe and trustworthy before playing with a
|
||||
custom world. Installed APWorlds are able to run custom code on your computer whenever you open Archipelago.
|
||||
|
||||
#### Alternate versions of included worlds
|
||||
|
||||
If you want to play with an alternate version of a game that is already included in Archipelago, you should also
|
||||
remove the original APWorld after completing the above installation. To do so, go to your Archipelago installation
|
||||
folder and navigate to the `lib/worlds` directory. Then move the `.apworld` or the folder corresponding to the game you
|
||||
want to play an alternate version of to somewhere else as a backup. If you want to play this original again, then
|
||||
restore the original version to `lib/worlds` and remove the alternate version, which is in the `custom_worlds` folder.
|
||||
|
||||
Note: Currently, this cannot be done on the Linux AppImage release.
|
||||
|
||||
## Hosting an Archipelago Server
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* A legal copy of Hollow Knight.
|
||||
* Steam, Gog, and Xbox Game Pass versions of the game are supported.
|
||||
* Windows, Mac, and Linux (including Steam Deck) are supported.
|
||||
|
||||
**Do NOT** install BepInEx, it is not required and is incompatible with most mods. Archipelago, along with the majority of mods use custom tooling pre-dating BepInEx, and they are only available through Lumafly and similar installers rather than sites like Nexus Mods.
|
||||
|
||||
## Installing the Archipelago Mod using Lumafly
|
||||
1. Launch Lumafly and ensure it locates your Hollow Knight installation directory.
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
* Tener una copia legal de Hollow Knight.
|
||||
* Las versiones de Steam, GOG y Xbox Game Pass son compatibles
|
||||
* Las versiones de Windows, Mac y Linux (Incluyendo Steam Deck) son compatibles
|
||||
|
||||
**NO** instales BepInEx, **no** es necesario y es incompatible con varios mods. Archipelago (y la mayoría de los mods)
|
||||
usan herramientas más antiguas que BepInEx, que solo están disponibles por medio de instaladores de mods como Lumafly y
|
||||
similares, en vez de sitios web como Nexus Mods.
|
||||
|
||||
## Instalación del mod de Archipelago con Lumafly
|
||||
1. Ejecuta Lumafly y asegurate de localizar la carpeta de instalación de Hollow Knight
|
||||
@@ -61,4 +65,4 @@ de Archipelago para generar un YAML usando una interfaz gráfica.
|
||||
## Consejos y otros comandos
|
||||
Mientras juegas en un multiworld, puedes interactuar con el servidor usando varios comandos listados en la
|
||||
[guía de comandos](/tutorial/Archipelago/commands/en). Puedes usar el Cliente de Texto Archipelago para hacer esto,
|
||||
que está incluido en la última versión del [software de Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest).
|
||||
que está incluido en la última versión del [software de Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest).
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
* Uma cópia legal de Hollow Knight.
|
||||
* Versões Steam, Gog, e Xbox Game Pass do jogo são suportadas.
|
||||
* Windows, Mac, e Linux (incluindo Steam Deck) são suportados.
|
||||
|
||||
**NÃO** instale o BepInEx, ele **não** é necessário e é incompatível com vários mods. O Archipelago (e a maioria dos mods)
|
||||
usam ferramentas mais antigas do que o BepInEx, disponíveis apenas a partir de gerenciadores como o Lumafly e semelhantes,
|
||||
ao invés de sites como o Nexus Mods.
|
||||
|
||||
## Instalando o mod Archipelago Mod usando Lumafly
|
||||
1. Abra o Lumafly e confirme que ele localizou sua pasta de instalação do Hollow Knight.
|
||||
|
||||
6
worlds/jakanddaxter/archipelago.json
Normal file
6
worlds/jakanddaxter/archipelago.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"game": "Jak and Daxter: The Precursor Legacy",
|
||||
"world_version": "1.0.0",
|
||||
"minimum_ap_version": "0.6.2",
|
||||
"authors": ["markustulliuscicero"]
|
||||
}
|
||||
@@ -6,7 +6,8 @@ class TradesCostNothingTest(JakAndDaxterTestBase):
|
||||
"enable_orbsanity": 2,
|
||||
"global_orbsanity_bundle_size": 10,
|
||||
"citizen_orb_trade_amount": 0,
|
||||
"oracle_orb_trade_amount": 0
|
||||
"oracle_orb_trade_amount": 0,
|
||||
"start_inventory": {"Power Cell": 100},
|
||||
}
|
||||
|
||||
def test_orb_items_are_filler(self):
|
||||
@@ -24,7 +25,8 @@ class TradesCostEverythingTest(JakAndDaxterTestBase):
|
||||
"enable_orbsanity": 2,
|
||||
"global_orbsanity_bundle_size": 10,
|
||||
"citizen_orb_trade_amount": 120,
|
||||
"oracle_orb_trade_amount": 150
|
||||
"oracle_orb_trade_amount": 150,
|
||||
"start_inventory": {"Power Cell": 100},
|
||||
}
|
||||
|
||||
def test_orb_items_are_progression(self):
|
||||
|
||||
@@ -303,9 +303,6 @@ class KDL3World(World):
|
||||
|
||||
def generate_basic(self) -> None:
|
||||
self.stage_shuffle_enabled = self.options.stage_shuffle > 0
|
||||
goal = self.options.goal.value
|
||||
goal_location = self.multiworld.get_location(location_name.goals[goal], self.player)
|
||||
goal_location.place_locked_item(KDL3Item("Love-Love Rod", ItemClassification.progression, None, self.player))
|
||||
for level in range(1, 6):
|
||||
self.multiworld.get_location(f"Level {level} Boss - Defeated", self.player) \
|
||||
.place_locked_item(
|
||||
@@ -313,7 +310,6 @@ class KDL3World(World):
|
||||
self.multiworld.get_location(f"Level {level} Boss - Purified", self.player) \
|
||||
.place_locked_item(
|
||||
KDL3Item(f"Level {level} Boss Purified", ItemClassification.progression, None, self.player))
|
||||
self.multiworld.completion_condition[self.player] = lambda state: state.has("Love-Love Rod", self.player)
|
||||
# this can technically be done at any point before generate_output
|
||||
if self.options.allow_bb:
|
||||
if self.options.allow_bb == self.options.allow_bb.option_enforced:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from BaseClasses import ItemClassification
|
||||
from worlds.generic.Rules import set_rule, add_rule
|
||||
from .names import location_name, enemy_abilities, animal_friend_spawns
|
||||
from .items import KDL3Item
|
||||
from .locations import location_table
|
||||
from .names import location_name, enemy_abilities, animal_friend_spawns
|
||||
from .options import GoalSpeed
|
||||
import typing
|
||||
|
||||
@@ -111,6 +113,11 @@ def can_fix_angel_wings(state: "CollectionState", player: int, copy_abilities: t
|
||||
|
||||
|
||||
def set_rules(world: "KDL3World") -> None:
|
||||
goal = world.options.goal.value
|
||||
goal_location = world.multiworld.get_location(location_name.goals[goal], world.player)
|
||||
goal_location.place_locked_item(KDL3Item("Love-Love Rod", ItemClassification.progression, None, world.player))
|
||||
world.multiworld.completion_condition[world.player] = lambda state: state.has("Love-Love Rod", world.player)
|
||||
|
||||
# Level 1
|
||||
set_rule(world.multiworld.get_location(location_name.grass_land_muchi, world.player),
|
||||
lambda state: can_reach_chuchu(state, world.player))
|
||||
|
||||
@@ -13,8 +13,6 @@ import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
death_link = False
|
||||
item_num = 1
|
||||
|
||||
logger = logging.getLogger("Client")
|
||||
|
||||
@@ -34,62 +32,57 @@ class KH1ClientCommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx):
|
||||
super().__init__(ctx)
|
||||
|
||||
def _cmd_slot_data(self):
|
||||
"""Prints slot data settings for the connected seed"""
|
||||
for key in self.ctx.slot_data.keys():
|
||||
if key not in ["remote_location_ids", "synthesis_item_name_byte_arrays"]:
|
||||
self.output(str(key) + ": " + str(self.ctx.slot_data[key]))
|
||||
|
||||
def _cmd_deathlink(self):
|
||||
"""Toggles Deathlink"""
|
||||
global death_link
|
||||
if death_link:
|
||||
death_link = False
|
||||
self.output(f"Death Link turned off")
|
||||
else:
|
||||
death_link = True
|
||||
self.output(f"Death Link turned on")
|
||||
|
||||
def _cmd_goal(self):
|
||||
"""Prints goal setting"""
|
||||
if "goal" in self.ctx.slot_data.keys():
|
||||
self.output(str(self.ctx.slot_data["goal"]))
|
||||
else:
|
||||
self.output("Unknown")
|
||||
|
||||
def _cmd_eotw_unlock(self):
|
||||
"""Prints End of the World Unlock setting"""
|
||||
if "required_reports_door" in self.ctx.slot_data.keys():
|
||||
if self.ctx.slot_data["required_reports_door"] > 13:
|
||||
self.output("Item")
|
||||
"""If your Death Link setting is set to "Toggle", use this command to turn Death Link on and off."""
|
||||
if "death_link" in self.ctx.slot_data.keys():
|
||||
if self.ctx.slot_data["death_link"] == "toggle":
|
||||
if self.ctx.death_link:
|
||||
self.ctx.death_link = False
|
||||
self.output(f"Death Link turned off")
|
||||
else:
|
||||
self.ctx.death_link = True
|
||||
self.output(f"Death Link turned on")
|
||||
else:
|
||||
self.output(str(self.ctx.slot_data["required_reports_eotw"]) + " reports")
|
||||
self.output(f"'death_link' is not set to 'toggle' for this seed.")
|
||||
self.output(f"'death_link' = " + str(self.ctx.slot_data["death_link"]))
|
||||
else:
|
||||
self.output("Unknown")
|
||||
self.output(f"No 'death_link' in slot_data keys. You probably aren't connected or are playing an older seed.")
|
||||
|
||||
def _cmd_door_unlock(self):
|
||||
"""Prints Final Rest Door Unlock setting"""
|
||||
if "door" in self.ctx.slot_data.keys():
|
||||
if self.ctx.slot_data["door"] == "reports":
|
||||
self.output(str(self.ctx.slot_data["required_reports_door"]) + " reports")
|
||||
else:
|
||||
self.output(str(self.ctx.slot_data["door"]))
|
||||
def _cmd_communication_path(self):
|
||||
"""Opens a file browser to allow Linux users to manually set their %LOCALAPPDATA% path"""
|
||||
directory = Utils.open_directory("Select %LOCALAPPDATA% dir", "~/.local/share/Steam/steamapps/compatdata/2552430/pfx/drive_c/users/steamuser/AppData/Local")
|
||||
if directory:
|
||||
directory += "/KH1FM"
|
||||
if not os.path.exists(directory):
|
||||
os.makedirs(directory)
|
||||
self.ctx.game_communication_path = directory
|
||||
else:
|
||||
self.output("Unknown")
|
||||
|
||||
def _cmd_advanced_logic(self):
|
||||
"""Prints advanced logic setting"""
|
||||
if "advanced_logic" in self.ctx.slot_data.keys():
|
||||
self.output(str(self.ctx.slot_data["advanced_logic"]))
|
||||
else:
|
||||
self.output("Unknown")
|
||||
self.output(self.ctx.game_communication_path)
|
||||
|
||||
class KH1Context(CommonContext):
|
||||
command_processor: int = KH1ClientCommandProcessor
|
||||
game = "Kingdom Hearts"
|
||||
items_handling = 0b111 # full remote
|
||||
items_handling = 0b011 # full remote except start inventory
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super(KH1Context, self).__init__(server_address, password)
|
||||
self.send_index: int = 0
|
||||
self.syncing = False
|
||||
self.awaiting_bridge = False
|
||||
self.hinted_synth_location_ids = False
|
||||
self.slot_data = {}
|
||||
self.hinted_location_ids: list[int] = []
|
||||
self.slot_data: dict = {}
|
||||
|
||||
# Moved globals into instance attributes
|
||||
self.death_link: bool = False
|
||||
self.item_num: int = 1
|
||||
self.remote_location_ids: list[int] = []
|
||||
|
||||
# self.game_communication_path: files go in this path to pass data between us and the actual game
|
||||
if "localappdata" in os.environ:
|
||||
self.game_communication_path = os.path.expandvars(r"%localappdata%/KH1FM")
|
||||
@@ -103,6 +96,10 @@ class KH1Context(CommonContext):
|
||||
os.remove(root+"/"+file)
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
for root, dirs, files in os.walk(self.game_communication_path):
|
||||
for file in files:
|
||||
if file.find("obtain") <= -1:
|
||||
os.remove(root+"/"+file)
|
||||
if password_requested and not self.password:
|
||||
await super(KH1Context, self).server_auth(password_requested)
|
||||
await self.get_username()
|
||||
@@ -114,8 +111,7 @@ class KH1Context(CommonContext):
|
||||
for file in files:
|
||||
if file.find("obtain") <= -1:
|
||||
os.remove(root + "/" + file)
|
||||
global item_num
|
||||
item_num = 1
|
||||
self.item_num = 1
|
||||
|
||||
@property
|
||||
def endpoints(self):
|
||||
@@ -130,8 +126,7 @@ class KH1Context(CommonContext):
|
||||
for file in files:
|
||||
if file.find("obtain") <= -1:
|
||||
os.remove(root+"/"+file)
|
||||
global item_num
|
||||
item_num = 1
|
||||
self.item_num = 1
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd in {"Connected"}:
|
||||
@@ -139,68 +134,82 @@ class KH1Context(CommonContext):
|
||||
os.makedirs(self.game_communication_path)
|
||||
for ss in self.checked_locations:
|
||||
filename = f"send{ss}"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w', encoding='utf-8') as f:
|
||||
f.close()
|
||||
|
||||
#Handle Slot Data
|
||||
# Handle Slot Data
|
||||
self.slot_data = args['slot_data']
|
||||
for key in list(args['slot_data'].keys()):
|
||||
with open(os.path.join(self.game_communication_path, key + ".cfg"), 'w') as f:
|
||||
with open(os.path.join(self.game_communication_path, key + ".cfg"), 'w', encoding='utf-8') as f:
|
||||
f.write(str(args['slot_data'][key]))
|
||||
f.close()
|
||||
|
||||
###Support Legacy Games
|
||||
if "Required Reports" in list(args['slot_data'].keys()) and "required_reports_eotw" not in list(args['slot_data'].keys()):
|
||||
reports_required = args['slot_data']["Required Reports"]
|
||||
with open(os.path.join(self.game_communication_path, "required_reports.cfg"), 'w') as f:
|
||||
f.write(str(reports_required))
|
||||
f.close()
|
||||
###End Support Legacy Games
|
||||
|
||||
#End Handle Slot Data
|
||||
if key == "remote_location_ids":
|
||||
self.remote_location_ids = args['slot_data'][key]
|
||||
if key == "death_link":
|
||||
if args['slot_data']["death_link"] != "off":
|
||||
self.death_link = True
|
||||
# End Handle Slot Data
|
||||
|
||||
if cmd in {"ReceivedItems"}:
|
||||
start_index = args["index"]
|
||||
if start_index != len(self.items_received):
|
||||
global item_num
|
||||
for item in args['items']:
|
||||
found = False
|
||||
item_filename = f"AP_{str(item_num)}.item"
|
||||
item_filename = f"AP_{str(self.item_num)}.item"
|
||||
for filename in os.listdir(self.game_communication_path):
|
||||
if filename == item_filename:
|
||||
found = True
|
||||
if not found:
|
||||
with open(os.path.join(self.game_communication_path, item_filename), 'w') as f:
|
||||
f.write(str(NetworkItem(*item).item) + "\n" + str(NetworkItem(*item).location) + "\n" + str(NetworkItem(*item).player))
|
||||
f.close()
|
||||
item_num = item_num + 1
|
||||
if (NetworkItem(*item).player == self.slot and (NetworkItem(*item).location in self.remote_location_ids) or (NetworkItem(*item).location < 0)) or NetworkItem(*item).player != self.slot:
|
||||
with open(os.path.join(self.game_communication_path, item_filename), 'w', encoding='utf-8') as f:
|
||||
f.write(str(NetworkItem(*item).item) + "\n" + str(NetworkItem(*item).location) + "\n" + str(NetworkItem(*item).player))
|
||||
f.close()
|
||||
self.item_num += 1
|
||||
|
||||
if cmd in {"RoomUpdate"}:
|
||||
if "checked_locations" in args:
|
||||
for ss in self.checked_locations:
|
||||
filename = f"send{ss}"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w', encoding='utf-8') as f:
|
||||
f.close()
|
||||
|
||||
if cmd in {"PrintJSON"} and "type" in args:
|
||||
if args["type"] == "ItemSend":
|
||||
item = args["item"]
|
||||
networkItem = NetworkItem(*item)
|
||||
recieverID = args["receiving"]
|
||||
receiverID = args["receiving"]
|
||||
senderID = networkItem.player
|
||||
locationID = networkItem.location
|
||||
if recieverID != self.slot and senderID == self.slot:
|
||||
itemName = self.item_names.lookup_in_slot(networkItem.item, recieverID)
|
||||
if receiverID == self.slot or senderID == self.slot:
|
||||
itemName = self.item_names.lookup_in_slot(networkItem.item, receiverID)[:20]
|
||||
itemCategory = networkItem.flags
|
||||
recieverName = self.player_names[recieverID]
|
||||
filename = "sent"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
f.write(
|
||||
re.sub('[^A-Za-z0-9 ]+', '',str(itemName))[:15] + "\n"
|
||||
+ re.sub('[^A-Za-z0-9 ]+', '',str(recieverName))[:6] + "\n"
|
||||
+ str(itemCategory) + "\n"
|
||||
+ str(locationID))
|
||||
f.close()
|
||||
receiverName = self.player_names[receiverID][:20]
|
||||
senderName = self.player_names[senderID][:20]
|
||||
message = ""
|
||||
if receiverID == self.slot and receiverID != senderID: # Item received from someone else
|
||||
message = "From " + senderName + "\n" + itemName
|
||||
elif senderID == self.slot and receiverID != senderID: # Item sent to someone else
|
||||
message = itemName + "\nTo " + receiverName
|
||||
elif locationID in self.remote_location_ids: # Found a remote item
|
||||
message = itemName
|
||||
filename = "msg"
|
||||
if message != "":
|
||||
if not os.path.exists(self.game_communication_path + "/" + filename):
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w', encoding='utf-8') as f:
|
||||
f.write(message)
|
||||
f.close()
|
||||
if args["type"] == "ItemCheat":
|
||||
item = args["item"]
|
||||
networkItem = NetworkItem(*item)
|
||||
receiverID = args["receiving"]
|
||||
if receiverID == self.slot:
|
||||
itemName = self.item_names.lookup_in_slot(networkItem.item, receiverID)[:20]
|
||||
filename = "msg"
|
||||
message = "Received " + itemName + "\nfrom server"
|
||||
if not os.path.exists(self.game_communication_path + "/" + filename):
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w', encoding='utf-8') as f:
|
||||
f.write(message)
|
||||
f.close()
|
||||
|
||||
def on_deathlink(self, data: dict[str, object]):
|
||||
self.last_death_link = max(data["time"], self.last_death_link)
|
||||
@@ -209,7 +218,7 @@ class KH1Context(CommonContext):
|
||||
logger.info(f"DeathLink: {text}")
|
||||
else:
|
||||
logger.info(f"DeathLink: Received from {data['source']}")
|
||||
with open(os.path.join(self.game_communication_path, 'dlreceive'), 'w') as f:
|
||||
with open(os.path.join(self.game_communication_path, 'dlreceive'), 'w', encoding='utf-8') as f:
|
||||
f.write(str(int(data["time"])))
|
||||
f.close()
|
||||
|
||||
@@ -230,12 +239,11 @@ class KH1Context(CommonContext):
|
||||
async def game_watcher(ctx: KH1Context):
|
||||
from .Locations import lookup_id_to_name
|
||||
while not ctx.exit_event.is_set():
|
||||
global death_link
|
||||
if death_link and "DeathLink" not in ctx.tags:
|
||||
await ctx.update_death_link(death_link)
|
||||
if not death_link and "DeathLink" in ctx.tags:
|
||||
await ctx.update_death_link(death_link)
|
||||
if ctx.syncing == True:
|
||||
if ctx.death_link and "DeathLink" not in ctx.tags:
|
||||
await ctx.update_death_link(ctx.death_link)
|
||||
if not ctx.death_link and "DeathLink" in ctx.tags:
|
||||
await ctx.update_death_link(ctx.death_link)
|
||||
if ctx.syncing is True:
|
||||
sync_msg = [{'cmd': 'Sync'}]
|
||||
if ctx.locations_checked:
|
||||
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
|
||||
@@ -256,17 +264,17 @@ async def game_watcher(ctx: KH1Context):
|
||||
if st != "nil":
|
||||
if timegm(time.strptime(st, '%Y%m%d%H%M%S')) > ctx.last_death_link and int(time.time()) % int(timegm(time.strptime(st, '%Y%m%d%H%M%S'))) < 10:
|
||||
await ctx.send_death(death_text = "Sora was defeated!")
|
||||
if file.find("insynthshop") > -1:
|
||||
if not ctx.hinted_synth_location_ids:
|
||||
if file.find("hint") > -1:
|
||||
hint_location_id = int(file.split("hint", -1)[1])
|
||||
if hint_location_id not in ctx.hinted_location_ids:
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "LocationScouts",
|
||||
"locations": [2656401,2656402,2656403,2656404,2656405,2656406],
|
||||
"locations": [hint_location_id],
|
||||
"create_as_hint": 2
|
||||
}])
|
||||
ctx.hinted_synth_location_ids = True
|
||||
ctx.hinted_location_ids.append(hint_location_id)
|
||||
ctx.locations_checked = sending
|
||||
message = [{"cmd": 'LocationChecks', "locations": sending}]
|
||||
await ctx.send_msgs(message)
|
||||
await ctx.check_locations(sending)
|
||||
if not ctx.finished_game and victory:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
|
||||
202
worlds/kh1/Data.py
Normal file
202
worlds/kh1/Data.py
Normal file
@@ -0,0 +1,202 @@
|
||||
VANILLA_KEYBLADE_STATS = [
|
||||
{"STR": 3, "CRR": 20, "CRB": 0, "REC": 30, "MP": 0}, # Kingdom Key
|
||||
{"STR": 1, "CRR": 20, "CRB": 0, "REC": 30, "MP": 0}, # Dream Sword
|
||||
{"STR": 1, "CRR": 0, "CRB": 0, "REC": 60, "MP": 0}, # Dream Shield
|
||||
{"STR": 1, "CRR": 10, "CRB": 0, "REC": 30, "MP": 0}, # Dream Rod
|
||||
{"STR": 0, "CRR": 20, "CRB": 0, "REC": 30, "MP": 0}, # Wooden Sword
|
||||
{"STR": 5, "CRR": 10, "CRB": 0, "REC": 1, "MP": 0}, # Jungle King
|
||||
{"STR": 6, "CRR": 20, "CRB": 0, "REC": 60, "MP": 0}, # Three Wishes
|
||||
{"STR": 8, "CRR": 10, "CRB": 2, "REC": 30, "MP": 1}, # Fairy Harp
|
||||
{"STR": 7, "CRR": 40, "CRB": 0, "REC": 1, "MP": 0}, # Pumpkinhead
|
||||
{"STR": 6, "CRR": 20, "CRB": 0, "REC": 30, "MP": 1}, # Crabclaw
|
||||
{"STR": 13, "CRR": 40, "CRB": 0, "REC": 60, "MP": 0}, # Divine Rose
|
||||
{"STR": 4, "CRR": 20, "CRB": 0, "REC": 30, "MP": 2}, # Spellbinder
|
||||
{"STR": 10, "CRR": 20, "CRB": 2, "REC": 90, "MP": 0}, # Olympia
|
||||
{"STR": 10, "CRR": 20, "CRB": 0, "REC": 30, "MP": 1}, # Lionheart
|
||||
{"STR": 9, "CRR": 2, "CRB": 0, "REC": 90, "MP": -1}, # Metal Chocobo
|
||||
{"STR": 9, "CRR": 40, "CRB": 0, "REC": 1, "MP": 1}, # Oathkeeper
|
||||
{"STR": 11, "CRR": 20, "CRB": 2, "REC": 30, "MP": -1}, # Oblivion
|
||||
{"STR": 7, "CRR": 20, "CRB": 0, "REC": 1, "MP": 2}, # Lady Luck
|
||||
{"STR": 5, "CRR": 200, "CRB": 2, "REC": 1, "MP": 0}, # Wishing Star
|
||||
{"STR": 14, "CRR": 40, "CRB": 2, "REC": 90, "MP": 2}, # Ultima Weapon
|
||||
{"STR": 3, "CRR": 20, "CRB": 0, "REC": 1, "MP": 3}, # Diamond Dust
|
||||
{"STR": 8, "CRR": 10, "CRB": 16, "REC": 90, "MP": -2}, # One-Winged Angel
|
||||
]
|
||||
VANILLA_PUPPY_LOCATIONS = [
|
||||
"Traverse Town Mystical House Glide Chest",
|
||||
"Traverse Town Alleyway Behind Crates Chest",
|
||||
"Traverse Town Item Workshop Left Chest",
|
||||
"Traverse Town Secret Waterway Near Stairs Chest",
|
||||
"Wonderland Queen's Castle Hedge Right Blue Chest",
|
||||
"Wonderland Lotus Forest Nut Chest",
|
||||
"Wonderland Tea Party Garden Above Lotus Forest Entrance 1st Chest",
|
||||
"Olympus Coliseum Coliseum Gates Right Blue Trinity Chest",
|
||||
"Deep Jungle Hippo's Lagoon Center Chest",
|
||||
"Deep Jungle Vines 2 Chest",
|
||||
"Deep Jungle Waterfall Cavern Middle Chest",
|
||||
"Deep Jungle Camp Blue Trinity Chest",
|
||||
"Agrabah Cave of Wonders Treasure Room Across Platforms Chest",
|
||||
"Halloween Town Oogie's Manor Hollow Chest",
|
||||
"Neverland Pirate Ship Deck White Trinity Chest",
|
||||
"Agrabah Cave of Wonders Hidden Room Left Chest",
|
||||
"Agrabah Cave of Wonders Entrance Tall Tower Chest",
|
||||
"Agrabah Palace Gates High Opposite Palace Chest",
|
||||
"Monstro Chamber 3 Platform Above Chamber 2 Entrance Chest",
|
||||
"Wonderland Lotus Forest Through the Painting Thunder Plant Chest",
|
||||
"Hollow Bastion Grand Hall Left of Gate Chest",
|
||||
"Halloween Town Cemetery By Cat Shape Chest",
|
||||
"Halloween Town Moonlight Hill White Trinity Chest",
|
||||
"Halloween Town Guillotine Square Pumpkin Structure Right Chest",
|
||||
"Monstro Mouth High Platform Across from Boat Chest",
|
||||
"Monstro Chamber 6 Low Chest",
|
||||
"Monstro Chamber 5 Atop Barrel Chest",
|
||||
"Neverland Hold Flight 1st Chest",
|
||||
"Neverland Hold Yellow Trinity Green Chest",
|
||||
"Neverland Captain's Cabin Chest",
|
||||
"Hollow Bastion Rising Falls Floating Platform Near Save Chest",
|
||||
"Hollow Bastion Castle Gates Gravity Chest",
|
||||
"Hollow Bastion Lift Stop Outside Library Gravity Chest"
|
||||
]
|
||||
CHAR_TO_KH = {
|
||||
" ": 0x01,
|
||||
"0": 0x21,
|
||||
"1": 0x22,
|
||||
"2": 0x23,
|
||||
"3": 0x24,
|
||||
"4": 0x25,
|
||||
"5": 0x26,
|
||||
"6": 0x27,
|
||||
"7": 0x28,
|
||||
"8": 0x29,
|
||||
"9": 0x2A,
|
||||
"A": 0x2B,
|
||||
"B": 0x2C,
|
||||
"C": 0x2D,
|
||||
"D": 0x2E,
|
||||
"E": 0x2F,
|
||||
"F": 0x30,
|
||||
"G": 0x31,
|
||||
"H": 0x32,
|
||||
"I": 0x33,
|
||||
"J": 0x34,
|
||||
"K": 0x35,
|
||||
"L": 0x36,
|
||||
"M": 0x37,
|
||||
"N": 0x38,
|
||||
"O": 0x39,
|
||||
"P": 0x3A,
|
||||
"Q": 0x3B,
|
||||
"R": 0x3C,
|
||||
"S": 0x3D,
|
||||
"T": 0x3E,
|
||||
"U": 0x3F,
|
||||
"V": 0x40,
|
||||
"W": 0x41,
|
||||
"X": 0x42,
|
||||
"Y": 0x43,
|
||||
"Z": 0x44,
|
||||
"a": 0x45,
|
||||
"b": 0x46,
|
||||
"c": 0x47,
|
||||
"d": 0x48,
|
||||
"e": 0x49,
|
||||
"f": 0x4A,
|
||||
"g": 0x4B,
|
||||
"h": 0x4C,
|
||||
"i": 0x4D,
|
||||
"j": 0x4E,
|
||||
"k": 0x4F,
|
||||
"l": 0x50,
|
||||
"m": 0x51,
|
||||
"n": 0x52,
|
||||
"o": 0x53,
|
||||
"p": 0x54,
|
||||
"q": 0x55,
|
||||
"r": 0x56,
|
||||
"s": 0x57,
|
||||
"t": 0x58,
|
||||
"u": 0x59,
|
||||
"v": 0x5A,
|
||||
"w": 0x5B,
|
||||
"x": 0x5C,
|
||||
"y": 0x5D,
|
||||
"z": 0x5E
|
||||
}
|
||||
VANILLA_ABILITY_AP_COSTS = [
|
||||
{"Ability Name": "Treasure Magnet", "AP Cost": 2, "Randomize": True},
|
||||
{"Ability Name": "Combo Plus", "AP Cost": 1, "Randomize": True},
|
||||
{"Ability Name": "Air Combo Plus", "AP Cost": 1, "Randomize": True},
|
||||
{"Ability Name": "Critical Plus", "AP Cost": 2, "Randomize": True},
|
||||
{"Ability Name": "Second Wind", "AP Cost": 3, "Randomize": True},
|
||||
{"Ability Name": "Scan", "AP Cost": 1, "Randomize": True},
|
||||
{"Ability Name": "Sonic Blade", "AP Cost": 3, "Randomize": True},
|
||||
{"Ability Name": "Ars Arcanum", "AP Cost": 4, "Randomize": True},
|
||||
{"Ability Name": "Strike Raid", "AP Cost": 3, "Randomize": True},
|
||||
{"Ability Name": "Ragnarok", "AP Cost": 4, "Randomize": True},
|
||||
{"Ability Name": "Trinity Limit", "AP Cost": 1, "Randomize": True},
|
||||
{"Ability Name": "Cheer", "AP Cost": 1, "Randomize": True},
|
||||
{"Ability Name": "Vortex", "AP Cost": 1, "Randomize": True},
|
||||
{"Ability Name": "Aerial Sweep", "AP Cost": 2, "Randomize": True},
|
||||
{"Ability Name": "Counter Attack", "AP Cost": 2, "Randomize": True},
|
||||
{"Ability Name": "Blitz", "AP Cost": 2, "Randomize": True},
|
||||
{"Ability Name": "Guard", "AP Cost": 2, "Randomize": True},
|
||||
{"Ability Name": "Dodge Roll", "AP Cost": 1, "Randomize": True},
|
||||
{"Ability Name": "MP Haste", "AP Cost": 3, "Randomize": True},
|
||||
{"Ability Name": "MP Rage", "AP Cost": 3, "Randomize": True},
|
||||
{"Ability Name": "Second Chance", "AP Cost": 5, "Randomize": True},
|
||||
{"Ability Name": "Berserk", "AP Cost": 1, "Randomize": True},
|
||||
{"Ability Name": "Jackpot", "AP Cost": 2, "Randomize": True},
|
||||
{"Ability Name": "Lucky Strike", "AP Cost": 2, "Randomize": True},
|
||||
{"Ability Name": "Charge", "AP Cost": 1, "Randomize": True},
|
||||
{"Ability Name": "Rocket", "AP Cost": 1, "Randomize": True},
|
||||
{"Ability Name": "Tornado", "AP Cost": 2, "Randomize": True},
|
||||
{"Ability Name": "MP Gift", "AP Cost": 3, "Randomize": True},
|
||||
{"Ability Name": "Raging Boar", "AP Cost": 2, "Randomize": True},
|
||||
{"Ability Name": "Asp's Bite", "AP Cost": 2, "Randomize": True},
|
||||
{"Ability Name": "Healing Herb", "AP Cost": 3, "Randomize": True},
|
||||
{"Ability Name": "Wind Armor", "AP Cost": 3, "Randomize": True},
|
||||
{"Ability Name": "Crescent", "AP Cost": 2, "Randomize": True},
|
||||
{"Ability Name": "Sandstorm", "AP Cost": 2, "Randomize": True},
|
||||
{"Ability Name": "Applause!", "AP Cost": 1, "Randomize": True},
|
||||
{"Ability Name": "Blazing Fury", "AP Cost": 2, "Randomize": True},
|
||||
{"Ability Name": "Icy Terror", "AP Cost": 2, "Randomize": True},
|
||||
{"Ability Name": "Bolts of Sorrow", "AP Cost": 3, "Randomize": True},
|
||||
{"Ability Name": "Ghostly Scream", "AP Cost": 3, "Randomize": True},
|
||||
{"Ability Name": "Hummingbird", "AP Cost": 2, "Randomize": True},
|
||||
{"Ability Name": "Time-Out", "AP Cost": 4, "Randomize": True},
|
||||
{"Ability Name": "Storm´s Eye", "AP Cost": 3, "Randomize": True},
|
||||
{"Ability Name": "Ferocious Lunge", "AP Cost": 2, "Randomize": True},
|
||||
{"Ability Name": "Furious Bellow", "AP Cost": 3, "Randomize": True},
|
||||
{"Ability Name": "Spiral Wave", "AP Cost": 1, "Randomize": True},
|
||||
{"Ability Name": "Thunder Potion", "AP Cost": 3, "Randomize": True},
|
||||
{"Ability Name": "Cure Potion", "AP Cost": 3, "Randomize": True},
|
||||
{"Ability Name": "Aero Potion", "AP Cost": 3, "Randomize": True},
|
||||
{"Ability Name": "Slapshot", "AP Cost": 1, "Randomize": True},
|
||||
{"Ability Name": "Sliding Dash", "AP Cost": 1, "Randomize": True},
|
||||
{"Ability Name": "Hurricane Blast", "AP Cost": 2, "Randomize": True},
|
||||
{"Ability Name": "Ripple Drive", "AP Cost": 3, "Randomize": True},
|
||||
{"Ability Name": "Stun Impact", "AP Cost": 2, "Randomize": True},
|
||||
{"Ability Name": "Gravity Break", "AP Cost": 2, "Randomize": True},
|
||||
{"Ability Name": "Zantetsuken", "AP Cost": 2, "Randomize": True},
|
||||
{"Ability Name": "Tech Boost", "AP Cost": 2, "Randomize": True},
|
||||
{"Ability Name": "Encounter Plus", "AP Cost": 1, "Randomize": True},
|
||||
{"Ability Name": "Leaf Bracer", "AP Cost": 5, "Randomize": True},
|
||||
{"Ability Name": "Evolution", "AP Cost": 3, "Randomize": True},
|
||||
{"Ability Name": "EXP Zero", "AP Cost": 0, "Randomize": True},
|
||||
{"Ability Name": "Combo Master", "AP Cost": 3, "Randomize": True}
|
||||
]
|
||||
|
||||
WORLD_KEY_ITEMS = {
|
||||
"Footprints": "Wonderland",
|
||||
"Entry Pass": "Olympus Coliseum",
|
||||
"Slides": "Deep Jungle",
|
||||
"Crystal Trident": "Atlantica",
|
||||
"Forget-Me-Not": "Halloween Town",
|
||||
"Jack-In-The-Box": "Halloween Town",
|
||||
"Theon Vol. 6": "Hollow Bastion"
|
||||
}
|
||||
|
||||
LOGIC_BEGINNER = 0
|
||||
LOGIC_NORMAL = 5
|
||||
LOGIC_PROUD = 10
|
||||
LOGIC_MINIMAL = 15
|
||||
67
worlds/kh1/GenerateJSON.py
Normal file
67
worlds/kh1/GenerateJSON.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import logging
|
||||
|
||||
import yaml
|
||||
import os
|
||||
import io
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, cast
|
||||
import Utils
|
||||
import zipfile
|
||||
import json
|
||||
|
||||
from .Locations import KH1Location, location_table
|
||||
|
||||
from worlds.Files import APPlayerContainer
|
||||
|
||||
|
||||
|
||||
class KH1Container(APPlayerContainer):
|
||||
game: str = 'Kingdom Hearts'
|
||||
patch_file_ending = ".zip"
|
||||
|
||||
def __init__(self, patch_data: Dict[str, str] | io.BytesIO, base_path: str = "", output_directory: str = "",
|
||||
player: Optional[int] = None, player_name: str = "", server: str = ""):
|
||||
self.patch_data = patch_data
|
||||
self.file_path = base_path
|
||||
container_path = os.path.join(output_directory, base_path + ".zip")
|
||||
super().__init__(container_path, player, player_name, server)
|
||||
|
||||
def write_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
|
||||
for filename, text in self.patch_data.items():
|
||||
opened_zipfile.writestr(filename, text)
|
||||
super().write_contents(opened_zipfile)
|
||||
|
||||
|
||||
def generate_json(world, output_directory):
|
||||
mod_name = f"AP-{world.multiworld.seed_name}-P{world.player}-{world.multiworld.get_file_safe_player_name(world.player)}"
|
||||
mod_dir = os.path.join(output_directory, mod_name + "_" + Utils.__version__)
|
||||
|
||||
item_location_map = get_item_location_map(world)
|
||||
settings = get_settings(world)
|
||||
|
||||
files = {
|
||||
"item_location_map.json": json.dumps(item_location_map),
|
||||
"keyblade_stats.json": json.dumps(world.get_keyblade_stats()),
|
||||
"settings.json": json.dumps(settings),
|
||||
"ap_costs.json": json.dumps(world.get_ap_costs())
|
||||
}
|
||||
|
||||
mod = KH1Container(files, mod_dir, output_directory, world.player,
|
||||
world.multiworld.get_file_safe_player_name(world.player))
|
||||
mod.write()
|
||||
|
||||
def get_item_location_map(world):
|
||||
location_item_map = {}
|
||||
for location in world.multiworld.get_filled_locations(world.player):
|
||||
if location.name != "Final Ansem":
|
||||
if world.player != location.item.player or (world.player == location.item.player and world.options.remote_items.current_key == "full" and (location_table[location.name].code < 2656800 or location_table[location.name].code > 2656814)):
|
||||
item_id = 2641230
|
||||
else:
|
||||
item_id = location.item.code
|
||||
location_data = location_table[location.name]
|
||||
location_id = location_data.code
|
||||
location_item_map[location_id] = item_id
|
||||
return location_item_map
|
||||
|
||||
def get_settings(world):
|
||||
settings = world.fill_slot_data()
|
||||
return settings
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user