Compare commits
268 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07ff0f1026 | ||
|
|
a080288e3e | ||
|
|
71bd87f293 | ||
|
|
574e2abba8 | ||
|
|
cffa772801 | ||
|
|
66bd793306 | ||
|
|
0eb37883ca | ||
|
|
356384ab05 | ||
|
|
8c2c6877b6 | ||
|
|
d1d40d8a60 | ||
|
|
b026a0a372 | ||
|
|
73bcd0058a | ||
|
|
0cf396e5d6 | ||
|
|
1bc09d4292 | ||
|
|
97d0c51db1 | ||
|
|
ed1c11267c | ||
|
|
a3e1ac896f | ||
|
|
37d9eb2752 | ||
|
|
05e267a0bd | ||
|
|
d1f0a29a02 | ||
|
|
fb2e780c56 | ||
|
|
ba3257f850 | ||
|
|
215d5e9adf | ||
|
|
5392b32d5c | ||
|
|
4dd0a75914 | ||
|
|
a2212002ae | ||
|
|
91ccee3513 | ||
|
|
2a593d5d0a | ||
|
|
a93b3d79aa | ||
|
|
938ab32cda | ||
|
|
6f5ab05345 | ||
|
|
95f8647f09 | ||
|
|
06c8caa3cc | ||
|
|
d206a562df | ||
|
|
a0a290e481 | ||
|
|
266ff0c520 | ||
|
|
931bf7da16 | ||
|
|
fe4a26d034 | ||
|
|
dca70a99ad | ||
|
|
1a24a73ccd | ||
|
|
ae163319e0 | ||
|
|
65864e273b | ||
|
|
199b778d2b | ||
|
|
70e3c47120 | ||
|
|
eddc5d6524 | ||
|
|
fae3068c25 | ||
|
|
b9014b2a60 | ||
|
|
6b07b6407c | ||
|
|
a10b987f1c | ||
|
|
1f16310797 | ||
|
|
0fd59063d9 | ||
|
|
aab477b874 | ||
|
|
098d939653 | ||
|
|
7d830362a7 | ||
|
|
0db1660369 | ||
|
|
c471a70b35 | ||
|
|
6aef6f2c11 | ||
|
|
000f0bf2f1 | ||
|
|
0f1c08b43a | ||
|
|
76ffb5cd53 | ||
|
|
23d245d43c | ||
|
|
aabc86fc01 | ||
|
|
cebd7fb545 | ||
|
|
8337689640 | ||
|
|
0263130126 | ||
|
|
c472d740ec | ||
|
|
0fd244eee0 | ||
|
|
7dcb6f66da | ||
|
|
14956d27bd | ||
|
|
420be2c44f | ||
|
|
3bb3a902b3 | ||
|
|
2b138ac940 | ||
|
|
b6eeef1db6 | ||
|
|
469dda7d85 | ||
|
|
3c2933d587 | ||
|
|
3b128c8512 | ||
|
|
fb1be7b003 | ||
|
|
e0aa52ed27 | ||
|
|
64ac619b46 | ||
|
|
902472be32 | ||
|
|
cb024b00d9 | ||
|
|
75de616465 | ||
|
|
c12d8e2f46 | ||
|
|
d8087660e6 | ||
|
|
87a8e6e20c | ||
|
|
b599a7607d | ||
|
|
a6b22d1f41 | ||
|
|
8e59761b03 | ||
|
|
8599506497 | ||
|
|
e4ab10fe92 | ||
|
|
171c297d1b | ||
|
|
5eccb0ed49 | ||
|
|
f326de2686 | ||
|
|
2ca6b7f929 | ||
|
|
79afae17e7 | ||
|
|
cb4d9dc365 | ||
|
|
4bf8b98681 | ||
|
|
7f1371ec00 | ||
|
|
cb3db8ae16 | ||
|
|
cf2e37f92d | ||
|
|
92319b0e31 | ||
|
|
d4ff653937 | ||
|
|
7df12930ef | ||
|
|
9ba70951d5 | ||
|
|
2d25369d06 | ||
|
|
affcaf1c02 | ||
|
|
7e314c0d7a | ||
|
|
1266ca314c | ||
|
|
7394598aff | ||
|
|
b02a710bc5 | ||
|
|
ce6966a823 | ||
|
|
689183edc0 | ||
|
|
43113c7844 | ||
|
|
fb8879a919 | ||
|
|
136b9f9138 | ||
|
|
eea326561e | ||
|
|
e3781c68be | ||
|
|
d2927dc68f | ||
|
|
ca95d47127 | ||
|
|
a5a0c94a2c | ||
|
|
cfa49ee757 | ||
|
|
8921baecd0 | ||
|
|
8b78477c69 | ||
|
|
14633724f2 | ||
|
|
8d3ea9c50f | ||
|
|
32a58b1adb | ||
|
|
f01a31ce56 | ||
|
|
3f69c3a2ab | ||
|
|
e0f3d6d0d7 | ||
|
|
a8f148acac | ||
|
|
0c57af40dc | ||
|
|
0714be6b73 | ||
|
|
b5ce6f0bb0 | ||
|
|
67d59067eb | ||
|
|
f1984a103d | ||
|
|
41fd7a8a56 | ||
|
|
14ac139d03 | ||
|
|
97b1ae5ee9 | ||
|
|
15e0763ed5 | ||
|
|
3ce5d14210 | ||
|
|
2c884e2ca5 | ||
|
|
c204fb9b14 | ||
|
|
69721d2d04 | ||
|
|
73b14d3826 | ||
|
|
7ca6f24e6c | ||
|
|
2c3e3f0d43 | ||
|
|
3b68c6902c | ||
|
|
c5926fcf2b | ||
|
|
e6546eea85 | ||
|
|
892357cc2c | ||
|
|
7c6fb26eb7 | ||
|
|
491530ad60 | ||
|
|
6667c1f03d | ||
|
|
e985fc41ce | ||
|
|
508eb04e94 | ||
|
|
68e9368bb3 | ||
|
|
db152e6790 | ||
|
|
6bf2f5611a | ||
|
|
11a13967d5 | ||
|
|
05fe423ef1 | ||
|
|
6e0165986f | ||
|
|
f167e11905 | ||
|
|
727cae902a | ||
|
|
f38f9a47da | ||
|
|
7708d3d157 | ||
|
|
4c64c5ad05 | ||
|
|
534ce179ec | ||
|
|
1b73bacde1 | ||
|
|
a13ad32ec5 | ||
|
|
13a6c86077 | ||
|
|
5fc1b760f4 | ||
|
|
a6d78d9af7 | ||
|
|
48669e96d1 | ||
|
|
071161176e | ||
|
|
f046d76c59 | ||
|
|
53ab224fba | ||
|
|
5faf1f27de | ||
|
|
f38b970ea2 | ||
|
|
5dbccfcbbd | ||
|
|
de5249f99e | ||
|
|
420320f896 | ||
|
|
06ac2d1805 | ||
|
|
cdc0b7a649 | ||
|
|
6c7be51221 | ||
|
|
1159137c0d | ||
|
|
a98cb040b7 | ||
|
|
170213e6d4 | ||
|
|
129c6d2d1e | ||
|
|
ad75ee8c50 | ||
|
|
e94b99da65 | ||
|
|
4f47709d32 | ||
|
|
71ea8d7148 | ||
|
|
919223cd2f | ||
|
|
fd8cace362 | ||
|
|
18d937d83e | ||
|
|
1d19868119 | ||
|
|
840e634161 | ||
|
|
731eef8c2f | ||
|
|
135ee018a9 | ||
|
|
7633392eea | ||
|
|
daea0f3e5e | ||
|
|
c525c80b49 | ||
|
|
311fb04647 | ||
|
|
219bd9c10e | ||
|
|
6d704eadd7 | ||
|
|
32da1993e1 | ||
|
|
d4cad980e5 | ||
|
|
53340ab22c | ||
|
|
2d3767a35c | ||
|
|
aaa9bc906e | ||
|
|
7503317d49 | ||
|
|
3fc93a33c8 | ||
|
|
d7d1d54a0b | ||
|
|
34b9344084 | ||
|
|
779f3a8a61 | ||
|
|
8c1690ef65 | ||
|
|
85f32d9a97 | ||
|
|
54c7ec5873 | ||
|
|
8d260708d3 | ||
|
|
f8009e4b84 | ||
|
|
a2260ee6b2 | ||
|
|
6193eafb7b | ||
|
|
a4eea3325f | ||
|
|
b93e61b758 | ||
|
|
14448ad97e | ||
|
|
3d17f0d588 | ||
|
|
ee5ea09cbc | ||
|
|
aac8ca97ed | ||
|
|
e4d6da47a4 | ||
|
|
9f7dbb394e | ||
|
|
f98063b97a | ||
|
|
ed607bdc37 | ||
|
|
a3c3e4cbd4 | ||
|
|
bffb8a034e | ||
|
|
8242d4fe92 | ||
|
|
279b682ac2 | ||
|
|
43ff476d98 | ||
|
|
28201a6c38 | ||
|
|
6923800081 | ||
|
|
700b83572e | ||
|
|
6e53cb2deb | ||
|
|
8e04182b3f | ||
|
|
9fd6d1b81f | ||
|
|
60379d9ae6 | ||
|
|
29ba1d4809 | ||
|
|
dc4b064c73 | ||
|
|
0f20888563 | ||
|
|
2361f8f9d3 | ||
|
|
feba54d5d2 | ||
|
|
3cecab25c7 | ||
|
|
814851ba60 | ||
|
|
6333cc3bea | ||
|
|
00bf9c569a | ||
|
|
6def1bce25 | ||
|
|
3ab5c90d7c | ||
|
|
0507d6923e | ||
|
|
e85baa8068 | ||
|
|
cbed5a0c14 | ||
|
|
e0628ec6c9 | ||
|
|
82637ff072 | ||
|
|
a95a18a8b5 | ||
|
|
d36637ed13 | ||
|
|
dd5e5dcda7 | ||
|
|
0ff7fe8479 | ||
|
|
8c638bcfd8 | ||
|
|
0bd252e7f5 | ||
|
|
ddd3073132 | ||
|
|
1788422abc |
98
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
# This workflow will build a release-like distribution when manually dispatched
|
||||
|
||||
name: Build
|
||||
|
||||
on: workflow_dispatch
|
||||
|
||||
jobs:
|
||||
# build-release-macos: # LF volunteer
|
||||
|
||||
build-win-py38: # RCs will still be built and signed by hand
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: '3.8'
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/v0.0.79/sni-v0.0.79-windows-amd64.zip -OutFile sni.zip
|
||||
Expand-Archive -Path sni.zip -DestinationPath SNI -Force
|
||||
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/6.4/win-x64.zip -OutFile enemizer.zip
|
||||
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
|
||||
- name: Build
|
||||
run: |
|
||||
python -m pip install --upgrade pip setuptools==60.10.0 # 61 does not work with the current layout
|
||||
pip install -r requirements.txt
|
||||
python setup.py build --yes
|
||||
$NAME="$(ls build)".Split('.',2)[1]
|
||||
$ZIP_NAME="Archipelago_$NAME.7z"
|
||||
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
|
||||
New-Item -Path dist -ItemType Directory -Force
|
||||
cd build
|
||||
Rename-Item exe.$NAME Archipelago
|
||||
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
|
||||
- name: Store 7z
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: ${{ env.ZIP_NAME }}
|
||||
path: dist/${{ env.ZIP_NAME }}
|
||||
retention-days: 7 # keep for 7 days, should be enough
|
||||
|
||||
build-ubuntu1804:
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install base dependencies
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
|
||||
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
|
||||
- name: Get a recent python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: '3.9'
|
||||
- name: Install build-time dependencies
|
||||
run: |
|
||||
echo "PYTHON=python3.9" >> $GITHUB_ENV
|
||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
|
||||
chmod a+rx appimagetool-x86_64.AppImage
|
||||
./appimagetool-x86_64.AppImage --appimage-extract
|
||||
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
||||
chmod a+rx appimagetool
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
wget -nv https://github.com/black-sliver/sni/releases/download/v0.0.78-2/sni-v0.0.78-2-manylinux2014-amd64.tar.xz
|
||||
tar xf sni-*.tar.xz
|
||||
rm sni-*.tar.xz
|
||||
mv sni-* SNI
|
||||
wget -nv https://github.com/Ijwu/Enemizer/releases/download/6.4/ubuntu.16.04-x64.7z
|
||||
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
|
||||
- name: Build
|
||||
run: |
|
||||
# pygobject is an optional dependency for kivy that's not in requirements
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip virtualenv PyGObject setuptools==60.10.0 # setuptools same as windows
|
||||
"${{ env.PYTHON }}" -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python setup.py build --yes bdist_appimage --yes
|
||||
echo -e "setup.py build output:\n `ls build`"
|
||||
echo -e "setup.py dist output:\n `ls dist`"
|
||||
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
||||
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
|
||||
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
|
||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||
- name: Store AppImage
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: ${{ env.APPIMAGE_NAME }}
|
||||
path: dist/${{ env.APPIMAGE_NAME }}
|
||||
retention-days: 7
|
||||
- name: Store .tar.gz
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: ${{ env.TAR_NAME }}
|
||||
path: dist/${{ env.TAR_NAME }}
|
||||
retention-days: 7
|
||||
84
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
# This workflow will create a release and store builds to it when an x.y.z tag is pushed
|
||||
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set env
|
||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV # tag x.y.z will become "Archipelago x.y.z"
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@b7e450da2a4b4cb4bfbae528f788167786cfcedf
|
||||
with:
|
||||
draft: true # don't publish right away, especially since windows build is added by hand
|
||||
prerelease: false
|
||||
name: Archipelago ${{ env.RELEASE_VERSION }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# build-release-windows: # this is done by hand because of signing
|
||||
# build-release-macos: # LF volunteer
|
||||
|
||||
build-release-ubuntu1804:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set env
|
||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||
# - code below copied from build.yml -
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install base dependencies
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
|
||||
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
|
||||
- name: Get a recent python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: '3.9'
|
||||
- name: Install build-time dependencies
|
||||
run: |
|
||||
echo "PYTHON=python3.9" >> $GITHUB_ENV
|
||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
|
||||
chmod a+rx appimagetool-x86_64.AppImage
|
||||
./appimagetool-x86_64.AppImage --appimage-extract
|
||||
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
||||
chmod a+rx appimagetool
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
wget -nv https://github.com/black-sliver/sni/releases/download/v0.0.78-2/sni-v0.0.78-2-manylinux2014-amd64.tar.xz
|
||||
tar xf sni-*.tar.xz
|
||||
rm sni-*.tar.xz
|
||||
mv sni-* SNI
|
||||
wget -nv https://github.com/Ijwu/Enemizer/releases/download/6.4/ubuntu.16.04-x64.7z
|
||||
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
|
||||
- name: Build
|
||||
run: |
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip setuptools virtualenv PyGObject # pygobject should probably move to requirements
|
||||
"${{ env.PYTHON }}" -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python setup.py build --yes bdist_appimage --yes
|
||||
echo -e "setup.py build output:\n `ls build`"
|
||||
echo -e "setup.py dist output:\n `ls dist`"
|
||||
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
||||
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
|
||||
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
|
||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||
# - code above copied from build.yml -
|
||||
- name: Add to Release
|
||||
uses: softprops/action-gh-release@b7e450da2a4b4cb4bfbae528f788167786cfcedf
|
||||
with:
|
||||
draft: true # see above
|
||||
prerelease: false
|
||||
name: Archipelago ${{ env.RELEASE_VERSION }}
|
||||
files: |
|
||||
dist/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
13
.github/workflows/unittests.yml
vendored
@@ -7,17 +7,22 @@ on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Test Python ${{ matrix.python.version }} ${{ matrix.os }}
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
name: Test Python ${{ matrix.python.version }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
python:
|
||||
- {version: '3.8'}
|
||||
- {version: '3.9'}
|
||||
#- {version: '3.10'}
|
||||
- {version: '3.10'}
|
||||
include:
|
||||
- python: {version: '3.8'} # win7 compat
|
||||
os: windows-latest
|
||||
- python: {version: '3.10'} # current
|
||||
os: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
307
BaseClasses.py
@@ -6,12 +6,22 @@ import logging
|
||||
import json
|
||||
import functools
|
||||
from collections import OrderedDict, Counter, deque
|
||||
from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple
|
||||
from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable
|
||||
import secrets
|
||||
import random
|
||||
|
||||
import Options
|
||||
import Utils
|
||||
import NetUtils
|
||||
|
||||
|
||||
class Group(TypedDict, total=False):
|
||||
name: str
|
||||
game: str
|
||||
world: auto_world
|
||||
players: Set[int]
|
||||
item_pool: Set[str]
|
||||
replacement_items: Dict[int, Optional[str]]
|
||||
|
||||
|
||||
class MultiWorld():
|
||||
@@ -23,11 +33,19 @@ class MultiWorld():
|
||||
dark_room_logic: Dict[int, str]
|
||||
restrict_dungeon_item_on_boss: Dict[int, bool]
|
||||
plando_texts: List[Dict[str, str]]
|
||||
plando_items: List
|
||||
plando_items: List[List[Dict[str, Any]]]
|
||||
plando_connections: List
|
||||
worlds: Dict[int, Any]
|
||||
groups: Dict[int, Group]
|
||||
itempool: List[Item]
|
||||
is_race: bool = False
|
||||
precollected_items: Dict[int, List[Item]]
|
||||
state: CollectionState
|
||||
|
||||
accessibility: Dict[int, Options.Accessibility]
|
||||
local_items: Dict[int, Options.LocalItems]
|
||||
non_local_items: Dict[int, Options.NonLocalItems]
|
||||
progression_balancing: Dict[int, Options.ProgressionBalancing]
|
||||
|
||||
class AttributeProxy():
|
||||
def __init__(self, rule):
|
||||
@@ -39,20 +57,21 @@ class MultiWorld():
|
||||
def __init__(self, players: int):
|
||||
self.random = random.Random() # world-local random state is saved for multiple generations running concurrently
|
||||
self.players = players
|
||||
self.player_types = {player: NetUtils.SlotType.player for player in self.player_ids}
|
||||
self.glitch_triforce = False
|
||||
self.algorithm = 'balanced'
|
||||
self.dungeons: Dict[Tuple[str, int], Dungeon] = {}
|
||||
self.groups = {}
|
||||
self.regions = []
|
||||
self.shops = []
|
||||
self.itempool = []
|
||||
self.seed = None
|
||||
self.seed_name: str = "Unavailable"
|
||||
self.precollected_items = {player: [] for player in self.player_ids}
|
||||
self.state = CollectionState(self)
|
||||
self._cached_entrances = None
|
||||
self._cached_locations = None
|
||||
self._entrance_cache = {}
|
||||
self._location_cache = {}
|
||||
self._location_cache: Dict[Tuple[str, int], Location] = {}
|
||||
self.required_locations = []
|
||||
self.light_world_light_cone = False
|
||||
self.dark_world_light_cone = False
|
||||
@@ -63,8 +82,6 @@ class MultiWorld():
|
||||
self.custom = False
|
||||
self.customitemarray = []
|
||||
self.shuffle_ganon = True
|
||||
self.dynamic_regions = []
|
||||
self.dynamic_locations = []
|
||||
self.spoiler = Spoiler(self)
|
||||
self.fix_trock_doors = self.AttributeProxy(
|
||||
lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
|
||||
@@ -74,7 +91,6 @@ class MultiWorld():
|
||||
lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
|
||||
self.fix_trock_exit = self.AttributeProxy(
|
||||
lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
|
||||
self.NOTCURSED = self.AttributeProxy(lambda player: not self.CURSED[player])
|
||||
|
||||
for player in range(1, players + 1):
|
||||
def set_player_attr(attr, val):
|
||||
@@ -130,6 +146,42 @@ class MultiWorld():
|
||||
self.worlds = {}
|
||||
self.slot_seeds = {}
|
||||
|
||||
def get_all_ids(self):
|
||||
return self.player_ids + tuple(self.groups)
|
||||
|
||||
def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tuple[int, Group]:
|
||||
"""Create a group with name and return the assigned player ID and group.
|
||||
If a group of this name already exists, the set of players is extended instead of creating a new one."""
|
||||
for group_id, group in self.groups.items():
|
||||
if group["name"] == name:
|
||||
group["players"] |= players
|
||||
return group_id, group
|
||||
new_id: int = self.players + len(self.groups) + 1
|
||||
|
||||
self.game[new_id] = game
|
||||
self.custom_data[new_id] = {}
|
||||
self.player_types[new_id] = NetUtils.SlotType.group
|
||||
self._region_cache[new_id] = {}
|
||||
world_type = AutoWorld.AutoWorldRegister.world_types[game]
|
||||
for option_key, option in world_type.options.items():
|
||||
getattr(self, option_key)[new_id] = option(option.default)
|
||||
for option_key, option in Options.common_options.items():
|
||||
getattr(self, option_key)[new_id] = option(option.default)
|
||||
for option_key, option in Options.per_game_common_options.items():
|
||||
getattr(self, option_key)[new_id] = option(option.default)
|
||||
|
||||
self.worlds[new_id] = world_type(self, new_id)
|
||||
self.worlds[new_id].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id])
|
||||
self.player_name[new_id] = name
|
||||
|
||||
new_group = self.groups[new_id] = Group(name=name, game=game, players=players,
|
||||
world=self.worlds[new_id])
|
||||
|
||||
return new_id, new_group
|
||||
|
||||
def get_player_groups(self, player) -> Set[int]:
|
||||
return {group_id for group_id, group in self.groups.items() if player in group["players"]}
|
||||
|
||||
def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optional[str] = None):
|
||||
self.seed = get_seed(seed)
|
||||
if secure:
|
||||
@@ -141,24 +193,58 @@ class MultiWorld():
|
||||
range(1, self.players + 1)}
|
||||
|
||||
def set_options(self, args):
|
||||
from worlds import AutoWorld
|
||||
for option_key in Options.common_options:
|
||||
setattr(self, option_key, getattr(args, option_key, {}))
|
||||
for option_key in Options.per_game_common_options:
|
||||
setattr(self, option_key, getattr(args, option_key, {}))
|
||||
|
||||
for player in self.player_ids:
|
||||
self.custom_data[player] = {}
|
||||
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
|
||||
for option_key in world_type.options:
|
||||
setattr(self, option_key, getattr(args, option_key, {}))
|
||||
for option_key in Options.common_options:
|
||||
setattr(self, option_key, getattr(args, option_key, {}))
|
||||
for option_key in Options.per_game_common_options:
|
||||
setattr(self, option_key, getattr(args, option_key, {}))
|
||||
|
||||
self.worlds[player] = world_type(self, player)
|
||||
|
||||
def set_item_links(self):
|
||||
item_links = {}
|
||||
|
||||
for player in self.player_ids:
|
||||
for item_link in self.item_links[player].value:
|
||||
if item_link["name"] in item_links:
|
||||
if item_link["game"] != self.game[player]:
|
||||
raise Exception(f"Cannot ItemLink across games. Link: {item_link['name']}")
|
||||
item_links[item_link["name"]]["players"][player] = item_link["replacement_item"]
|
||||
item_links[item_link["name"]]["item_pool"] &= set(item_link["item_pool"])
|
||||
else:
|
||||
if item_link["name"] in self.player_name.values():
|
||||
raise Exception(f"Cannot name a ItemLink group the same as a player ({item_link['name']}) ({self.get_player_name(player)}).")
|
||||
item_links[item_link["name"]] = {
|
||||
"players": {player: item_link["replacement_item"]},
|
||||
"item_pool": set(item_link["item_pool"]),
|
||||
"game": self.game[player]
|
||||
}
|
||||
|
||||
for name, item_link in item_links.items():
|
||||
current_item_name_groups = AutoWorld.AutoWorldRegister.world_types[item_link["game"]].item_name_groups
|
||||
pool = set()
|
||||
for item in item_link["item_pool"]:
|
||||
pool |= current_item_name_groups.get(item, {item})
|
||||
item_link["item_pool"] = pool
|
||||
|
||||
for group_name, item_link in item_links.items():
|
||||
game = item_link["game"]
|
||||
group_id, group = self.add_group(group_name, game, set(item_link["players"]))
|
||||
group["item_pool"] = item_link["item_pool"]
|
||||
group["replacement_items"] = item_link["players"]
|
||||
|
||||
# intended for unittests
|
||||
def set_default_common_options(self):
|
||||
for option_key, option in Options.common_options.items():
|
||||
setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids})
|
||||
for option_key, option in Options.per_game_common_options.items():
|
||||
setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids})
|
||||
self.state = CollectionState(self)
|
||||
|
||||
def secure(self):
|
||||
self.random = secrets.SystemRandom()
|
||||
@@ -174,7 +260,8 @@ class MultiWorld():
|
||||
|
||||
@functools.lru_cache()
|
||||
def get_game_worlds(self, game_name: str):
|
||||
return tuple(world for player, world in self.worlds.items() if self.game[player] == game_name)
|
||||
return tuple(world for player, world in self.worlds.items() if
|
||||
player not in self.groups and self.game[player] == game_name)
|
||||
|
||||
def get_name_string_for_object(self, obj) -> str:
|
||||
return obj.name if self.players == 1 else f'{obj.name} ({self.get_player_name(obj.player)})'
|
||||
@@ -182,6 +269,9 @@ class MultiWorld():
|
||||
def get_player_name(self, player: int) -> str:
|
||||
return self.player_name[player]
|
||||
|
||||
def get_file_safe_player_name(self, player: int) -> str:
|
||||
return ''.join(c for c in self.get_player_name(player) if c not in '<>:"/\\|?*')
|
||||
|
||||
def initialize_regions(self, regions=None):
|
||||
for region in regions if regions else self.regions:
|
||||
region.world = self
|
||||
@@ -193,6 +283,7 @@ class MultiWorld():
|
||||
|
||||
def _recache(self):
|
||||
"""Rebuild world cache"""
|
||||
self._cached_locations = None
|
||||
for region in self.regions:
|
||||
player = region.player
|
||||
self._region_cache[player][region.name] = region
|
||||
@@ -241,10 +332,9 @@ class MultiWorld():
|
||||
|
||||
for item in self.itempool:
|
||||
self.worlds[item.player].collect(ret, item)
|
||||
from worlds.alttp.Dungeons import get_dungeon_item_pool
|
||||
for item in get_dungeon_item_pool(self):
|
||||
subworld = self.worlds[item.player]
|
||||
if item.name in subworld.dungeon_local_item_names:
|
||||
for player in self.player_ids:
|
||||
subworld = self.worlds[player]
|
||||
for item in subworld.get_pre_fill_items():
|
||||
subworld.collect(ret, item)
|
||||
ret.sweep_for_events()
|
||||
|
||||
@@ -252,7 +342,7 @@ class MultiWorld():
|
||||
self._all_state = ret
|
||||
return ret
|
||||
|
||||
def get_items(self) -> list:
|
||||
def get_items(self) -> List[Item]:
|
||||
return [loc.item for loc in self.get_filled_locations()] + self.itempool
|
||||
|
||||
def find_item_locations(self, item, player: int) -> List[Location]:
|
||||
@@ -307,7 +397,7 @@ class MultiWorld():
|
||||
def clear_location_cache(self):
|
||||
self._cached_locations = None
|
||||
|
||||
def get_unfilled_locations(self, player=None) -> List[Location]:
|
||||
def get_unfilled_locations(self, player: Optional[int] = None) -> List[Location]:
|
||||
if player is not None:
|
||||
return [location for location in self.get_locations() if
|
||||
location.player == player and not location.item]
|
||||
@@ -316,13 +406,13 @@ class MultiWorld():
|
||||
def get_unfilled_dungeon_locations(self):
|
||||
return [location for location in self.get_locations() if not location.item and location.parent_region.dungeon]
|
||||
|
||||
def get_filled_locations(self, player=None) -> List[Location]:
|
||||
def get_filled_locations(self, player: Optional[int] = None) -> List[Location]:
|
||||
if player is not None:
|
||||
return [location for location in self.get_locations() if
|
||||
location.player == player and location.item is not None]
|
||||
return [location for location in self.get_locations() if location.item is not None]
|
||||
|
||||
def get_reachable_locations(self, state=None, player=None) -> List[Location]:
|
||||
def get_reachable_locations(self, state: Optional[CollectionState] = None, player: Optional[int] = None) -> List[Location]:
|
||||
if state is None:
|
||||
state = self.state
|
||||
return [location for location in self.get_locations() if
|
||||
@@ -334,7 +424,7 @@ class MultiWorld():
|
||||
return [location for location in self.get_locations() if
|
||||
(player is None or location.player == player) and location.item is None and location.can_reach(state)]
|
||||
|
||||
def get_unfilled_locations_for_players(self, locations, players: Iterable[int]):
|
||||
def get_unfilled_locations_for_players(self, locations: List[str], players: Iterable[int]):
|
||||
for player in players:
|
||||
if len(locations) == 0:
|
||||
locations = [location.name for location in self.get_unfilled_locations(player)]
|
||||
@@ -343,7 +433,7 @@ class MultiWorld():
|
||||
if location is not None and location.item is None:
|
||||
yield location
|
||||
|
||||
def unlocks_new_location(self, item) -> bool:
|
||||
def unlocks_new_location(self, item: Item) -> bool:
|
||||
temp_state = self.state.copy()
|
||||
temp_state.collect(item, True)
|
||||
|
||||
@@ -353,7 +443,7 @@ class MultiWorld():
|
||||
|
||||
return False
|
||||
|
||||
def has_beaten_game(self, state, player: Optional[int] = None):
|
||||
def has_beaten_game(self, state: CollectionState, player: Optional[int] = None) -> bool:
|
||||
if player:
|
||||
return self.completion_condition[player](state)
|
||||
else:
|
||||
@@ -432,8 +522,9 @@ class MultiWorld():
|
||||
|
||||
def location_relevant(location: Location):
|
||||
"""Determine if this location is relevant to sweep."""
|
||||
if location.player in players["locations"] or location.event or \
|
||||
(location.item and location.item.advancement):
|
||||
if location.progress_type != LocationProgressType.EXCLUDED \
|
||||
and (location.player in players["locations"] or location.event
|
||||
or (location.item and location.item.advancement)):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -471,17 +562,21 @@ class MultiWorld():
|
||||
return False
|
||||
|
||||
|
||||
class CollectionState(object):
|
||||
class CollectionState():
|
||||
additional_init_functions: List[Callable] = []
|
||||
additional_copy_functions: List[Callable] = []
|
||||
|
||||
def __init__(self, parent: MultiWorld):
|
||||
self.prog_items = Counter()
|
||||
self.world = parent
|
||||
self.reachable_regions = {player: set() for player in range(1, parent.players + 1)}
|
||||
self.blocked_connections = {player: set() for player in range(1, parent.players + 1)}
|
||||
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
||||
self.blocked_connections = {player: set() for player in parent.get_all_ids()}
|
||||
self.events = set()
|
||||
self.path = {}
|
||||
self.locations_checked = set()
|
||||
self.stale = {player: True for player in range(1, parent.players + 1)}
|
||||
self.stale = {player: True for player in parent.get_all_ids()}
|
||||
for function in self.additional_init_functions:
|
||||
function(self, parent)
|
||||
for items in parent.precollected_items.values():
|
||||
for item in items:
|
||||
self.collect(item, True)
|
||||
@@ -523,16 +618,21 @@ class CollectionState(object):
|
||||
ret = CollectionState(self.world)
|
||||
ret.prog_items = self.prog_items.copy()
|
||||
ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
|
||||
range(1, self.world.players + 1)}
|
||||
self.reachable_regions}
|
||||
ret.blocked_connections = {player: copy.copy(self.blocked_connections[player]) for player in
|
||||
range(1, self.world.players + 1)}
|
||||
self.blocked_connections}
|
||||
ret.events = copy.copy(self.events)
|
||||
ret.path = copy.copy(self.path)
|
||||
ret.locations_checked = copy.copy(self.locations_checked)
|
||||
for function in self.additional_copy_functions:
|
||||
ret = function(self, ret)
|
||||
return ret
|
||||
|
||||
def can_reach(self, spot, resolution_hint=None, player=None) -> bool:
|
||||
if not hasattr(spot, "spot_type"):
|
||||
def can_reach(self,
|
||||
spot: Union[Location, Entrance, Region, str],
|
||||
resolution_hint: Optional[str] = None,
|
||||
player: Optional[int] = None) -> bool:
|
||||
if not hasattr(spot, "can_reach"):
|
||||
# try to resolve a name
|
||||
if resolution_hint == 'Location':
|
||||
spot = self.world.get_location(spot, player)
|
||||
@@ -543,30 +643,32 @@ class CollectionState(object):
|
||||
spot = self.world.get_region(spot, player)
|
||||
return spot.can_reach(self)
|
||||
|
||||
def sweep_for_events(self, key_only: bool = False, locations=None):
|
||||
def sweep_for_events(self, key_only: bool = False, locations: Set[Location] = None):
|
||||
if locations is None:
|
||||
locations = self.world.get_filled_locations()
|
||||
new_locations = True
|
||||
# since the loop has a good chance to run more than once, only filter the events once
|
||||
locations = {location for location in locations if location.event}
|
||||
locations = {location for location in locations if location.event and
|
||||
not key_only or getattr(location.item, "locked_dungeon_item", False)}
|
||||
while new_locations:
|
||||
reachable_events = {location for location in locations if
|
||||
(not key_only or getattr(location.item, "locked_dungeon_item", False))
|
||||
and location.can_reach(self)}
|
||||
reachable_events = {location for location in locations if location.can_reach(self)}
|
||||
new_locations = reachable_events - self.events
|
||||
for event in new_locations:
|
||||
self.events.add(event)
|
||||
self.collect(event.item, True, event)
|
||||
|
||||
def has(self, item, player: int, count: int = 1):
|
||||
def has(self, item: str, player: int, count: int = 1) -> bool:
|
||||
return self.prog_items[item, player] >= count
|
||||
|
||||
def has_all(self, items: Set[str], player: int):
|
||||
def has_all(self, items: Set[str], player: int) -> bool:
|
||||
return all(self.prog_items[item, player] for item in items)
|
||||
|
||||
def has_any(self, items: Set[str], player: int):
|
||||
def has_any(self, items: Set[str], player: int) -> bool:
|
||||
return any(self.prog_items[item, player] for item in items)
|
||||
|
||||
def count(self, item: str, player: int) -> int:
|
||||
return self.prog_items[item, player]
|
||||
|
||||
def has_group(self, item_name_group: str, player: int, count: int = 1):
|
||||
found: int = 0
|
||||
for item_name in self.world.worlds[player].item_name_groups[item_name_group]:
|
||||
@@ -641,7 +743,7 @@ class CollectionState(object):
|
||||
basemagic = basemagic + basemagic * self.bottle_count(player)
|
||||
return basemagic >= smallmagic
|
||||
|
||||
def can_kill_most_things(self, player: int, enemies=5) -> bool:
|
||||
def can_kill_most_things(self, player: int, enemies: int = 5) -> bool:
|
||||
return (self.has_melee_weapon(player)
|
||||
or self.has('Cane of Somaria', player)
|
||||
or (self.has('Cane of Byrna', player) and (enemies < 6 or self.can_extend_magic(player)))
|
||||
@@ -716,26 +818,26 @@ class CollectionState(object):
|
||||
def has_turtle_rock_medallion(self, player: int) -> bool:
|
||||
return self.has(self.world.required_medallions[player][1], player)
|
||||
|
||||
def can_boots_clip_lw(self, player: int):
|
||||
def can_boots_clip_lw(self, player: int) -> bool:
|
||||
if self.world.mode[player] == 'inverted':
|
||||
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
|
||||
return self.has('Pegasus Boots', player)
|
||||
|
||||
def can_boots_clip_dw(self, player: int):
|
||||
def can_boots_clip_dw(self, player: int) -> bool:
|
||||
if self.world.mode[player] != 'inverted':
|
||||
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
|
||||
return self.has('Pegasus Boots', player)
|
||||
|
||||
def can_get_glitched_speed_lw(self, player: int):
|
||||
def can_get_glitched_speed_lw(self, player: int) -> bool:
|
||||
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
|
||||
if self.world.mode[player] == 'inverted':
|
||||
rules.append(self.has('Moon Pearl', player))
|
||||
return all(rules)
|
||||
|
||||
def can_superbunny_mirror_with_sword(self, player: int):
|
||||
def can_superbunny_mirror_with_sword(self, player: int) -> bool:
|
||||
return self.has('Magic Mirror', player) and self.has_sword(player)
|
||||
|
||||
def can_get_glitched_speed_dw(self, player: int):
|
||||
def can_get_glitched_speed_dw(self, player: int) -> bool:
|
||||
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
|
||||
if self.world.mode[player] != 'inverted':
|
||||
rules.append(self.has('Moon Pearl', player))
|
||||
@@ -744,7 +846,7 @@ class CollectionState(object):
|
||||
def can_bomb_clip(self, region: Region, player: int) -> bool:
|
||||
return self.is_not_bunny(region, player) and self.has('Pegasus Boots', player)
|
||||
|
||||
def collect(self, item: Item, event: bool = False, location: Location = None) -> bool:
|
||||
def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool:
|
||||
if location:
|
||||
self.locations_checked.add(location)
|
||||
|
||||
@@ -784,19 +886,30 @@ class RegionType(int, Enum):
|
||||
return self in (RegionType.Cave, RegionType.Dungeon)
|
||||
|
||||
|
||||
class Region(object):
|
||||
def __init__(self, name: str, type_: RegionType, hint, player: int, world: Optional[MultiWorld] = None):
|
||||
class Region:
|
||||
name: str
|
||||
type: RegionType
|
||||
hint_text: str
|
||||
player: int
|
||||
world: Optional[MultiWorld]
|
||||
entrances: List[Entrance]
|
||||
exits: List[Entrance]
|
||||
locations: List[Location]
|
||||
dungeon: Optional[Dungeon] = None
|
||||
shop: Optional = None
|
||||
|
||||
# LttP specific. TODO: move to a LttPRegion
|
||||
# will be set after making connections.
|
||||
is_light_world: bool = False
|
||||
is_dark_world: bool = False
|
||||
|
||||
def __init__(self, name: str, type_: RegionType, hint: str, player: int, world: Optional[MultiWorld] = None):
|
||||
self.name = name
|
||||
self.type = type_
|
||||
self.entrances = []
|
||||
self.exits = []
|
||||
self.locations: List[Location] = []
|
||||
self.dungeon = None
|
||||
self.shop = None
|
||||
self.locations = []
|
||||
self.world = world
|
||||
self.is_light_world = False # will be set after making connections.
|
||||
self.is_dark_world = False
|
||||
self.spot_type = 'Region'
|
||||
self.hint_text = hint
|
||||
self.player = player
|
||||
|
||||
@@ -820,18 +933,21 @@ class Region(object):
|
||||
return self.world.get_name_string_for_object(self) if self.world else f'{self.name} (Player {self.player})'
|
||||
|
||||
|
||||
class Entrance(object):
|
||||
spot_type = 'Entrance'
|
||||
class Entrance:
|
||||
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
||||
hide_path: bool = False
|
||||
player: int
|
||||
name: str
|
||||
parent_region: Optional[Region]
|
||||
connected_region: Optional[Region] = None
|
||||
# LttP specific, TODO: should make a LttPEntrance
|
||||
addresses = None
|
||||
target = None
|
||||
|
||||
def __init__(self, player: int, name: str = '', parent=None):
|
||||
def __init__(self, player: int, name: str = '', parent: Region = None):
|
||||
self.name = name
|
||||
self.parent_region = parent
|
||||
self.connected_region = None
|
||||
self.target = None
|
||||
self.addresses = None
|
||||
self.access_rule = lambda state: True
|
||||
self.player = player
|
||||
self.hide_path = False
|
||||
|
||||
def can_reach(self, state: CollectionState) -> bool:
|
||||
if self.parent_region.can_reach(state) and self.access_rule(state):
|
||||
@@ -841,7 +957,7 @@ class Entrance(object):
|
||||
|
||||
return False
|
||||
|
||||
def connect(self, region: Region, addresses=None, target = None):
|
||||
def connect(self, region: Region, addresses=None, target=None):
|
||||
self.connected_region = region
|
||||
self.target = target
|
||||
self.addresses = addresses
|
||||
@@ -856,7 +972,8 @@ class Entrance(object):
|
||||
|
||||
|
||||
class Dungeon(object):
|
||||
def __init__(self, name: str, regions, big_key, small_keys, dungeon_items, player: int):
|
||||
def __init__(self, name: str, regions: List[Region], big_key: Item, small_keys: List[Item],
|
||||
dungeon_items: List[Item], player: int):
|
||||
self.name = name
|
||||
self.regions = regions
|
||||
self.big_key = big_key
|
||||
@@ -875,11 +992,11 @@ class Dungeon(object):
|
||||
self.bosses[None] = value
|
||||
|
||||
@property
|
||||
def keys(self):
|
||||
def keys(self) -> List[Item]:
|
||||
return self.small_keys + ([self.big_key] if self.big_key else [])
|
||||
|
||||
@property
|
||||
def all_items(self):
|
||||
def all_items(self) -> List[Item]:
|
||||
return self.dungeon_items + self.keys
|
||||
|
||||
def is_dungeon_item(self, item: Item) -> bool:
|
||||
@@ -898,7 +1015,7 @@ class Dungeon(object):
|
||||
|
||||
|
||||
class Boss():
|
||||
def __init__(self, name: str, enemizer_name: str, defeat_rule, player: int):
|
||||
def __init__(self, name: str, enemizer_name: str, defeat_rule: Callable, player: int):
|
||||
self.name = name
|
||||
self.enemizer_name = enemizer_name
|
||||
self.defeat_rule = defeat_rule
|
||||
@@ -916,28 +1033,28 @@ class LocationProgressType(Enum):
|
||||
PRIORITY = 2
|
||||
EXCLUDED = 3
|
||||
|
||||
class Location():
|
||||
|
||||
class Location:
|
||||
# If given as integer, then this is the shop's inventory index
|
||||
shop_slot: Optional[int] = None
|
||||
shop_slot_disabled: bool = False
|
||||
event: bool = False
|
||||
locked: bool = False
|
||||
spot_type = 'Location'
|
||||
game: str = "Generic"
|
||||
show_in_spoiler: bool = True
|
||||
excluded: bool = False
|
||||
crystal: bool = False
|
||||
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
||||
always_allow = staticmethod(lambda item, state: False)
|
||||
access_rule = staticmethod(lambda state: True)
|
||||
item_rule = staticmethod(lambda item: True)
|
||||
item: Optional[Item] = None
|
||||
parent_region: Optional[Region]
|
||||
|
||||
def __init__(self, player: int, name: str = '', address: int = None, parent=None):
|
||||
self.name: str = name
|
||||
self.address: Optional[int] = address
|
||||
self.parent_region: Region = parent
|
||||
self.parent_region = parent
|
||||
self.player: int = player
|
||||
self.item: Optional[Item] = None
|
||||
|
||||
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
|
||||
return self.always_allow(state, item) or (self.item_rule(item) and (not check_access or self.can_reach(state)))
|
||||
@@ -952,6 +1069,7 @@ class Location():
|
||||
if self.item:
|
||||
raise Exception(f"Location {self} already filled.")
|
||||
self.item = item
|
||||
item.location = self
|
||||
self.event = item.advancement
|
||||
self.item.world = self.parent_region.world
|
||||
self.locked = True
|
||||
@@ -966,7 +1084,7 @@ class Location():
|
||||
def __hash__(self):
|
||||
return hash((self.name, self.player))
|
||||
|
||||
def __lt__(self, other):
|
||||
def __lt__(self, other: Location):
|
||||
return (self.player, self.name) < (other.player, other.name)
|
||||
|
||||
@property
|
||||
@@ -975,7 +1093,7 @@ class Location():
|
||||
return self.item and self.item.game == self.game
|
||||
|
||||
@property
|
||||
def hint_text(self):
|
||||
def hint_text(self) -> str:
|
||||
hint_text = getattr(self, "_hint_text", None)
|
||||
if hint_text:
|
||||
return hint_text
|
||||
@@ -993,6 +1111,8 @@ class Item():
|
||||
trap: bool = False
|
||||
# change manually to ensure that a specific non-progression item never goes on an excluded location
|
||||
never_exclude = False
|
||||
# item is not considered by progression balancing despite being progression
|
||||
skip_in_prog_balancing: bool = False
|
||||
|
||||
# need to find a decent place for these to live and to allow other games to register texts if they want.
|
||||
pedestal_credit_text: str = "and the Unknown Item"
|
||||
@@ -1028,7 +1148,7 @@ class Item():
|
||||
def __eq__(self, other):
|
||||
return self.name == other.name and self.player == other.player
|
||||
|
||||
def __lt__(self, other):
|
||||
def __lt__(self, other: Item):
|
||||
if other.player != self.player:
|
||||
return other.player < self.player
|
||||
return self.name < other.name
|
||||
@@ -1045,6 +1165,7 @@ class Item():
|
||||
|
||||
class Spoiler():
|
||||
world: MultiWorld
|
||||
unreachables: Set[Location]
|
||||
|
||||
def __init__(self, world):
|
||||
self.world = world
|
||||
@@ -1052,19 +1173,19 @@ class Spoiler():
|
||||
self.entrances = OrderedDict()
|
||||
self.medallions = {}
|
||||
self.playthrough = {}
|
||||
self.unreachables = []
|
||||
self.unreachables = set()
|
||||
self.locations = {}
|
||||
self.paths = {}
|
||||
self.shops = []
|
||||
self.bosses = OrderedDict()
|
||||
|
||||
def set_entrance(self, entrance, exit, direction, player):
|
||||
def set_entrance(self, entrance: str, exit_: str, direction: str, player: int):
|
||||
if self.world.players == 1:
|
||||
self.entrances[(entrance, direction, player)] = OrderedDict(
|
||||
[('entrance', entrance), ('exit', exit), ('direction', direction)])
|
||||
[('entrance', entrance), ('exit', exit_), ('direction', direction)])
|
||||
else:
|
||||
self.entrances[(entrance, direction, player)] = OrderedDict(
|
||||
[('player', player), ('entrance', entrance), ('exit', exit), ('direction', direction)])
|
||||
[('player', player), ('entrance', entrance), ('exit', exit_), ('direction', direction)])
|
||||
|
||||
def parse_data(self):
|
||||
self.medallions = OrderedDict()
|
||||
@@ -1193,8 +1314,7 @@ class Spoiler():
|
||||
|
||||
return json.dumps(out)
|
||||
|
||||
def to_file(self, filename):
|
||||
from worlds.AutoWorld import call_all, call_single, call_stage
|
||||
def to_file(self, filename: str):
|
||||
self.parse_data()
|
||||
|
||||
def bool_to_text(variable: Union[bool, str]) -> str:
|
||||
@@ -1204,9 +1324,9 @@ class Spoiler():
|
||||
|
||||
def write_option(option_key: str, option_obj: type(Options.Option)):
|
||||
res = getattr(self.world, option_key)[player]
|
||||
displayname = getattr(option_obj, "displayname", option_key)
|
||||
display_name = getattr(option_obj, "display_name", option_key)
|
||||
try:
|
||||
outfile.write(f'{displayname + ":":33}{res.get_current_option_name()}\n')
|
||||
outfile.write(f'{display_name + ":":33}{res.get_current_option_name()}\n')
|
||||
except:
|
||||
raise Exception
|
||||
|
||||
@@ -1216,7 +1336,7 @@ class Spoiler():
|
||||
Utils.__version__, self.world.seed))
|
||||
outfile.write('Filling Algorithm: %s\n' % self.world.algorithm)
|
||||
outfile.write('Players: %d\n' % self.world.players)
|
||||
call_stage(self.world, "write_spoiler_header", outfile)
|
||||
AutoWorld.call_stage(self.world, "write_spoiler_header", outfile)
|
||||
|
||||
for player in range(1, self.world.players + 1):
|
||||
if self.world.players > 1:
|
||||
@@ -1228,7 +1348,7 @@ class Spoiler():
|
||||
if options:
|
||||
for f_option, option in options.items():
|
||||
write_option(f_option, option)
|
||||
call_single(self.world, "write_spoiler_header", player, outfile)
|
||||
AutoWorld.call_single(self.world, "write_spoiler_header", player, outfile)
|
||||
|
||||
if player in self.world.get_game_players("A Link to the Past"):
|
||||
outfile.write('%s%s\n' % ('Hash: ', self.hashes[player]))
|
||||
@@ -1278,7 +1398,7 @@ class Spoiler():
|
||||
for dungeon, medallion in self.medallions.items():
|
||||
outfile.write(f'\n{dungeon}: {medallion}')
|
||||
|
||||
call_all(self.world, "write_spoiler", outfile)
|
||||
AutoWorld.call_all(self.world, "write_spoiler", outfile)
|
||||
|
||||
outfile.write('\n\nLocations:\n\n')
|
||||
outfile.write('\n'.join(
|
||||
@@ -1319,14 +1439,19 @@ class Spoiler():
|
||||
path_listings.append("{}\n {}".format(location, "\n => ".join(path_lines)))
|
||||
|
||||
outfile.write('\n'.join(path_listings))
|
||||
call_all(self.world, "write_spoiler_end", outfile)
|
||||
AutoWorld.call_all(self.world, "write_spoiler_end", outfile)
|
||||
|
||||
|
||||
seeddigits = 20
|
||||
|
||||
|
||||
def get_seed(seed=None):
|
||||
def get_seed(seed=None) -> int:
|
||||
if seed is None:
|
||||
random.seed(None)
|
||||
return random.randint(0, pow(10, seeddigits) - 1)
|
||||
return seed
|
||||
|
||||
|
||||
from worlds import AutoWorld
|
||||
|
||||
auto_world = AutoWorld.World
|
||||
660
ChecksFinderClient.py
Normal file
@@ -0,0 +1,660 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import logging
|
||||
import asyncio
|
||||
import urllib.parse
|
||||
import sys
|
||||
import typing
|
||||
import time
|
||||
|
||||
import websockets
|
||||
|
||||
import Utils
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("ChecksFinderClient", exception_logger="Client")
|
||||
|
||||
from MultiServer import CommandProcessor
|
||||
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission
|
||||
from Utils import Version, stream_input
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
from CommonClient import gui_enabled, console_loop, logger, server_autoreconnect, get_base_parser, \
|
||||
keep_alive
|
||||
from worlds.checksfinder import ChecksFinderWorld
|
||||
|
||||
|
||||
class ClientCommandProcessor(CommandProcessor):
|
||||
def __init__(self, ctx: CommonContext):
|
||||
self.ctx = ctx
|
||||
|
||||
def output(self, text: str):
|
||||
logger.info(text)
|
||||
|
||||
def _cmd_exit(self) -> bool:
|
||||
"""Close connections and client"""
|
||||
self.ctx.exit_event.set()
|
||||
return True
|
||||
|
||||
def _cmd_connect(self, address: str = "") -> bool:
|
||||
"""Connect to a MultiWorld Server"""
|
||||
self.ctx.server_address = None
|
||||
asyncio.create_task(self.ctx.connect(address if address else None), name="connecting")
|
||||
return True
|
||||
|
||||
def _cmd_disconnect(self) -> bool:
|
||||
"""Disconnect from a MultiWorld Server"""
|
||||
self.ctx.server_address = None
|
||||
asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
|
||||
return True
|
||||
|
||||
def _cmd_received(self) -> bool:
|
||||
"""List all received items"""
|
||||
logger.info(f'{len(self.ctx.items_received)} received items:')
|
||||
for index, item in enumerate(self.ctx.items_received, 1):
|
||||
self.output(f"{self.ctx.item_name_getter(item.item)} from {self.ctx.player_names[item.player]}")
|
||||
return True
|
||||
|
||||
def _cmd_missing(self) -> bool:
|
||||
"""List all missing location checks, from your local game state"""
|
||||
if not self.ctx.game:
|
||||
self.output("No game set, cannot determine missing checks.")
|
||||
return False
|
||||
count = 0
|
||||
checked_count = 0
|
||||
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
|
||||
if location_id < 0:
|
||||
continue
|
||||
if location_id not in self.ctx.locations_checked:
|
||||
if location_id in self.ctx.missing_locations:
|
||||
self.output('Missing: ' + location)
|
||||
count += 1
|
||||
elif location_id in self.ctx.checked_locations:
|
||||
self.output('Checked: ' + location)
|
||||
count += 1
|
||||
checked_count += 1
|
||||
|
||||
if count:
|
||||
self.output(
|
||||
f"Found {count} missing location checks{f'. {checked_count} location checks previously visited.' if checked_count else ''}")
|
||||
else:
|
||||
self.output("No missing location checks found.")
|
||||
return True
|
||||
|
||||
def _cmd_items(self):
|
||||
"""List all item names for the currently running game."""
|
||||
self.output(f"Item Names for {self.ctx.game}")
|
||||
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
|
||||
self.output(item_name)
|
||||
|
||||
def _cmd_locations(self):
|
||||
"""List all location names for the currently running game."""
|
||||
self.output(f"Location Names for {self.ctx.game}")
|
||||
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
|
||||
self.output(location_name)
|
||||
|
||||
def _cmd_resync(self):
|
||||
"""Manually trigger a resync."""
|
||||
self.output(f"Syncing items.")
|
||||
self.ctx.syncing = True
|
||||
|
||||
def _cmd_ready(self):
|
||||
"""Send ready status to server."""
|
||||
self.ctx.ready = not self.ctx.ready
|
||||
if self.ctx.ready:
|
||||
state = ClientStatus.CLIENT_READY
|
||||
self.output("Readied up.")
|
||||
else:
|
||||
state = ClientStatus.CLIENT_CONNECTED
|
||||
self.output("Unreadied.")
|
||||
asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
||||
|
||||
def default(self, raw: str):
|
||||
raw = self.ctx.on_user_say(raw)
|
||||
if raw:
|
||||
asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
|
||||
|
||||
|
||||
class CommonContext():
|
||||
tags: typing.Set[str] = {"AP"}
|
||||
starting_reconnect_delay: int = 5
|
||||
current_reconnect_delay: int = starting_reconnect_delay
|
||||
command_processor: int = ClientCommandProcessor
|
||||
game = None
|
||||
ui = None
|
||||
keep_alive_task = None
|
||||
items_handling: typing.Optional[int] = None
|
||||
current_energy_link_value = 0 # to display in UI, gets set by server
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
# server state
|
||||
self.send_index: int = 0
|
||||
self.server_address = server_address
|
||||
self.password = password
|
||||
self.syncing = False
|
||||
self.awaiting_bridge = False
|
||||
self.server_task = None
|
||||
self.server: typing.Optional[Endpoint] = None
|
||||
self.server_version = Version(0, 0, 0)
|
||||
self.hint_cost: typing.Optional[int] = None
|
||||
self.games: typing.Dict[int, str] = {}
|
||||
self.permissions = {
|
||||
"forfeit": "disabled",
|
||||
"collect": "disabled",
|
||||
"remaining": "disabled",
|
||||
}
|
||||
|
||||
# own state
|
||||
self.finished_game = False
|
||||
self.ready = False
|
||||
self.team = None
|
||||
self.slot = None
|
||||
self.auth = None
|
||||
self.seed_name = None
|
||||
|
||||
self.locations_checked: typing.Set[int] = set() # local state
|
||||
self.locations_scouted: typing.Set[int] = set()
|
||||
self.items_received = []
|
||||
self.missing_locations: typing.Set[int] = set()
|
||||
self.checked_locations: typing.Set[int] = set() # server state
|
||||
self.locations_info = {}
|
||||
|
||||
self.input_queue = asyncio.Queue()
|
||||
self.input_requests = 0
|
||||
|
||||
self.last_death_link: float = time.time() # last send/received death link on AP layer
|
||||
|
||||
# game state
|
||||
self.player_names: typing.Dict[int: str] = {0: "Archipelago"}
|
||||
self.exit_event = asyncio.Event()
|
||||
self.watcher_event = asyncio.Event()
|
||||
|
||||
self.slow_mode = False
|
||||
self.jsontotextparser = JSONtoTextParser(self)
|
||||
self.set_getters(network_data_package)
|
||||
|
||||
# execution
|
||||
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
|
||||
|
||||
@property
|
||||
def total_locations(self) -> typing.Optional[int]:
|
||||
"""Will return None until connected."""
|
||||
if self.checked_locations or self.missing_locations:
|
||||
return len(self.checked_locations | self.missing_locations)
|
||||
|
||||
async def connection_closed(self):
|
||||
self.auth = None
|
||||
self.items_received = []
|
||||
self.locations_info = {}
|
||||
self.server_version = Version(0, 0, 0)
|
||||
if self.server and self.server.socket is not None:
|
||||
await self.server.socket.close()
|
||||
self.server = None
|
||||
self.server_task = None
|
||||
path = os.path.expandvars(r"%localappdata%/ChecksFinder")
|
||||
for root, dirs, files in os.walk(path):
|
||||
for file in files:
|
||||
if file.find("obtain") <= -1:
|
||||
os.remove(root+"/"+file)
|
||||
|
||||
# noinspection PyAttributeOutsideInit
|
||||
def set_getters(self, data_package: dict, network=False):
|
||||
if not network: # local data; check if newer data was already downloaded
|
||||
local_package = Utils.persistent_load().get("datapackage", {}).get("latest", {})
|
||||
if local_package and local_package["version"] > network_data_package["version"]:
|
||||
data_package: dict = local_package
|
||||
elif network: # check if data from server is newer
|
||||
|
||||
if data_package["version"] > network_data_package["version"]:
|
||||
Utils.persistent_store("datapackage", "latest", network_data_package)
|
||||
|
||||
item_lookup: dict = {}
|
||||
locations_lookup: dict = {}
|
||||
for game, gamedata in data_package["games"].items():
|
||||
for item_name, item_id in gamedata["item_name_to_id"].items():
|
||||
item_lookup[item_id] = item_name
|
||||
for location_name, location_id in gamedata["location_name_to_id"].items():
|
||||
locations_lookup[location_id] = location_name
|
||||
|
||||
def get_item_name_from_id(code: int):
|
||||
return item_lookup.get(code, f'Unknown item (ID:{code})')
|
||||
|
||||
self.item_name_getter = get_item_name_from_id
|
||||
|
||||
def get_location_name_from_address(address: int):
|
||||
return locations_lookup.get(address, f'Unknown location (ID:{address})')
|
||||
|
||||
self.location_name_getter = get_location_name_from_address
|
||||
|
||||
@property
|
||||
def endpoints(self):
|
||||
if self.server:
|
||||
return [self.server]
|
||||
else:
|
||||
return []
|
||||
|
||||
async def disconnect(self):
|
||||
if self.server and not self.server.socket.closed:
|
||||
await self.server.socket.close()
|
||||
if self.server_task is not None:
|
||||
await self.server_task
|
||||
|
||||
async def send_msgs(self, msgs):
|
||||
if not self.server or not self.server.socket.open or self.server.socket.closed:
|
||||
return
|
||||
await self.server.socket.send(encode(msgs))
|
||||
|
||||
def consume_players_package(self, package: typing.List[tuple]):
|
||||
self.player_names = {slot: name for team, slot, name, orig_name in package if self.team == team}
|
||||
self.player_names[0] = "Archipelago"
|
||||
|
||||
def event_invalid_slot(self):
|
||||
raise Exception('Invalid Slot; please verify that you have connected to the correct world.')
|
||||
|
||||
def event_invalid_game(self):
|
||||
raise Exception('Invalid Game; please verify that you connected with the right game to the correct world.')
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
logger.info('Enter the password required to join this game:')
|
||||
self.password = await self.console_input()
|
||||
return self.password
|
||||
|
||||
async def send_connect(self, **kwargs):
|
||||
payload = {
|
||||
'cmd': 'Connect',
|
||||
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
||||
'tags': self.tags, 'items_handling': self.items_handling,
|
||||
'uuid': Utils.get_unique_identifier(), 'game': self.game
|
||||
}
|
||||
if kwargs:
|
||||
payload.update(kwargs)
|
||||
await self.send_msgs([payload])
|
||||
|
||||
async def console_input(self):
|
||||
self.input_requests += 1
|
||||
return await self.input_queue.get()
|
||||
|
||||
async def connect(self, address=None):
|
||||
await self.disconnect()
|
||||
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
|
||||
|
||||
def on_print(self, args: dict):
|
||||
logger.info(args["text"])
|
||||
|
||||
def on_print_json(self, args: dict):
|
||||
if self.ui:
|
||||
self.ui.print_json(args["data"])
|
||||
else:
|
||||
text = self.jsontotextparser(args["data"])
|
||||
logger.info(text)
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
pass
|
||||
|
||||
def on_user_say(self, text: str) -> typing.Optional[str]:
|
||||
"""Gets called before sending a Say to the server from the user.
|
||||
Returned text is sent, or sending is aborted if None is returned."""
|
||||
return text
|
||||
|
||||
def update_permissions(self, permissions: typing.Dict[str, int]):
|
||||
for permission_name, permission_flag in permissions.items():
|
||||
try:
|
||||
flag = Permission(permission_flag)
|
||||
logger.info(f"{permission_name.capitalize()} permission: {flag.name}")
|
||||
self.permissions[permission_name] = flag.name
|
||||
except Exception as e: # safeguard against permissions that may be implemented in the future
|
||||
logger.exception(e)
|
||||
|
||||
async def shutdown(self):
|
||||
self.server_address = None
|
||||
if self.server and not self.server.socket.closed:
|
||||
await self.server.socket.close()
|
||||
if self.server_task:
|
||||
await self.server_task
|
||||
|
||||
while self.input_requests > 0:
|
||||
self.input_queue.put_nowait(None)
|
||||
self.input_requests -= 1
|
||||
self.keep_alive_task.cancel()
|
||||
path = os.path.expandvars(r"%localappdata%/ChecksFinder")
|
||||
for root, dirs, files in os.walk(path):
|
||||
for file in files:
|
||||
if file.find("obtain") <= -1:
|
||||
os.remove(root+"/"+file)
|
||||
|
||||
# DeathLink hooks
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
"""Gets dispatched when a new DeathLink is triggered by another linked player."""
|
||||
self.last_death_link = max(data["time"], self.last_death_link)
|
||||
text = data.get("cause", "")
|
||||
if text:
|
||||
logger.info(f"DeathLink: {text}")
|
||||
else:
|
||||
logger.info(f"DeathLink: Received from {data['source']}")
|
||||
|
||||
async def send_death(self, death_text: str = ""):
|
||||
if self.server and self.server.socket:
|
||||
logger.info("DeathLink: Sending death to your friends...")
|
||||
self.last_death_link = time.time()
|
||||
await self.send_msgs([{
|
||||
"cmd": "Bounce", "tags": ["DeathLink"],
|
||||
"data": {
|
||||
"time": self.last_death_link,
|
||||
"source": self.player_names[self.slot],
|
||||
"cause": death_text
|
||||
}
|
||||
}])
|
||||
|
||||
async def update_death_link(self, death_link):
|
||||
old_tags = self.tags.copy()
|
||||
if death_link:
|
||||
self.tags.add("DeathLink")
|
||||
else:
|
||||
self.tags -= {"DeathLink"}
|
||||
if old_tags != self.tags and self.server and not self.server.socket.closed:
|
||||
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
|
||||
|
||||
|
||||
async def server_loop(ctx: CommonContext, address=None):
|
||||
cached_address = None
|
||||
if ctx.server and ctx.server.socket:
|
||||
logger.error('Already connected')
|
||||
return
|
||||
|
||||
if address is None: # set through CLI or APBP
|
||||
address = ctx.server_address
|
||||
|
||||
# Wait for the user to provide a multiworld server address
|
||||
if not address:
|
||||
logger.info('Please connect to an Archipelago server.')
|
||||
return
|
||||
|
||||
address = f"ws://{address}" if "://" not in address else address
|
||||
port = urllib.parse.urlparse(address).port or 38281
|
||||
logger.info(f'Connecting to Archipelago server at {address}')
|
||||
try:
|
||||
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
|
||||
ctx.server = Endpoint(socket)
|
||||
logger.info('Connected')
|
||||
ctx.server_address = address
|
||||
ctx.current_reconnect_delay = ctx.starting_reconnect_delay
|
||||
async for data in ctx.server.socket:
|
||||
for msg in decode(data):
|
||||
await process_server_cmd(ctx, msg)
|
||||
logger.warning('Disconnected from multiworld server, type /connect to reconnect')
|
||||
except ConnectionRefusedError:
|
||||
if cached_address:
|
||||
logger.error('Unable to connect to multiworld server at cached address. '
|
||||
'Please use the connect button above.')
|
||||
else:
|
||||
logger.exception('Connection refused by the multiworld server')
|
||||
except websockets.InvalidURI:
|
||||
logger.exception('Failed to connect to the multiworld server (invalid URI)')
|
||||
except (OSError, websockets.InvalidURI):
|
||||
logger.exception('Failed to connect to the multiworld server')
|
||||
except Exception as e:
|
||||
logger.exception('Lost connection to the multiworld server, type /connect to reconnect')
|
||||
finally:
|
||||
await ctx.connection_closed()
|
||||
if ctx.server_address:
|
||||
logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s")
|
||||
asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
|
||||
ctx.current_reconnect_delay *= 2
|
||||
|
||||
|
||||
async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
try:
|
||||
cmd = args["cmd"]
|
||||
except:
|
||||
logger.exception(f"Could not get command from {args}")
|
||||
raise
|
||||
if cmd == 'RoomInfo':
|
||||
if ctx.seed_name and ctx.seed_name != args["seed_name"]:
|
||||
logger.info("The server is running a different multiworld than your client is. (invalid seed_name)")
|
||||
else:
|
||||
logger.info('--------------------------------')
|
||||
logger.info('Room Information:')
|
||||
logger.info('--------------------------------')
|
||||
version = args["version"]
|
||||
ctx.server_version = tuple(version)
|
||||
version = ".".join(str(item) for item in version)
|
||||
|
||||
logger.info(f'Server protocol version: {version}')
|
||||
logger.info("Server protocol tags: " + ", ".join(args["tags"]))
|
||||
if args['password']:
|
||||
logger.info('Password required')
|
||||
ctx.update_permissions(args.get("permissions", {}))
|
||||
if "games" in args:
|
||||
ctx.games = {x: game for x, game in enumerate(args["games"], start=1)}
|
||||
logger.info(
|
||||
f"A !hint costs {args['hint_cost']}% of your total location count as points"
|
||||
f" and you get {args['location_check_points']}"
|
||||
f" for each location checked. Use !hint for more information.")
|
||||
ctx.hint_cost = int(args['hint_cost'])
|
||||
ctx.check_points = int(args['location_check_points'])
|
||||
|
||||
if len(args['players']) < 1:
|
||||
logger.info('No player connected')
|
||||
else:
|
||||
args['players'].sort()
|
||||
current_team = -1
|
||||
logger.info('Connected Players:')
|
||||
for network_player in args['players']:
|
||||
if network_player.team != current_team:
|
||||
logger.info(f' Team #{network_player.team + 1}')
|
||||
current_team = network_player.team
|
||||
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
||||
if args["datapackage_version"] > network_data_package["version"] or args["datapackage_version"] == 0:
|
||||
await ctx.send_msgs([{"cmd": "GetDataPackage"}])
|
||||
await ctx.server_auth(args['password'])
|
||||
|
||||
elif cmd == 'DataPackage':
|
||||
logger.info("Got new ID/Name Datapackage")
|
||||
ctx.set_getters(args['data'], network=True)
|
||||
|
||||
elif cmd == 'ConnectionRefused':
|
||||
errors = args["errors"]
|
||||
if 'InvalidSlot' in errors:
|
||||
ctx.event_invalid_slot()
|
||||
elif 'InvalidGame' in errors:
|
||||
ctx.event_invalid_game()
|
||||
elif 'SlotAlreadyTaken' in errors:
|
||||
raise Exception('Player slot already in use for that team')
|
||||
elif 'IncompatibleVersion' in errors:
|
||||
raise Exception('Server reported your client version as incompatible')
|
||||
elif 'InvalidItemsHandling' in errors:
|
||||
raise Exception('The item handling flags requested by the client are not supported')
|
||||
# last to check, recoverable problem
|
||||
elif 'InvalidPassword' in errors:
|
||||
logger.error('Invalid password')
|
||||
ctx.password = None
|
||||
await ctx.server_auth(True)
|
||||
elif errors:
|
||||
raise Exception("Unknown connection errors: " + str(errors))
|
||||
else:
|
||||
raise Exception('Connection refused by the multiworld host, no reason provided')
|
||||
|
||||
elif cmd == 'Connected':
|
||||
if not os.path.exists(os.path.expandvars(r"%localappdata%/ChecksFinder")):
|
||||
os.mkdir(os.path.expandvars(r"%localappdata%/ChecksFinder"))
|
||||
ctx.team = args["team"]
|
||||
ctx.slot = args["slot"]
|
||||
ctx.consume_players_package(args["players"])
|
||||
msgs = []
|
||||
if ctx.locations_checked:
|
||||
msgs.append({"cmd": "LocationChecks",
|
||||
"locations": list(ctx.locations_checked)})
|
||||
if ctx.locations_scouted:
|
||||
msgs.append({"cmd": "LocationScouts",
|
||||
"locations": list(ctx.locations_scouted)})
|
||||
if msgs:
|
||||
await ctx.send_msgs(msgs)
|
||||
if ctx.finished_game:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
|
||||
# Get the server side view of missing as of time of connecting.
|
||||
# This list is used to only send to the server what is reported as ACTUALLY Missing.
|
||||
# This also serves to allow an easy visual of what locations were already checked previously
|
||||
# when /missing is used for the client side view of what is missing.
|
||||
ctx.missing_locations = set(args["missing_locations"])
|
||||
ctx.checked_locations = set(args["checked_locations"])
|
||||
for ss in ctx.checked_locations:
|
||||
filename = f"send{ss}"
|
||||
with open(os.path.expandvars(r"%localappdata%/ChecksFinder/"+filename), 'w') as f:
|
||||
f.close()
|
||||
|
||||
elif cmd == 'ReceivedItems':
|
||||
start_index = args["index"]
|
||||
|
||||
if start_index == 0:
|
||||
ctx.items_received = []
|
||||
elif start_index != len(ctx.items_received):
|
||||
sync_msg = [{'cmd': 'Sync'}]
|
||||
if ctx.locations_checked:
|
||||
sync_msg.append({"cmd": "LocationChecks",
|
||||
"locations": list(ctx.locations_checked)})
|
||||
await ctx.send_msgs(sync_msg)
|
||||
if start_index == len(ctx.items_received):
|
||||
for item in args['items']:
|
||||
filename = f"AP_{str(NetworkItem(*item).location)}PLR{str(NetworkItem(*item).player)}.item"
|
||||
with open(os.path.expandvars(r"%localappdata%/ChecksFinder/"+filename), 'w') as f:
|
||||
f.write(str(NetworkItem(*item).item))
|
||||
f.close()
|
||||
ctx.items_received.append(NetworkItem(*item))
|
||||
ctx.watcher_event.set()
|
||||
|
||||
elif cmd == 'LocationInfo':
|
||||
for item, location, player in args['locations']:
|
||||
if location not in ctx.locations_info:
|
||||
ctx.locations_info[location] = (item, player)
|
||||
ctx.watcher_event.set()
|
||||
|
||||
elif cmd == "RoomUpdate":
|
||||
if "players" in args:
|
||||
ctx.consume_players_package(args["players"])
|
||||
if "hint_points" in args:
|
||||
ctx.hint_points = args['hint_points']
|
||||
if "checked_locations" in args:
|
||||
checked = set(args["checked_locations"])
|
||||
ctx.checked_locations |= checked
|
||||
ctx.missing_locations -= checked
|
||||
for ss in ctx.checked_locations:
|
||||
filename = f"send{ss}"
|
||||
with open(os.path.expandvars(r"%localappdata%/ChecksFinder/"+filename), 'w') as f:
|
||||
f.close()
|
||||
if "permissions" in args:
|
||||
ctx.update_permissions(args["permissions"])
|
||||
|
||||
elif cmd == 'Print':
|
||||
ctx.on_print(args)
|
||||
|
||||
elif cmd == 'PrintJSON':
|
||||
ctx.on_print_json(args)
|
||||
|
||||
elif cmd == 'InvalidPacket':
|
||||
logger.warning(f"Invalid Packet of {args['type']}: {args['text']}")
|
||||
|
||||
elif cmd == "Bounced":
|
||||
tags = args.get("tags", [])
|
||||
# we can skip checking "DeathLink" in ctx.tags, as otherwise we wouldn't have been send this
|
||||
if "DeathLink" in tags and ctx.last_death_link != args["data"]["time"]:
|
||||
ctx.on_deathlink(args["data"])
|
||||
elif cmd == "SetReply":
|
||||
if args["key"] == "EnergyLink":
|
||||
ctx.current_energy_link_value = args["value"]
|
||||
if ctx.ui:
|
||||
ctx.ui.set_new_energy_link_value()
|
||||
else:
|
||||
logger.debug(f"unknown command {cmd}")
|
||||
|
||||
ctx.on_package(cmd, args)
|
||||
|
||||
|
||||
async def game_watcher(ctx: CommonContext):
|
||||
from worlds.checksfinder.Locations import lookup_id_to_name
|
||||
while not ctx.exit_event.is_set():
|
||||
if ctx.syncing == True:
|
||||
sync_msg = [{'cmd': 'Sync'}]
|
||||
if ctx.locations_checked:
|
||||
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
|
||||
await ctx.send_msgs(sync_msg)
|
||||
ctx.syncing = False
|
||||
path = os.path.expandvars(r"%localappdata%/ChecksFinder")
|
||||
sending = []
|
||||
victory = False
|
||||
for root, dirs, files in os.walk(path):
|
||||
for file in files:
|
||||
if file.find("send") > -1:
|
||||
st = file.split("send", -1)[1]
|
||||
sending = sending+[(int(st))]
|
||||
if file.find("victory") > -1:
|
||||
victory = True
|
||||
ctx.locations_checked = sending
|
||||
message = [{"cmd": 'LocationChecks', "locations": sending}]
|
||||
await ctx.send_msgs(message)
|
||||
if not ctx.finished_game and victory:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Text Mode to use !hint and such with games that have no text entry
|
||||
|
||||
class TextContext(CommonContext):
|
||||
game = "ChecksFinder"
|
||||
items_handling = 0b111 # full remote
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(TextContext, self).server_auth(password_requested)
|
||||
if not self.auth:
|
||||
logger.info('Enter slot name:')
|
||||
self.auth = await self.console_input()
|
||||
|
||||
await self.send_connect()
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
self.game = self.games.get(self.slot, None)
|
||||
|
||||
|
||||
async def main(args):
|
||||
ctx = TextContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||
input_task = None
|
||||
if gui_enabled:
|
||||
from kvui import ChecksFinderManager
|
||||
ctx.ui = ChecksFinderManager(ctx)
|
||||
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
|
||||
else:
|
||||
ui_task = None
|
||||
if sys.stdin:
|
||||
input_task = asyncio.create_task(console_loop(ctx), name="Input")
|
||||
progression_watcher = asyncio.create_task(
|
||||
game_watcher(ctx), name="ChecksFinderProgressionWatcher")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await progression_watcher
|
||||
|
||||
await ctx.shutdown()
|
||||
if ui_task:
|
||||
await ui_task
|
||||
|
||||
if input_task:
|
||||
input_task.cancel()
|
||||
|
||||
import colorama
|
||||
|
||||
parser = get_base_parser(description="ChecksFinder Client, for text interfacing.")
|
||||
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main(args))
|
||||
loop.close()
|
||||
colorama.deinit()
|
||||
@@ -119,6 +119,7 @@ class CommonContext():
|
||||
ui = None
|
||||
keep_alive_task = None
|
||||
items_handling: typing.Optional[int] = None
|
||||
current_energy_link_value = 0 # to display in UI, gets set by server
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
# server state
|
||||
@@ -148,7 +149,7 @@ class CommonContext():
|
||||
self.items_received = []
|
||||
self.missing_locations: typing.Set[int] = set()
|
||||
self.checked_locations: typing.Set[int] = set() # server state
|
||||
self.locations_info = {}
|
||||
self.locations_info: typing.Dict[int, NetworkItem] = {}
|
||||
|
||||
self.input_queue = asyncio.Queue()
|
||||
self.input_requests = 0
|
||||
@@ -317,16 +318,17 @@ class CommonContext():
|
||||
logger.info(f"DeathLink: Received from {data['source']}")
|
||||
|
||||
async def send_death(self, death_text: str = ""):
|
||||
logger.info("DeathLink: Sending death to your friends...")
|
||||
self.last_death_link = time.time()
|
||||
await self.send_msgs([{
|
||||
"cmd": "Bounce", "tags": ["DeathLink"],
|
||||
"data": {
|
||||
"time": self.last_death_link,
|
||||
"source": self.player_names[self.slot],
|
||||
"cause": death_text
|
||||
}
|
||||
}])
|
||||
if self.server and self.server.socket:
|
||||
logger.info("DeathLink: Sending death to your friends...")
|
||||
self.last_death_link = time.time()
|
||||
await self.send_msgs([{
|
||||
"cmd": "Bounce", "tags": ["DeathLink"],
|
||||
"data": {
|
||||
"time": self.last_death_link,
|
||||
"source": self.player_names[self.slot],
|
||||
"cause": death_text
|
||||
}
|
||||
}])
|
||||
|
||||
async def update_death_link(self, death_link):
|
||||
old_tags = self.tags.copy()
|
||||
@@ -441,7 +443,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
else:
|
||||
args['players'].sort()
|
||||
current_team = -1
|
||||
logger.info('Players:')
|
||||
logger.info('Connected Players:')
|
||||
for network_player in args['players']:
|
||||
if network_player.team != current_team:
|
||||
logger.info(f' Team #{network_player.team + 1}')
|
||||
@@ -517,9 +519,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
ctx.watcher_event.set()
|
||||
|
||||
elif cmd == 'LocationInfo':
|
||||
for item, location, player in args['locations']:
|
||||
if location not in ctx.locations_info:
|
||||
ctx.locations_info[location] = (item, player)
|
||||
for item in [NetworkItem(*item) for item in args['locations']]:
|
||||
ctx.locations_info[item.location] = item
|
||||
ctx.watcher_event.set()
|
||||
|
||||
elif cmd == "RoomUpdate":
|
||||
@@ -548,7 +549,11 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
# we can skip checking "DeathLink" in ctx.tags, as otherwise we wouldn't have been send this
|
||||
if "DeathLink" in tags and ctx.last_death_link != args["data"]["time"]:
|
||||
ctx.on_deathlink(args["data"])
|
||||
|
||||
elif cmd == "SetReply":
|
||||
if args["key"] == "EnergyLink":
|
||||
ctx.current_energy_link_value = args["value"]
|
||||
if ctx.ui:
|
||||
ctx.ui.set_new_energy_link_value()
|
||||
else:
|
||||
logger.debug(f"unknown command {cmd}")
|
||||
|
||||
|
||||
15
FF1Client.py
@@ -18,6 +18,8 @@ CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||
|
||||
DISPLAY_MSGS = True
|
||||
|
||||
|
||||
class FF1CommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx: CommonContext):
|
||||
@@ -28,6 +30,12 @@ class FF1CommandProcessor(ClientCommandProcessor):
|
||||
if isinstance(self.ctx, FF1Context):
|
||||
logger.info(f"NES Status: {self.ctx.nes_status}")
|
||||
|
||||
def _cmd_toggle_msgs(self):
|
||||
"""Toggle displaying messages in bizhawk"""
|
||||
global DISPLAY_MSGS
|
||||
DISPLAY_MSGS = not DISPLAY_MSGS
|
||||
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
|
||||
|
||||
|
||||
class FF1Context(CommonContext):
|
||||
command_processor = FF1CommandProcessor
|
||||
@@ -42,6 +50,7 @@ class FF1Context(CommonContext):
|
||||
self.nes_status = CONNECTION_INITIAL_STATUS
|
||||
self.game = 'Final Fantasy'
|
||||
self.awaiting_rom = False
|
||||
self.display_msgs = True
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
@@ -54,7 +63,8 @@ class FF1Context(CommonContext):
|
||||
await self.send_connect()
|
||||
|
||||
def _set_message(self, msg: str, msg_id: int):
|
||||
self.messages[(time.time(), msg_id)] = msg
|
||||
if DISPLAY_MSGS:
|
||||
self.messages[(time.time(), msg_id)] = msg
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == 'Connected':
|
||||
@@ -215,6 +225,9 @@ if __name__ == '__main__':
|
||||
# Text Mode to use !hint and such with games that have no text entry
|
||||
Utils.init_logging("FF1Client")
|
||||
|
||||
options = Utils.get_options()
|
||||
DISPLAY_MSGS = options["ffr_options"]["display_msgs"]
|
||||
|
||||
async def main(args):
|
||||
ctx = FF1Context(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
|
||||
@@ -19,7 +19,7 @@ if __name__ == "__main__":
|
||||
Utils.init_logging("FactorioClient", exception_logger="Client")
|
||||
|
||||
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger, gui_enabled, \
|
||||
get_base_parser
|
||||
get_base_parser
|
||||
from MultiServer import mark_raw
|
||||
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
|
||||
|
||||
@@ -62,6 +62,8 @@ class FactorioContext(CommonContext):
|
||||
self.write_data_path = None
|
||||
self.death_link_tick: int = 0 # last send death link on Factorio layer
|
||||
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
|
||||
self.energy_link_increment = 0
|
||||
self.last_deplete = 0
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
@@ -105,6 +107,20 @@ class FactorioContext(CommonContext):
|
||||
if "checked_locations" in args and args["checked_locations"]:
|
||||
self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for
|
||||
item_name in args["checked_locations"]})
|
||||
if cmd == "Connected" and self.energy_link_increment:
|
||||
asyncio.create_task(self.send_msgs([{
|
||||
"cmd": "SetNotify", "keys": ["EnergyLink"]
|
||||
}]))
|
||||
elif cmd == "SetReply":
|
||||
if args["key"] == "EnergyLink":
|
||||
if self.energy_link_increment and args.get("last_deplete", -1) == self.last_deplete:
|
||||
# it's our deplete request
|
||||
gained = int(args["original_value"] - args["value"])
|
||||
gained_text = Utils.format_SI_prefix(gained) + "J"
|
||||
if gained:
|
||||
logger.info(f"EnergyLink: Received {gained_text}. "
|
||||
f"{Utils.format_SI_prefix(args['value'])}J remaining.")
|
||||
self.rcon_client.send_command(f"/ap-energylink {gained}")
|
||||
|
||||
|
||||
async def game_watcher(ctx: FactorioContext):
|
||||
@@ -113,7 +129,8 @@ async def game_watcher(ctx: FactorioContext):
|
||||
next_bridge = time.perf_counter() + 1
|
||||
try:
|
||||
while not ctx.exit_event.is_set():
|
||||
if ctx.awaiting_bridge and ctx.rcon_client and time.perf_counter() > next_bridge:
|
||||
# TODO: restore on-demand refresh
|
||||
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"))
|
||||
@@ -127,8 +144,7 @@ async def game_watcher(ctx: FactorioContext):
|
||||
research_data = data["research_done"]
|
||||
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
|
||||
victory = data["victory"]
|
||||
if "death_link" in data: # TODO: Remove this if statement around version 0.2.4 or so
|
||||
await ctx.update_death_link(data["death_link"])
|
||||
await ctx.update_death_link(data["death_link"])
|
||||
|
||||
if not ctx.finished_game and victory:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
@@ -144,7 +160,31 @@ async def game_watcher(ctx: FactorioContext):
|
||||
if death_link_tick != ctx.death_link_tick:
|
||||
ctx.death_link_tick = death_link_tick
|
||||
if "DeathLink" in ctx.tags:
|
||||
await ctx.send_death()
|
||||
asyncio.create_task(ctx.send_death())
|
||||
if ctx.energy_link_increment:
|
||||
in_world_bridges = data["energy_bridges"]
|
||||
if in_world_bridges:
|
||||
in_world_energy = data["energy"]
|
||||
if in_world_energy < (ctx.energy_link_increment * in_world_bridges):
|
||||
# attempt to refill
|
||||
ctx.last_deplete = time.time()
|
||||
asyncio.create_task(ctx.send_msgs([{
|
||||
"cmd": "Set", "key": "EnergyLink", "operations":
|
||||
[{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges},
|
||||
{"operation": "max", "value": 0}],
|
||||
"last_deplete": ctx.last_deplete
|
||||
}]))
|
||||
# Above Capacity - (len(Bridges) * ENERGY_INCREMENT)
|
||||
elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \
|
||||
ctx.energy_link_increment*in_world_bridges:
|
||||
value = ctx.energy_link_increment * in_world_bridges
|
||||
asyncio.create_task(ctx.send_msgs([{
|
||||
"cmd": "Set", "key": "EnergyLink", "operations":
|
||||
[{"operation": "add", "value": value}]
|
||||
}]))
|
||||
ctx.rcon_client.send_command(
|
||||
f"/ap-energylink -{value}")
|
||||
logger.info(f"EnergyLink: Sent {Utils.format_SI_prefix(value)}J")
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
@@ -233,12 +273,16 @@ async def factorio_server_watcher(ctx: FactorioContext):
|
||||
factorio_process.wait(5)
|
||||
|
||||
|
||||
async def get_info(ctx, rcon_client):
|
||||
async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient):
|
||||
info = json.loads(rcon_client.send_command("/ap-rcon-info"))
|
||||
ctx.auth = info["slot_name"]
|
||||
ctx.seed_name = info["seed_name"]
|
||||
# 0.2.0 addition, not present earlier
|
||||
death_link = bool(info.get("death_link", False))
|
||||
ctx.energy_link_increment = info.get("energy_link", 0)
|
||||
logger.debug(f"Energy Link Increment: {ctx.energy_link_increment}")
|
||||
if ctx.energy_link_increment and ctx.ui:
|
||||
ctx.ui.enable_energy_link()
|
||||
await ctx.update_death_link(death_link)
|
||||
|
||||
|
||||
|
||||
203
Fill.py
@@ -4,7 +4,6 @@ import collections
|
||||
import itertools
|
||||
from collections import Counter, deque
|
||||
|
||||
|
||||
from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item
|
||||
|
||||
from worlds.AutoWorld import call_all
|
||||
@@ -14,7 +13,7 @@ class FillError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def sweep_from_pool(base_state: CollectionState, itempool=[]):
|
||||
def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] = tuple()) -> CollectionState:
|
||||
new_state = base_state.copy()
|
||||
for item in itempool:
|
||||
new_state.collect(item, True)
|
||||
@@ -22,13 +21,13 @@ def sweep_from_pool(base_state: CollectionState, itempool=[]):
|
||||
return new_state
|
||||
|
||||
|
||||
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations, itempool: typing.List[Item],
|
||||
single_player_placement=False, lock=False):
|
||||
unplaced_items = []
|
||||
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
|
||||
itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False) -> None:
|
||||
unplaced_items: typing.List[Item] = []
|
||||
placements: typing.List[Location] = []
|
||||
|
||||
swapped_items = Counter()
|
||||
reachable_items: typing.Dict[int, deque] = {}
|
||||
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
||||
reachable_items: typing.Dict[int, typing.Deque[Item]] = {}
|
||||
for item in itempool:
|
||||
reachable_items.setdefault(item.player, deque()).append(item)
|
||||
|
||||
@@ -38,7 +37,8 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations,
|
||||
for items in reachable_items.values() if items]
|
||||
for item in items_to_place:
|
||||
itempool.remove(item)
|
||||
maximum_exploration_state = sweep_from_pool(base_state, itempool)
|
||||
maximum_exploration_state = sweep_from_pool(
|
||||
base_state, itempool + unplaced_items)
|
||||
|
||||
has_beaten_game = world.has_beaten_game(maximum_exploration_state)
|
||||
|
||||
@@ -46,7 +46,8 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations,
|
||||
spot_to_fill: typing.Optional[Location] = None
|
||||
if world.accessibility[item_to_place.player] == 'minimal':
|
||||
perform_access_check = not world.has_beaten_game(maximum_exploration_state,
|
||||
item_to_place.player) if single_player_placement else not has_beaten_game
|
||||
item_to_place.player) \
|
||||
if single_player_placement else not has_beaten_game
|
||||
else:
|
||||
perform_access_check = True
|
||||
|
||||
@@ -61,7 +62,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations,
|
||||
else:
|
||||
# we filled all reachable spots.
|
||||
# try swapping this item with previously placed items
|
||||
for(i, location) in enumerate(placements):
|
||||
for (i, location) in enumerate(placements):
|
||||
placed_item = location.item
|
||||
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
||||
# number of times we will swap an individual item to prevent this
|
||||
@@ -106,35 +107,39 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations,
|
||||
placed_item.location = location
|
||||
|
||||
if spot_to_fill is None:
|
||||
# Maybe the game can be beaten anyway?
|
||||
# Can't place this item, move on to the next
|
||||
unplaced_items.append(item_to_place)
|
||||
if world.accessibility[item_to_place.player] != 'minimal' and world.can_beat_game():
|
||||
logging.warning(
|
||||
f'Not all items placed. Game beatable anyway. (Could not place {item_to_place})')
|
||||
continue
|
||||
raise FillError(f'No more spots to place {item_to_place}, locations {locations} are invalid. '
|
||||
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
|
||||
continue
|
||||
|
||||
world.push_item(spot_to_fill, item_to_place, False)
|
||||
spot_to_fill.locked = lock
|
||||
placements.append(spot_to_fill)
|
||||
spot_to_fill.event = True
|
||||
spot_to_fill.event = item_to_place.advancement
|
||||
|
||||
if len(unplaced_items) > 0 and len(locations) > 0:
|
||||
# There are leftover unplaceable items and locations that won't accept them
|
||||
if world.can_beat_game():
|
||||
logging.warning(
|
||||
f'Not all items placed. Game beatable anyway. (Could not place {unplaced_items})')
|
||||
else:
|
||||
raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
|
||||
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
|
||||
|
||||
itempool.extend(unplaced_items)
|
||||
|
||||
|
||||
def distribute_items_restrictive(world: MultiWorld):
|
||||
def distribute_items_restrictive(world: MultiWorld) -> None:
|
||||
fill_locations = sorted(world.get_unfilled_locations())
|
||||
world.random.shuffle(fill_locations)
|
||||
|
||||
# get items to distribute
|
||||
itempool = sorted(world.itempool)
|
||||
world.random.shuffle(itempool)
|
||||
progitempool = []
|
||||
nonexcludeditempool = []
|
||||
localrestitempool = {player: [] for player in range(1, world.players + 1)}
|
||||
nonlocalrestitempool = []
|
||||
restitempool = []
|
||||
progitempool: typing.List[Item] = []
|
||||
nonexcludeditempool: typing.List[Item] = []
|
||||
localrestitempool: typing.Dict[int, typing.List[Item]] = {player: [] for player in range(1, world.players + 1)}
|
||||
nonlocalrestitempool: typing.List[Item] = []
|
||||
restitempool: typing.List[Item] = []
|
||||
|
||||
for item in itempool:
|
||||
if item.advancement:
|
||||
@@ -152,7 +157,7 @@ def distribute_items_restrictive(world: MultiWorld):
|
||||
localrestitempool, nonlocalrestitempool, restitempool, fill_locations)
|
||||
|
||||
locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
|
||||
type: [] for type in LocationProgressType}
|
||||
loc_type: [] for loc_type in LocationProgressType}
|
||||
|
||||
for loc in fill_locations:
|
||||
locations[loc.progress_type].append(loc)
|
||||
@@ -174,8 +179,8 @@ def distribute_items_restrictive(world: MultiWorld):
|
||||
if nonexcludeditempool:
|
||||
world.random.shuffle(defaultlocations)
|
||||
# needs logical fill to not conflict with local items
|
||||
nonexcludeditempool, defaultlocations = fast_fill(
|
||||
world, nonexcludeditempool, defaultlocations)
|
||||
fill_restrictive(
|
||||
world, world.state, defaultlocations, nonexcludeditempool)
|
||||
if nonexcludeditempool:
|
||||
raise FillError(
|
||||
f'Not enough locations for non-excluded items. There are {len(nonexcludeditempool)} more items than locations')
|
||||
@@ -184,7 +189,7 @@ def distribute_items_restrictive(world: MultiWorld):
|
||||
world.random.shuffle(defaultlocations)
|
||||
|
||||
if any(localrestitempool.values()): # we need to make sure some fills are limited to certain worlds
|
||||
local_locations = {player: [] for player in world.player_ids}
|
||||
local_locations: typing.Dict[int, typing.List[Location]] = {player: [] for player in world.player_ids}
|
||||
for location in defaultlocations:
|
||||
local_locations[location.player].append(location)
|
||||
for player_locations in local_locations.values():
|
||||
@@ -220,7 +225,7 @@ def distribute_items_restrictive(world: MultiWorld):
|
||||
if unplaced or unfilled:
|
||||
logging.warning(
|
||||
f'Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}')
|
||||
items_counter = Counter([location.item.player for location in world.get_locations()])
|
||||
items_counter = Counter([location.item.player for location in world.get_locations() if location.item])
|
||||
locations_counter = Counter([location.player for location in world.get_locations()])
|
||||
items_counter.update([item.player for item in unplaced])
|
||||
locations_counter.update([location.player for location in unfilled])
|
||||
@@ -228,14 +233,16 @@ def distribute_items_restrictive(world: MultiWorld):
|
||||
logging.info(f'Per-Player counts: {print_data})')
|
||||
|
||||
|
||||
def fast_fill(world: MultiWorld, item_pool: typing.List, fill_locations: typing.List) -> typing.Tuple[typing.List, typing.List]:
|
||||
def fast_fill(world: MultiWorld,
|
||||
item_pool: typing.List[Item],
|
||||
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
|
||||
placing = min(len(item_pool), len(fill_locations))
|
||||
for item, location in zip(item_pool, fill_locations):
|
||||
world.push_item(location, item, False)
|
||||
return item_pool[placing:], fill_locations[placing:]
|
||||
|
||||
|
||||
def flood_items(world: MultiWorld):
|
||||
def flood_items(world: MultiWorld) -> None:
|
||||
# get items to distribute
|
||||
world.random.shuffle(world.itempool)
|
||||
itempool = world.itempool
|
||||
@@ -274,7 +281,8 @@ def flood_items(world: MultiWorld):
|
||||
item_to_place = item
|
||||
break
|
||||
|
||||
# we might be in a situation where all new locations require multiple items to reach. If that is the case, just place any advancement item we've found and continue trying
|
||||
# we might be in a situation where all new locations require multiple items to reach.
|
||||
# If that is the case, just place any advancement item we've found and continue trying
|
||||
if item_to_place is None:
|
||||
if candidate_item_to_place is not None:
|
||||
item_to_place = candidate_item_to_place
|
||||
@@ -295,71 +303,113 @@ def flood_items(world: MultiWorld):
|
||||
break
|
||||
|
||||
|
||||
def balance_multiworld_progression(world: MultiWorld):
|
||||
balanceable_players = {player for player in range(1, world.players + 1) if world.progression_balancing[player]}
|
||||
def balance_multiworld_progression(world: MultiWorld) -> None:
|
||||
# A system to reduce situations where players have no checks remaining, popularly known as "BK mode."
|
||||
# Overall progression balancing algorithm:
|
||||
# Gather up all locations in a sphere.
|
||||
# Define a threshold value based on the player with the most available locations.
|
||||
# If other players are below the threshold value, swap progression in this sphere into earlier spheres,
|
||||
# which gives more locations available by this sphere.
|
||||
balanceable_players = {player for player in world.player_ids if world.progression_balancing[player]}
|
||||
if not balanceable_players:
|
||||
logging.info('Skipping multiworld progression balancing.')
|
||||
else:
|
||||
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
|
||||
state = CollectionState(world)
|
||||
checked_locations = set()
|
||||
checked_locations: typing.Set[Location] = set()
|
||||
unchecked_locations = set(world.get_locations())
|
||||
|
||||
reachable_locations_count = {player: 0 for player in world.player_ids}
|
||||
reachable_locations_count = {
|
||||
player: 0
|
||||
for player in world.player_ids
|
||||
if len(world.get_filled_locations(player)) != 0
|
||||
}
|
||||
total_locations_count = Counter(location.player for location in world.get_locations() if not location.locked)
|
||||
balanceable_players = {player for player in balanceable_players if total_locations_count[player]}
|
||||
sphere_num = 1
|
||||
moved_item_count = 0
|
||||
|
||||
def get_sphere_locations(sphere_state, locations):
|
||||
def get_sphere_locations(sphere_state: CollectionState,
|
||||
locations: typing.Set[Location]) -> typing.Set[Location]:
|
||||
sphere_state.sweep_for_events(key_only=True, locations=locations)
|
||||
return {loc for loc in locations if sphere_state.can_reach(loc)}
|
||||
|
||||
def item_percentage(player: int, num: int) -> float:
|
||||
return num / total_locations_count[player]
|
||||
|
||||
while True:
|
||||
# Gather non-locked locations.
|
||||
# This ensures that only shuffled locations get counted for progression balancing,
|
||||
# i.e. the items the players will be checking.
|
||||
sphere_locations = get_sphere_locations(state, unchecked_locations)
|
||||
for location in sphere_locations:
|
||||
unchecked_locations.remove(location)
|
||||
reachable_locations_count[location.player] += 1
|
||||
if not location.locked:
|
||||
reachable_locations_count[location.player] += 1
|
||||
|
||||
logging.debug(f"Sphere {sphere_num}")
|
||||
logging.debug(f"Reachable locations: {reachable_locations_count}")
|
||||
debug_percentages = {
|
||||
player: round(item_percentage(player, num), 2)
|
||||
for player, num in reachable_locations_count.items()
|
||||
}
|
||||
logging.debug(f"Reachable percentages: {debug_percentages}\n")
|
||||
sphere_num += 1
|
||||
|
||||
if checked_locations:
|
||||
threshold = max(reachable_locations_count.values()) - 20
|
||||
# The 10% threshold can be modified for "progression balancing strength"
|
||||
# right now it approximates the old 20/216 bound.
|
||||
threshold_percentage = max(map(lambda p: item_percentage(p, reachable_locations_count[p]),
|
||||
reachable_locations_count)) - 0.10
|
||||
logging.debug(f"Threshold: {threshold_percentage}")
|
||||
balancing_players = {player for player, reachables in reachable_locations_count.items() if
|
||||
reachables < threshold and player in balanceable_players}
|
||||
item_percentage(player, reachables) < threshold_percentage and player in balanceable_players}
|
||||
if balancing_players:
|
||||
balancing_state = state.copy()
|
||||
balancing_unchecked_locations = unchecked_locations.copy()
|
||||
balancing_reachables = reachable_locations_count.copy()
|
||||
balancing_sphere = sphere_locations.copy()
|
||||
candidate_items = collections.defaultdict(set)
|
||||
candidate_items: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
|
||||
while True:
|
||||
# Check locations in the current sphere and gather progression items to swap earlier
|
||||
for location in balancing_sphere:
|
||||
if location.event:
|
||||
balancing_state.collect(location.item, True, location)
|
||||
player = location.item.player
|
||||
# only replace items that end up in another player's world
|
||||
if(not location.locked and
|
||||
if (not location.locked and not location.item.skip_in_prog_balancing and
|
||||
player in balancing_players and
|
||||
location.player != player and
|
||||
location.progress_type != LocationProgressType.PRIORITY):
|
||||
candidate_items[player].add(location)
|
||||
logging.debug(f"Candidate item: {location.name}, {location.item.name}")
|
||||
balancing_sphere = get_sphere_locations(balancing_state, balancing_unchecked_locations)
|
||||
for location in balancing_sphere:
|
||||
balancing_unchecked_locations.remove(location)
|
||||
balancing_reachables[location.player] += 1
|
||||
if not location.locked:
|
||||
balancing_reachables[location.player] += 1
|
||||
if world.has_beaten_game(balancing_state) or all(
|
||||
reachables >= threshold for reachables in balancing_reachables.values()):
|
||||
item_percentage(player, reachables) >= threshold_percentage
|
||||
for player, reachables in balancing_reachables.items()):
|
||||
break
|
||||
elif not balancing_sphere:
|
||||
raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
|
||||
unlocked_locations = collections.defaultdict(set)
|
||||
# Gather a set of locations which we can swap items into
|
||||
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
|
||||
for l in unchecked_locations:
|
||||
if l not in balancing_unchecked_locations:
|
||||
unlocked_locations[l.player].add(l)
|
||||
items_to_replace = []
|
||||
items_to_replace: typing.List[Location] = []
|
||||
for player in balancing_players:
|
||||
locations_to_test = unlocked_locations[player]
|
||||
items_to_test = candidate_items[player]
|
||||
while items_to_test:
|
||||
testing = items_to_test.pop()
|
||||
reducing_state = state.copy()
|
||||
for location in itertools.chain((l for l in items_to_replace if l.item.player == player),
|
||||
items_to_test):
|
||||
for location in itertools.chain((
|
||||
l for l in items_to_replace
|
||||
if l.item.player == player
|
||||
), items_to_test):
|
||||
reducing_state.collect(location.item, True, location)
|
||||
|
||||
reducing_state.sweep_for_events(locations=locations_to_test)
|
||||
@@ -369,7 +419,8 @@ def balance_multiworld_progression(world: MultiWorld):
|
||||
items_to_replace.append(testing)
|
||||
else:
|
||||
reduced_sphere = get_sphere_locations(reducing_state, locations_to_test)
|
||||
if reachable_locations_count[player] + len(reduced_sphere) < threshold:
|
||||
p = item_percentage(player, reachable_locations_count[player] + len(reduced_sphere))
|
||||
if p < threshold_percentage:
|
||||
items_to_replace.append(testing)
|
||||
|
||||
replaced_items = False
|
||||
@@ -381,6 +432,7 @@ def balance_multiworld_progression(world: MultiWorld):
|
||||
items_to_replace.sort()
|
||||
world.random.shuffle(items_to_replace)
|
||||
|
||||
# Start swapping items. Since we swap into earlier spheres, no need for accessibility checks.
|
||||
while replacement_locations and items_to_replace:
|
||||
old_location = items_to_replace.pop()
|
||||
for new_location in replacement_locations:
|
||||
@@ -390,6 +442,7 @@ def balance_multiworld_progression(world: MultiWorld):
|
||||
swap_location_item(old_location, new_location)
|
||||
logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, "
|
||||
f"displacing {old_location.item} into {old_location}")
|
||||
moved_item_count += 1
|
||||
state.collect(new_location.item, True, new_location)
|
||||
replaced_items = True
|
||||
break
|
||||
@@ -397,10 +450,12 @@ def balance_multiworld_progression(world: MultiWorld):
|
||||
logging.warning(f"Could not Progression Balance {old_location.item}")
|
||||
|
||||
if replaced_items:
|
||||
logging.debug(f"Moved {moved_item_count} items so far\n")
|
||||
unlocked = {fresh for player in balancing_players for fresh in unlocked_locations[player]}
|
||||
for location in get_sphere_locations(state, unlocked):
|
||||
unchecked_locations.remove(location)
|
||||
reachable_locations_count[location.player] += 1
|
||||
if not location.locked:
|
||||
reachable_locations_count[location.player] += 1
|
||||
sphere_locations.add(location)
|
||||
|
||||
for location in sphere_locations:
|
||||
@@ -415,7 +470,7 @@ def balance_multiworld_progression(world: MultiWorld):
|
||||
break
|
||||
|
||||
|
||||
def swap_location_item(location_1: Location, location_2: Location, check_locked=True):
|
||||
def swap_location_item(location_1: Location, location_2: Location, check_locked: bool = True) -> None:
|
||||
"""Swaps Items of locations. Does NOT swap flags like shop_slot or locked, but does swap event"""
|
||||
if check_locked:
|
||||
if location_1.locked:
|
||||
@@ -426,16 +481,17 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked=
|
||||
location_1.item.location = location_1
|
||||
location_2.item.location = location_2
|
||||
location_1.event, location_2.event = location_2.event, location_1.event
|
||||
|
||||
def distribute_planned(world: MultiWorld):
|
||||
def warn(warning: str, force):
|
||||
|
||||
|
||||
def distribute_planned(world: MultiWorld) -> None:
|
||||
def warn(warning: str, force: typing.Union[bool, str]) -> None:
|
||||
if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']:
|
||||
logging.warning(f'{warning}')
|
||||
else:
|
||||
logging.debug(f'{warning}')
|
||||
|
||||
def failed(warning: str, force):
|
||||
if force in [True, 'fail', 'failure']:
|
||||
def failed(warning: str, force: typing.Union[bool, str]) -> None:
|
||||
if force in [True, 'fail', 'failure']:
|
||||
raise Exception(warning)
|
||||
else:
|
||||
warn(warning, force)
|
||||
@@ -444,7 +500,8 @@ def distribute_planned(world: MultiWorld):
|
||||
from worlds.alttp.Regions import key_drop_data
|
||||
world_name_lookup = world.world_name_lookup
|
||||
|
||||
plando_blocks = []
|
||||
block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
|
||||
plando_blocks: typing.List[typing.Dict[str, typing.Any]] = []
|
||||
player_ids = set(world.player_ids)
|
||||
for player in player_ids:
|
||||
for block in world.plando_items[player]:
|
||||
@@ -455,7 +512,7 @@ def distribute_planned(world: MultiWorld):
|
||||
block['from_pool'] = True
|
||||
if 'world' not in block:
|
||||
block['world'] = False
|
||||
items = []
|
||||
items: block_value = []
|
||||
if "items" in block:
|
||||
items = block["items"]
|
||||
if 'count' not in block:
|
||||
@@ -468,7 +525,7 @@ def distribute_planned(world: MultiWorld):
|
||||
failed("You must specify at least one item to place items with plando.", block['force'])
|
||||
continue
|
||||
if isinstance(items, dict):
|
||||
item_list = []
|
||||
item_list: typing.List[str] = []
|
||||
for key, value in items.items():
|
||||
if value is True:
|
||||
value = world.itempool.count(world.worlds[player].create_item(key))
|
||||
@@ -478,7 +535,7 @@ def distribute_planned(world: MultiWorld):
|
||||
items = [items]
|
||||
block['items'] = items
|
||||
|
||||
locations = []
|
||||
locations: block_value = []
|
||||
if 'location' in block:
|
||||
locations = block['location'] # just allow 'location' to keep old yamls compatible
|
||||
elif 'locations' in block:
|
||||
@@ -491,20 +548,18 @@ def distribute_planned(world: MultiWorld):
|
||||
for key, value in locations.items():
|
||||
location_list += [key] * value
|
||||
locations = location_list
|
||||
if isinstance(locations, str):
|
||||
locations = [locations]
|
||||
block['locations'] = locations
|
||||
|
||||
if not block['count']:
|
||||
block['count'] = (min(len(block['items']), len(block['locations'])) if len(block['locations'])
|
||||
> 0 else len(block['items']))
|
||||
block['count'] = (min(len(block['items']), len(block['locations'])) if
|
||||
len(block['locations']) > 0 else len(block['items']))
|
||||
if isinstance(block['count'], int):
|
||||
block['count'] = {'min': block['count'], 'max': block['count']}
|
||||
if 'min' not in block['count']:
|
||||
block['count']['min'] = 0
|
||||
if 'max' not in block['count']:
|
||||
block['count']['max'] = (min(len(block['items']), len(block['locations'])) if len(block['locations'])
|
||||
> 0 else len(block['items']))
|
||||
block['count']['max'] = (min(len(block['items']), len(block['locations'])) if
|
||||
len(block['locations']) > 0 else len(block['items']))
|
||||
if block['count']['max'] > len(block['items']):
|
||||
count = block['count']
|
||||
failed(f"Plando count {count} greater than items specified", block['force'])
|
||||
@@ -534,20 +589,19 @@ def distribute_planned(world: MultiWorld):
|
||||
maxcount = placement['count']['target']
|
||||
from_pool = placement['from_pool']
|
||||
if target_world is False or world.players == 1: # target own world
|
||||
worlds = {player}
|
||||
worlds: typing.Set[int] = {player}
|
||||
elif target_world is True: # target any worlds besides own
|
||||
worlds = set(world.player_ids) - {player}
|
||||
elif target_world is None: # target all worlds
|
||||
worlds = set(world.player_ids)
|
||||
elif type(target_world) == list: # list of target worlds
|
||||
worlds = []
|
||||
worlds = set()
|
||||
for listed_world in target_world:
|
||||
if listed_world not in world_name_lookup:
|
||||
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
||||
placement['force'])
|
||||
continue
|
||||
worlds.append(world_name_lookup[listed_world])
|
||||
worlds = set(worlds)
|
||||
worlds.add(world_name_lookup[listed_world])
|
||||
elif type(target_world) == int: # target world by slot number
|
||||
if target_world not in range(1, world.players + 1):
|
||||
failed(
|
||||
@@ -567,8 +621,8 @@ def distribute_planned(world: MultiWorld):
|
||||
world.random.shuffle(candidates)
|
||||
world.random.shuffle(items)
|
||||
count = 0
|
||||
err = []
|
||||
successful_pairs = []
|
||||
err: typing.List[str] = []
|
||||
successful_pairs: typing.List[typing.Tuple[Item, Location]] = []
|
||||
for item_name in items:
|
||||
item = world.worlds[player].create_item(item_name)
|
||||
for location in reversed(candidates):
|
||||
@@ -579,7 +633,7 @@ def distribute_planned(world: MultiWorld):
|
||||
if not location.item:
|
||||
if location.item_rule(item):
|
||||
if location.can_fill(world.state, item, False):
|
||||
successful_pairs.append([item, location])
|
||||
successful_pairs.append((item, location))
|
||||
candidates.remove(location)
|
||||
count = count + 1
|
||||
break
|
||||
@@ -592,10 +646,9 @@ def distribute_planned(world: MultiWorld):
|
||||
if count == maxcount:
|
||||
break
|
||||
if count < placement['count']['min']:
|
||||
err = " ".join(err)
|
||||
m = placement['count']['min']
|
||||
failed(
|
||||
f"Plando block failed to place {m - count} of {m} item(s) for {world.player_name[player]}, error(s): {err}",
|
||||
f"Plando block failed to place {m - count} of {m} item(s) for {world.player_name[player]}, error(s): {' '.join(err)}",
|
||||
placement['force'])
|
||||
for (item, location) in successful_pairs:
|
||||
world.push_item(location, item, collect=False)
|
||||
|
||||
46
Generate.py
@@ -14,8 +14,8 @@ ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
from worlds.alttp import Options as LttPOptions
|
||||
from worlds.generic import PlandoItem, PlandoConnection
|
||||
from Utils import parse_yaml, version_tuple, __version__, tuplize_version, get_options
|
||||
from worlds.generic import PlandoConnection
|
||||
from Utils import parse_yaml, version_tuple, __version__, tuplize_version, get_options, local_path, user_path
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from Main import main as ERmain
|
||||
from BaseClasses import seeddigits, get_seed
|
||||
@@ -27,24 +27,31 @@ import copy
|
||||
|
||||
categories = set(AutoWorldRegister.world_types)
|
||||
|
||||
|
||||
def mystery_argparse():
|
||||
options = get_options()
|
||||
defaults = options["generator"]
|
||||
|
||||
def resolve_path(path: str, resolver: typing.Callable[[str], str]) -> str:
|
||||
return path if os.path.isabs(path) else resolver(path)
|
||||
|
||||
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
|
||||
parser.add_argument('--weights_file_path', default = defaults["weights_file_path"],
|
||||
parser.add_argument('--weights_file_path', default=defaults["weights_file_path"],
|
||||
help='Path to the weights file to use for rolling game settings, urls are also valid')
|
||||
parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player',
|
||||
action='store_true')
|
||||
parser.add_argument('--player_files_path', default=defaults["player_files_path"],
|
||||
parser.add_argument('--player_files_path', default=resolve_path(defaults["player_files_path"], user_path),
|
||||
help="Input directory for player files.")
|
||||
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
|
||||
parser.add_argument('--multi', default=defaults["players"], type=lambda value: max(int(value), 1))
|
||||
parser.add_argument('--spoiler', type=int, default=defaults["spoiler"])
|
||||
parser.add_argument('--lttp_rom', default=options["lttp_options"]["rom_file"], help="Path to the 1.0 JP LttP Baserom.")
|
||||
parser.add_argument('--sm_rom', default=options["sm_options"]["rom_file"], help="Path to the 1.0 JP SM Baserom.")
|
||||
parser.add_argument('--enemizercli', default=defaults["enemizer_path"])
|
||||
parser.add_argument('--outputpath', default=options["general_options"]["output_path"])
|
||||
parser.add_argument('--lttp_rom', default=options["lttp_options"]["rom_file"],
|
||||
help="Path to the 1.0 JP LttP Baserom.") # absolute, relative to cwd or relative to app path
|
||||
parser.add_argument('--sm_rom', default=options["sm_options"]["rom_file"],
|
||||
help="Path to the 1.0 JP SM Baserom.")
|
||||
parser.add_argument('--enemizercli', default=resolve_path(defaults["enemizer_path"], local_path))
|
||||
parser.add_argument('--outputpath', default=resolve_path(options["general_options"]["output_path"], user_path),
|
||||
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
|
||||
parser.add_argument('--race', action='store_true', default=defaults["race"])
|
||||
parser.add_argument('--meta_file_path', default=defaults["meta_file_path"])
|
||||
parser.add_argument('--log_level', default='info', help='Sets log level')
|
||||
@@ -180,7 +187,7 @@ def main(args=None, callback=ERmain):
|
||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||
|
||||
if len(set(erargs.name.values())) != len(erargs.name):
|
||||
raise Exception(f"Names have to be unique. Names: {erargs.name}")
|
||||
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
|
||||
|
||||
if args.yaml_output:
|
||||
import yaml
|
||||
@@ -209,11 +216,11 @@ def main(args=None, callback=ERmain):
|
||||
|
||||
def read_weights_yaml(path):
|
||||
try:
|
||||
if urllib.parse.urlparse(path).scheme:
|
||||
yaml = str(urllib.request.urlopen(path).read(), "utf-8")
|
||||
if urllib.parse.urlparse(path).scheme in ('https', 'file'):
|
||||
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
|
||||
else:
|
||||
with open(path, 'rb') as f:
|
||||
yaml = str(f.read(), "utf-8")
|
||||
yaml = str(f.read(), "utf-8-sig")
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to read weights ({path})") from e
|
||||
|
||||
@@ -426,17 +433,8 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
|
||||
except Exception as e:
|
||||
raise Exception(f"Error generating option {option_key} in {ret.game}") from e
|
||||
else:
|
||||
# verify item names existing
|
||||
if getattr(player_option, "verify_item_name", False):
|
||||
for item_name in player_option.value:
|
||||
if item_name not in AutoWorldRegister.world_types[ret.game].item_names:
|
||||
raise Exception(f"Item {item_name} from option {player_option} "
|
||||
f"is not a valid item name from {ret.game}")
|
||||
elif getattr(player_option, "verify_location_name", False):
|
||||
for location_name in player_option.value:
|
||||
if location_name not in AutoWorldRegister.world_types[ret.game].location_names:
|
||||
raise Exception(f"Location {location_name} from option {player_option} "
|
||||
f"is not a valid location name from {ret.game}")
|
||||
if hasattr(player_option, "verify"):
|
||||
player_option.verify(AutoWorldRegister.world_types[ret.game])
|
||||
else:
|
||||
setattr(ret, option_key, option(option.default))
|
||||
|
||||
@@ -511,8 +509,10 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
|
||||
roll_alttp_settings(ret, game_weights, plando_options)
|
||||
else:
|
||||
raise Exception(f"Unsupported game {ret.game}")
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||
if "dungeon_items" in weights and get_choice_legacy('dungeon_items', weights, "none") != "none":
|
||||
raise Exception(f"dungeon_items key in A Link to the Past was removed, but is present in these weights as {get_choice_legacy('dungeon_items', weights, False)}.")
|
||||
|
||||
4
LICENSE
@@ -1,8 +1,8 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 LLCoolDave
|
||||
Copyright (c) 2021 Berserker66
|
||||
Copyright (c) 2021 CaitSith2
|
||||
Copyright (c) 2022 Berserker66
|
||||
Copyright (c) 2022 CaitSith2
|
||||
Copyright (c) 2021 LegendaryLinux
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
|
||||
307
Launcher.py
Normal file
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
Archipelago launcher for bundled app.
|
||||
|
||||
* if run with APBP as argument, launch corresponding client.
|
||||
* if run with executable as argument, run it passing argv[2:] as arguments
|
||||
* if run without arguments, open launcher GUI
|
||||
|
||||
Scroll down to components= to add components to the launcher as well as setup.py
|
||||
"""
|
||||
|
||||
|
||||
import argparse
|
||||
from os.path import isfile
|
||||
import sys
|
||||
from typing import Iterable, Sequence, Callable, Union, Optional
|
||||
import subprocess
|
||||
import itertools
|
||||
from Utils import is_frozen, user_path, local_path, init_logging
|
||||
from shutil import which
|
||||
import shlex
|
||||
from enum import Enum, auto
|
||||
import logging
|
||||
|
||||
|
||||
is_linux = sys.platform.startswith('linux')
|
||||
is_macos = sys.platform == 'darwin'
|
||||
is_windows = sys.platform in ("win32", "cygwin", "msys")
|
||||
|
||||
|
||||
def open_host_yaml():
|
||||
file = user_path('host.yaml')
|
||||
if is_linux:
|
||||
exe = which('sensible-editor') or which('gedit') or \
|
||||
which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||
subprocess.Popen([exe, file])
|
||||
elif is_macos:
|
||||
exe = which("open")
|
||||
subprocess.Popen([exe, file])
|
||||
else:
|
||||
import webbrowser
|
||||
webbrowser.open(file)
|
||||
|
||||
|
||||
def open_patch():
|
||||
try:
|
||||
import tkinter
|
||||
import tkinter.filedialog
|
||||
except Exception as e:
|
||||
logging.error("Could not load tkinter, which is likely not installed. "
|
||||
"This attempt was made because Launcher.open_patch was used.")
|
||||
raise e
|
||||
else:
|
||||
root = tkinter.Tk()
|
||||
root.withdraw()
|
||||
suffixes = []
|
||||
for c in components:
|
||||
if isfile(get_exe(c)[-1]):
|
||||
suffixes += c.file_identifier.suffixes if c.type == Type.CLIENT and \
|
||||
isinstance(c.file_identifier, SuffixIdentifier) else []
|
||||
filename = tkinter.filedialog.askopenfilename(filetypes=(('Patches', ' '.join(suffixes)),))
|
||||
file, _, component = identify(filename)
|
||||
if file and component:
|
||||
launch([*get_exe(component), file], component.cli)
|
||||
|
||||
|
||||
def browse_files():
|
||||
file = user_path()
|
||||
if is_linux:
|
||||
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||
subprocess.Popen([exe, file])
|
||||
elif is_macos:
|
||||
exe = which("open")
|
||||
subprocess.Popen([exe, file])
|
||||
else:
|
||||
import webbrowser
|
||||
webbrowser.open(file)
|
||||
|
||||
|
||||
class Type(Enum):
|
||||
TOOL = auto()
|
||||
FUNC = auto() # not a real component
|
||||
CLIENT = auto()
|
||||
ADJUSTER = auto()
|
||||
|
||||
|
||||
class SuffixIdentifier:
|
||||
suffixes: Iterable[str]
|
||||
|
||||
def __init__(self, *args: str):
|
||||
self.suffixes = args
|
||||
|
||||
def __call__(self, path: str):
|
||||
if isinstance(path, str):
|
||||
for suffix in self.suffixes:
|
||||
if path.endswith(suffix):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class Component:
|
||||
display_name: str
|
||||
type: Optional[Type]
|
||||
script_name: Optional[str]
|
||||
frozen_name: Optional[str]
|
||||
icon: str # just the name, no suffix
|
||||
cli: bool
|
||||
func: Optional[Callable]
|
||||
file_identifier: Optional[Callable[[str], bool]]
|
||||
|
||||
def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None,
|
||||
cli: bool = False, icon: str = 'icon', component_type: Type = None, func: Optional[Callable] = None,
|
||||
file_identifier: Optional[Callable[[str], bool]] = None):
|
||||
self.display_name = display_name
|
||||
self.script_name = script_name
|
||||
self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None
|
||||
self.icon = icon
|
||||
self.cli = cli
|
||||
self.type = component_type or \
|
||||
None if not display_name else \
|
||||
Type.FUNC if func else \
|
||||
Type.CLIENT if 'Client' in display_name else \
|
||||
Type.ADJUSTER if 'Adjuster' in display_name else Type.TOOL
|
||||
self.func = func
|
||||
self.file_identifier = file_identifier
|
||||
|
||||
def handles_file(self, path: str):
|
||||
return self.file_identifier(path) if self.file_identifier else False
|
||||
|
||||
|
||||
components: Iterable[Component] = (
|
||||
# Launcher
|
||||
Component('', 'Launcher'),
|
||||
# Core
|
||||
Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True,
|
||||
file_identifier=SuffixIdentifier('.archipelago', '.zip')),
|
||||
Component('Generate', 'Generate', cli=True),
|
||||
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
|
||||
# SNI
|
||||
Component('SNI Client', 'SNIClient',
|
||||
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3')),
|
||||
Component('LttP Adjuster', 'LttPAdjuster'),
|
||||
# Factorio
|
||||
Component('Factorio Client', 'FactorioClient'),
|
||||
# Minecraft
|
||||
Component('Minecraft Client', 'MinecraftClient', icon='mcicon', cli=True,
|
||||
file_identifier=SuffixIdentifier('.apmc')),
|
||||
# Ocarina of Time
|
||||
Component('OoT Client', 'OoTClient',
|
||||
file_identifier=SuffixIdentifier('.apz5')),
|
||||
Component('OoT Adjuster', 'OoTAdjuster'),
|
||||
# FF1
|
||||
Component('FF1 Client', 'FF1Client'),
|
||||
# ChecksFinder
|
||||
Component('ChecksFinder Client', 'ChecksFinderClient'),
|
||||
# Functions
|
||||
Component('Open host.yaml', func=open_host_yaml),
|
||||
Component('Open Patch', func=open_patch),
|
||||
Component('Browse Files', func=browse_files),
|
||||
)
|
||||
icon_paths = {
|
||||
'icon': local_path('data', 'icon.ico' if is_windows else 'icon.png'),
|
||||
'mcicon': local_path('data', 'mcicon.ico')
|
||||
}
|
||||
|
||||
|
||||
def identify(path: Union[None, str]):
|
||||
if path is None:
|
||||
return None, None, None
|
||||
for component in components:
|
||||
if component.handles_file(path):
|
||||
return path, component.script_name, component
|
||||
return (None, None, None) if '/' in path or '\\' in path else (None, path, None)
|
||||
|
||||
|
||||
def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
|
||||
if isinstance(component, str):
|
||||
name = component
|
||||
component = None
|
||||
if name.startswith('Archipelago'):
|
||||
name = name[11:]
|
||||
if name.endswith('.exe'):
|
||||
name = name[:-4]
|
||||
if name.endswith('.py'):
|
||||
name = name[:-3]
|
||||
if not name:
|
||||
return None
|
||||
for c in components:
|
||||
if c.script_name == name or c.frozen_name == f'Archipelago{name}':
|
||||
component = c
|
||||
break
|
||||
if not component:
|
||||
return None
|
||||
if is_frozen():
|
||||
suffix = '.exe' if is_windows else ''
|
||||
return [local_path(f'{component.frozen_name}{suffix}')]
|
||||
else:
|
||||
return [sys.executable, local_path(f'{component.script_name}.py')]
|
||||
|
||||
|
||||
def launch(exe, in_terminal=False):
|
||||
if in_terminal:
|
||||
if is_windows:
|
||||
subprocess.Popen(['start', *exe], shell=True)
|
||||
return
|
||||
elif is_linux:
|
||||
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
|
||||
if terminal:
|
||||
subprocess.Popen([terminal, '-e', shlex.join(exe)])
|
||||
return
|
||||
elif is_macos:
|
||||
terminal = [which('open'), '-W', '-a', 'Terminal.app']
|
||||
subprocess.Popen([*terminal, *exe])
|
||||
return
|
||||
subprocess.Popen(exe)
|
||||
|
||||
|
||||
def run_gui():
|
||||
if not sys.stdout:
|
||||
from kvui import App, ContainerLayout, GridLayout, Button, Label # this kills stdout
|
||||
else:
|
||||
from kivy.app import App
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.floatlayout import FloatLayout as ContainerLayout
|
||||
from kivy.uix.gridlayout import GridLayout
|
||||
from kivy.uix.label import Label
|
||||
|
||||
class Launcher(App):
|
||||
base_title: str = "Archipelago Launcher"
|
||||
container: ContainerLayout
|
||||
grid: GridLayout
|
||||
|
||||
_tools = {c.display_name: c for c in components if c.type == Type.TOOL and isfile(get_exe(c)[-1])}
|
||||
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT and isfile(get_exe(c)[-1])}
|
||||
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER and isfile(get_exe(c)[-1])}
|
||||
_funcs = {c.display_name: c for c in components if c.type == Type.FUNC}
|
||||
|
||||
def __init__(self, ctx=None):
|
||||
self.title = self.base_title
|
||||
self.ctx = ctx
|
||||
self.icon = r"data/icon.png"
|
||||
super().__init__()
|
||||
|
||||
def build(self):
|
||||
self.container = ContainerLayout()
|
||||
self.grid = GridLayout(cols=2)
|
||||
self.container.add_widget(self.grid)
|
||||
|
||||
button_layout = self.grid # make buttons fill the window
|
||||
for (tool, client) in itertools.zip_longest(itertools.chain(
|
||||
self._tools.items(), self._funcs.items(), self._adjusters.items()), self._clients.items()):
|
||||
# column 1
|
||||
if tool:
|
||||
button = Button(text=tool[0])
|
||||
button.component = tool[1]
|
||||
button.bind(on_release=self.component_action)
|
||||
button_layout.add_widget(button)
|
||||
else:
|
||||
button_layout.add_widget(Label())
|
||||
# column 2
|
||||
if client:
|
||||
button = Button(text=client[0])
|
||||
button.component = client[1]
|
||||
button.bind(on_press=self.component_action)
|
||||
button_layout.add_widget(button)
|
||||
else:
|
||||
button_layout.add_widget(Label())
|
||||
|
||||
return self.container
|
||||
|
||||
@staticmethod
|
||||
def component_action(button):
|
||||
if button.component.type == Type.FUNC:
|
||||
button.component.func()
|
||||
else:
|
||||
launch(get_exe(button.component), button.component.cli)
|
||||
|
||||
Launcher().run()
|
||||
|
||||
|
||||
def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
||||
if isinstance(args, argparse.Namespace):
|
||||
args = {k: v for k, v in args._get_kwargs()}
|
||||
elif not args:
|
||||
args = {}
|
||||
|
||||
if "Patch|Game|Component" in args:
|
||||
file, component, _ = identify(args["Patch|Game|Component"])
|
||||
if file:
|
||||
args['file'] = file
|
||||
if component:
|
||||
args['component'] = component
|
||||
|
||||
if 'file' in args:
|
||||
subprocess.run([*get_exe(args['component']), args['file'], *args['args']])
|
||||
elif 'component' in args:
|
||||
subprocess.run([*get_exe(args['component']), *args['args']])
|
||||
else:
|
||||
run_gui()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_logging('Launcher')
|
||||
parser = argparse.ArgumentParser(description='Archipelago Launcher')
|
||||
parser.add_argument('Patch|Game|Component', type=str, nargs='?',
|
||||
help="Pass either a patch file, a generated game or the name of a component to run.")
|
||||
parser.add_argument('args', nargs="*", help="Arguments to pass to component.")
|
||||
main(parser.parse_args())
|
||||
@@ -21,7 +21,7 @@ from urllib.parse import urlparse
|
||||
from urllib.request import urlopen
|
||||
|
||||
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
|
||||
from Utils import output_path, local_path, open_file, get_cert_none_ssl_context, persistent_store, get_adjuster_settings, tkinter_center_window
|
||||
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, get_adjuster_settings, tkinter_center_window
|
||||
from Patch import GAME_ALTTP
|
||||
|
||||
class AdjusterWorld(object):
|
||||
@@ -130,7 +130,7 @@ def adjust(args):
|
||||
logger = logging.getLogger('Adjuster')
|
||||
logger.info('Patching ROM.')
|
||||
vanillaRom = args.baserom
|
||||
if os.path.splitext(args.rom)[-1].lower() == '.apbp':
|
||||
if os.path.splitext(args.rom)[-1].lower() in {'.apbp', '.aplttp'}:
|
||||
import Patch
|
||||
meta, args.rom = Patch.create_rom_file(args.rom)
|
||||
|
||||
@@ -286,7 +286,7 @@ def run_sprite_update():
|
||||
def update_sprites(task, on_finish=None):
|
||||
resultmessage = ""
|
||||
successful = True
|
||||
sprite_dir = local_path("data", "sprites", "alttpr")
|
||||
sprite_dir = user_path("data", "sprites", "alttpr")
|
||||
os.makedirs(sprite_dir, exist_ok=True)
|
||||
ctx = get_cert_none_ssl_context()
|
||||
def finished():
|
||||
@@ -1013,11 +1013,11 @@ class SpriteSelector():
|
||||
|
||||
@property
|
||||
def alttpr_sprite_dir(self):
|
||||
return local_path("data", "sprites", "alttpr")
|
||||
return user_path("data", "sprites", "alttpr")
|
||||
|
||||
@property
|
||||
def custom_sprite_dir(self):
|
||||
return local_path("data", "sprites", "custom")
|
||||
return user_path("data", "sprites", "custom")
|
||||
|
||||
|
||||
def get_image_for_sprite(sprite, gif_only: bool = False):
|
||||
|
||||
143
Main.py
@@ -1,3 +1,5 @@
|
||||
import copy
|
||||
import collections
|
||||
from itertools import zip_longest, chain
|
||||
import logging
|
||||
import os
|
||||
@@ -7,9 +9,9 @@ import concurrent.futures
|
||||
import pickle
|
||||
import tempfile
|
||||
import zipfile
|
||||
from typing import Dict, Tuple, Optional
|
||||
from typing import Dict, Tuple, Optional, Set
|
||||
|
||||
from BaseClasses import Item, MultiWorld, CollectionState, Region, RegionType
|
||||
from BaseClasses import MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location
|
||||
from worlds.alttp.Items import item_name_groups
|
||||
from worlds.alttp.Regions import lookup_vanilla_location_to_entrance
|
||||
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
|
||||
@@ -18,7 +20,6 @@ from Utils import output_path, get_options, __version__, version_tuple
|
||||
from worlds.generic.Rules import locality_rules, exclusion_rules
|
||||
from worlds import AutoWorld
|
||||
|
||||
|
||||
ordered_areas = (
|
||||
'Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace',
|
||||
'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace',
|
||||
@@ -47,13 +48,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
world.item_functionality = args.item_functionality.copy()
|
||||
world.timer = args.timer.copy()
|
||||
world.goal = args.goal.copy()
|
||||
|
||||
if hasattr(args, "algorithm"): # current GUI options
|
||||
world.algorithm = args.algorithm
|
||||
world.shuffleganon = args.shuffleganon
|
||||
world.custom = args.custom
|
||||
world.customitemarray = args.customitemarray
|
||||
|
||||
world.open_pyramid = args.open_pyramid.copy()
|
||||
world.boss_shuffle = args.shufflebosses.copy()
|
||||
world.enemy_health = args.enemy_health.copy()
|
||||
@@ -77,12 +71,14 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
world.plando_connections = args.plando_connections.copy()
|
||||
world.required_medallions = args.required_medallions.copy()
|
||||
world.game = args.game.copy()
|
||||
world.set_options(args)
|
||||
world.player_name = args.name.copy()
|
||||
world.enemizer = args.enemizercli
|
||||
world.sprite = args.sprite.copy()
|
||||
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
|
||||
|
||||
world.set_options(args)
|
||||
world.set_item_links()
|
||||
world.state = CollectionState(world)
|
||||
logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
|
||||
|
||||
logger.info("Found World Types:")
|
||||
@@ -137,9 +133,88 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
|
||||
for player in world.player_ids:
|
||||
exclusion_rules(world, player, world.exclude_locations[player].value)
|
||||
world.priority_locations[player].value -= world.exclude_locations[player].value
|
||||
for location_name in world.priority_locations[player].value:
|
||||
world.get_location(location_name, player).progress_type = LocationProgressType.PRIORITY
|
||||
|
||||
AutoWorld.call_all(world, "generate_basic")
|
||||
|
||||
# temporary home for item links, should be moved out of Main
|
||||
for group_id, group in world.groups.items():
|
||||
def find_common_pool(players: Set[int], shared_pool: Set[str]):
|
||||
advancement = set()
|
||||
counters = {player: {name: 0 for name in shared_pool} for player in players}
|
||||
for item in world.itempool:
|
||||
if item.player in counters and item.name in shared_pool:
|
||||
counters[item.player][item.name] += 1
|
||||
if item.advancement:
|
||||
advancement.add(item.name)
|
||||
|
||||
for player in players.copy():
|
||||
if all([counters[player][item] == 0 for item in shared_pool]):
|
||||
players.remove(player)
|
||||
del(counters[player])
|
||||
|
||||
if not players:
|
||||
return None, None
|
||||
|
||||
for item in shared_pool:
|
||||
count = min(counters[player][item] for player in players)
|
||||
if count:
|
||||
for player in players:
|
||||
counters[player][item] = count
|
||||
else:
|
||||
for player in players:
|
||||
del(counters[player][item])
|
||||
return counters, advancement
|
||||
|
||||
common_item_count, common_advancement_items = find_common_pool(group["players"], group["item_pool"])
|
||||
if not common_item_count:
|
||||
continue
|
||||
|
||||
new_itempool = []
|
||||
for item_name, item_count in next(iter(common_item_count.values())).items():
|
||||
advancement = item_name in common_advancement_items
|
||||
for _ in range(item_count):
|
||||
new_item = group["world"].create_item(item_name)
|
||||
new_item.advancement = advancement
|
||||
new_itempool.append(new_item)
|
||||
|
||||
region = Region("Menu", RegionType.Generic, "ItemLink", group_id, world)
|
||||
world.regions.append(region)
|
||||
locations = region.locations = []
|
||||
for item in world.itempool:
|
||||
count = common_item_count.get(item.player, {}).get(item.name, 0)
|
||||
if count:
|
||||
loc = Location(group_id, f"Item Link: {item.name} -> {world.player_name[item.player]} {count}",
|
||||
None, region)
|
||||
loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
|
||||
state.has(item_name, group_id_, count_)
|
||||
|
||||
locations.append(loc)
|
||||
loc.place_locked_item(item)
|
||||
common_item_count[item.player][item.name] -= 1
|
||||
else:
|
||||
new_itempool.append(item)
|
||||
|
||||
itemcount = len(world.itempool)
|
||||
world.itempool = new_itempool
|
||||
|
||||
while itemcount > len(world.itempool):
|
||||
items_to_add = []
|
||||
for player in group["players"]:
|
||||
if group["replacement_items"][player]:
|
||||
items_to_add.append(AutoWorld.call_single(world, "create_item", player,
|
||||
group["replacement_items"][player]))
|
||||
else:
|
||||
items_to_add.append(AutoWorld.call_single(world, "create_filler", player))
|
||||
world.random.shuffle(items_to_add)
|
||||
world.itempool.extend(items_to_add[:itemcount - len(world.itempool)])
|
||||
|
||||
if any(world.item_links.values()):
|
||||
world._recache()
|
||||
world._all_state = None
|
||||
|
||||
logger.info("Running Item Plando")
|
||||
|
||||
for item in world.itempool:
|
||||
@@ -250,46 +325,58 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
import NetUtils
|
||||
slot_data = {}
|
||||
client_versions = {}
|
||||
minimum_versions = {"server": (0, 1, 8), "clients": client_versions}
|
||||
games = {}
|
||||
minimum_versions = {"server": (0, 2, 4), "clients": client_versions}
|
||||
slot_info = {}
|
||||
names = [[name for player, name in sorted(world.player_name.items())]]
|
||||
for slot in world.player_ids:
|
||||
client_versions[slot] = world.worlds[slot].get_required_client_version()
|
||||
games[slot] = world.game[slot]
|
||||
precollected_items = {player: [item.code for item in world_precollected]
|
||||
slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], world.game[slot],
|
||||
world.player_types[slot])
|
||||
for slot, group in world.groups.items():
|
||||
games[slot] = world.game[slot]
|
||||
slot_info[slot] = NetUtils.NetworkSlot(group["name"], world.game[slot], world.player_types[slot],
|
||||
group_members=sorted(group["players"]))
|
||||
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
|
||||
for player, world_precollected in world.precollected_items.items()}
|
||||
precollected_hints = {player: set() for player in range(1, world.players + 1)}
|
||||
precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))}
|
||||
|
||||
sending_visible_players = set()
|
||||
|
||||
for slot in world.player_ids:
|
||||
slot_data[slot] = world.worlds[slot].fill_slot_data()
|
||||
if world.worlds[slot].sending_visible:
|
||||
sending_visible_players.add(slot)
|
||||
|
||||
def precollect_hint(location):
|
||||
entrance = er_hint_data.get(location.player, {}).get(location.address, "")
|
||||
hint = NetUtils.Hint(location.item.player, location.player, location.address,
|
||||
location.item.code, False, "", location.item.flags)
|
||||
location.item.code, False, entrance, location.item.flags)
|
||||
precollected_hints[location.player].add(hint)
|
||||
precollected_hints[location.item.player].add(hint)
|
||||
if location.item.player not in world.groups:
|
||||
precollected_hints[location.item.player].add(hint)
|
||||
else:
|
||||
for player in world.groups[location.item.player]["players"]:
|
||||
precollected_hints[player].add(hint)
|
||||
|
||||
locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in world.player_ids}
|
||||
for location in world.get_filled_locations():
|
||||
if type(location.address) == int:
|
||||
# item code None should be event, location.address should then also be None
|
||||
assert location.item.code is not None
|
||||
assert location.item.code is not None, "item code None should be event, " \
|
||||
"location.address should then also be None"
|
||||
locations_data[location.player][location.address] = \
|
||||
location.item.code, location.item.player, location.item.flags
|
||||
if location.player in sending_visible_players:
|
||||
precollect_hint(location)
|
||||
elif location.name in world.start_location_hints[location.player]:
|
||||
if location.name in world.start_location_hints[location.player]:
|
||||
precollect_hint(location)
|
||||
elif location.item.name in world.start_hints[location.item.player]:
|
||||
precollect_hint(location)
|
||||
elif any([location.item.name in world.start_hints[player]
|
||||
for player in world.groups.get(location.item.player, {}).get("players", [])]):
|
||||
precollect_hint(location)
|
||||
|
||||
multidata = {
|
||||
"slot_data": slot_data,
|
||||
"games": games,
|
||||
"names": [[name for player, name in sorted(world.player_name.items())]],
|
||||
"slot_info": slot_info,
|
||||
"names": names, # TODO: remove around 0.2.5 in favor of slot_info
|
||||
"games": games, # TODO: remove around 0.2.5 in favor of slot_info
|
||||
"connect_names": {name: (0, player) for player, name in world.player_name.items()},
|
||||
"remote_items": {player for player in world.player_ids if
|
||||
world.worlds[player].remote_items},
|
||||
@@ -311,7 +398,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
multidata = zlib.compress(pickle.dumps(multidata), 9)
|
||||
|
||||
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
|
||||
f.write(bytes([2])) # version of format
|
||||
f.write(bytes([3])) # version of format
|
||||
f.write(multidata)
|
||||
|
||||
multidata_task = pool.submit(write_multidata)
|
||||
@@ -321,7 +408,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
else:
|
||||
logger.warning("Location Accessibility requirements not fulfilled.")
|
||||
|
||||
# retrieve exceptions via .result() if they occured.
|
||||
# retrieve exceptions via .result() if they occurred.
|
||||
multidata_task.result()
|
||||
for i, future in enumerate(concurrent.futures.as_completed(output_file_futures), start=1):
|
||||
if i % 10 == 0 or i == len(output_file_futures):
|
||||
|
||||
@@ -2,6 +2,7 @@ import argparse
|
||||
import os, sys
|
||||
import re
|
||||
import atexit
|
||||
import shutil
|
||||
from subprocess import Popen
|
||||
from shutil import copyfile
|
||||
from time import strftime
|
||||
@@ -16,6 +17,7 @@ atexit.register(input, "Press enter to exit.")
|
||||
# 1 or more digits followed by m or g, then optional b
|
||||
max_heap_re = re.compile(r"^\d+[mMgG][bB]?$")
|
||||
forge_version = "1.17.1-37.1.1"
|
||||
is_windows = sys.platform in ("win32", "cygwin", "msys")
|
||||
|
||||
|
||||
def prompt_yes_no(prompt):
|
||||
@@ -158,9 +160,15 @@ def find_jdk_dir() -> str:
|
||||
|
||||
# get the java exe location
|
||||
def find_jdk() -> str:
|
||||
jdk = find_jdk_dir()
|
||||
jdk_exe = os.path.join(jdk, "bin", "java.exe")
|
||||
if os.path.isfile(jdk_exe):
|
||||
if is_windows:
|
||||
jdk = find_jdk_dir()
|
||||
jdk_exe = os.path.join(jdk, "bin", "java.exe")
|
||||
if os.path.isfile(jdk_exe):
|
||||
return jdk_exe
|
||||
else:
|
||||
jdk_exe = shutil.which(options["minecraft_options"].get("java", "java"))
|
||||
if not jdk_exe:
|
||||
raise Exception("Could not find Java. Is Java installed on the system?")
|
||||
return jdk_exe
|
||||
|
||||
|
||||
@@ -203,7 +211,7 @@ def install_forge(directory: str):
|
||||
f.write(resp.content)
|
||||
print(f"Installing Forge...")
|
||||
argstring = ' '.join([jdk, "-jar", "\"" + forge_install_jar+ "\"", "--installServer", "\"" + directory + "\""])
|
||||
install_process = Popen(argstring)
|
||||
install_process = Popen(argstring, shell=not is_windows)
|
||||
install_process.wait()
|
||||
os.remove(forge_install_jar)
|
||||
|
||||
@@ -220,7 +228,8 @@ def run_forge_server(forge_dir: str, heap_arg):
|
||||
heap_arg = heap_arg[:-1]
|
||||
heap_arg = "-Xmx" + heap_arg
|
||||
|
||||
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, "win_args.txt")
|
||||
os_args = "win_args.txt" if is_windows else "unix_args.txt"
|
||||
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, os_args)
|
||||
win_args = []
|
||||
with open(args_file) as argfile:
|
||||
for line in argfile:
|
||||
@@ -229,7 +238,7 @@ def run_forge_server(forge_dir: str, heap_arg):
|
||||
argstring = ' '.join([java_exe, heap_arg] + win_args + ["-nogui"])
|
||||
logging.info(f"Running Forge server: {argstring}")
|
||||
os.chdir(forge_dir)
|
||||
return Popen(argstring)
|
||||
return Popen(argstring, shell=not is_windows)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
@@ -252,15 +261,21 @@ if __name__ == '__main__':
|
||||
max_heap = options["minecraft_options"]["max_heap_size"]
|
||||
|
||||
if args.install:
|
||||
print("Installing Java and Minecraft Forge")
|
||||
download_java()
|
||||
if is_windows:
|
||||
print("Installing Java and Minecraft Forge")
|
||||
download_java()
|
||||
else:
|
||||
print("Installing Minecraft Forge")
|
||||
install_forge(forge_dir)
|
||||
sys.exit(0)
|
||||
|
||||
if apmc_file is not None and not os.path.isfile(apmc_file):
|
||||
raise FileNotFoundError(f"Path {apmc_file} does not exist or could not be accessed.")
|
||||
if not os.path.isdir(forge_dir):
|
||||
raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.")
|
||||
if prompt_yes_no("Did not find forge directory. Download and install forge now?"):
|
||||
install_forge(forge_dir)
|
||||
if not os.path.isdir(forge_dir):
|
||||
raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.")
|
||||
if not max_heap_re.match(max_heap):
|
||||
raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.")
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import sys
|
||||
import subprocess
|
||||
import pkg_resources
|
||||
|
||||
requirements_files = {'requirements.txt'}
|
||||
local_dir = os.path.dirname(__file__)
|
||||
requirements_files = {os.path.join(local_dir, 'requirements.txt')}
|
||||
|
||||
if sys.version_info < (3, 8, 6):
|
||||
raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
|
||||
@@ -11,7 +12,7 @@ if sys.version_info < (3, 8, 6):
|
||||
update_ran = getattr(sys, "frozen", False) # don't run update if environment is frozen/compiled
|
||||
|
||||
if not update_ran:
|
||||
for entry in os.scandir("worlds"):
|
||||
for entry in os.scandir(os.path.join(local_dir, "worlds")):
|
||||
if entry.is_dir():
|
||||
req_file = os.path.join(entry.path, "requirements.txt")
|
||||
if os.path.exists(req_file):
|
||||
|
||||
315
MultiServer.py
@@ -15,6 +15,7 @@ import random
|
||||
import pickle
|
||||
import itertools
|
||||
import time
|
||||
import operator
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
@@ -33,10 +34,29 @@ from worlds import network_data_package, lookup_any_item_id_to_name, lookup_any_
|
||||
import Utils
|
||||
from Utils import get_item_name_from_id, get_location_name_from_id, \
|
||||
version_tuple, restricted_loads, Version
|
||||
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission
|
||||
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
||||
SlotType
|
||||
|
||||
colorama.init()
|
||||
|
||||
# functions callable on storable data on the server by clients
|
||||
modify_functions = {
|
||||
"add": operator.add, # add together two objects, using python's "+" operator (works on strings and lists as append)
|
||||
"mul": operator.mul,
|
||||
"mod": operator.mod,
|
||||
"max": max,
|
||||
"min": min,
|
||||
"replace": lambda old, new: new,
|
||||
"default": lambda old, new: old,
|
||||
"pow": operator.pow,
|
||||
# bitwise:
|
||||
"xor": operator.xor,
|
||||
"or": operator.or_,
|
||||
"and": operator.and_,
|
||||
"left_shift": operator.lshift,
|
||||
"right_shift": operator.rshift,
|
||||
}
|
||||
|
||||
|
||||
class Client(Endpoint):
|
||||
version = Version(0, 0, 0)
|
||||
@@ -97,13 +117,17 @@ class Context:
|
||||
# team -> slot id -> list of clients authenticated to slot.
|
||||
clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]]
|
||||
locations: typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
|
||||
groups: typing.Dict[int, typing.Set[int]]
|
||||
save_version = 2
|
||||
stored_data: typing.Dict[str, object]
|
||||
stored_data_notification_clients: typing.Dict[str, typing.Set[Client]]
|
||||
|
||||
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
||||
hint_cost: int, item_cheat: bool, forfeit_mode: str = "disabled", collect_mode="disabled",
|
||||
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
|
||||
log_network: bool = False):
|
||||
super(Context, self).__init__()
|
||||
self.slot_info: typing.Dict[int, NetworkSlot] = {}
|
||||
self.log_network = log_network
|
||||
self.endpoints = []
|
||||
self.clients = {}
|
||||
@@ -156,7 +180,10 @@ class Context:
|
||||
self.games: typing.Dict[int, str] = {}
|
||||
self.minimum_client_versions: typing.Dict[int, Utils.Version] = {}
|
||||
self.seed_name = ""
|
||||
self.groups = {}
|
||||
self.random = random.Random()
|
||||
self.stored_data = {}
|
||||
self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet)
|
||||
|
||||
# General networking
|
||||
|
||||
@@ -261,7 +288,7 @@ class Context:
|
||||
@staticmethod
|
||||
def decompress(data: bytes) -> dict:
|
||||
format_version = data[0]
|
||||
if format_version > 2:
|
||||
if format_version > 3:
|
||||
raise Utils.VersionException("Incompatible multidata.")
|
||||
return restricted_loads(zlib.decompress(data[1:]))
|
||||
|
||||
@@ -292,18 +319,40 @@ class Context:
|
||||
self.slot_data = decoded_obj['slot_data']
|
||||
self.er_hint_data = {int(player): {int(address): name for address, name in loc_data.items()}
|
||||
for player, loc_data in decoded_obj["er_hint_data"].items()}
|
||||
self.games = decoded_obj["games"]
|
||||
|
||||
# load start inventory:
|
||||
for slot, item_codes in decoded_obj["precollected_items"].items():
|
||||
self.start_inventory[slot] = [NetworkItem(item_code, -2, 0) for item_code in item_codes]
|
||||
|
||||
for team in range(len(decoded_obj['names'])):
|
||||
for slot, hints in decoded_obj["precollected_hints"].items():
|
||||
self.hints[team, slot].update(hints)
|
||||
# declare slots without checks as done, as they're assumed to be spectators
|
||||
for slot, locations in self.locations.items():
|
||||
if not locations:
|
||||
if "slot_info" in decoded_obj:
|
||||
self.slot_info = decoded_obj["slot_info"]
|
||||
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
|
||||
self.groups = {slot: slot_info.group_members for slot, slot_info in self.slot_info.items()
|
||||
if slot_info.type == SlotType.group}
|
||||
else:
|
||||
self.games = decoded_obj["games"]
|
||||
self.groups = {}
|
||||
self.slot_info = {
|
||||
slot: NetworkSlot(
|
||||
self.player_names[0, slot],
|
||||
self.games[slot],
|
||||
SlotType(int(bool(locations))))
|
||||
for slot, locations in self.locations.items()
|
||||
}
|
||||
# locations may need converting
|
||||
for slot, locations in self.locations.items():
|
||||
for location, item_data in locations.items():
|
||||
if len(item_data) < 3:
|
||||
locations[location] = (*item_data, 0)
|
||||
# declare slots that aren't players as done
|
||||
for slot, slot_info in self.slot_info.items():
|
||||
if slot_info.type.always_goal:
|
||||
for team in self.clients:
|
||||
self.client_game_state[team, slot] = ClientStatus.CLIENT_GOAL
|
||||
|
||||
if use_embedded_server_options:
|
||||
server_options = decoded_obj.get("server_options", {})
|
||||
self._set_options(server_options)
|
||||
@@ -382,7 +431,13 @@ class Context:
|
||||
(key, value.timestamp()) for key, value in self.client_activity_timers.items()),
|
||||
"client_connection_timers": tuple(
|
||||
(key, value.timestamp()) for key, value in self.client_connection_timers.items()),
|
||||
"random_state": self.random.getstate()
|
||||
"random_state": self.random.getstate(),
|
||||
"stored_data": self.stored_data,
|
||||
"game_options": {"hint_cost": self.hint_cost, "location_check_points": self.location_check_points,
|
||||
"server_password": self.server_password, "password": self.password, "forfeit_mode":
|
||||
self.forfeit_mode, "remaining_mode": self.remaining_mode, "collect_mode":
|
||||
self.collect_mode, "item_cheat": self.item_cheat, "compatibility": self.compatibility}
|
||||
|
||||
}
|
||||
|
||||
return d
|
||||
@@ -398,7 +453,7 @@ class Context:
|
||||
self.received_items[(*old, False)] = items.copy()
|
||||
for (team, slot, remote) in self.received_items:
|
||||
# remove start inventory from items, since this is separate now
|
||||
start_inventory = get_start_inventory(self, team, slot, slot in self.remote_start_inventory)
|
||||
start_inventory = get_start_inventory(self, slot, slot in self.remote_start_inventory)
|
||||
if start_inventory:
|
||||
del self.received_items[team, slot, remote][:len(start_inventory)]
|
||||
logging.info("Upgraded save data")
|
||||
@@ -418,10 +473,25 @@ class Context:
|
||||
{tuple(key): datetime.datetime.fromtimestamp(value, datetime.timezone.utc) for key, value
|
||||
in savedata["client_activity_timers"]})
|
||||
self.location_checks.update(savedata["location_checks"])
|
||||
if "random_state" in savedata:
|
||||
self.random.setstate(savedata["random_state"])
|
||||
logging.info(f'Loaded save file with {sum([len(p) for p in self.received_items.values()])} received items '
|
||||
f'for {len(self.received_items)} players')
|
||||
self.random.setstate(savedata["random_state"])
|
||||
|
||||
if "game_options" in savedata:
|
||||
self.hint_cost = savedata["game_options"]["hint_cost"]
|
||||
self.location_check_points = savedata["game_options"]["location_check_points"]
|
||||
self.server_password = savedata["game_options"]["server_password"]
|
||||
self.password = savedata["game_options"]["password"]
|
||||
self.forfeit_mode = savedata["game_options"]["forfeit_mode"]
|
||||
self.remaining_mode = savedata["game_options"]["remaining_mode"]
|
||||
self.collect_mode = savedata["game_options"]["collect_mode"]
|
||||
self.item_cheat = savedata["game_options"]["item_cheat"]
|
||||
self.compatibility = savedata["game_options"]["compatibility"]
|
||||
|
||||
if "stored_data" in savedata:
|
||||
self.stored_data = savedata["stored_data"]
|
||||
# count items and slots from lists for item_handling = remote
|
||||
logging.info(
|
||||
f'Loaded save file with {sum([len(v) for k, v in self.received_items.items() if k[2]])} received items '
|
||||
f'for {sum(k[2] for k in self.received_items)} players')
|
||||
|
||||
# rest
|
||||
|
||||
@@ -478,20 +548,34 @@ class Context:
|
||||
|
||||
|
||||
def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint]):
|
||||
"""Send and remember hints"""
|
||||
concerns = collections.defaultdict(list)
|
||||
for hint in hints:
|
||||
net_msg = hint.as_network_message()
|
||||
concerns[hint.receiving_player].append(net_msg)
|
||||
if not hint.local:
|
||||
if hint.receiving_player in ctx.groups:
|
||||
for player in ctx.groups[hint.receiving_player]:
|
||||
concerns[player].append(net_msg)
|
||||
else:
|
||||
concerns[hint.receiving_player].append(net_msg)
|
||||
if not hint.local and net_msg not in concerns[hint.finding_player]:
|
||||
concerns[hint.finding_player].append(net_msg)
|
||||
# remember hints in all cases
|
||||
if not hint.found:
|
||||
ctx.hints[team, hint.finding_player].add(hint)
|
||||
if hint.receiving_player in ctx.groups:
|
||||
for player in ctx.groups[hint.receiving_player]:
|
||||
ctx.hints[team, player].add(hint)
|
||||
else:
|
||||
ctx.hints[team, hint.receiving_player].add(hint)
|
||||
for text in (format_hint(ctx, team, hint) for hint in hints):
|
||||
logging.info("Notice (Team #%d): %s" % (team + 1, text))
|
||||
|
||||
for slot, clients in ctx.clients[team].items():
|
||||
client_hints = concerns[slot]
|
||||
if client_hints:
|
||||
for client in clients:
|
||||
asyncio.create_task(ctx.send_msgs(client, client_hints))
|
||||
if hints:
|
||||
for slot, clients in ctx.clients[team].items():
|
||||
client_hints = concerns[slot]
|
||||
if client_hints:
|
||||
for client in clients:
|
||||
asyncio.create_task(ctx.send_msgs(client, client_hints))
|
||||
|
||||
|
||||
def update_aliases(ctx: Context, team: int):
|
||||
@@ -541,6 +625,8 @@ async def on_client_connected(ctx: Context, client: Client):
|
||||
'cmd': 'RoomInfo',
|
||||
'password': bool(ctx.password),
|
||||
'players': players,
|
||||
# TODO remove around 0.2.5 in favor of slot_info ?
|
||||
# Maybe convert into a list of games that are present to fetch relevant datapackage entries before Connect?
|
||||
'games': [ctx.games[x] for x in range(1, len(ctx.games) + 1)],
|
||||
# tags are for additional features in the communication.
|
||||
# Name them by feature or fork, as you feel is appropriate.
|
||||
@@ -579,7 +665,7 @@ async def on_client_joined(ctx: Context, client: Client):
|
||||
f"{verb} {ctx.games[client.slot]} has joined. "
|
||||
f"Client({version_str}), {client.tags}).")
|
||||
ctx.notify_client(client, "Now that you are connected, "
|
||||
"you can use !help to list commands to run via the server."
|
||||
"you can use !help to list commands to run via the server. "
|
||||
"If your client supports it, "
|
||||
"you may have additional local commands you can list with /help.")
|
||||
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
|
||||
@@ -612,16 +698,19 @@ def get_players_string(ctx: Context):
|
||||
player_names = sorted(ctx.player_names.keys())
|
||||
current_team = -1
|
||||
text = ''
|
||||
total = 0
|
||||
for team, slot in player_names:
|
||||
player_name = ctx.player_names[team, slot]
|
||||
if team != current_team:
|
||||
text += f':: Team #{team + 1}: '
|
||||
current_team = team
|
||||
if (team, slot) in auth_clients:
|
||||
text += f'{player_name} '
|
||||
else:
|
||||
text += f'({player_name}) '
|
||||
return f'{len(auth_clients)} players of {len(ctx.player_names)} connected ' + text[:-1]
|
||||
if ctx.slot_info[slot].type == SlotType.player:
|
||||
total += 1
|
||||
player_name = ctx.player_names[team, slot]
|
||||
if team != current_team:
|
||||
text += f':: Team #{team + 1}: '
|
||||
current_team = team
|
||||
if (team, slot) in auth_clients:
|
||||
text += f'{player_name} '
|
||||
else:
|
||||
text += f'({player_name}) '
|
||||
return f'{len(auth_clients)} players of {total} connected ' + text[:-1]
|
||||
|
||||
|
||||
def get_status_string(ctx: Context, team: int):
|
||||
@@ -641,7 +730,7 @@ def get_received_items(ctx: Context, team: int, player: int, remote_items: bool)
|
||||
return ctx.received_items.setdefault((team, player, remote_items), [])
|
||||
|
||||
|
||||
def get_start_inventory(ctx: Context, team: int, player: int, remote_start_inventory: bool) -> typing.List[NetworkItem]:
|
||||
def get_start_inventory(ctx: Context, player: int, remote_start_inventory: bool) -> typing.List[NetworkItem]:
|
||||
return ctx.start_inventory.setdefault(player, []) if remote_start_inventory else []
|
||||
|
||||
|
||||
@@ -651,7 +740,7 @@ def send_new_items(ctx: Context):
|
||||
for client in clients:
|
||||
if client.no_items:
|
||||
continue
|
||||
start_inventory = get_start_inventory(ctx, team, slot, client.remote_start_inventory)
|
||||
start_inventory = get_start_inventory(ctx, slot, client.remote_start_inventory)
|
||||
items = get_received_items(ctx, team, slot, client.remote_items)
|
||||
if len(start_inventory) + len(items) > client.send_index:
|
||||
first_new_item = max(0, client.send_index - len(start_inventory))
|
||||
@@ -697,6 +786,15 @@ def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
|
||||
return sorted(items)
|
||||
|
||||
|
||||
def send_items_to(ctx: Context, team: int, target_slot: int, *items: NetworkItem):
|
||||
targets = ctx.groups.get(target_slot, [target_slot])
|
||||
for target in targets:
|
||||
for item in items:
|
||||
if item.player != target_slot:
|
||||
get_received_items(ctx, team, target, False).append(item)
|
||||
get_received_items(ctx, team, target, True).append(item)
|
||||
|
||||
|
||||
def register_location_checks(ctx: Context, team: int, slot: int, locations: typing.Iterable[int],
|
||||
count_activity: bool = True):
|
||||
new_locations = set(locations) - ctx.location_checks[team, slot]
|
||||
@@ -705,17 +803,9 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
|
||||
if count_activity:
|
||||
ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc)
|
||||
for location in new_locations:
|
||||
if len(ctx.locations[slot][location]) == 3:
|
||||
item_id, target_player, flags = ctx.locations[slot][location]
|
||||
else:
|
||||
# TODO: remove around version 0.2.5
|
||||
item_id, target_player = ctx.locations[slot][location]
|
||||
flags = 0
|
||||
|
||||
item_id, target_player, flags = ctx.locations[slot][location]
|
||||
new_item = NetworkItem(item_id, location, slot, flags)
|
||||
if target_player != slot:
|
||||
get_received_items(ctx, team, target_player, False).append(new_item)
|
||||
get_received_items(ctx, team, target_player, True).append(new_item)
|
||||
send_items_to(ctx, team, target_player, new_item)
|
||||
|
||||
logging.info('(Team #%d) %s sent %s to %s (%s)' % (
|
||||
team + 1, ctx.player_names[(team, slot)], get_item_name_from_id(item_id),
|
||||
@@ -736,17 +826,16 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
|
||||
|
||||
def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[NetUtils.Hint]:
|
||||
hints = []
|
||||
slots = []
|
||||
for group_id, group in ctx.groups.items():
|
||||
if slot in group:
|
||||
slots.append(group_id)
|
||||
seeked_item_id = proxy_worlds[ctx.games[slot]].item_name_to_id[item]
|
||||
for finding_player, check_data in ctx.locations.items():
|
||||
for location_id, result in check_data.items():
|
||||
if len(result) == 3:
|
||||
item_id, receiving_player, item_flags = result
|
||||
else:
|
||||
# TODO: remove around version 0.2.5
|
||||
item_id, receiving_player = result
|
||||
item_flags = 0
|
||||
item_id, receiving_player, item_flags = result
|
||||
|
||||
if receiving_player == slot and item_id == seeked_item_id:
|
||||
if (receiving_player == slot or receiving_player in slots) and item_id == seeked_item_id:
|
||||
found = location_id in ctx.location_checks[team, finding_player]
|
||||
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
|
||||
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
|
||||
@@ -755,16 +844,15 @@ def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[
|
||||
return hints
|
||||
|
||||
|
||||
def collect_hints_location(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]:
|
||||
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]:
|
||||
seeked_location: int = proxy_worlds[ctx.games[slot]].location_name_to_id[location]
|
||||
return collect_hint_location_id(ctx, team, slot, seeked_location)
|
||||
|
||||
|
||||
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int) -> typing.List[NetUtils.Hint]:
|
||||
result = ctx.locations[slot].get(seeked_location, (None, None, None))
|
||||
if result:
|
||||
if len(result) == 3:
|
||||
item_id, receiving_player, item_flags = result
|
||||
else:
|
||||
# TODO: remove around version 0.2.5
|
||||
item_id, receiving_player = result
|
||||
item_flags = 0
|
||||
if any(result):
|
||||
item_id, receiving_player, item_flags = result
|
||||
|
||||
found = seeked_location in ctx.location_checks[team, slot]
|
||||
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
|
||||
@@ -1170,14 +1258,14 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
if hint_name in world.hint_blacklist:
|
||||
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
|
||||
hints = []
|
||||
elif not for_location and hint_name in world.item_name_groups: # item group name
|
||||
elif not for_location and hint_name in world.item_name_groups: # item group name
|
||||
hints = []
|
||||
for item in world.item_name_groups[hint_name]:
|
||||
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item))
|
||||
elif not for_location and hint_name in world.item_names: # item name
|
||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
|
||||
else: # location name
|
||||
hints = collect_hints_location(self.ctx, self.client.team, self.client.slot, hint_name)
|
||||
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
|
||||
cost = self.ctx.get_hint_cost(self.client.slot)
|
||||
if hints:
|
||||
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
|
||||
@@ -1209,10 +1297,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
self.ctx.hints_used[self.client.team, self.client.slot] += 1
|
||||
points_available = get_client_points(self.ctx, self.client)
|
||||
|
||||
if not hint.found:
|
||||
self.ctx.hints[self.client.team, hint.finding_player].add(hint)
|
||||
self.ctx.hints[self.client.team, hint.receiving_player].add(hint)
|
||||
|
||||
if not_found_hints:
|
||||
if hints and cost and int((points_available // cost) == 0):
|
||||
self.output(
|
||||
@@ -1300,7 +1384,6 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
errors.add('InvalidPassword')
|
||||
|
||||
if args['name'] not in ctx.connect_names:
|
||||
logging.info((args["name"], ctx.connect_names))
|
||||
errors.add('InvalidSlot')
|
||||
else:
|
||||
team, slot = ctx.connect_names[args['name']]
|
||||
@@ -1315,6 +1398,10 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
client.no_items = False
|
||||
client.remote_items = slot in ctx.remote_items
|
||||
client.remote_start_inventory = slot in ctx.remote_start_inventory
|
||||
await ctx.send_msgs(client, [{
|
||||
"cmd": "Print", "text":
|
||||
"Warning: Client is not sending items_handling flags, "
|
||||
"which will not be supported in the future."}])
|
||||
else:
|
||||
try:
|
||||
client.items_handling = args['items_handling']
|
||||
@@ -1347,9 +1434,10 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
"players": ctx.get_players_package(),
|
||||
"missing_locations": get_missing_checks(ctx, team, slot),
|
||||
"checked_locations": get_checked_checks(ctx, team, slot),
|
||||
"slot_data": ctx.slot_data[client.slot]
|
||||
"slot_data": ctx.slot_data[client.slot],
|
||||
"slot_info": ctx.slot_info
|
||||
}]
|
||||
start_inventory = get_start_inventory(ctx, team, slot, client.remote_start_inventory)
|
||||
start_inventory = get_start_inventory(ctx, slot, client.remote_start_inventory)
|
||||
items = get_received_items(ctx, client.team, client.slot, client.remote_items)
|
||||
if (start_inventory or items) and not client.no_items:
|
||||
reply.append({"cmd": 'ReceivedItems', "index": 0, "items": start_inventory + items})
|
||||
@@ -1384,7 +1472,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
if args.get('items_handling', None) is not None and client.items_handling != args['items_handling']:
|
||||
try:
|
||||
client.items_handling = args['items_handling']
|
||||
start_inventory = get_start_inventory(ctx, client.team, client.slot, client.remote_start_inventory)
|
||||
start_inventory = get_start_inventory(ctx, client.slot, client.remote_start_inventory)
|
||||
items = get_received_items(ctx, client.team, client.slot, client.remote_items)
|
||||
if (items or start_inventory) and not client.no_items:
|
||||
client.send_index = len(start_inventory) + len(items)
|
||||
@@ -1408,7 +1496,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
f"from {old_tags} to {client.tags}.")
|
||||
|
||||
elif cmd == 'Sync':
|
||||
start_inventory = get_start_inventory(ctx, client.team, client.slot, client.remote_start_inventory)
|
||||
start_inventory = get_start_inventory(ctx, client.slot, client.remote_start_inventory)
|
||||
items = get_received_items(ctx, client.team, client.slot, client.remote_items)
|
||||
if (start_inventory or items) and not client.no_items:
|
||||
client.send_index = len(start_inventory) + len(items)
|
||||
@@ -1425,21 +1513,20 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
|
||||
elif cmd == 'LocationScouts':
|
||||
locs = []
|
||||
create_as_hint = args.get("create_as_hint", False)
|
||||
hints = []
|
||||
for location in args["locations"]:
|
||||
if type(location) is not int or location not in lookup_any_location_id_to_name:
|
||||
await ctx.send_msgs(client,
|
||||
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts',
|
||||
"original_cmd": cmd}])
|
||||
return
|
||||
if len(ctx.locations[client.slot][location]) == 3:
|
||||
target_item, target_player, flags = ctx.locations[client.slot][location]
|
||||
else:
|
||||
# TODO: remove around version 0.2.5
|
||||
target_item, target_player = ctx.locations[client.slot][location]
|
||||
flags = 0
|
||||
|
||||
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))
|
||||
locs.append(NetworkItem(target_item, location, target_player, flags))
|
||||
|
||||
notify_hints(ctx, client.team, hints)
|
||||
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
|
||||
|
||||
elif cmd == 'StatusUpdate':
|
||||
@@ -1466,6 +1553,43 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
bounceclient.slot in slots):
|
||||
await ctx.send_encoded_msgs(bounceclient, msg)
|
||||
|
||||
elif cmd == "Get":
|
||||
if "keys" not in args or type(args["keys"]) != list:
|
||||
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments",
|
||||
"text": 'Retrieve', "original_cmd": cmd}])
|
||||
return
|
||||
args["cmd"] = "Retrieved"
|
||||
keys = args["keys"]
|
||||
args["keys"] = {key: ctx.stored_data.get(key, None) for key in keys}
|
||||
await ctx.send_msgs(client, [args])
|
||||
|
||||
elif cmd == "Set":
|
||||
if "key" not in args or \
|
||||
"operations" not in args or not type(args["operations"]) == list:
|
||||
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments",
|
||||
"text": 'Set', "original_cmd": cmd}])
|
||||
return
|
||||
args["cmd"] = "SetReply"
|
||||
value = ctx.stored_data.get(args["key"], args.get("default", 0))
|
||||
args["original_value"] = value
|
||||
for operation in args["operations"]:
|
||||
func = modify_functions[operation["operation"]]
|
||||
value = func(value, operation["value"])
|
||||
ctx.stored_data[args["key"]] = args["value"] = value
|
||||
targets = set(ctx.stored_data_notification_clients[args["key"]])
|
||||
if args.get("want_reply", True):
|
||||
targets.add(client)
|
||||
if targets:
|
||||
ctx.broadcast(targets, [args])
|
||||
|
||||
elif cmd == "SetNotify":
|
||||
if "keys" not in args or type(args["keys"]) != list:
|
||||
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments",
|
||||
"text": 'SetNotify', "original_cmd": cmd}])
|
||||
return
|
||||
for key in args["keys"]:
|
||||
ctx.stored_data_notification_clients[key].add(client)
|
||||
|
||||
|
||||
def update_client_status(ctx: Context, client: Client, new_status: ClientStatus):
|
||||
current = ctx.client_game_state[client.team, client.slot]
|
||||
@@ -1588,8 +1712,8 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
self.output(f"Could not find player {player_name} to forbid the !forfeit command for.")
|
||||
return False
|
||||
|
||||
def _cmd_send(self, player_name: str, *item_name: str) -> bool:
|
||||
"""Sends an item to the specified player"""
|
||||
def _cmd_send_multiple(self, amount: typing.Union[int, str], player_name: str, *item_name: str) -> bool:
|
||||
"""Sends multiples of an item to the specified player"""
|
||||
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
||||
if usable:
|
||||
team, slot = self.ctx.player_name_lookup[seeked_player]
|
||||
@@ -1597,12 +1721,14 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
world = proxy_worlds[self.ctx.games[slot]]
|
||||
item, usable, response = get_intended_text(item, world.item_names)
|
||||
if usable:
|
||||
new_item = NetworkItem(world.item_name_to_id[item], -1, 0)
|
||||
get_received_items(self.ctx, team, slot, True).append(new_item)
|
||||
get_received_items(self.ctx, team, slot, False).append(new_item)
|
||||
self.ctx.notify_all('Cheat console: sending "' + item + '" to ' +
|
||||
self.ctx.get_aliased_name(team, slot))
|
||||
amount: int = int(amount)
|
||||
new_items = [NetworkItem(world.item_name_to_id[item], -1, 0) for i in range(int(amount))]
|
||||
send_items_to(self.ctx, team, slot, *new_items)
|
||||
|
||||
send_new_items(self.ctx)
|
||||
self.ctx.notify_all(
|
||||
'Cheat console: sending ' + ('' if amount == 1 else f'{amount} of ') +
|
||||
f'"{item}" to {self.ctx.get_aliased_name(team, slot)}')
|
||||
return True
|
||||
else:
|
||||
self.output(response)
|
||||
@@ -1611,6 +1737,10 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
self.output(response)
|
||||
return False
|
||||
|
||||
def _cmd_send(self, player_name: str, *item_name: str) -> bool:
|
||||
"""Sends an item to the specified player"""
|
||||
return self._cmd_send_multiple(1, player_name, *item_name)
|
||||
|
||||
def _cmd_hint(self, player_name: str, *item: str) -> bool:
|
||||
"""Send out a hint for a player's item to their team"""
|
||||
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
||||
@@ -1626,8 +1756,10 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
hints.extend(collect_hints(self.ctx, team, slot, item))
|
||||
else: # item name
|
||||
hints = collect_hints(self.ctx, team, slot, item)
|
||||
|
||||
if hints:
|
||||
notify_hints(self.ctx, team, hints)
|
||||
|
||||
else:
|
||||
self.output("No hints found.")
|
||||
return True
|
||||
@@ -1648,7 +1780,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
world = proxy_worlds[self.ctx.games[slot]]
|
||||
item, usable, response = get_intended_text(item, world.location_names)
|
||||
if usable:
|
||||
hints = collect_hints_location(self.ctx, team, slot, item)
|
||||
hints = collect_hint_location_name(self.ctx, team, slot, item)
|
||||
if hints:
|
||||
notify_hints(self.ctx, team, hints)
|
||||
else:
|
||||
@@ -1679,6 +1811,8 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
self.output(f"Set option {option_name} to {getattr(self.ctx, option_name)}")
|
||||
if option_name in {"forfeit_mode", "remaining_mode", "collect_mode"}:
|
||||
self.ctx.broadcast_all([{"cmd": "RoomUpdate", 'permissions': get_permissions(self.ctx)}])
|
||||
elif option_name in {"hint_cost", "location_check_points"}:
|
||||
self.ctx.broadcast_all([{"cmd": "RoomUpdate", option_name: getattr(self.ctx, option_name)}])
|
||||
return True
|
||||
else:
|
||||
known = (f"{option}:{otype}" for option, otype in self.ctx.simple_options.items())
|
||||
@@ -1798,11 +1932,18 @@ async def main(args: argparse.Namespace):
|
||||
|
||||
try:
|
||||
if not data_filename:
|
||||
import tkinter
|
||||
import tkinter.filedialog
|
||||
root = tkinter.Tk()
|
||||
root.withdraw()
|
||||
data_filename = tkinter.filedialog.askopenfilename(filetypes=(("Multiworld data", "*.archipelago *.zip"),))
|
||||
try:
|
||||
import tkinter
|
||||
import tkinter.filedialog
|
||||
except Exception as e:
|
||||
logging.error("Could not load tkinter, which is likely not installed. "
|
||||
"This attempt was made because no .archipelago file was provided as argument. "
|
||||
"Either provide a file or ensure the tkinter package is installed.")
|
||||
raise e
|
||||
else:
|
||||
root = tkinter.Tk()
|
||||
root.withdraw()
|
||||
data_filename = tkinter.filedialog.askopenfilename(filetypes=(("Multiworld data", "*.archipelago *.zip"),))
|
||||
|
||||
ctx.load(data_filename, args.use_embedded_options)
|
||||
|
||||
|
||||
33
NetUtils.py
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import typing
|
||||
import enum
|
||||
from json import JSONEncoder, JSONDecoder
|
||||
@@ -29,7 +28,18 @@ class ClientStatus(enum.IntEnum):
|
||||
CLIENT_GOAL = 30
|
||||
|
||||
|
||||
class Permission(enum.IntEnum):
|
||||
class SlotType(enum.IntFlag):
|
||||
spectator = 0b00
|
||||
player = 0b01
|
||||
group = 0b10
|
||||
|
||||
@property
|
||||
def always_goal(self) -> bool:
|
||||
"""Mark this slot has having reached its goal instantly."""
|
||||
return self.value != 0b01
|
||||
|
||||
|
||||
class Permission(enum.IntFlag):
|
||||
disabled = 0b000 # 0, completely disables access
|
||||
enabled = 0b001 # 1, allows manual use
|
||||
goal = 0b010 # 2, allows manual use after goal completion
|
||||
@@ -49,12 +59,21 @@ class Permission(enum.IntEnum):
|
||||
|
||||
|
||||
class NetworkPlayer(typing.NamedTuple):
|
||||
"""Represents a particular player on a particular team."""
|
||||
team: int
|
||||
slot: int
|
||||
alias: str
|
||||
name: str
|
||||
|
||||
|
||||
class NetworkSlot(typing.NamedTuple):
|
||||
"""Represents a particular slot across teams."""
|
||||
name: str
|
||||
game: str
|
||||
type: SlotType
|
||||
group_members: typing.Union[typing.List[int], typing.Tuple] = () # only populated if type == group
|
||||
|
||||
|
||||
class NetworkItem(typing.NamedTuple):
|
||||
item: int
|
||||
location: int
|
||||
@@ -89,9 +108,10 @@ def get_any_version(data: dict) -> Version:
|
||||
return Version(int(data["major"]), int(data["minor"]), int(data["build"]))
|
||||
|
||||
|
||||
whitelist = {"NetworkPlayer": NetworkPlayer,
|
||||
"NetworkItem": NetworkItem,
|
||||
}
|
||||
whitelist = {
|
||||
"NetworkPlayer": NetworkPlayer,
|
||||
"NetworkItem": NetworkItem,
|
||||
}
|
||||
|
||||
custom_hooks = {
|
||||
"Version": get_any_version
|
||||
@@ -122,9 +142,6 @@ class Endpoint:
|
||||
def __init__(self, socket):
|
||||
self.socket = socket
|
||||
|
||||
async def disconnect(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class HandlerMeta(type):
|
||||
def __new__(mcs, name, bases, attrs):
|
||||
|
||||
@@ -87,7 +87,7 @@ def adjustGUI():
|
||||
option = sfx_options[option_name]
|
||||
optionFrame = Frame(romSettingsFrame)
|
||||
optionFrame.grid(row=row, column=column, sticky=E)
|
||||
optionLabel = Label(optionFrame, text=option.displayname)
|
||||
optionLabel = Label(optionFrame, text=option.display_name)
|
||||
optionLabel.pack(side=LEFT)
|
||||
setattr(opts, option_name, StringVar())
|
||||
getattr(opts, option_name).set(option.name_lookup[option.default])
|
||||
@@ -143,7 +143,7 @@ def adjustGUI():
|
||||
option = cosmetic_options['sword_trail_duration']
|
||||
optionFrame = Frame(romSettingsFrame)
|
||||
optionFrame.grid(row=8, column=2, sticky=E)
|
||||
optionLabel = Label(optionFrame, text=option.displayname)
|
||||
optionLabel = Label(optionFrame, text=option.display_name)
|
||||
optionLabel.pack(side=LEFT)
|
||||
setattr(opts, 'sword_trail_duration', StringVar())
|
||||
getattr(opts, 'sword_trail_duration').set(option.default)
|
||||
|
||||
287
OoTClient.py
Normal file
@@ -0,0 +1,287 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import multiprocessing
|
||||
import subprocess
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, \
|
||||
ClientCommandProcessor, logger, get_base_parser
|
||||
import Utils
|
||||
from worlds import network_data_package
|
||||
from worlds.oot.Rom import Rom, compress_rom_file
|
||||
from worlds.oot.N64Patch import apply_patch_file
|
||||
from worlds.oot.Utils import data_path
|
||||
|
||||
|
||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart oot_connector.lua"
|
||||
CONNECTION_REFUSED_STATUS = "Connection refused. Please start your emulator and make sure oot_connector.lua is running"
|
||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart oot_connector.lua"
|
||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||
|
||||
"""
|
||||
Payload: lua -> client
|
||||
{
|
||||
playerName: string,
|
||||
locations: dict,
|
||||
deathlinkActive: bool,
|
||||
isDead: bool,
|
||||
gameComplete: bool
|
||||
}
|
||||
|
||||
Payload: client -> lua
|
||||
{
|
||||
items: list,
|
||||
playerNames: list,
|
||||
triggerDeath: bool
|
||||
}
|
||||
|
||||
Deathlink logic:
|
||||
"Dead" is true <-> Link is at 0 hp.
|
||||
|
||||
deathlink_pending: we need to kill the player
|
||||
deathlink_sent_this_death: we interacted with the multiworld on this death, waiting to reset with living link
|
||||
|
||||
"""
|
||||
|
||||
oot_loc_name_to_id = network_data_package["games"]["Ocarina of Time"]["location_name_to_id"]
|
||||
|
||||
def get_item_value(ap_id):
|
||||
return ap_id - 66000
|
||||
|
||||
class OoTCommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx):
|
||||
super().__init__(ctx)
|
||||
|
||||
def _cmd_n64(self):
|
||||
"""Check N64 Connection State"""
|
||||
if isinstance(self.ctx, OoTContext):
|
||||
logger.info(f"N64 Status: {self.ctx.n64_status}")
|
||||
|
||||
|
||||
class OoTContext(CommonContext):
|
||||
command_processor = OoTCommandProcessor
|
||||
items_handling = 0b001 # full local
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super().__init__(server_address, password)
|
||||
self.game = 'Ocarina of Time'
|
||||
self.n64_streams: (StreamReader, StreamWriter) = None
|
||||
self.n64_sync_task = None
|
||||
self.n64_status = CONNECTION_INITIAL_STATUS
|
||||
self.awaiting_rom = False
|
||||
self.location_table = {}
|
||||
self.deathlink_enabled = False
|
||||
self.deathlink_pending = False
|
||||
self.deathlink_sent_this_death = False
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(OoTContext, self).server_auth(password_requested)
|
||||
if not self.auth:
|
||||
self.awaiting_rom = True
|
||||
logger.info('Awaiting connection to Bizhawk to get player information')
|
||||
return
|
||||
|
||||
await self.send_connect()
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
self.deathlink_pending = True
|
||||
super().on_deathlink(data)
|
||||
|
||||
|
||||
def get_payload(ctx: OoTContext):
|
||||
if ctx.deathlink_enabled and ctx.deathlink_pending:
|
||||
trigger_death = True
|
||||
ctx.deathlink_sent_this_death = True
|
||||
else:
|
||||
trigger_death = False
|
||||
|
||||
return json.dumps({
|
||||
"items": [get_item_value(item.item) for item in ctx.items_received],
|
||||
"playerNames": [name for (i, name) in ctx.player_names.items() if i != 0],
|
||||
"triggerDeath": trigger_death
|
||||
})
|
||||
|
||||
|
||||
async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
|
||||
|
||||
# Turn on deathlink if it is on
|
||||
if payload['deathlinkActive'] and not ctx.deathlink_enabled:
|
||||
await ctx.update_death_link(True)
|
||||
ctx.deathlink_enabled = True
|
||||
|
||||
# Game completion handling
|
||||
if payload['gameComplete'] and not ctx.finished_game:
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "StatusUpdate",
|
||||
"status": 30
|
||||
}])
|
||||
ctx.finished_game = True
|
||||
|
||||
# Locations handling
|
||||
if ctx.location_table != payload['locations']:
|
||||
ctx.location_table = payload['locations']
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "LocationChecks",
|
||||
"locations": [oot_loc_name_to_id[loc] for loc in ctx.location_table if ctx.location_table[loc]]
|
||||
}])
|
||||
|
||||
# Deathlink handling
|
||||
if ctx.deathlink_enabled:
|
||||
if payload['isDead']: # link is dead
|
||||
ctx.deathlink_pending = False
|
||||
if not ctx.deathlink_sent_this_death:
|
||||
ctx.deathlink_sent_this_death = True
|
||||
await ctx.send_death()
|
||||
else: # link is alive
|
||||
ctx.deathlink_sent_this_death = False
|
||||
|
||||
|
||||
async def n64_sync_task(ctx: OoTContext):
|
||||
logger.info("Starting n64 connector. Use /n64 for status information.")
|
||||
while not ctx.exit_event.is_set():
|
||||
error_status = None
|
||||
if ctx.n64_streams:
|
||||
(reader, writer) = ctx.n64_streams
|
||||
msg = get_payload(ctx).encode()
|
||||
writer.write(msg)
|
||||
writer.write(b'\n')
|
||||
try:
|
||||
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
||||
try:
|
||||
# Data will return a dict with up to five fields:
|
||||
# 1. str: player name (always)
|
||||
# 2. bool: deathlink active (always)
|
||||
# 3. dict[str, bool]: checked locations
|
||||
# 4. bool: whether Link is currently at 0 HP
|
||||
# 5. bool: whether the game currently registers as complete
|
||||
data = await asyncio.wait_for(reader.readline(), timeout=10)
|
||||
data_decoded = json.loads(data.decode())
|
||||
if ctx.game is not None and 'locations' in data_decoded:
|
||||
# Not just a keep alive ping, parse
|
||||
asyncio.create_task(parse_payload(data_decoded, ctx, False))
|
||||
if not ctx.auth:
|
||||
ctx.auth = data_decoded['playerName']
|
||||
if ctx.awaiting_rom:
|
||||
await ctx.server_auth(False)
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Read Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.n64_streams = None
|
||||
except ConnectionResetError as e:
|
||||
logger.debug("Read failed due to Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.n64_streams = None
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.n64_streams = None
|
||||
except ConnectionResetError:
|
||||
logger.debug("Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.n64_streams = None
|
||||
if ctx.n64_status == CONNECTION_TENTATIVE_STATUS:
|
||||
if not error_status:
|
||||
logger.info("Successfully Connected to N64")
|
||||
ctx.n64_status = CONNECTION_CONNECTED_STATUS
|
||||
else:
|
||||
ctx.n64_status = f"Was tentatively connected but error occured: {error_status}"
|
||||
elif error_status:
|
||||
ctx.n64_status = error_status
|
||||
logger.info("Lost connection to N64 and attempting to reconnect. Use /n64 for status updates")
|
||||
else:
|
||||
try:
|
||||
logger.debug("Attempting to connect to N64")
|
||||
ctx.n64_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 28921), timeout=10)
|
||||
ctx.n64_status = CONNECTION_TENTATIVE_STATUS
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Trying Again")
|
||||
ctx.n64_status = CONNECTION_TIMING_OUT_STATUS
|
||||
continue
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.n64_status = CONNECTION_REFUSED_STATUS
|
||||
continue
|
||||
|
||||
|
||||
async def run_game(romfile):
|
||||
auto_start = Utils.get_options()["oot_options"].get("rom_start", True)
|
||||
if auto_start is True:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
elif os.path.isfile(auto_start):
|
||||
subprocess.Popen([auto_start, romfile],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
async def patch_and_run_game(apz5_file):
|
||||
base_name = os.path.splitext(apz5_file)[0]
|
||||
decomp_path = base_name + '-decomp.z64'
|
||||
comp_path = base_name + '.z64'
|
||||
# Load vanilla ROM, patch file, compress ROM
|
||||
rom = Rom(Utils.local_path(Utils.get_options()["oot_options"]["rom_file"]))
|
||||
apply_patch_file(rom, apz5_file)
|
||||
rom.write_to_file(decomp_path)
|
||||
os.chdir(data_path("Compress"))
|
||||
compress_rom_file(decomp_path, comp_path)
|
||||
os.remove(decomp_path)
|
||||
asyncio.create_task(run_game(comp_path))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Utils.init_logging("OoTClient")
|
||||
|
||||
async def main():
|
||||
multiprocessing.freeze_support()
|
||||
parser = get_base_parser()
|
||||
parser.add_argument('apz5_file', default="", type=str, nargs="?",
|
||||
help='Path to an APZ5 file')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.apz5_file:
|
||||
logger.info("APZ5 file supplied, beginning patching process...")
|
||||
asyncio.create_task(patch_and_run_game(args.apz5_file))
|
||||
|
||||
ctx = OoTContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
|
||||
if gui_enabled:
|
||||
input_task = None
|
||||
from kvui import OoTManager
|
||||
ctx.ui = OoTManager(ctx)
|
||||
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
|
||||
else:
|
||||
input_task = asyncio.create_task(console_loop(ctx), name="Input")
|
||||
ui_task = None
|
||||
|
||||
ctx.n64_sync_task = asyncio.create_task(n64_sync_task(ctx), name="N64 Sync")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
if ctx.n64_sync_task:
|
||||
await ctx.n64_sync_task
|
||||
|
||||
if ui_task:
|
||||
await ui_task
|
||||
|
||||
if input_task:
|
||||
input_task.cancel()
|
||||
|
||||
import colorama
|
||||
|
||||
colorama.init()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main())
|
||||
loop.close()
|
||||
colorama.deinit()
|
||||
216
Options.py
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
import typing
|
||||
import random
|
||||
|
||||
from schema import Schema, And, Or
|
||||
|
||||
|
||||
class AssembleOptions(type):
|
||||
def __new__(mcs, name, bases, attrs):
|
||||
@@ -14,8 +16,10 @@ class AssembleOptions(type):
|
||||
name_lookup.update(base.name_lookup)
|
||||
new_options = {name[7:].lower(): option_id for name, option_id in attrs.items() if
|
||||
name.startswith("option_")}
|
||||
if "random" in new_options:
|
||||
raise Exception("Choice option 'random' cannot be manually assigned.")
|
||||
|
||||
assert "random" not in new_options, "Choice option 'random' cannot be manually assigned."
|
||||
assert len(new_options) == len(set(new_options.values())), "same ID cannot be used twice. Try alias?"
|
||||
|
||||
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
|
||||
options.update(new_options)
|
||||
|
||||
@@ -25,29 +29,50 @@ class AssembleOptions(type):
|
||||
|
||||
# auto-validate schema on __init__
|
||||
if "schema" in attrs.keys():
|
||||
def validate_decorator(func):
|
||||
def validate(self, *args, **kwargs):
|
||||
func(self, *args, **kwargs)
|
||||
|
||||
if "__init__" in attrs:
|
||||
def validate_decorator(func):
|
||||
def validate(self, *args, **kwargs):
|
||||
ret = func(self, *args, **kwargs)
|
||||
self.value = self.schema.validate(self.value)
|
||||
return ret
|
||||
|
||||
return validate
|
||||
|
||||
attrs["__init__"] = validate_decorator(attrs["__init__"])
|
||||
else:
|
||||
# construct an __init__ that calls parent __init__
|
||||
|
||||
cls = super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
|
||||
|
||||
def meta__init__(self, *args, **kwargs):
|
||||
super(cls, self).__init__(*args, **kwargs)
|
||||
self.value = self.schema.validate(self.value)
|
||||
|
||||
return validate
|
||||
cls.__init__ = meta__init__
|
||||
return cls
|
||||
|
||||
attrs["__init__"] = validate_decorator(attrs["__init__"])
|
||||
return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
|
||||
|
||||
|
||||
class Option(metaclass=AssembleOptions):
|
||||
value: int
|
||||
name_lookup: typing.Dict[int, str]
|
||||
T = typing.TypeVar('T')
|
||||
|
||||
|
||||
class Option(typing.Generic[T], metaclass=AssembleOptions):
|
||||
value: T
|
||||
default = 0
|
||||
|
||||
# convert option_name_long into Name Long as displayname, otherwise name_long is the result.
|
||||
# convert option_name_long into Name Long as display_name, otherwise name_long is the result.
|
||||
# Handled in get_option_name()
|
||||
autodisplayname = False
|
||||
auto_display_name = False
|
||||
|
||||
# can be weighted between selections
|
||||
supports_weighting = True
|
||||
|
||||
# filled by AssembleOptions:
|
||||
name_lookup: typing.Dict[int, str]
|
||||
options: typing.Dict[str, int]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}({self.get_current_option_name()})"
|
||||
|
||||
@@ -63,13 +88,13 @@ class Option(metaclass=AssembleOptions):
|
||||
return self.get_option_name(self.value)
|
||||
|
||||
@classmethod
|
||||
def get_option_name(cls, value: typing.Any) -> str:
|
||||
if cls.autodisplayname:
|
||||
def get_option_name(cls, value: T) -> str:
|
||||
if cls.auto_display_name:
|
||||
return cls.name_lookup[value].replace("_", " ").title()
|
||||
else:
|
||||
return cls.name_lookup[value]
|
||||
|
||||
def __int__(self) -> int:
|
||||
def __int__(self) -> T:
|
||||
return self.value
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
@@ -80,18 +105,20 @@ class Option(metaclass=AssembleOptions):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Toggle(Option):
|
||||
class Toggle(Option[int]):
|
||||
option_false = 0
|
||||
option_true = 1
|
||||
default = 0
|
||||
|
||||
def __init__(self, value: int):
|
||||
assert value == 0 or value == 1
|
||||
assert value == 0 or value == 1, "value of Toggle can only be 0 or 1"
|
||||
self.value = value
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text: str) -> Toggle:
|
||||
if text.lower() in {"off", "0", "false", "none", "null", "no"}:
|
||||
if text == "random":
|
||||
return cls(random.choice(list(cls.name_lookup)))
|
||||
elif text.lower() in {"off", "0", "false", "none", "null", "no"}:
|
||||
return cls(0)
|
||||
else:
|
||||
return cls(1)
|
||||
@@ -132,8 +159,8 @@ class DefaultOnToggle(Toggle):
|
||||
default = 1
|
||||
|
||||
|
||||
class Choice(Option):
|
||||
autodisplayname = True
|
||||
class Choice(Option[int]):
|
||||
auto_display_name = True
|
||||
|
||||
def __init__(self, value: int):
|
||||
self.value: int = value
|
||||
@@ -143,8 +170,8 @@ class Choice(Option):
|
||||
text = text.lower()
|
||||
if text == "random":
|
||||
return cls(random.choice(list(cls.name_lookup)))
|
||||
for optionname, value in cls.options.items():
|
||||
if optionname == text:
|
||||
for option_name, value in cls.options.items():
|
||||
if option_name == text:
|
||||
return cls(value)
|
||||
raise KeyError(
|
||||
f'Could not find option "{text}" for "{cls.__name__}", '
|
||||
@@ -160,10 +187,10 @@ class Choice(Option):
|
||||
if isinstance(other, self.__class__):
|
||||
return other.value == self.value
|
||||
elif isinstance(other, str):
|
||||
assert other in self.options
|
||||
assert other in self.options, f"compared against a str that could never be equal. {self} == {other}"
|
||||
return other == self.current_key
|
||||
elif isinstance(other, int):
|
||||
assert other in self.name_lookup
|
||||
assert other in self.name_lookup, f"compared against an int that could never be equal. {self} == {other}"
|
||||
return other == self.value
|
||||
elif isinstance(other, bool):
|
||||
return other == bool(self.value)
|
||||
@@ -174,10 +201,10 @@ class Choice(Option):
|
||||
if isinstance(other, self.__class__):
|
||||
return other.value != self.value
|
||||
elif isinstance(other, str):
|
||||
assert other in self.options
|
||||
assert other in self.options, f"compared against a str that could never be equal. {self} != {other}"
|
||||
return other != self.current_key
|
||||
elif isinstance(other, int):
|
||||
assert other in self.name_lookup
|
||||
assert other in self.name_lookup, f"compared against am int that could never be equal. {self} != {other}"
|
||||
return other != self.value
|
||||
elif isinstance(other, bool):
|
||||
return other != bool(self.value)
|
||||
@@ -189,7 +216,7 @@ class Choice(Option):
|
||||
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
||||
|
||||
|
||||
class Range(Option, int):
|
||||
class Range(Option[int], int):
|
||||
range_start = 0
|
||||
range_end = 1
|
||||
|
||||
@@ -213,22 +240,26 @@ class Range(Option, int):
|
||||
elif text.startswith("random-range-"):
|
||||
textsplit = text.split("-")
|
||||
try:
|
||||
randomrange = [int(textsplit[len(textsplit)-2]), int(textsplit[len(textsplit)-1])]
|
||||
random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])]
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid random range {text} for option {cls.__name__}")
|
||||
randomrange.sort()
|
||||
if randomrange[0] < cls.range_start or randomrange[1] > cls.range_end:
|
||||
raise Exception(f"{randomrange[0]}-{randomrange[1]} is outside allowed range {cls.range_start}-{cls.range_end} for option {cls.__name__}")
|
||||
random_range.sort()
|
||||
if random_range[0] < cls.range_start or random_range[1] > cls.range_end:
|
||||
raise Exception(
|
||||
f"{random_range[0]}-{random_range[1]} is outside allowed range "
|
||||
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
|
||||
if text.startswith("random-range-low"):
|
||||
return cls(int(round(random.triangular(randomrange[0], randomrange[1], randomrange[0]))))
|
||||
return cls(int(round(random.triangular(random_range[0], random_range[1], random_range[0]))))
|
||||
elif text.startswith("random-range-middle"):
|
||||
return cls(int(round(random.triangular(randomrange[0], randomrange[1]))))
|
||||
return cls(int(round(random.triangular(random_range[0], random_range[1]))))
|
||||
elif text.startswith("random-range-high"):
|
||||
return cls(int(round(random.triangular(randomrange[0], randomrange[1], randomrange[1]))))
|
||||
return cls(int(round(random.triangular(random_range[0], random_range[1], random_range[1]))))
|
||||
else:
|
||||
return cls(int(round(random.randint(randomrange[0], randomrange[1]))))
|
||||
else:
|
||||
return cls(int(round(random.randint(random_range[0], random_range[1]))))
|
||||
elif text == "random":
|
||||
return cls(random.randint(cls.range_start, cls.range_end))
|
||||
else:
|
||||
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. Acceptable values are: random, random-high, random-middle, random-low, random-range-low-<min>-<max>, random-range-middle-<min>-<max>, random-range-high-<min>-<max>, or random-range-<min>-<max>.")
|
||||
return cls(int(text))
|
||||
|
||||
@classmethod
|
||||
@@ -244,26 +275,13 @@ class Range(Option, int):
|
||||
return str(self.value)
|
||||
|
||||
|
||||
class OptionNameSet(Option):
|
||||
default = frozenset()
|
||||
|
||||
def __init__(self, value: typing.Set[str]):
|
||||
self.value: typing.Set[str] = value
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text: str) -> OptionNameSet:
|
||||
return cls({option.strip() for option in text.split(",")})
|
||||
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Any) -> OptionNameSet:
|
||||
if type(data) == set:
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
|
||||
|
||||
class VerifyKeys:
|
||||
valid_keys = frozenset()
|
||||
valid_keys_casefold: bool = False
|
||||
convert_name_groups: bool = False
|
||||
verify_item_name: bool = False
|
||||
verify_location_name: bool = False
|
||||
value: typing.Any
|
||||
|
||||
@classmethod
|
||||
def verify_keys(cls, data):
|
||||
@@ -275,11 +293,27 @@ class VerifyKeys:
|
||||
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
|
||||
f"Allowed keys: {cls.valid_keys}.")
|
||||
|
||||
def verify(self, world):
|
||||
if self.convert_name_groups and self.verify_item_name:
|
||||
new_value = type(self.value)() # empty container of whatever value is
|
||||
for item_name in self.value:
|
||||
new_value |= world.item_name_groups.get(item_name, {item_name})
|
||||
self.value = new_value
|
||||
if self.verify_item_name:
|
||||
for item_name in self.value:
|
||||
if item_name not in world.item_names:
|
||||
raise Exception(f"Item {item_name} from option {self} "
|
||||
f"is not a valid item name from {world.game}")
|
||||
elif self.verify_location_name:
|
||||
for location_name in self.value:
|
||||
if location_name not in world.location_names:
|
||||
raise Exception(f"Location {location_name} from option {self} "
|
||||
f"is not a valid location name from {world.game}")
|
||||
|
||||
class OptionDict(Option, VerifyKeys):
|
||||
|
||||
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
|
||||
default = {}
|
||||
supports_weighting = False
|
||||
value: typing.Dict[str, typing.Any]
|
||||
|
||||
def __init__(self, value: typing.Dict[str, typing.Any]):
|
||||
self.value = value
|
||||
@@ -300,7 +334,6 @@ class OptionDict(Option, VerifyKeys):
|
||||
|
||||
|
||||
class ItemDict(OptionDict):
|
||||
# implemented by Generate
|
||||
verify_item_name = True
|
||||
|
||||
def __init__(self, value: typing.Dict[str, int]):
|
||||
@@ -309,10 +342,9 @@ class ItemDict(OptionDict):
|
||||
super(ItemDict, self).__init__(value)
|
||||
|
||||
|
||||
class OptionList(Option, VerifyKeys):
|
||||
class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
||||
default = []
|
||||
supports_weighting = False
|
||||
value: list
|
||||
|
||||
def __init__(self, value: typing.List[typing.Any]):
|
||||
self.value = value or []
|
||||
@@ -336,10 +368,9 @@ class OptionList(Option, VerifyKeys):
|
||||
return item in self.value
|
||||
|
||||
|
||||
class OptionSet(Option):
|
||||
class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
||||
default = frozenset()
|
||||
supports_weighting = False
|
||||
value: set
|
||||
|
||||
def __init__(self, value: typing.Union[typing.Set[str, typing.Any], typing.List[str, typing.Any]]):
|
||||
self.value = set(value)
|
||||
@@ -352,13 +383,15 @@ class OptionSet(Option):
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Any):
|
||||
if type(data) == list:
|
||||
cls.verify_keys(data)
|
||||
return cls(data)
|
||||
elif type(data) == set:
|
||||
cls.verify_keys(data)
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
|
||||
def get_option_name(self, value):
|
||||
return ", ".join(value)
|
||||
return ", ".join(sorted(value))
|
||||
|
||||
def __contains__(self, item):
|
||||
return item in self.value
|
||||
@@ -372,7 +405,7 @@ class Accessibility(Choice):
|
||||
Locations: ensure everything can be reached and acquired.
|
||||
Items: ensure all logically relevant items can be acquired.
|
||||
Minimal: ensure what is needed to reach your goal can be acquired."""
|
||||
displayname = "Accessibility"
|
||||
display_name = "Accessibility"
|
||||
option_locations = 0
|
||||
option_items = 1
|
||||
option_minimal = 2
|
||||
@@ -382,7 +415,7 @@ class Accessibility(Choice):
|
||||
|
||||
class ProgressionBalancing(DefaultOnToggle):
|
||||
"""A system that moves progression earlier, to try and prevent the player from getting stuck and bored early."""
|
||||
displayname = "Progression Balancing"
|
||||
display_name = "Progression Balancing"
|
||||
|
||||
|
||||
common_options = {
|
||||
@@ -392,45 +425,78 @@ common_options = {
|
||||
|
||||
|
||||
class ItemSet(OptionSet):
|
||||
# implemented by Generate
|
||||
verify_item_name = True
|
||||
convert_name_groups = True
|
||||
|
||||
|
||||
class LocalItems(ItemSet):
|
||||
"""Forces these items to be in their native world."""
|
||||
displayname = "Local Items"
|
||||
display_name = "Local Items"
|
||||
|
||||
|
||||
class NonLocalItems(ItemSet):
|
||||
"""Forces these items to be outside their native world."""
|
||||
displayname = "Not Local Items"
|
||||
display_name = "Not Local Items"
|
||||
|
||||
|
||||
class StartInventory(ItemDict):
|
||||
"""Start with these items."""
|
||||
verify_item_name = True
|
||||
displayname = "Start Inventory"
|
||||
display_name = "Start Inventory"
|
||||
|
||||
|
||||
class StartHints(ItemSet):
|
||||
"""Start with these item's locations prefilled into the !hint command."""
|
||||
displayname = "Start Hints"
|
||||
display_name = "Start Hints"
|
||||
|
||||
|
||||
class StartLocationHints(OptionSet):
|
||||
"""Start with these locations and their item prefilled into the !hint command"""
|
||||
displayname = "Start Location Hints"
|
||||
display_name = "Start Location Hints"
|
||||
|
||||
|
||||
class ExcludeLocations(OptionSet):
|
||||
"""Prevent these locations from having an important item"""
|
||||
displayname = "Excluded Locations"
|
||||
display_name = "Excluded Locations"
|
||||
verify_location_name = True
|
||||
|
||||
|
||||
class PriorityLocations(OptionSet):
|
||||
"""Prevent these locations from having an unimportant item"""
|
||||
display_name = "Priority Locations"
|
||||
verify_location_name = True
|
||||
|
||||
|
||||
class DeathLink(Toggle):
|
||||
"""When you die, everyone dies. Of course the reverse is true too."""
|
||||
displayname = "Death Link"
|
||||
display_name = "Death Link"
|
||||
|
||||
|
||||
class ItemLinks(OptionList):
|
||||
"""Share part of your item pool with other players."""
|
||||
default = []
|
||||
schema = Schema([
|
||||
{
|
||||
"name": And(str, len),
|
||||
"item_pool": [And(str, len)],
|
||||
"replacement_item": Or(And(str, len), None)
|
||||
}
|
||||
])
|
||||
|
||||
def verify(self, world):
|
||||
super(ItemLinks, self).verify(world)
|
||||
existing_links = set()
|
||||
for link in self.value:
|
||||
if link["name"] in existing_links:
|
||||
raise Exception(f"You cannot have more than one link named {link['name']}.")
|
||||
existing_links.add(link["name"])
|
||||
for item_name in link["item_pool"]:
|
||||
if item_name not in world.item_names and item_name not in world.item_name_groups:
|
||||
raise Exception(f"Item {item_name} from item link {link} "
|
||||
f"is not a valid item name from {world.game}")
|
||||
if link["replacement_item"] and link["replacement_item"] not in world.item_names:
|
||||
raise Exception(f"Item {link['replacement_item']} from item link {link} "
|
||||
f"is not a valid item name from {world.game}")
|
||||
|
||||
|
||||
per_game_common_options = {
|
||||
@@ -440,7 +506,9 @@ per_game_common_options = {
|
||||
"start_inventory": StartInventory,
|
||||
"start_hints": StartHints,
|
||||
"start_location_hints": StartLocationHints,
|
||||
"exclude_locations": ExcludeLocations
|
||||
"exclude_locations": ExcludeLocations,
|
||||
"priority_locations": PriorityLocations,
|
||||
"item_links": ItemLinks
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -450,8 +518,8 @@ if __name__ == "__main__":
|
||||
|
||||
map_shuffle = Toggle
|
||||
compass_shuffle = Toggle
|
||||
keyshuffle = Toggle
|
||||
bigkey_shuffle = Toggle
|
||||
key_shuffle = Toggle
|
||||
big_key_shuffle = Toggle
|
||||
hints = Toggle
|
||||
test = argparse.Namespace()
|
||||
test.logic = Logic.from_text("no_logic")
|
||||
|
||||
212
Patch.py
@@ -1,6 +1,7 @@
|
||||
# TODO: convert this into a system like AutoWorld
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import json
|
||||
import bsdiff4
|
||||
import yaml
|
||||
import os
|
||||
@@ -9,21 +10,166 @@ import threading
|
||||
import concurrent.futures
|
||||
import zipfile
|
||||
import sys
|
||||
from typing import Tuple, Optional
|
||||
from typing import Tuple, Optional, Dict, Any, Union, BinaryIO
|
||||
|
||||
import Utils
|
||||
|
||||
current_patch_version = 3
|
||||
current_patch_version = 4
|
||||
|
||||
|
||||
class AutoPatchRegister(type):
|
||||
patch_types: Dict[str, APDeltaPatch] = {}
|
||||
file_endings: Dict[str, APDeltaPatch] = {}
|
||||
|
||||
def __new__(cls, name: str, bases, dct: Dict[str, Any]):
|
||||
# construct class
|
||||
new_class = super().__new__(cls, name, bases, dct)
|
||||
if "game" in dct:
|
||||
AutoPatchRegister.patch_types[dct["game"]] = new_class
|
||||
if not dct["patch_file_ending"]:
|
||||
raise Exception(f"Need an expected file ending for {name}")
|
||||
AutoPatchRegister.file_endings[dct["patch_file_ending"]] = new_class
|
||||
return new_class
|
||||
|
||||
@staticmethod
|
||||
def get_handler(file: str) -> Optional[type(APDeltaPatch)]:
|
||||
for file_ending, handler in AutoPatchRegister.file_endings.items():
|
||||
if file.endswith(file_ending):
|
||||
return handler
|
||||
|
||||
|
||||
class APContainer:
|
||||
"""A zipfile containing at least archipelago.json"""
|
||||
version: int = current_patch_version
|
||||
compression_level: int = 9
|
||||
compression_method: int = zipfile.ZIP_DEFLATED
|
||||
game: Optional[str] = None
|
||||
|
||||
# instance attributes:
|
||||
path: Optional[str]
|
||||
player: Optional[int]
|
||||
player_name: str
|
||||
server: str
|
||||
|
||||
def __init__(self, path: Optional[str] = None, player: Optional[int] = None,
|
||||
player_name: str = "", server: str = ""):
|
||||
self.path = path
|
||||
self.player = player
|
||||
self.player_name = player_name
|
||||
self.server = server
|
||||
|
||||
def write(self, file: Optional[Union[str, BinaryIO]] = None):
|
||||
if not self.path and not file:
|
||||
raise FileNotFoundError(f"Cannot write {self.__class__.__name__} due to no path provided.")
|
||||
with zipfile.ZipFile(file if file else self.path, "w", self.compression_method, True, self.compression_level) \
|
||||
as zf:
|
||||
if file:
|
||||
self.path = zf.filename
|
||||
self.write_contents(zf)
|
||||
|
||||
def write_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||
manifest = self.get_manifest()
|
||||
try:
|
||||
manifest = json.dumps(manifest)
|
||||
except Exception as e:
|
||||
raise Exception(f"Manifest {manifest} did not convert to json.") from e
|
||||
else:
|
||||
opened_zipfile.writestr("archipelago.json", manifest)
|
||||
|
||||
def read(self, file: Optional[Union[str, BinaryIO]] = None):
|
||||
"""Read data into patch object. file can be file-like, such as an outer zip file's stream."""
|
||||
if not self.path and not file:
|
||||
raise FileNotFoundError(f"Cannot read {self.__class__.__name__} due to no path provided.")
|
||||
with zipfile.ZipFile(file if file else self.path, "r") as zf:
|
||||
if file:
|
||||
self.path = zf.filename
|
||||
self.read_contents(zf)
|
||||
|
||||
def read_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||
with opened_zipfile.open("archipelago.json", "r") as f:
|
||||
manifest = json.load(f)
|
||||
if manifest["compatible_version"] > self.version:
|
||||
raise Exception(f"File (version: {manifest['compatible_version']}) too new "
|
||||
f"for this handler (version: {self.version})")
|
||||
self.player = manifest["player"]
|
||||
self.server = manifest["server"]
|
||||
self.player_name = manifest["player_name"]
|
||||
|
||||
def get_manifest(self) -> dict:
|
||||
return {
|
||||
"server": self.server, # allow immediate connection to server in multiworld. Empty string otherwise
|
||||
"player": self.player,
|
||||
"player_name": self.player_name,
|
||||
"game": self.game,
|
||||
# minimum version of patch system expected for patching to be successful
|
||||
"compatible_version": 4,
|
||||
"version": current_patch_version,
|
||||
}
|
||||
|
||||
|
||||
class APDeltaPatch(APContainer, metaclass=AutoPatchRegister):
|
||||
"""An APContainer that additionally has delta.bsdiff4
|
||||
containing a delta patch to get the desired file, often a rom."""
|
||||
|
||||
hash = Optional[str] # base checksum of source file
|
||||
patch_file_ending: str = ""
|
||||
delta: Optional[bytes] = None
|
||||
result_file_ending: str = ".sfc"
|
||||
source_data: bytes
|
||||
|
||||
def __init__(self, *args, patched_path: str = "", **kwargs):
|
||||
self.patched_path = patched_path
|
||||
super(APDeltaPatch, self).__init__(*args, **kwargs)
|
||||
|
||||
def get_manifest(self) -> dict:
|
||||
manifest = super(APDeltaPatch, self).get_manifest()
|
||||
manifest["base_checksum"] = self.hash
|
||||
manifest["result_file_ending"] = self.result_file_ending
|
||||
return manifest
|
||||
|
||||
@classmethod
|
||||
def get_source_data(cls) -> bytes:
|
||||
"""Get Base data"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def get_source_data_with_cache(cls) -> bytes:
|
||||
if not hasattr(cls, "source_data"):
|
||||
cls.source_data = cls.get_source_data()
|
||||
return cls.source_data
|
||||
|
||||
def write_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||
super(APDeltaPatch, self).write_contents(opened_zipfile)
|
||||
# write Delta
|
||||
opened_zipfile.writestr("delta.bsdiff4",
|
||||
bsdiff4.diff(self.get_source_data_with_cache(), open(self.patched_path, "rb").read()),
|
||||
compress_type=zipfile.ZIP_STORED) # bsdiff4 is a format with integrated compression
|
||||
|
||||
def read_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||
super(APDeltaPatch, self).read_contents(opened_zipfile)
|
||||
self.delta = opened_zipfile.read("delta.bsdiff4")
|
||||
|
||||
def patch(self, target: str):
|
||||
"""Base + Delta -> Patched"""
|
||||
if not self.delta:
|
||||
self.read()
|
||||
result = bsdiff4.patch(self.get_source_data_with_cache(), self.delta)
|
||||
with open(target, "wb") as f:
|
||||
f.write(result)
|
||||
|
||||
|
||||
# legacy patch handling follows:
|
||||
GAME_ALTTP = "A Link to the Past"
|
||||
GAME_SM = "Super Metroid"
|
||||
GAME_SOE = "Secret of Evermore"
|
||||
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore"}
|
||||
GAME_SMZ3 = "SMZ3"
|
||||
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore", "SMZ3"}
|
||||
|
||||
preferred_endings = {
|
||||
GAME_ALTTP: "apbp",
|
||||
GAME_SM: "apm3",
|
||||
GAME_SOE: "apsoe"
|
||||
GAME_SOE: "apsoe",
|
||||
GAME_SMZ3: "apsmz"
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +180,10 @@ def generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAM
|
||||
from worlds.sm.Rom import JAP10HASH as HASH
|
||||
elif game == GAME_SOE:
|
||||
from worlds.soe.Patch import USHASH as HASH
|
||||
elif game == GAME_SMZ3:
|
||||
from worlds.alttp.Rom import JAP10HASH as ALTTPHASH
|
||||
from worlds.sm.Rom import JAP10HASH as SMHASH
|
||||
HASH = ALTTPHASH + SMHASH
|
||||
else:
|
||||
raise RuntimeError(f"Selected game {game} for base rom not found.")
|
||||
|
||||
@@ -63,7 +213,7 @@ def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str
|
||||
meta,
|
||||
game)
|
||||
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + (
|
||||
".apbp" if game == GAME_ALTTP else ".apm3")
|
||||
".apbp" if game == GAME_ALTTP else ".apsmz" if game == GAME_SMZ3 else ".apm3")
|
||||
write_lzma(bytes, target)
|
||||
return target
|
||||
|
||||
@@ -88,18 +238,29 @@ def get_base_rom_data(game: str):
|
||||
elif game == GAME_SM:
|
||||
from worlds.sm.Rom import get_base_rom_bytes
|
||||
elif game == GAME_SOE:
|
||||
file_name = Utils.get_options()["soe_options"]["rom_file"]
|
||||
get_base_rom_bytes = lambda: bytes(read_rom(open(file_name, "rb")))
|
||||
from worlds.soe.Patch import get_base_rom_path
|
||||
get_base_rom_bytes = lambda: bytes(read_rom(open(get_base_rom_path(), "rb")))
|
||||
elif game == GAME_SMZ3:
|
||||
from worlds.smz3.Rom import get_base_rom_bytes
|
||||
else:
|
||||
raise RuntimeError("Selected game for base rom not found.")
|
||||
return get_base_rom_bytes()
|
||||
|
||||
|
||||
def create_rom_file(patch_file: str) -> Tuple[dict, str]:
|
||||
data, target, patched_data = create_rom_bytes(patch_file)
|
||||
with open(target, "wb") as f:
|
||||
f.write(patched_data)
|
||||
return data, target
|
||||
auto_handler = AutoPatchRegister.get_handler(patch_file)
|
||||
if auto_handler:
|
||||
handler: APDeltaPatch = auto_handler(patch_file)
|
||||
target = os.path.splitext(patch_file)[0]+handler.result_file_ending
|
||||
handler.patch(target)
|
||||
return {"server": handler.server,
|
||||
"player": handler.player,
|
||||
"player_name": handler.player_name}, target
|
||||
else:
|
||||
data, target, patched_data = create_rom_bytes(patch_file)
|
||||
with open(target, "wb") as f:
|
||||
f.write(patched_data)
|
||||
return data, target
|
||||
|
||||
|
||||
def update_patch_data(patch_data: bytes, server: str = "") -> bytes:
|
||||
@@ -218,24 +379,13 @@ if __name__ == "__main__":
|
||||
if 'server' in data:
|
||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||
print(f"Host is {data['server']}")
|
||||
elif rom.endswith(".archipelago"):
|
||||
import json
|
||||
import zlib
|
||||
|
||||
with open(rom, 'rb') as fr:
|
||||
|
||||
multidata = zlib.decompress(fr.read()).decode("utf-8")
|
||||
with open(rom + '.txt', 'w') as fw:
|
||||
fw.write(multidata)
|
||||
multidata = json.loads(multidata)
|
||||
for romname in multidata['roms']:
|
||||
Utils.persistent_store("servers", "".join(chr(byte) for byte in romname[2]), address)
|
||||
from Utils import get_options
|
||||
|
||||
multidata["server_options"] = get_options()["server_options"]
|
||||
multidata = zlib.compress(json.dumps(multidata).encode("utf-8"), 9)
|
||||
with open(rom + "_updated.archipelago", 'wb') as f:
|
||||
f.write(multidata)
|
||||
elif rom.endswith(".apsmz"):
|
||||
print(f"Applying patch {rom}")
|
||||
data, target = create_rom_file(rom)
|
||||
print(f"Created rom {target}.")
|
||||
if 'server' in data:
|
||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||
print(f"Host is {data['server']}")
|
||||
|
||||
elif rom.endswith(".zip"):
|
||||
print(f"Updating host in patch files contained in {rom}")
|
||||
@@ -264,4 +414,4 @@ if __name__ == "__main__":
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
input("Press enter to close.")
|
||||
input("Press enter to close.")
|
||||
|
||||
@@ -18,6 +18,11 @@ Currently, the following games are supported:
|
||||
* VVVVVV
|
||||
* Raft
|
||||
* Super Mario 64
|
||||
* Meritous
|
||||
* Super Metroid/Link to the Past combo randomizer (SMZ3)
|
||||
* ChecksFinder
|
||||
* ArchipIDLE
|
||||
* Hollow Knight
|
||||
|
||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||
@@ -58,6 +63,8 @@ Contributions are welcome. We have a few asks of any new contributors.
|
||||
|
||||
Otherwise, we tend to judge code on a case to case basis. It is a generally good idea to stick to PEP-8 guidelines to ensure consistency with existing code. (And to make the linter happy.)
|
||||
|
||||
For adding a new game to Archipelago please see the docs folder for the relevant information and feel free to ask any questions in the #archipelago-dev channel in our discord.
|
||||
|
||||
## Code of Conduct
|
||||
We conduct ourselves openly and inclusively here. Please do not contribute to an environment which makes other people uncomfortable. This means that we expect all contributors or participants here to:
|
||||
|
||||
|
||||
342
SNIClient.py
@@ -11,9 +11,8 @@ import shutil
|
||||
import logging
|
||||
import asyncio
|
||||
from json import loads, dumps
|
||||
from tkinter import font
|
||||
|
||||
from Utils import get_item_name_from_id, init_logging
|
||||
from Utils import init_logging
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_logging("SNIClient", exception_logger="Client")
|
||||
@@ -22,13 +21,12 @@ import colorama
|
||||
|
||||
from NetUtils import *
|
||||
from worlds.alttp import Regions, Shops
|
||||
from worlds.alttp import Items
|
||||
from worlds.alttp.Rom import ROM_PLAYER_LIMIT
|
||||
from worlds.sm.Rom import ROM_PLAYER_LIMIT as SM_ROM_PLAYER_LIMIT
|
||||
from worlds.smz3.Rom import ROM_PLAYER_LIMIT as SMZ3_ROM_PLAYER_LIMIT
|
||||
import Utils
|
||||
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||
from Patch import GAME_ALTTP, GAME_SM
|
||||
|
||||
from Patch import GAME_ALTTP, GAME_SM, GAME_SMZ3
|
||||
|
||||
snes_logger = logging.getLogger("SNES")
|
||||
|
||||
@@ -41,7 +39,7 @@ class DeathState(enum.IntEnum):
|
||||
dead = 3
|
||||
|
||||
|
||||
class LttPCommandProcessor(ClientCommandProcessor):
|
||||
class SNIClientCommandProcessor(ClientCommandProcessor):
|
||||
ctx: Context
|
||||
|
||||
def _cmd_slow_mode(self, toggle: str = ""):
|
||||
@@ -56,7 +54,8 @@ class LttPCommandProcessor(ClientCommandProcessor):
|
||||
@mark_raw
|
||||
def _cmd_snes(self, snes_options: str = "") -> bool:
|
||||
"""Connect to a snes. Optionally include network address of a snes to connect to,
|
||||
otherwise show available devices; and a SNES device number if more than one SNES is detected"""
|
||||
otherwise show available devices; and a SNES device number if more than one SNES is detected.
|
||||
Examples: "/snes", "/snes 1", "/snes localhost:8080 1" """
|
||||
|
||||
snes_address = self.ctx.snes_address
|
||||
snes_device_number = -1
|
||||
@@ -65,13 +64,11 @@ class LttPCommandProcessor(ClientCommandProcessor):
|
||||
num_options = len(options)
|
||||
|
||||
if num_options > 0:
|
||||
snes_address = options[0]
|
||||
snes_device_number = int(options[0])
|
||||
|
||||
if num_options > 1:
|
||||
try:
|
||||
snes_device_number = int(options[1])
|
||||
except:
|
||||
pass
|
||||
snes_address = options[0]
|
||||
snes_device_number = int(options[1])
|
||||
|
||||
self.ctx.snes_reconnect_address = None
|
||||
asyncio.create_task(snes_connect(self.ctx, snes_address, snes_device_number), name="SNES Connect")
|
||||
@@ -92,15 +89,23 @@ class LttPCommandProcessor(ClientCommandProcessor):
|
||||
# if self.ctx.snes_state != SNESState.SNES_ATTACHED:
|
||||
# self.output("No attached SNES Device.")
|
||||
# return False
|
||||
#
|
||||
# snes_buffered_write(self.ctx, int(address, 16), bytes([int(data)]))
|
||||
# asyncio.create_task(snes_flush_writes(self.ctx))
|
||||
# self.output("Data Sent")
|
||||
# return True
|
||||
|
||||
# def _cmd_snes_read(self, address, size=1):
|
||||
# """Read the SNES' memory address (base16)."""
|
||||
# if self.ctx.snes_state != SNESState.SNES_ATTACHED:
|
||||
# self.output("No attached SNES Device.")
|
||||
# return False
|
||||
# data = await snes_read(self.ctx, int(address, 16), size)
|
||||
# self.output(f"Data Read: {data}")
|
||||
# return True
|
||||
|
||||
|
||||
class Context(CommonContext):
|
||||
command_processor = LttPCommandProcessor
|
||||
command_processor = SNIClientCommandProcessor
|
||||
game = "A Link to the Past"
|
||||
items_handling = None # set in game_watcher
|
||||
|
||||
@@ -167,6 +172,16 @@ class Context(CommonContext):
|
||||
if not currently_dead:
|
||||
self.death_state = DeathState.alive
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd in {"Connected", "RoomUpdate"}:
|
||||
if "checked_locations" in args and args["checked_locations"]:
|
||||
new_locations = set(args["checked_locations"])
|
||||
self.checked_locations |= new_locations
|
||||
self.locations_scouted |= new_locations
|
||||
# Items belonging to the player should not be marked as checked in game, since the player will likely need that item.
|
||||
# Once the games handled by SNIClient gets made to be remote items, this will no longer be needed.
|
||||
asyncio.create_task(self.send_msgs([{"cmd": "LocationScouts", "locations": list(new_locations)}]))
|
||||
|
||||
|
||||
async def deathlink_kill_player(ctx: Context):
|
||||
ctx.death_state = DeathState.killing_player
|
||||
@@ -183,9 +198,11 @@ async def deathlink_kill_player(ctx: Context):
|
||||
continue
|
||||
if not invincible[0] and last_health[0] == health[0]:
|
||||
snes_buffered_write(ctx, WRAM_START + 0xF36D, bytes([0])) # set current health to 0
|
||||
snes_buffered_write(ctx, WRAM_START + 0x0373, bytes([8])) # deal 1 full heart of damage at next opportunity
|
||||
snes_buffered_write(ctx, WRAM_START + 0x0373,
|
||||
bytes([8])) # deal 1 full heart of damage at next opportunity
|
||||
elif ctx.game == GAME_SM:
|
||||
snes_buffered_write(ctx, WRAM_START + 0x09C2, bytes([0, 0])) # set current health to 0
|
||||
snes_buffered_write(ctx, WRAM_START + 0x09C2, bytes([1, 0])) # set current health to 1 (to prevent saving with 0 energy)
|
||||
snes_buffered_write(ctx, WRAM_START + 0x0A50, bytes([255])) # deal 255 of damage at next opportunity
|
||||
if not ctx.death_link_allow_survive:
|
||||
snes_buffered_write(ctx, WRAM_START + 0x09D6, bytes([0, 0])) # set current reserve to 0
|
||||
await snes_flush_writes(ctx)
|
||||
@@ -200,7 +217,8 @@ async def deathlink_kill_player(ctx: Context):
|
||||
health = await snes_read(ctx, WRAM_START + 0x09C2, 2)
|
||||
if health is not None:
|
||||
health = health[0] | (health[1] << 8)
|
||||
if not gamemode or gamemode[0] in SM_DEATH_MODES or (ctx.death_link_allow_survive and health is not None and health > 0):
|
||||
if not gamemode or gamemode[0] in SM_DEATH_MODES or (
|
||||
ctx.death_link_allow_survive and health is not None and health > 0):
|
||||
ctx.death_state = DeathState.dead
|
||||
ctx.last_death_link = time.time()
|
||||
|
||||
@@ -233,11 +251,12 @@ SCOUTREPLY_LOCATION_ADDR = SAVEDATA_START + 0x4D8 # 1 byte
|
||||
SCOUTREPLY_ITEM_ADDR = SAVEDATA_START + 0x4D9 # 1 byte
|
||||
SCOUTREPLY_PLAYER_ADDR = SAVEDATA_START + 0x4DA # 1 byte
|
||||
SHOP_ADDR = SAVEDATA_START + 0x302 # 2 bytes
|
||||
SHOP_LEN = (len(Shops.shop_table) * 3) + 5
|
||||
|
||||
DEATH_LINK_ACTIVE_ADDR = ROMNAME_START + 0x15 # 1 byte
|
||||
|
||||
# SM
|
||||
SM_ROMNAME_START = 0x1C4F00
|
||||
SM_ROMNAME_START = 0x007FC0
|
||||
|
||||
SM_INGAME_MODES = {0x07, 0x09, 0x0b}
|
||||
SM_ENDGAME_MODES = {0x26, 0x27}
|
||||
@@ -248,6 +267,19 @@ SM_RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte
|
||||
SM_RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte
|
||||
|
||||
SM_DEATH_LINK_ACTIVE_ADDR = ROM_START + 0x277f04 # 1 byte
|
||||
SM_REMOTE_ITEM_FLAG_ADDR = ROM_START + 0x277f06 # 1 byte
|
||||
|
||||
# SMZ3
|
||||
SMZ3_ROMNAME_START = 0x00FFC0
|
||||
|
||||
SMZ3_INGAME_MODES = {0x07, 0x09, 0x0b}
|
||||
SMZ3_ENDGAME_MODES = {0x26, 0x27}
|
||||
SMZ3_DEATH_MODES = {0x15, 0x17, 0x18, 0x19, 0x1A}
|
||||
|
||||
SMZ3_RECV_PROGRESS_ADDR = SRAM_START + 0x4000 # 2 bytes
|
||||
SMZ3_RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte
|
||||
SMZ3_RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte
|
||||
|
||||
|
||||
location_shop_ids = set([info[0] for name, info in Shops.shop_table.items()])
|
||||
|
||||
@@ -472,6 +504,17 @@ location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10),
|
||||
'Ganons Tower - Pre-Moldorm Chest': (0x3d, 0x40),
|
||||
'Ganons Tower - Validation Chest': (0x4d, 0x10)}
|
||||
|
||||
boss_locations = {Regions.lookup_name_to_id[name] for name in {'Eastern Palace - Boss',
|
||||
'Desert Palace - Boss',
|
||||
'Tower of Hera - Boss',
|
||||
'Palace of Darkness - Boss',
|
||||
'Swamp Palace - Boss',
|
||||
'Skull Woods - Boss',
|
||||
"Thieves' Town - Boss",
|
||||
'Ice Palace - Boss',
|
||||
'Misery Mire - Boss',
|
||||
'Turtle Rock - Boss'}}
|
||||
|
||||
location_table_uw_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_uw.items()}
|
||||
|
||||
location_table_npc = {'Mushroom': 0x1000,
|
||||
@@ -540,8 +583,14 @@ def launch_sni(ctx: Context):
|
||||
if not sys.stdout: # if it spawns a visible console, may as well populate it
|
||||
subprocess.Popen(os.path.abspath(sni_path), cwd=os.path.dirname(sni_path))
|
||||
else:
|
||||
subprocess.Popen(os.path.abspath(sni_path), cwd=os.path.dirname(sni_path), stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL)
|
||||
proc = subprocess.Popen(os.path.abspath(sni_path), cwd=os.path.dirname(sni_path),
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
proc.wait(.1) # wait a bit to see if startup fails (missing dependencies)
|
||||
snes_logger.info('Failed to start SNI. Try running it externally for error output.')
|
||||
except subprocess.TimeoutExpired:
|
||||
pass # seems to be running
|
||||
|
||||
else:
|
||||
snes_logger.info(
|
||||
f"Attempt to start SNI was aborted as path {sni_path} was not found, "
|
||||
@@ -625,24 +674,24 @@ async def snes_connect(ctx: Context, address, deviceIndex=-1):
|
||||
|
||||
try:
|
||||
devices = await get_snes_devices(ctx)
|
||||
numDevices = len(devices)
|
||||
device_count = len(devices)
|
||||
|
||||
if numDevices == 1:
|
||||
if device_count == 1:
|
||||
device = devices[0]
|
||||
elif ctx.snes_reconnect_address:
|
||||
if ctx.snes_attached_device[1] in devices:
|
||||
device = ctx.snes_attached_device[1]
|
||||
else:
|
||||
device = devices[ctx.snes_attached_device[0]]
|
||||
elif numDevices > 1:
|
||||
elif device_count > 1:
|
||||
if deviceIndex == -1:
|
||||
snes_logger.info(
|
||||
"Found " + str(numDevices) + " SNES devices; connect to one with /snes <address> <device number>:")
|
||||
snes_logger.info(f"Found {device_count} SNES devices. "
|
||||
f"Connect to one with /snes <address> <device number>. For example /snes {address} 1")
|
||||
|
||||
for idx, availableDevice in enumerate(devices):
|
||||
snes_logger.info(str(idx + 1) + ": " + availableDevice)
|
||||
|
||||
elif (deviceIndex < 0) or (deviceIndex - 1) > numDevices:
|
||||
elif (deviceIndex < 0) or (deviceIndex - 1) > device_count:
|
||||
snes_logger.warning("SNES device number out of range")
|
||||
|
||||
else:
|
||||
@@ -664,8 +713,6 @@ async def snes_connect(ctx: Context, address, deviceIndex=-1):
|
||||
ctx.snes_attached_device = (devices.index(device), device)
|
||||
ctx.snes_reconnect_address = address
|
||||
recv_task = asyncio.create_task(snes_recv_loop(ctx))
|
||||
SNES_RECONNECT_DELAY = ctx.starting_reconnect_delay
|
||||
snes_logger.info(f"Attached to {device}")
|
||||
|
||||
except Exception as e:
|
||||
if recv_task is not None:
|
||||
@@ -684,6 +731,10 @@ async def snes_connect(ctx: Context, address, deviceIndex=-1):
|
||||
asyncio.create_task(snes_autoreconnect(ctx))
|
||||
SNES_RECONNECT_DELAY *= 2
|
||||
|
||||
else:
|
||||
SNES_RECONNECT_DELAY = ctx.starting_reconnect_delay
|
||||
snes_logger.info(f"Attached to {device}")
|
||||
|
||||
|
||||
async def snes_disconnect(ctx: Context):
|
||||
if ctx.snes_socket:
|
||||
@@ -814,19 +865,27 @@ async def track_locations(ctx: Context, roomid, roomdata):
|
||||
f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
|
||||
|
||||
try:
|
||||
if roomid in location_shop_ids:
|
||||
misc_data = await snes_read(ctx, SHOP_ADDR, (len(Shops.shop_table) * 3) + 5)
|
||||
for cnt, b in enumerate(misc_data):
|
||||
if int(b) and (Shops.SHOP_ID_START + cnt) not in ctx.locations_checked:
|
||||
new_check(Shops.SHOP_ID_START + cnt)
|
||||
shop_data = await snes_read(ctx, SHOP_ADDR, SHOP_LEN)
|
||||
shop_data_changed = False
|
||||
shop_data = list(shop_data)
|
||||
for cnt, b in enumerate(shop_data):
|
||||
location = Shops.SHOP_ID_START + cnt
|
||||
if int(b) and location not in ctx.locations_checked:
|
||||
new_check(location)
|
||||
if location in ctx.checked_locations and location not in ctx.locations_checked \
|
||||
and location in ctx.locations_info and ctx.locations_info[location].player != ctx.slot:
|
||||
if not int(b):
|
||||
shop_data[cnt] += 1
|
||||
shop_data_changed = True
|
||||
if shop_data_changed:
|
||||
snes_buffered_write(ctx, SHOP_ADDR, bytes(shop_data))
|
||||
except Exception as e:
|
||||
snes_logger.info(f"Exception: {e}")
|
||||
|
||||
for location_id, (loc_roomid, loc_mask) in location_table_uw_id.items():
|
||||
try:
|
||||
|
||||
if location_id not in ctx.locations_checked and loc_roomid == roomid and (
|
||||
roomdata << 4) & loc_mask != 0:
|
||||
if location_id not in ctx.locations_checked and loc_roomid == roomid and \
|
||||
(roomdata << 4) & loc_mask != 0:
|
||||
new_check(location_id)
|
||||
except Exception as e:
|
||||
snes_logger.exception(f"Exception: {e}")
|
||||
@@ -834,12 +893,19 @@ async def track_locations(ctx: Context, roomid, roomdata):
|
||||
uw_begin = 0x129
|
||||
ow_end = uw_end = 0
|
||||
uw_unchecked = {}
|
||||
uw_checked = {}
|
||||
for location, (roomid, mask) in location_table_uw.items():
|
||||
location_id = Regions.lookup_name_to_id[location]
|
||||
if location_id not in ctx.locations_checked:
|
||||
uw_unchecked[location_id] = (roomid, mask)
|
||||
uw_begin = min(uw_begin, roomid)
|
||||
uw_end = max(uw_end, roomid + 1)
|
||||
if location_id in ctx.checked_locations and location_id not in ctx.locations_checked and \
|
||||
location_id in ctx.locations_info and ctx.locations_info[location_id].player != ctx.slot and \
|
||||
location_id not in boss_locations:
|
||||
uw_begin = min(uw_begin, roomid)
|
||||
uw_end = max(uw_end, roomid + 1)
|
||||
uw_checked[location_id] = (roomid, mask)
|
||||
|
||||
if uw_begin < uw_end:
|
||||
uw_data = await snes_read(ctx, SAVEDATA_START + (uw_begin * 2), (uw_end - uw_begin) * 2)
|
||||
@@ -849,14 +915,27 @@ async def track_locations(ctx: Context, roomid, roomdata):
|
||||
roomdata = uw_data[offset] | (uw_data[offset + 1] << 8)
|
||||
if roomdata & mask != 0:
|
||||
new_check(location_id)
|
||||
if uw_checked:
|
||||
uw_data = list(uw_data)
|
||||
for location_id, (roomid, mask) in uw_checked.items():
|
||||
offset = (roomid - uw_begin) * 2
|
||||
roomdata = uw_data[offset] | (uw_data[offset + 1] << 8)
|
||||
roomdata |= mask
|
||||
uw_data[offset] = roomdata & 0xFF
|
||||
uw_data[offset + 1] = roomdata >> 8
|
||||
snes_buffered_write(ctx, SAVEDATA_START + (uw_begin * 2), bytes(uw_data))
|
||||
|
||||
ow_begin = 0x82
|
||||
ow_unchecked = {}
|
||||
ow_checked = {}
|
||||
for location_id, screenid in location_table_ow_id.items():
|
||||
if location_id not in ctx.locations_checked:
|
||||
ow_unchecked[location_id] = screenid
|
||||
ow_begin = min(ow_begin, screenid)
|
||||
ow_end = max(ow_end, screenid + 1)
|
||||
if location_id in ctx.checked_locations and location_id in ctx.locations_info \
|
||||
and ctx.locations_info[location_id].player != ctx.slot:
|
||||
ow_checked[location_id] = screenid
|
||||
|
||||
if ow_begin < ow_end:
|
||||
ow_data = await snes_read(ctx, SAVEDATA_START + 0x280 + ow_begin, ow_end - ow_begin)
|
||||
@@ -864,25 +943,48 @@ async def track_locations(ctx: Context, roomid, roomdata):
|
||||
for location_id, screenid in ow_unchecked.items():
|
||||
if ow_data[screenid - ow_begin] & 0x40 != 0:
|
||||
new_check(location_id)
|
||||
if ow_checked:
|
||||
ow_data = list(ow_data)
|
||||
for location_id, screenid in ow_checked.items():
|
||||
ow_data[screenid - ow_begin] |= 0x40
|
||||
snes_buffered_write(ctx, SAVEDATA_START + 0x280 + ow_begin, bytes(ow_data))
|
||||
|
||||
if not ctx.locations_checked.issuperset(location_table_npc_id):
|
||||
npc_data = await snes_read(ctx, SAVEDATA_START + 0x410, 2)
|
||||
if npc_data is not None:
|
||||
npc_value_changed = False
|
||||
npc_value = npc_data[0] | (npc_data[1] << 8)
|
||||
for location_id, mask in location_table_npc_id.items():
|
||||
if npc_value & mask != 0 and location_id not in ctx.locations_checked:
|
||||
new_check(location_id)
|
||||
if location_id in ctx.checked_locations and location_id not in ctx.locations_checked \
|
||||
and location_id in ctx.locations_info and ctx.locations_info[location_id].player != ctx.slot:
|
||||
npc_value |= mask
|
||||
npc_value_changed = True
|
||||
if npc_value_changed:
|
||||
npc_data = bytes([npc_value & 0xFF, npc_value >> 8])
|
||||
snes_buffered_write(ctx, SAVEDATA_START + 0x410, npc_data)
|
||||
|
||||
if not ctx.locations_checked.issuperset(location_table_misc_id):
|
||||
misc_data = await snes_read(ctx, SAVEDATA_START + 0x3c6, 4)
|
||||
if misc_data is not None:
|
||||
misc_data = list(misc_data)
|
||||
misc_data_changed = False
|
||||
for location_id, (offset, mask) in location_table_misc_id.items():
|
||||
assert (0x3c6 <= offset <= 0x3c9)
|
||||
if misc_data[offset - 0x3c6] & mask != 0 and location_id not in ctx.locations_checked:
|
||||
new_check(location_id)
|
||||
if location_id in ctx.checked_locations and location_id not in ctx.locations_checked \
|
||||
and location_id in ctx.locations_info and ctx.locations_info[location_id].player != ctx.slot:
|
||||
misc_data_changed = True
|
||||
misc_data[offset - 0x3c6] |= mask
|
||||
if misc_data_changed:
|
||||
snes_buffered_write(ctx, SAVEDATA_START + 0x3c6, bytes(misc_data))
|
||||
|
||||
|
||||
if new_locations:
|
||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": new_locations}])
|
||||
await snes_flush_writes(ctx)
|
||||
|
||||
|
||||
async def game_watcher(ctx: Context):
|
||||
@@ -898,29 +1000,42 @@ async def game_watcher(ctx: Context):
|
||||
if not ctx.rom:
|
||||
ctx.finished_game = False
|
||||
ctx.death_link_allow_survive = False
|
||||
game_name = await snes_read(ctx, SM_ROMNAME_START, 2)
|
||||
game_name = await snes_read(ctx, SM_ROMNAME_START, 5)
|
||||
if game_name is None:
|
||||
continue
|
||||
elif game_name == b"SM":
|
||||
elif game_name[:2] == b"SM":
|
||||
ctx.game = GAME_SM
|
||||
ctx.items_handling = 0b001 # full local
|
||||
# versions lower than 0.3.0 dont have item handling flag nor remote item support
|
||||
romVersion = int(game_name[2:5].decode('UTF-8'))
|
||||
if romVersion < 30:
|
||||
ctx.items_handling = 0b001 # full local
|
||||
else:
|
||||
item_handling = await snes_read(ctx, SM_REMOTE_ITEM_FLAG_ADDR, 1)
|
||||
ctx.items_handling = 0b001 if item_handling is None else item_handling[0]
|
||||
else:
|
||||
ctx.game = GAME_ALTTP
|
||||
ctx.items_handling = 0b001 # full local
|
||||
game_name = await snes_read(ctx, SMZ3_ROMNAME_START, 3)
|
||||
if game_name == b"ZSM":
|
||||
ctx.game = GAME_SMZ3
|
||||
ctx.items_handling = 0b101 # local items and remote start inventory
|
||||
else:
|
||||
ctx.game = GAME_ALTTP
|
||||
ctx.items_handling = 0b001 # full local
|
||||
|
||||
rom = await snes_read(ctx, SM_ROMNAME_START if ctx.game == GAME_SM else ROMNAME_START, ROMNAME_SIZE)
|
||||
rom = await snes_read(ctx, SM_ROMNAME_START if ctx.game == GAME_SM else SMZ3_ROMNAME_START if ctx.game == GAME_SMZ3 else ROMNAME_START, ROMNAME_SIZE)
|
||||
if rom is None or rom == bytes([0] * ROMNAME_SIZE):
|
||||
continue
|
||||
|
||||
ctx.rom = rom
|
||||
death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR if ctx.game == GAME_ALTTP else
|
||||
SM_DEATH_LINK_ACTIVE_ADDR, 1)
|
||||
if death_link:
|
||||
ctx.death_link_allow_survive = bool(death_link[0] & 0b10)
|
||||
await ctx.update_death_link(bool(death_link[0] & 0b1))
|
||||
if ctx.game != GAME_SMZ3:
|
||||
death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR if ctx.game == GAME_ALTTP else
|
||||
SM_DEATH_LINK_ACTIVE_ADDR, 1)
|
||||
if death_link:
|
||||
ctx.death_link_allow_survive = bool(death_link[0] & 0b10)
|
||||
await ctx.update_death_link(bool(death_link[0] & 0b1))
|
||||
if not ctx.prev_rom or ctx.prev_rom != ctx.rom:
|
||||
ctx.locations_checked = set()
|
||||
ctx.locations_scouted = set()
|
||||
ctx.locations_info = {}
|
||||
ctx.prev_rom = ctx.rom
|
||||
|
||||
if ctx.awaiting_rom:
|
||||
@@ -976,7 +1091,8 @@ async def game_watcher(ctx: Context):
|
||||
item = ctx.items_received[recv_index]
|
||||
recv_index += 1
|
||||
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
|
||||
color(ctx.item_name_getter(item.item), 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'),
|
||||
color(ctx.item_name_getter(item.item), 'red', 'bold'),
|
||||
color(ctx.player_names[item.player], 'yellow'),
|
||||
ctx.location_name_getter(item.location), recv_index, len(ctx.items_received)))
|
||||
|
||||
snes_buffered_write(ctx, RECV_PROGRESS_ADDR,
|
||||
@@ -989,16 +1105,16 @@ async def game_watcher(ctx: Context):
|
||||
snes_buffered_write(ctx, SCOUTREPLY_LOCATION_ADDR,
|
||||
bytes([scout_location]))
|
||||
snes_buffered_write(ctx, SCOUTREPLY_ITEM_ADDR,
|
||||
bytes([ctx.locations_info[scout_location][0]]))
|
||||
bytes([ctx.locations_info[scout_location].item]))
|
||||
snes_buffered_write(ctx, SCOUTREPLY_PLAYER_ADDR,
|
||||
bytes([min(ROM_PLAYER_LIMIT, ctx.locations_info[scout_location][1])]))
|
||||
bytes([min(ROM_PLAYER_LIMIT, ctx.locations_info[scout_location].player)]))
|
||||
|
||||
await snes_flush_writes(ctx)
|
||||
|
||||
if scout_location > 0 and scout_location not in ctx.locations_scouted:
|
||||
ctx.locations_scouted.add(scout_location)
|
||||
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": [scout_location]}])
|
||||
await track_locations(ctx, roomid, roomdata)
|
||||
await track_locations(ctx, roomid, roomdata)
|
||||
elif ctx.game == GAME_SM:
|
||||
gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1)
|
||||
if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time():
|
||||
@@ -1025,14 +1141,16 @@ async def game_watcher(ctx: Context):
|
||||
itemIndex = (message[4] | (message[5] << 8)) >> 3
|
||||
|
||||
recv_index += 1
|
||||
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x680, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
|
||||
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x680,
|
||||
bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
|
||||
|
||||
from worlds.sm.Locations import locations_start_id
|
||||
location_id = locations_start_id + itemIndex
|
||||
|
||||
ctx.locations_checked.add(location_id)
|
||||
location = ctx.location_name_getter(location_id)
|
||||
snes_logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
|
||||
snes_logger.info(
|
||||
f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
|
||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}])
|
||||
|
||||
data = await snes_read(ctx, SM_RECV_PROGRESS_ADDR + 0x600, 4)
|
||||
@@ -1043,14 +1161,82 @@ async def game_watcher(ctx: Context):
|
||||
itemOutPtr = data[2] | (data[3] << 8)
|
||||
|
||||
from worlds.sm.Items import items_start_id
|
||||
from worlds.sm.Locations import locations_start_id
|
||||
if itemOutPtr < len(ctx.items_received):
|
||||
item = ctx.items_received[itemOutPtr]
|
||||
itemId = item.item - items_start_id
|
||||
locationId = (item.location - locations_start_id) if item.location >= 0 and bool(ctx.items_handling & 0b010) else 0x00
|
||||
|
||||
playerID = item.player if item.player <= SM_ROM_PLAYER_LIMIT else 0
|
||||
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + itemOutPtr * 4, bytes(
|
||||
[playerID & 0xFF, (playerID >> 8) & 0xFF, itemId & 0xFF, locationId & 0xFF]))
|
||||
itemOutPtr += 1
|
||||
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x602,
|
||||
bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF]))
|
||||
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
|
||||
color(ctx.item_name_getter(item.item), 'red', 'bold'),
|
||||
color(ctx.player_names[item.player], 'yellow'),
|
||||
ctx.location_name_getter(item.location), itemOutPtr, len(ctx.items_received)))
|
||||
await snes_flush_writes(ctx)
|
||||
elif ctx.game == GAME_SMZ3:
|
||||
currentGame = await snes_read(ctx, SRAM_START + 0x33FE, 2)
|
||||
if (currentGame is not None):
|
||||
if (currentGame[0] != 0):
|
||||
gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1)
|
||||
endGameModes = SM_ENDGAME_MODES
|
||||
else:
|
||||
gamemode = await snes_read(ctx, WRAM_START + 0x10, 1)
|
||||
endGameModes = ENDGAME_MODES
|
||||
|
||||
if gamemode is not None and (gamemode[0] in endGameModes):
|
||||
if not ctx.finished_game:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
continue
|
||||
|
||||
data = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x680, 4)
|
||||
if data is None:
|
||||
continue
|
||||
|
||||
recv_index = data[0] | (data[1] << 8)
|
||||
recv_item = data[2] | (data[3] << 8)
|
||||
|
||||
while (recv_index < recv_item):
|
||||
itemAdress = recv_index * 8
|
||||
message = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x700 + itemAdress, 8)
|
||||
# worldId = message[0] | (message[1] << 8) # unused
|
||||
# itemId = message[2] | (message[3] << 8) # unused
|
||||
isZ3Item = ((message[5] & 0x80) != 0)
|
||||
maskedPart = (message[5] & 0x7F) if isZ3Item else message[5]
|
||||
itemIndex = ((message[4] | (maskedPart << 8)) >> 3) + (256 if isZ3Item else 0)
|
||||
|
||||
recv_index += 1
|
||||
snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x680, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
|
||||
|
||||
from worlds.smz3.TotalSMZ3.Location import locations_start_id
|
||||
location_id = locations_start_id + itemIndex
|
||||
|
||||
ctx.locations_checked.add(location_id)
|
||||
location = ctx.location_name_getter(location_id)
|
||||
snes_logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
|
||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}])
|
||||
|
||||
data = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x600, 4)
|
||||
if data is None:
|
||||
continue
|
||||
|
||||
# recv_itemOutPtr = data[0] | (data[1] << 8) # unused
|
||||
itemOutPtr = data[2] | (data[3] << 8)
|
||||
|
||||
from worlds.smz3.TotalSMZ3.Item import items_start_id
|
||||
if itemOutPtr < len(ctx.items_received):
|
||||
item = ctx.items_received[itemOutPtr]
|
||||
itemId = item.item - items_start_id
|
||||
|
||||
playerID = item.player if item.player <= SM_ROM_PLAYER_LIMIT else 0
|
||||
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + itemOutPtr * 4, bytes([playerID & 0xFF, (playerID >> 8) & 0xFF, itemId & 0xFF, (itemId >> 8) & 0xFF]))
|
||||
playerID = item.player if item.player <= SMZ3_ROM_PLAYER_LIMIT else 0
|
||||
snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + itemOutPtr * 4, bytes([playerID & 0xFF, (playerID >> 8) & 0xFF, itemId & 0xFF, (itemId >> 8) & 0xFF]))
|
||||
itemOutPtr += 1
|
||||
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x602, bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF]))
|
||||
snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x602, bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF]))
|
||||
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
|
||||
color(ctx.item_name_getter(item.item), 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'),
|
||||
ctx.location_name_getter(item.location), itemOutPtr, len(ctx.items_received)))
|
||||
@@ -1130,6 +1316,7 @@ async def main():
|
||||
if input_task:
|
||||
input_task.cancel()
|
||||
|
||||
|
||||
def get_alttp_settings(romfile: str):
|
||||
lastSettings = Utils.get_adjuster_settings(GAME_ALTTP)
|
||||
adjusted = False
|
||||
@@ -1139,8 +1326,8 @@ def get_alttp_settings(romfile: str):
|
||||
if not hasattr(lastSettings, 'auto_apply') or 'ask' in lastSettings.auto_apply:
|
||||
|
||||
whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
|
||||
"uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes",
|
||||
"reduceflashing", "deathlink"}
|
||||
"uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes",
|
||||
"reduceflashing", "deathlink"}
|
||||
printed_options = {name: value for name, value in vars(lastSettings).items() if name in whitelist}
|
||||
if hasattr(lastSettings, "sprite_pool"):
|
||||
sprite_pool = {}
|
||||
@@ -1154,40 +1341,41 @@ def get_alttp_settings(romfile: str):
|
||||
import pprint
|
||||
|
||||
if gui_enabled:
|
||||
|
||||
|
||||
from tkinter import Tk, PhotoImage, Label, LabelFrame, Frame, Button
|
||||
applyPromptWindow = Tk()
|
||||
applyPromptWindow.resizable(False, False)
|
||||
applyPromptWindow.protocol('WM_DELETE_WINDOW',lambda: onButtonClick())
|
||||
applyPromptWindow.protocol('WM_DELETE_WINDOW', lambda: onButtonClick())
|
||||
logo = PhotoImage(file=Utils.local_path('data', 'icon.png'))
|
||||
applyPromptWindow.tk.call('wm', 'iconphoto', applyPromptWindow._w, logo)
|
||||
applyPromptWindow.wm_title("Last adjuster settings LttP")
|
||||
|
||||
label = LabelFrame(applyPromptWindow,
|
||||
text='Last used adjuster settings were found. Would you like to apply these?')
|
||||
label.grid(column=0,row=0, padx=5, pady=5, ipadx=5, ipady=5)
|
||||
label.grid_columnconfigure (0, weight=1)
|
||||
label.grid_columnconfigure (1, weight=1)
|
||||
label.grid_columnconfigure (2, weight=1)
|
||||
label.grid_columnconfigure (3, weight=1)
|
||||
def onButtonClick(answer: str='no'):
|
||||
text='Last used adjuster settings were found. Would you like to apply these?')
|
||||
label.grid(column=0, row=0, padx=5, pady=5, ipadx=5, ipady=5)
|
||||
label.grid_columnconfigure(0, weight=1)
|
||||
label.grid_columnconfigure(1, weight=1)
|
||||
label.grid_columnconfigure(2, weight=1)
|
||||
label.grid_columnconfigure(3, weight=1)
|
||||
|
||||
def onButtonClick(answer: str = 'no'):
|
||||
setattr(onButtonClick, 'choice', answer)
|
||||
applyPromptWindow.destroy()
|
||||
|
||||
framedOptions = Frame(label)
|
||||
framedOptions.grid(column=0, columnspan=4,row=0)
|
||||
framedOptions.grid(column=0, columnspan=4, row=0)
|
||||
framedOptions.grid_columnconfigure(0, weight=1)
|
||||
framedOptions.grid_columnconfigure(1, weight=1)
|
||||
framedOptions.grid_columnconfigure(2, weight=1)
|
||||
curRow = 0
|
||||
curCol = 0
|
||||
for name, value in printed_options.items():
|
||||
Label(framedOptions, text=name+": "+str(value)).grid(column=curCol, row=curRow, padx=5)
|
||||
if(curCol==2):
|
||||
curRow+=1
|
||||
curCol=0
|
||||
Label(framedOptions, text=name + ": " + str(value)).grid(column=curCol, row=curRow, padx=5)
|
||||
if (curCol == 2):
|
||||
curRow += 1
|
||||
curCol = 0
|
||||
else:
|
||||
curCol+=1
|
||||
curCol += 1
|
||||
|
||||
yesButton = Button(label, text='Yes', command=lambda: onButtonClick('yes'), width=10)
|
||||
yesButton.grid(column=0, row=1)
|
||||
@@ -1203,8 +1391,8 @@ def get_alttp_settings(romfile: str):
|
||||
choice = getattr(onButtonClick, 'choice')
|
||||
else:
|
||||
choice = input(f"Last used adjuster settings were found. Would you like to apply these? \n"
|
||||
f"{pprint.pformat(printed_options)}\n"
|
||||
f"Enter yes, no, always or never: ")
|
||||
f"{pprint.pformat(printed_options)}\n"
|
||||
f"Enter yes, no, always or never: ")
|
||||
if choice and choice.startswith("y"):
|
||||
choice = 'yes'
|
||||
elif choice and "never" in choice:
|
||||
@@ -1221,7 +1409,7 @@ def get_alttp_settings(romfile: str):
|
||||
choice = 'no'
|
||||
elif 'always' in lastSettings.auto_apply:
|
||||
choice = 'yes'
|
||||
|
||||
|
||||
if 'yes' in choice:
|
||||
from worlds.alttp.Rom import get_base_rom_path
|
||||
lastSettings.rom = romfile
|
||||
@@ -1247,10 +1435,10 @@ def get_alttp_settings(romfile: str):
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
else:
|
||||
|
||||
adjusted = False
|
||||
return adjustedromfile, adjusted
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
colorama.init()
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
89
Utils.py
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import typing
|
||||
import builtins
|
||||
import os
|
||||
@@ -24,7 +25,7 @@ class Version(typing.NamedTuple):
|
||||
build: int
|
||||
|
||||
|
||||
__version__ = "0.2.4"
|
||||
__version__ = "0.3.1"
|
||||
version_tuple = tuplize_version(__version__)
|
||||
|
||||
from yaml import load, dump, SafeLoader
|
||||
@@ -72,10 +73,10 @@ def is_frozen() -> bool:
|
||||
return getattr(sys, 'frozen', False)
|
||||
|
||||
|
||||
def local_path(*path):
|
||||
if local_path.cached_path:
|
||||
return os.path.join(local_path.cached_path, *path)
|
||||
|
||||
def local_path(*path: str) -> str:
|
||||
"""Returns path to a file in the local Archipelago installation or source."""
|
||||
if hasattr(local_path, 'cached_path'):
|
||||
pass
|
||||
elif is_frozen():
|
||||
if hasattr(sys, "_MEIPASS"):
|
||||
# we are running in a PyInstaller bundle
|
||||
@@ -95,21 +96,47 @@ def local_path(*path):
|
||||
return os.path.join(local_path.cached_path, *path)
|
||||
|
||||
|
||||
local_path.cached_path = None
|
||||
def home_path(*path: str) -> str:
|
||||
"""Returns path to a file in the user home's Archipelago directory."""
|
||||
if hasattr(home_path, 'cached_path'):
|
||||
pass
|
||||
elif sys.platform.startswith('linux'):
|
||||
home_path.cached_path = os.path.expanduser('~/Archipelago')
|
||||
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
|
||||
else:
|
||||
# not implemented
|
||||
home_path.cached_path = local_path() # this will generate the same exceptions we got previously
|
||||
|
||||
return os.path.join(home_path.cached_path, *path)
|
||||
|
||||
|
||||
def output_path(*path):
|
||||
if output_path.cached_path:
|
||||
def user_path(*path: str) -> str:
|
||||
"""Returns either local_path or home_path based on write permissions."""
|
||||
if hasattr(user_path, 'cached_path'):
|
||||
pass
|
||||
elif os.access(local_path(), os.W_OK):
|
||||
user_path.cached_path = local_path()
|
||||
else:
|
||||
user_path.cached_path = home_path()
|
||||
# populate home from local - TODO: upgrade feature
|
||||
if user_path.cached_path != local_path() and not os.path.exists(user_path('host.yaml')):
|
||||
for dn in ('Players', 'data/sprites'):
|
||||
shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True)
|
||||
for fn in ('manifest.json', 'host.yaml'):
|
||||
shutil.copy2(local_path(fn), user_path(fn))
|
||||
|
||||
return os.path.join(user_path.cached_path, *path)
|
||||
|
||||
|
||||
def output_path(*path: str):
|
||||
if hasattr(output_path, 'cached_path'):
|
||||
return os.path.join(output_path.cached_path, *path)
|
||||
output_path.cached_path = local_path(get_options()["general_options"]["output_path"])
|
||||
output_path.cached_path = user_path(get_options()["general_options"]["output_path"])
|
||||
path = os.path.join(output_path.cached_path, *path)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
output_path.cached_path = None
|
||||
|
||||
|
||||
def open_file(filename):
|
||||
if sys.platform == 'win32':
|
||||
os.startfile(filename)
|
||||
@@ -182,7 +209,7 @@ def get_default_options() -> dict:
|
||||
"output_path": "output",
|
||||
},
|
||||
"factorio_options": {
|
||||
"executable": "factorio\\bin\\x64\\factorio",
|
||||
"executable": os.path.join("factorio", "bin", "x64", "factorio"),
|
||||
},
|
||||
"sm_options": {
|
||||
"rom_file": "Super Metroid (JU).sfc",
|
||||
@@ -219,7 +246,7 @@ def get_default_options() -> dict:
|
||||
},
|
||||
"generator": {
|
||||
"teams": 1,
|
||||
"enemizer_path": "EnemizerCLI/EnemizerCLI.Core.exe",
|
||||
"enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core.exe"),
|
||||
"player_files_path": "Players",
|
||||
"players": 0,
|
||||
"weights_file_path": "weights.yaml",
|
||||
@@ -263,8 +290,11 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
|
||||
@cache_argsless
|
||||
def get_options() -> dict:
|
||||
if not hasattr(get_options, "options"):
|
||||
locations = ("options.yaml", "host.yaml",
|
||||
local_path("options.yaml"), local_path("host.yaml"))
|
||||
filenames = ("options.yaml", "host.yaml")
|
||||
locations = []
|
||||
if os.path.join(os.getcwd()) != local_path():
|
||||
locations += filenames # use files from cwd only if it's not the local_path
|
||||
locations += [user_path(filename) for filename in filenames]
|
||||
|
||||
for location in locations:
|
||||
if os.path.exists(location):
|
||||
@@ -274,7 +304,7 @@ def get_options() -> dict:
|
||||
get_options.options = update_options(get_default_options(), options, location, list())
|
||||
break
|
||||
else:
|
||||
raise FileNotFoundError(f"Could not find {locations[1]} to load options.")
|
||||
raise FileNotFoundError(f"Could not find {filenames[1]} to load options.")
|
||||
return get_options.options
|
||||
|
||||
|
||||
@@ -289,7 +319,7 @@ def get_location_name_from_id(code: int) -> str:
|
||||
|
||||
|
||||
def persistent_store(category: str, key: typing.Any, value: typing.Any):
|
||||
path = local_path("_persistent_storage.yaml")
|
||||
path = user_path("_persistent_storage.yaml")
|
||||
storage: dict = persistent_load()
|
||||
category = storage.setdefault(category, {})
|
||||
category[key] = value
|
||||
@@ -301,7 +331,7 @@ def persistent_load() -> typing.Dict[dict]:
|
||||
storage = getattr(persistent_load, "storage", None)
|
||||
if storage:
|
||||
return storage
|
||||
path = local_path("_persistent_storage.yaml")
|
||||
path = user_path("_persistent_storage.yaml")
|
||||
storage: dict = {}
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
@@ -349,7 +379,7 @@ class RestrictedUnpickler(pickle.Unpickler):
|
||||
if module == "builtins" and name in safe_builtins:
|
||||
return getattr(builtins, name)
|
||||
# used by MultiServer -> savegame/multidata
|
||||
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint"}:
|
||||
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint", "SlotType", "NetworkSlot"}:
|
||||
return getattr(self.net_utils_module, name)
|
||||
# Options and Plando are unpickled by WebHost -> Generate
|
||||
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
|
||||
@@ -388,7 +418,7 @@ loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': log
|
||||
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
|
||||
log_format: str = "[%(name)s]: %(message)s", exception_logger: str = ""):
|
||||
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
|
||||
log_folder = local_path("logs")
|
||||
log_folder = user_path("logs")
|
||||
os.makedirs(log_folder, exist_ok=True)
|
||||
root_logger = logging.getLogger()
|
||||
for handler in root_logger.handlers[:]:
|
||||
@@ -438,11 +468,22 @@ def stream_input(stream, queue):
|
||||
|
||||
def tkinter_center_window(window: Tk):
|
||||
window.update()
|
||||
xPos = int(window.winfo_screenwidth()/2 - window.winfo_reqwidth()/2)
|
||||
yPos = int(window.winfo_screenheight()/2 - window.winfo_reqheight()/2)
|
||||
xPos = int(window.winfo_screenwidth() / 2 - window.winfo_reqwidth() / 2)
|
||||
yPos = int(window.winfo_screenheight() / 2 - window.winfo_reqheight() / 2)
|
||||
window.geometry("+{}+{}".format(xPos, yPos))
|
||||
|
||||
|
||||
|
||||
class VersionException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def format_SI_prefix(value, power=1000, power_labels=('', 'k', 'M', 'G', 'T', "P", "E", "Z", "Y")):
|
||||
n = 0
|
||||
|
||||
while value > power:
|
||||
value /= power
|
||||
n += 1
|
||||
if type(value) == int:
|
||||
return f"{value} {power_labels[n]}"
|
||||
else:
|
||||
return f"{value:0.3f} {power_labels[n]}"
|
||||
|
||||
14
WebHost.py
@@ -3,11 +3,13 @@ import multiprocessing
|
||||
import logging
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
ModuleUpdate.requirements_files.add(os.path.join("WebHostLib", "requirements.txt"))
|
||||
ModuleUpdate.update()
|
||||
|
||||
# in case app gets imported by something like gunicorn
|
||||
import Utils
|
||||
|
||||
Utils.local_path.cached_path = os.path.dirname(__file__)
|
||||
|
||||
from WebHostLib import app as raw_app
|
||||
@@ -19,6 +21,8 @@ from WebHostLib.lttpsprites import update_sprites_lttp
|
||||
from WebHostLib.options import create as create_options_files
|
||||
|
||||
configpath = os.path.abspath("config.yaml")
|
||||
if not os.path.exists(configpath): # fall back to config.yaml in home
|
||||
configpath = os.path.abspath(Utils.user_path('config.yaml'))
|
||||
|
||||
|
||||
def get_app():
|
||||
@@ -32,6 +36,15 @@ def get_app():
|
||||
return app
|
||||
|
||||
|
||||
def create_ordered_tutorials_file():
|
||||
import json
|
||||
with open(os.path.join("WebHostLib", "static", "assets", "tutorial", "tutorials.json")) as source:
|
||||
data = json.load(source)
|
||||
data = sorted(data, key=lambda entry: entry["gameTitle"].lower())
|
||||
with open(os.path.join("WebHostLib", "static", "generated", "tutorials.json"), "w") as target:
|
||||
json.dump(data, target)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
multiprocessing.freeze_support()
|
||||
multiprocessing.set_start_method('spawn')
|
||||
@@ -43,6 +56,7 @@ if __name__ == "__main__":
|
||||
logging.warning("Could not update LttP sprites.")
|
||||
app = get_app()
|
||||
create_options_files()
|
||||
create_ordered_tutorials_file()
|
||||
if app.config["SELFLAUNCH"]:
|
||||
autohost(app.config)
|
||||
if app.config["SELFGEN"]:
|
||||
|
||||
@@ -70,6 +70,12 @@ app.url_map.converters["suuid"] = B64UUIDConverter
|
||||
app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
||||
|
||||
|
||||
def get_world_theme(game_name: str):
|
||||
if game_name in AutoWorldRegister.world_types:
|
||||
return AutoWorldRegister.world_types[game_name].web.theme
|
||||
return 'grass'
|
||||
|
||||
|
||||
@app.before_request
|
||||
def register_session():
|
||||
session.permanent = True # technically 31 days after the last visit
|
||||
@@ -97,13 +103,13 @@ def weighted_settings():
|
||||
# Player settings pages
|
||||
@app.route('/games/<string:game>/player-settings')
|
||||
def player_settings(game):
|
||||
return render_template(f"player-settings.html", game=game)
|
||||
return render_template(f"player-settings.html", game=game, theme=get_world_theme(game))
|
||||
|
||||
|
||||
# Game Info Pages
|
||||
@app.route('/games/<string:game>/info/<string:lang>')
|
||||
def game_info(game, lang):
|
||||
return render_template('gameInfo.html', game=game, lang=lang)
|
||||
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
|
||||
|
||||
|
||||
# List of supported games
|
||||
@@ -112,13 +118,13 @@ def games():
|
||||
worlds = {}
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
worlds[game] = world.__doc__ if world.__doc__ else "No description provided."
|
||||
worlds[game] = world
|
||||
return render_template("supportedGames.html", worlds=worlds)
|
||||
|
||||
|
||||
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
|
||||
def tutorial(game, file, lang):
|
||||
return render_template("tutorial.html", game=game, file=file, lang=lang)
|
||||
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
|
||||
|
||||
|
||||
@app.route('/tutorial/')
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import zipfile
|
||||
import json
|
||||
from io import BytesIO
|
||||
|
||||
from flask import send_file, Response, render_template
|
||||
from pony.orm import select
|
||||
|
||||
from Patch import update_patch_data, preferred_endings
|
||||
from Patch import update_patch_data, preferred_endings, AutoPatchRegister
|
||||
from WebHostLib import app, Slot, Room, Seed, cache
|
||||
import zipfile
|
||||
|
||||
|
||||
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
|
||||
@@ -12,16 +15,34 @@ def download_patch(room_id, patch_id):
|
||||
if not patch:
|
||||
return "Patch not found"
|
||||
else:
|
||||
import io
|
||||
|
||||
room = Room.get(id=room_id)
|
||||
last_port = room.last_port
|
||||
filelike = BytesIO(patch.data)
|
||||
greater_than_version_3 = zipfile.is_zipfile(filelike)
|
||||
if greater_than_version_3:
|
||||
# Python's zipfile module cannot overwrite/delete files in a zip, so we recreate the whole thing in ram
|
||||
new_file = BytesIO()
|
||||
with zipfile.ZipFile(filelike, "a") as zf:
|
||||
with zf.open("archipelago.json", "r") as f:
|
||||
manifest = json.load(f)
|
||||
manifest["server"] = f"{app.config['PATCH_TARGET']}:{last_port}"
|
||||
with zipfile.ZipFile(new_file, "w") as new_zip:
|
||||
for file in zf.infolist():
|
||||
if file.filename == "archipelago.json":
|
||||
new_zip.writestr("archipelago.json", json.dumps(manifest))
|
||||
else:
|
||||
new_zip.writestr(file.filename, zf.read(file), file.compress_type, 9)
|
||||
|
||||
patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}")
|
||||
patch_data = io.BytesIO(patch_data)
|
||||
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}" \
|
||||
f"{AutoPatchRegister.patch_types[patch.game].patch_file_ending}"
|
||||
new_file.seek(0)
|
||||
return send_file(new_file, as_attachment=True, attachment_filename=fname)
|
||||
else:
|
||||
patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}")
|
||||
patch_data = BytesIO(patch_data)
|
||||
|
||||
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}." \
|
||||
f"{preferred_endings[patch.game]}"
|
||||
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}." \
|
||||
f"{preferred_endings[patch.game]}"
|
||||
return send_file(patch_data, as_attachment=True, attachment_filename=fname)
|
||||
|
||||
|
||||
@@ -53,6 +74,10 @@ def download_slot_file(room_id, player_id: int):
|
||||
fname = name.rsplit("/", 1)[0]+".zip"
|
||||
elif slot_data.game == "Ocarina of Time":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
|
||||
elif slot_data.game == "VVVVVV":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
|
||||
elif slot_data.game == "Super Mario 64":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
|
||||
else:
|
||||
return "Game download not supported."
|
||||
return send_file(io.BytesIO(slot_data.data), as_attachment=True, attachment_filename=fname)
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
import zipfile
|
||||
from collections import Counter
|
||||
from typing import Dict, Optional as TypeOptional
|
||||
from Utils import __version__
|
||||
|
||||
from flask import request, flash, redirect, url_for, session, render_template
|
||||
|
||||
@@ -78,7 +79,7 @@ def generate(race=False):
|
||||
|
||||
return redirect(url_for("view_seed", seed=seed_id))
|
||||
|
||||
return render_template("generate.html", race=race)
|
||||
return render_template("generate.html", race=race, version=__version__)
|
||||
|
||||
|
||||
def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=None, sid=None):
|
||||
@@ -120,7 +121,8 @@ def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=No
|
||||
if not erargs.name[player]:
|
||||
erargs.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
|
||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||
|
||||
if len(set(erargs.name.values())) != len(erargs.name):
|
||||
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
|
||||
ERmain(erargs, seed, baked_server_options=meta)
|
||||
|
||||
return upload_to_db(target.name, sid, owner, race)
|
||||
|
||||
@@ -2,7 +2,7 @@ import os
|
||||
import threading
|
||||
import json
|
||||
|
||||
from Utils import local_path
|
||||
from Utils import local_path, user_path
|
||||
from worlds.alttp.Rom import Sprite
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ def update_sprites_lttp():
|
||||
from LttPAdjuster import update_sprites
|
||||
|
||||
# Target directories
|
||||
input_dir = local_path("data", "sprites", "alttpr")
|
||||
output_dir = local_path("WebHostLib", "static", "generated")
|
||||
input_dir = user_path("data", "sprites", "alttpr")
|
||||
output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path
|
||||
|
||||
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)
|
||||
# update sprites through gui.py's functions
|
||||
|
||||
@@ -12,7 +12,7 @@ STATE_ERROR = -1
|
||||
class Slot(db.Entity):
|
||||
id = PrimaryKey(int, auto=True)
|
||||
player_id = Required(int)
|
||||
player_name = Required(str, 16)
|
||||
player_name = Required(str)
|
||||
data = Optional(bytes, lazy=True)
|
||||
seed = Optional('Seed')
|
||||
game = Required(str)
|
||||
|
||||
@@ -69,7 +69,7 @@ def create():
|
||||
elif option.options:
|
||||
game_options[option_name] = this_option = {
|
||||
"type": "select",
|
||||
"displayName": option.displayname if hasattr(option, "displayname") else option_name,
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
||||
"defaultValue": None,
|
||||
"options": []
|
||||
@@ -92,7 +92,7 @@ def create():
|
||||
elif hasattr(option, "range_start") and hasattr(option, "range_end"):
|
||||
game_options[option_name] = {
|
||||
"type": "range",
|
||||
"displayName": option.displayname if hasattr(option, "displayname") else option_name,
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
||||
"defaultValue": option.default if hasattr(option, "default") else option.range_start,
|
||||
"min": option.range_start,
|
||||
@@ -102,14 +102,14 @@ def create():
|
||||
elif getattr(option, "verify_item_name", False):
|
||||
game_options[option_name] = {
|
||||
"type": "items-list",
|
||||
"displayName": option.displayname if hasattr(option, "displayname") else option_name,
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
||||
}
|
||||
|
||||
elif getattr(option, "verify_location_name", False):
|
||||
game_options[option_name] = {
|
||||
"type": "locations-list",
|
||||
"displayName": option.displayname if hasattr(option, "displayname") else option_name,
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ def create():
|
||||
if option.valid_keys:
|
||||
game_options[option_name] = {
|
||||
"type": "custom-list",
|
||||
"displayName": option.displayname if hasattr(option, "displayname") else option_name,
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
||||
"options": list(option.valid_keys),
|
||||
}
|
||||
@@ -132,7 +132,7 @@ def create():
|
||||
with open(os.path.join(target_folder, 'player-settings', game_name + ".json"), "w") as f:
|
||||
json.dump(player_settings, f, indent=2, separators=(',', ': '))
|
||||
|
||||
if not world.hidden:
|
||||
if not world.hidden and world.web.settings_page is True:
|
||||
weighted_settings["baseOptions"]["game"][game_name] = 0
|
||||
weighted_settings["games"][game_name] = {}
|
||||
weighted_settings["games"][game_name]["gameSettings"] = game_options
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
flask>=2.0.2
|
||||
pony>=0.7.14
|
||||
waitress>=2.0.0
|
||||
flask>=2.0.3
|
||||
pony>=0.7.16
|
||||
waitress>=2.1.0
|
||||
flask-caching>=1.10.1
|
||||
Flask-Compress>=1.10.1
|
||||
Flask-Limiter>=2.1
|
||||
Flask-Compress>=1.11
|
||||
Flask-Limiter>=2.2.0
|
||||
|
||||
12
WebHostLib/static/assets/gameInfo/en_ArchipIDLE.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# ArchipIDLE
|
||||
|
||||
## What is this game?
|
||||
|
||||
ArchipIDLE is the 2022 Archipelago April Fools' Day joke. It is an idle game that sends a location check every
|
||||
thirty seconds, up to one hundred checks.
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
The [player settings page for this game](../player-settings) contains all the options you need to configure
|
||||
and export a config file.
|
||||
|
||||
24
WebHostLib/static/assets/gameInfo/en_ChecksFinder.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# ChecksFinder
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
The [player settings page for this game](../player-settings) contains all the options you need to configure and export a
|
||||
config file.
|
||||
|
||||
## What is considered a location check in ChecksFinder?
|
||||
|
||||
Location checks in are completed when the player finds a spot on a board that has the archipelago logo. The bottom of
|
||||
the screen has a number next to the archipelago logo, that number is how many you can find so far. You can only get as
|
||||
many checks as you have gained items, plus five to start with being available.
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
|
||||
When the player receives an item in ChecksFinder, it either can make the future boards they play be bigger in width or
|
||||
height, or add a new bomb to the future boards, with a limit to having up to one fifth of the _current_ board being
|
||||
bombs. The items you have gained _before_ the current board was made will be said at the bottom of the screen as a number
|
||||
next to an icon, the number is how many you have gotten and the icon represents which item it is.
|
||||
|
||||
## What is the victory condition?
|
||||
|
||||
Victory is achieved when the player wins a board they were given after they have received all of their Map Width, Map
|
||||
Height, and Map Bomb items. The game will say at the bottom of the screen how many of each you have received.
|
||||
22
WebHostLib/static/assets/gameInfo/en_Hollow Knight.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Hollow Knight
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
The [player settings page for this game](../player-settings) contains all the options you need to configure and export a
|
||||
config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
|
||||
Randomization swaps around the locations of items. The items being swapped around are chosen within your YAML.
|
||||
Shop costs are presently always randomized.
|
||||
|
||||
## What Hollow Knight items can appear in other players' worlds?
|
||||
|
||||
This is dependent entirely upon your YAML settings. Some examples include: charms, grubs, lifeblood cocoons, geo, etc.
|
||||
|
||||
## What does another world's item look like in Hollow Knight?
|
||||
|
||||
When the Hollow Knight player picks up an item from a location and it is an item for another game it will appear in that
|
||||
player's recent items display as an item being sent to another player. If the item is for another Hollow Knight player
|
||||
then the sprite will be that of the item's original sprite. If the item belongs to a player that is not playing Hollow
|
||||
Knight then the sprite will be the Archipelago logo.
|
||||
25
WebHostLib/static/assets/gameInfo/en_Meritous.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Meritous
|
||||
|
||||
## Where is the settings page?
|
||||
The [player settings page for Meritous](../player-settings) contains all the options you need to configure and export a config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
The PSI Enhancement Tiles have become general-purpose Item Caches, and all upgrades and artifacts are added to the multiworld item pool. Optionally, the progression-critical PSI Keys can also be added to the pool, as well as monster evolution traps which (in vanilla) trigger when bosses are defeated.
|
||||
|
||||
## What is the goal of Meritous when randomized?
|
||||
At minimum, you will need to get the PSI Keys, defeat the three bosses, retrieve the Cursed Seal, and return it to the entrance. Depending on your selected goal, you may also have to defeat the final boss, or you may also need to explore every last room of the Atlas Dome and retrieve the Agate Knife before getting the Cursed Seal and defeating the final boss' true form.
|
||||
|
||||
## Which items can be in another player's world?
|
||||
Every item added to the multiworld pool (as outlined above) can be distributed to other players' worlds.
|
||||
|
||||
## What is considered a location check in Meritous?
|
||||
The Alpha, Beta, and Gamma item caches each have 24 checks to buy, increasing in cost each time. Reward chests obtained from clearing ambush rooms will contain up to 24 location checks, thereafter always awarding a cache of PSI Crystals. If enabled, PSI Key Pedestals will contain checks, which must be unlocked by eliminating a certain percentage of monsters. If enabled, defeating bosses will result in an automatic check.
|
||||
|
||||
## Which notable items are not randomized?
|
||||
The Cursed Seal and Agate Knife will always be in the farthest-away room from the Entrance and the final room explored, respectively.
|
||||
|
||||
## What does another world's item look like in Meritous?
|
||||
There is no visual representation of other players' items in Meritous. You will be buying checks from item caches and opening chests in ambush rooms blindly.
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
A sound will play, and a notification will briefly appear on the lower half of the screen informing you of what you have received.
|
||||
35
WebHostLib/static/assets/gameInfo/en_SMZ3.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# SMZ3
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
The [player settings page for this game](../player-settings) contains all the options you need to configure and export a
|
||||
config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
|
||||
Items which the player would normally acquire throughout the game have been moved around. Logic remains, so the game is
|
||||
always able to be completed, but because of the item shuffle the player may need to access certain areas before they
|
||||
would in the vanilla game.
|
||||
|
||||
## What items and locations get shuffled?
|
||||
|
||||
All main inventory items, collectables, power-ups and ammunition can be shuffled, and all locations in the game which
|
||||
could contain any of those items may have their contents changed.
|
||||
|
||||
## Which items can be in another player's world?
|
||||
|
||||
Any of the items which can be shuffled may also be placed into another player's world. It is possible to choose to limit
|
||||
certain items to your own world.
|
||||
|
||||
## What does another world's item look like in Super Metroid?
|
||||
|
||||
A unique item sprite has been added to the game to represent items belonging to another world.
|
||||
|
||||
## What does another world's item look like in LttP?
|
||||
|
||||
Items belonging to other worlds are represented by a Power Star from Super Mario World.
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
|
||||
When the player receives an item, a text box will appear to show which item was received, and from whom.
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
- Hardware or software capable of loading and playing SNES ROM files
|
||||
- An emulator capable of connecting to SNI
|
||||
([snes9x rr](https://github.com/gocha/snes9x-rr/releases),
|
||||
[BizHawk](http://tasvideos.org/BizHawk.html))
|
||||
[BizHawk](http://tasvideos.org/BizHawk.html), or
|
||||
[RetroArch](https://retroarch.com?page=platforms) 1.10.1 or newer). Or,
|
||||
- An SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), or other compatible hardware
|
||||
- Your Japanese v1.0 ROM file, probably named `Zelda no Densetsu - Kamigami no Triforce (Japan).sfc`
|
||||
|
||||
@@ -102,6 +103,21 @@ first time launching, you may be prompted to allow it to communicate through the
|
||||
- SNIClient users should look in their Archipelago folder for `/SNI/lua/x64` or `/SNI/lua/x86` depending on if the
|
||||
emulator is 64-bit or 32-bit.
|
||||
|
||||
##### RetroArch 1.10.1 or newer
|
||||
|
||||
You only have to do these steps once.
|
||||
|
||||
1. Enter the RetroArch main menu screen.
|
||||
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
|
||||
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
|
||||
Network Command Port at 55355.
|
||||

|
||||
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
|
||||
Performance)".
|
||||
|
||||
When loading a ROM, be sure to select a **bsnes-mercury** core. These are the only cores that allow external tools to
|
||||
read ROM data.
|
||||
|
||||
#### With hardware
|
||||
|
||||
This guide assumes you have downloaded the correct firmware for your device. If you have not done so already, please do
|
||||
@@ -13,7 +13,8 @@
|
||||
- Hardware o software capaz de cargar y ejecutar archivos de ROM de SNES
|
||||
- Un emulador capaz de ejecutar scripts Lua
|
||||
([snes9x Multitroid](https://drive.google.com/drive/folders/1_ej-pwWtCAHYXIrvs5Hro16A1s9Hi3Jz),
|
||||
[BizHawk](http://tasvideos.org/BizHawk.html))
|
||||
[BizHawk](http://tasvideos.org/BizHawk.html), o
|
||||
[RetroArch](https://retroarch.com?page=platforms) 1.10.1 o más nuevo). O,
|
||||
- Un flashcart SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), o otro hardware compatible
|
||||
- Tu archivo ROM japones v1.0, probablemente se llame `Zelda no Densetsu - Kamigami no Triforce (Japan).sfc`
|
||||
|
||||
@@ -135,6 +136,21 @@ ejecutas, puedes ser que el firewall de Windows te pregunte si le permites la co
|
||||
7. Observa que se ha asignado un nombre al dispositivo, y el cliente muestra "SNES Device: Connected", con el mismo
|
||||
nombre en la esquina superior izquierda.
|
||||
|
||||
##### RetroArch 1.10.1 o más nuevo
|
||||
|
||||
Sólo hay que segiur estos pasos una vez.
|
||||
|
||||
1. Comienza en la pantalla del menú principal de RetroArch.
|
||||
2. Ve a Ajustes --> Interfaz de usario. Configura "Mostrar ajustes avanzados" en ON.
|
||||
3. Ve a Ajustes --> Red. Configura "Comandos de red" en ON. (Se encuentra bajo Request Device 16.) Deja en 55355 (el
|
||||
default) el Puerto de comandos de red.
|
||||

|
||||
4. Ve a Menú principal --> Actualizador en línea --> Descargador de núcleos. Desplázate y selecciona "Nintendo - SNES /
|
||||
SFC (bsnes-mercury Performance)".
|
||||
|
||||
Cuando cargas un ROM, asegúrate de seleccionar un núcleo **bsnes-mercury**. Estos son los sólos núcleos que permiten
|
||||
que herramientas externas lean datos del ROM.
|
||||
|
||||
#### Con Hardware
|
||||
|
||||
Esta guía asume que ya has descargado el firmware correcto para tu dispositivo. Si no lo has hecho ya, hazlo ahora. Los
|
||||
@@ -203,4 +219,4 @@ El programa recomentdado actualmente es:
|
||||
4. Si quieres que las llaves y los objetos de mazmorra tambien sean marcados, activa la caja con nombre **Race Illegal
|
||||
Tracking**
|
||||
5. Haz click en el boton **Start Autotracking**
|
||||
6. Cierra la ventana AutoTracker, ya que deja de ser necesaria
|
||||
6. Cierra la ventana AutoTracker, ya que deja de ser necesaria
|
||||
12
WebHostLib/static/assets/tutorial/ArchipIDLE/guide_en.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# ArchipIdle Setup Guide
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
1. Generate a `.yaml` file from the [ArchipIDLE Player Settings Page](/games/ArchipIDLE/player-settings)
|
||||
2. Open the ArchipIDLE Client in your web browser by either:
|
||||
- Navigate to the [ArchipIDLE Client](http://idle.multiworld.link)
|
||||
- Download the client and run it locally from the
|
||||
[ArchipIDLE GitHub Releases Page](https://github.com/ArchipelagoMW/archipidle/releases)
|
||||
3. Enter the server address in the `Server Address` field and press enter
|
||||
4. Enter your slot name when prompted. This should be the same as the `name` you entered on the
|
||||
setting page above, or the `name` field in your yaml file.
|
||||
5. Click the "Begin!" button.
|
||||
@@ -1,17 +1,15 @@
|
||||
# Advanced YAML Guide
|
||||
|
||||
This guide covers more the more advanced options available in YAML files. This guide is intended for the user who is
|
||||
intent on editing their YAML file manually. This guide should take about 10 minutes to read.
|
||||
This guide covers more the more advanced options available in YAML files. This guide is intended for the user who is intent on editing their YAML file manually. This guide should take about 10 minutes to read.
|
||||
|
||||
If you would like to generate a basic, fully playable, YAML without editing a file then visit the settings page for the
|
||||
game you intend to play.
|
||||
If you would like to generate a basic, fully playable, YAML without editing a file then visit the settings page for the game you intend to play. The weighted settings page can also handle most of the advanced settings discussed here.
|
||||
|
||||
The settings page can be found on the supported games page, just click the "Settings Page" link under the name of the
|
||||
game you would like. Supported games page: [Archipelago Games List](https://archipelago.gg/games)
|
||||
The settings page can be found on the supported games page, just click the "Settings Page" link under the name of the game you would like.
|
||||
* Supported games page: [Archipelago Games List](/games)
|
||||
* Weighted settings page: [Archipelago Weighted Settings](/weighted-settings)
|
||||
|
||||
Clicking on the "Export Settings" button at the bottom-left will provide you with a pre-filled YAML with your options.
|
||||
The player settings page also has an option to download a fully filled out yaml containing every option with every
|
||||
available setting for the available options.
|
||||
The player settings page also has a link to download a full template file for that game which will have every option possible for the game including some that don't display correctly on the site.
|
||||
|
||||
## YAML Overview
|
||||
|
||||
@@ -91,5 +91,6 @@ including the exclamation point.
|
||||
- `/allow_forfeit <player name>` Allows the given player to use the `!forfeit` command.
|
||||
- `/forbid_forfeit <player name>` Bars the given player from using the `!forfeit` command.
|
||||
- `/send <player name> <item name>` Grants the given player the specified item.
|
||||
- `/send_multiple <amount> <player name> <item name>` Grants the given player the stated amount of the specified item.
|
||||
- `/hint <player name> <item or location name>` Send out a hint for the given item or location for the specified player.
|
||||
- `/option <option name> <option value>` Set a server option. For a list of options, use the `/options` command.
|
||||
@@ -19,9 +19,7 @@ enabled (opt-in).
|
||||
editor and find the `plando_options` key. The available plando modules can be enabled by adding them after this such
|
||||
as
|
||||
`plando_options: bosses, items, texts, connections`.
|
||||
* If you are not the one doing the generation or even if you are you can add to the `requires` section of your yaml so
|
||||
that it will throw an error if the options that you need to generate properly are not enabled to ensure you will get
|
||||
the results you desire. Only enter in the plando modules that you are using here but it should look like:
|
||||
* You can add the necessary plando modules for your settings to the `requires` section of your yaml. Doing so will throw an error if the options that you need to generate properly are not enabled to ensure you will get the results you desire. Only enter in the plando modules that you are using here but it should look like:
|
||||
|
||||
```yaml
|
||||
requires:
|
||||
@@ -66,38 +64,9 @@ list of specific locations both in their own game or in another player's game.
|
||||
* If `min` and `max` are defined, it will try to place a number of items between these two numbers at random
|
||||
|
||||
|
||||
### Available Items
|
||||
### Available Items and Locations
|
||||
|
||||
* [A Link to the Past](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Items.py#L52)
|
||||
* [Factorio Non-Progressive](https://wiki.factorio.com/Technologies) Note that these use the *internal names*. For
|
||||
example, `advanced-electronics`
|
||||
* [Factorio Progressive](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/factorio/Technologies.py#L374)
|
||||
* [Final Fantasy 1](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/ff1/data/items.json)
|
||||
* [Minecraft](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/minecraft/Items.py#L14)
|
||||
* [Ocarina of Time](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/oot/Items.py#L61)
|
||||
* [Risk of Rain 2](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/ror2/Items.py#L8)
|
||||
* [Rogue Legacy](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/rogue-legacy/Names/ItemName.py)
|
||||
* [Slay the Spire](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/spire/Items.py#L13)
|
||||
* [Subnautica](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/subnautica/items.json)
|
||||
* [Super Metroid](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/sm/variaRandomizer/rando/Items.py#L37) Look for "Name="
|
||||
* [Timespinner](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/timespinner/Items.py#L11)
|
||||
|
||||
### Available Locations
|
||||
|
||||
* [A Link to the Past](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Regions.py#L429)
|
||||
* [Factorio](https://wiki.factorio.com/Technologies) Same as items
|
||||
* [Final Fantasy 1](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/ff1/data/locations.json)
|
||||
* [Minecraft](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/minecraft/Locations.py#L18)
|
||||
* [Ocarina of Time](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/oot/LocationList.py#L38)
|
||||
* [Risk of Rain 2](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/ror2/Locations.py#L17) This is a
|
||||
special case. The locations are "ItemPickup[number]" up to the maximum set in the yaml.
|
||||
* [Rogue Legacy](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/rogue-legacy/Names/LocationName.py)
|
||||
* [Slay the Spire](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/spire/Locations.py)
|
||||
* [Subnautica](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/subnautica/locations.json)
|
||||
* [Super Metroid](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/sm/variaRandomizer/graph/location.py#L132)
|
||||
* [Timespinner](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/timespinner/Locations.py#L13)
|
||||
|
||||
A list of all available items and locations can also be found in the [server's datapackage](/api/datapackage).
|
||||
A list of all available items and locations can be found in the [website's datapackage](/datapackage). The items and locations will be in the `"item_name_to_id"` and `"location_name_to_id"` sections of the relevant game. You do not need the quotes but the name must be entered in the same as it appears on that page and is caps-sensitive.
|
||||
|
||||
### Examples
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# ChecksFinder Randomizer Setup Guide
|
||||
|
||||
## Required Software
|
||||
|
||||
- ChecksFinder from
|
||||
the [Github releases Page for the game](https://github.com/jonloveslegos/ChecksFinder/releases) (latest version)
|
||||
- Archipelago from the [Archipelago Releases Page](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
- (select `ChecksFinder Client` during installation.)
|
||||
|
||||
## Configuring your YAML file
|
||||
|
||||
### What is a YAML file and why do I need one?
|
||||
|
||||
See the guide on setting up a basic YAML at the Archipelago setup
|
||||
guide: [Basic Multiworld Setup Guide](/tutorial/archipelago/setup/en)
|
||||
|
||||
### Where do I get a YAML file?
|
||||
|
||||
You can customize your settings by visiting the [ChecksFinder Player Settings Page](/games/ChecksFinder/player-settings)
|
||||
|
||||
### Generating a ChecksFinder game
|
||||
|
||||
**ChecksFinder is meant to be played _alongside_ another game! You may not be playing it for long periods of time if
|
||||
you play it by itself with another person!**
|
||||
|
||||
When you join a multiworld game, you will be asked to provide your YAML file to whoever is hosting. Once that is done,
|
||||
the host will provide you with either a link to download your data file, or with a zip file containing everyone's data
|
||||
files. You do not have a file inside that zip though!
|
||||
|
||||
You need to start ChecksFinder client yourself, it is located within the Archipelago folder.
|
||||
|
||||
### Connect to the MultiServer
|
||||
|
||||
First start ChecksFinder.
|
||||
|
||||
Once both ChecksFinder and the client are started. In the client at the top type in the spot labeled `Server` type the
|
||||
`Ip Address` and `Port` separated with a `:` symbol.
|
||||
|
||||
The client will then ask for the username you chose, input that in the text box at the bottom of the client.
|
||||
|
||||
### Play the game
|
||||
|
||||
When the console tells you that you have joined the room, you're all set. Congratulations on successfully joining a
|
||||
multiworld game!
|
||||
|
||||
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 627 KiB After Width: | Height: | Size: 627 KiB |
30
WebHostLib/static/assets/tutorial/Hollow Knight/setup_en.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Hollow Knight for Archipelago Setup Guide
|
||||
|
||||
## Required Software
|
||||
* Download and unzip the Scarab Mod Manager from the [Scarab GitHub Releases page](https://github.com/fifty-six/Scarab/releases).
|
||||
* A legal copy of Hollow Knight, not purchased or played through XBox Game Pass.
|
||||
* Unfortunately, the Game Pass version is not currently compatible with mods.
|
||||
|
||||
## Installing the Archipelago Mod using Scarab
|
||||
1. Launch Scarab and ensure it locates your Hollow Knight installation directory.
|
||||
2. Click the "Install" checkbox near the "Archipelago" mod entry.
|
||||
3. Launch the game, you're all set!
|
||||
|
||||
## Configuring your YAML File
|
||||
### What is a YAML and why do I need one?
|
||||
You can see the [basic multiworld setup guide](/tutorial/Archipelago/setup/en) here on the Archipelago website to learn
|
||||
about why Archipelago uses YAML files and what they're for.
|
||||
|
||||
### Where do I get a YAML?
|
||||
You can use the [game settings page for Hollow Knight](/games/Hollow%20Knight/player-settings) here on the Archipelago
|
||||
website to generate a YAML using a graphical interface.
|
||||
|
||||
### Joining an Archipelago Game in Hollow Knight
|
||||
1. Start the game after installing all necessary mods.
|
||||
2. Create a **new save game.**
|
||||
3. Select the **Archipelago** game mode from the mode selection screen.
|
||||
4. Enter the correct settings for your Archipelago server.
|
||||
5. Hit **Start** to begin the game. The game will stall for a few seconds while it does all item placements.
|
||||
6. The game will immediately drop you into the randomized game.
|
||||
* If you are waiting for a countdown then wait for it to lapse before hitting Start.
|
||||
* Or hit Start then pause the game once you're in it.
|
||||
64
WebHostLib/static/assets/tutorial/Meritous/setup_en.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Meritous Randomizer Setup Guide
|
||||
|
||||
## Required Software
|
||||
|
||||
Download the game from the [Meritous Gaiden GitHub releases page](https://github.com/FelicitusNeko/meritous-ap/releases)
|
||||
|
||||
## Installation Procedures
|
||||
|
||||
Simply download the latest version of Meritous Gaiden from the link above, and extract it wherever you like.
|
||||
|
||||
- ⚠️ Do not extract Meritous Gaiden to Program Files, as this will cause file access issues.
|
||||
|
||||
## Joining a Multiworld Game
|
||||
|
||||
1. Modify the `meritous-ap.json` file with your server details, as outlined in the next section.
|
||||
2. Run `meritous.exe`. If the AP settings file is detected, you will see "AP Enabled" show up in the bottom left of the menu screen.
|
||||
3. Start a new game. If it is able to successfully connect to the AP server, "Connected" will show up in the bottom left of the game screen for a few seconds.
|
||||
|
||||
## AP Settings File
|
||||
|
||||
The format of `meritous-ap.json` should be as follows:
|
||||
|
||||
```json
|
||||
{
|
||||
"ap-enable": true,
|
||||
"server": "archipelago.gg",
|
||||
"port": 38281,
|
||||
"password": null,
|
||||
"slotname": "YourName"
|
||||
}
|
||||
```
|
||||
|
||||
- `ap-enable`: Enables the game to connect to the Archipelago server. If this is `false` or missing, it will generate a local item randomizer.
|
||||
- `server`: The server to which to connect. This can be a domain name (such as archipelago.gg) or an IP address (such as 127.0.0.1). If this is missing, the game will assume archipelago.gg.
|
||||
- `port`: The port number to which to connect. By default, Archipelago will use port 38281 to host, unless the game is hosted on the Archipelago webhost. If this is missing, the game will assume 38281.
|
||||
- `password`: The password to use for this game, if any. This can be omitted or set to `null` if there is no password.
|
||||
- `slotname`: The slot name to use for this game. This is required, and must match the name provided on your YAML file.
|
||||
|
||||
Eventually, this process will be moved to in-game menus for better ease of use.
|
||||
|
||||
## Finishing the Game
|
||||
|
||||
Your initial goal is to find all three PSI Keys. Depending on your YAML settings, these may be located on pedestals in special rooms in the Atlas Dome, or they may be scattered across other players' worlds. These PSI Keys are then brought to their respective locations in the Dome, where you will be subjected to a boss battle. Once all three bosses are defeated, this unlocks the Cursed Seal, hidden in the farthest-away location from the Entrance. The Compass tiles can help you find your way to these locations.
|
||||
|
||||
At minimum, every seed will require you to find the Cursed Seal and bring it back to the Entrance. The goal can then vary based on your `goal` YAML setting:
|
||||
|
||||
- `return_the_cursed_seal`: You will fight the final boss, but win or lose, a victory will be posted.
|
||||
- `any_ending`: You must defeat the final boss.
|
||||
- `true_ending`: You must first explore all 3000 rooms of the Atlas Dome and find the Agate Knife, then fight the final boss' true form.
|
||||
|
||||
Once the goal has been completed, you may press F to send a forfeit, sending out all of your world's remaining items to their respective players, and C to send a collect, which gathers up all of your world's items from their shuffled locations in other player's worlds. You may also press S to view your statistics, if you're a fan of numbers.
|
||||
|
||||
More in-depth information about the game can be found in the game's help file, accessed by pressing H while playing.
|
||||
|
||||
## Game Troubleshooting
|
||||
|
||||
### An error message shows up at the bottom-left
|
||||
|
||||
- `Disconnected`: If the game does not reconnect automatically, you may need to save, quit, and reload the game to reconnect. Keep in mind that the game does not auto-save, and it is only possible to save the game at Save Tiles.
|
||||
- `InvalidSlot`, `InvalidGame`: Make sure the `slotname` in `meritous-ap.json` matches the name provided in your Meritous YAML file.
|
||||
- `SlotAlreadyTaken`: Make sure Meritous Gaiden is not already running and connected to the server.
|
||||
- `IncompatibleVersion`: Make sure Meritous Gaiden has been updated to the latest version.
|
||||
- `InvalidPassword`: Make sure the `password` in `meritous-ap.json` matches the password for your game. If there is no password, either set this to `null` (no quotes) or omit/remove it completely.
|
||||
- `InvalidItemsHandling`: This is a bug and shouldn't happen if you downloaded a precompiled copy of the game. If you downloaded a precompiled copy, please let KewlioMZX know over GitHub or the AP Discord.
|
||||
@@ -1,27 +1,38 @@
|
||||
# Setup Guide for Ocarina of time Archipelago
|
||||
# Setup Guide for Ocarina of Time Archipelago
|
||||
|
||||
## Important
|
||||
|
||||
As we are using Z5Client and BizHawk, this guide is only applicable to Windows.
|
||||
As we are using Bizhawk, this guide is only applicable to Windows and Linux systems.
|
||||
|
||||
## Required Software
|
||||
|
||||
- BizHawk and Z5Client from: [Z5Client Releases Page](https://github.com/ArchipelagoMW/Z5Client/releases)
|
||||
- We recommend download Z5Client-setup as it makes some steps automatic.
|
||||
- Bizhawk: [Bizhawk Releases from TASVideos](https://tasvideos.org/BizHawk/ReleaseHistory)
|
||||
- Version 2.3.1 and later are supported. Version 2.7 is recommended for stability.
|
||||
- Detailed installation instructions for Bizhawk can be found at the above link.
|
||||
- Windows users must run the prereq installer first, which can also be found at the above link.
|
||||
- The built-in Archipelago client, which can be installed [here](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
(select `Ocarina of Time Client` during installation).
|
||||
- An Ocarina of Time v1.0 ROM.
|
||||
|
||||
## Install Emulator and client
|
||||
## Configuring Bizhawk
|
||||
|
||||
Download getBizhawk.ps1 from previous link. Place it on the folder where you want your emulator to be installed, right
|
||||
click on it and select "Run with PowerShell". This will download all the needed dependencies used by the emulator. This
|
||||
can take a while.
|
||||
Once Bizhawk has been installed, open Bizhawk and change the following settings:
|
||||
|
||||
It is strongly recommended to associate N64 rom extension (\*.n64) to the BizHawk we've just installed. To do so, we
|
||||
simply have to search any N64 rom we happened to own, right click and select "Open with...", we unfold the list that
|
||||
appears and select the bottom option "Look for another application", we browse to BizHawk folder and select EmuHawk.exe
|
||||
- Go to Config > Customize. Switch to the Advanced tab, then switch the Lua Core from "NLua+KopiLua" to
|
||||
"Lua+LuaInterface". This is required for the Lua script to function correctly.
|
||||
- Under Config > Customize > Advanced, make sure the box for AutoSaveRAM is checked, and click the 5s button.
|
||||
This reduces the possibility of losing save data in emulator crashes.
|
||||
- Under Config > Customize, check the "Run in background" and "Accept background input" boxes. This will allow you to
|
||||
continue playing in the background, even if another window is selected.
|
||||
- Under Config > Hotkeys, many hotkeys are listed, with many bound to common keys on the keyboard. You will likely want
|
||||
to disable most of these, which you can do quickly using `Esc`.
|
||||
- If playing with a controller, when you bind controls, disable "P1 A Up", "P1 A Down", "P1 A Left", and "P1 A Right"
|
||||
as these interfere with aiming if bound. Set directional input using the Analog tab instead.
|
||||
|
||||
Place the ootMulti.lua file from the previous link inside the "lua" folder from the just installed emulator.
|
||||
|
||||
Install the Z5Client using its setup.
|
||||
It is strongly recommended to associate N64 rom extensions (\*.n64, \*.z64) to the Bizhawk we've just installed.
|
||||
To do so, we simply have to search any N64 rom we happened to own, right click and select "Open with...", unfold
|
||||
the list that appears and select the bottom option "Look for another application", then browse to the Bizhawk folder
|
||||
and select EmuHawk.exe.
|
||||
|
||||
## Configuring your YAML file
|
||||
|
||||
@@ -33,7 +44,7 @@ an experience customized for their taste, and different players in the same mult
|
||||
|
||||
### Where do I get a YAML file?
|
||||
|
||||
A basic OOT yaml will look like this. There are lots of cosmetic options that have been removed for the sake of this
|
||||
A basic OoT yaml will look like this. There are lots of cosmetic options that have been removed for the sake of this
|
||||
tutorial, if you want to see a complete list, download Archipelago from
|
||||
the [Archipelago Releases Page](https://github.com/ArchipelagoMW/Archipelago/releases) and look for the sample file in
|
||||
the "Players" folder.
|
||||
@@ -382,22 +393,20 @@ Ocarina of Time:
|
||||
|
||||
When you join a multiworld game, you will be asked to provide your YAML file to whoever is hosting. Once that is done,
|
||||
the host will provide you with either a link to download your data file, or with a zip file containing everyone's data
|
||||
files. Your data file should have a `.z5ap` extension.
|
||||
files. Your data file should have a `.apz5` extension.
|
||||
|
||||
Double-click on your `.z5ap` file to start Z5Client and start the ROM patch process. Once the process is finished (this
|
||||
can take a while), the emulator will be started automatically (If we associated the extension to the emulator as
|
||||
recommended)
|
||||
Double-click on your `.apz5` file to start your client and start the ROM patch process. Once the process is finished
|
||||
(this can take a while), the client and the emulator will be started automatically (if you associated the extension
|
||||
to the emulator as recommended).
|
||||
|
||||
### Connect to the Multiserver
|
||||
|
||||
Once both the Z5Client and the emulator are started we must connect them. Within the emulator we click on the "Tools"
|
||||
menu and select "Lua console". In the new window click on the folder icon and look for the ootMulti.lua file. Once the
|
||||
file is loaded it will connect automatically to Z5Client.
|
||||
Once both the client and the emulator are started, you must connect them. Within the emulator click on the "Tools"
|
||||
menu and select "Lua Console". Click the folder button or press Ctrl+O to open a Lua script.
|
||||
|
||||
Note: We strongly advise you don't open any emulator menu while it and Z5client are connected, as the script will halt
|
||||
and disconnects can happen. If you get disconnected just double-click on the script again.
|
||||
Navigate to your Archipelago install folder and open `data/lua/OOT/oot_connector.lua`.
|
||||
|
||||
To connect the client to the multiserver simply put `<address>:<port>` on the textfield on top and press enter (if the
|
||||
server uses password, type in the bottom textfield `/connect <address>:<port> [password]`)
|
||||
|
||||
Now you are ready to start your adventure in Hyrule.
|
||||
Now you are ready to start your adventure in Hyrule.
|
||||
163
WebHostLib/static/assets/tutorial/SMZ3/multiworld_en.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# SMZ3 Setup Guide
|
||||
|
||||
## Required Software
|
||||
|
||||
- One of the client programs:
|
||||
- [SNIClient](https://github.com/ArchipelagoMW/Archipelago/releases), included with the main
|
||||
Archipelago install. Make sure to check the box for `SNI Client - Super Metroid Patch Setup` and
|
||||
`SNI Client - A Link to the Past Patch Setup`
|
||||
- Hardware or software capable of loading and playing SNES ROM files
|
||||
- An emulator capable of connecting to SNI such as:
|
||||
- snes9x Multitroid
|
||||
from: [snes9x Multitroid Download](https://drive.google.com/drive/folders/1_ej-pwWtCAHYXIrvs5Hro16A1s9Hi3Jz),
|
||||
- BizHawk from: [BizHawk Website](http://tasvideos.org/BizHawk.html)
|
||||
- RetroArch 1.10.3 or newer from: [RetroArch BuildBot Website](https://buildbot.libretro.com/) - nightly builds
|
||||
are required until 1.10.3 is released. Or,
|
||||
- An SD2SNES, FXPak Pro ([FXPak Pro Store Page](https://krikzz.com/store/home/54-fxpak-pro.html)), or other
|
||||
compatible hardware
|
||||
- Your legally obtained Super Metroid ROM file, probably named `Super Metroid (Japan, USA).sfc` and
|
||||
Your Japanese Zelda3 v1.0 ROM file, probably named `Zelda no Densetsu - Kamigami no Triforce (Japan).sfc`
|
||||
|
||||
## Installation Procedures
|
||||
|
||||
### Windows Setup
|
||||
|
||||
1. During the installation of Archipelago, you will have been asked to install the SNI Client. If you did not do this,
|
||||
or you are on an older version, you may run the installer again to install the SNI Client.
|
||||
2. During setup, you will be asked to locate your base ROM files. This is your Super Metroid and Zelda3 ROM files.
|
||||
3. If you are using an emulator, you should assign your Lua capable emulator as your default program for launching ROM
|
||||
files.
|
||||
1. Extract your emulator's folder to your Desktop, or somewhere you will remember.
|
||||
2. Right-click on a ROM file and select **Open with...**
|
||||
3. Check the box next to **Always use this app to open .sfc files**
|
||||
4. Scroll to the bottom of the list and click the grey text **Look for another App on this PC**
|
||||
5. Browse for your emulator's `.exe` file and click **Open**. This file should be located inside the folder you
|
||||
extracted in step one.
|
||||
|
||||
## Create a Config (.yaml) File
|
||||
|
||||
### What is a config file and why do I need one?
|
||||
|
||||
See the guide on setting up a basic YAML at the Archipelago setup
|
||||
guide: [Basic Multiworld Setup Guide](/tutorial/archipelago/setup/en)
|
||||
|
||||
### Where do I get a config file?
|
||||
|
||||
The Player Settings page on the website allows you to configure your personal settings and export a config file from
|
||||
them. Player settings page: [SMZ3 Player Settings Page](/games/SMZ3/player-settings)
|
||||
|
||||
### Verifying your config file
|
||||
|
||||
If you would like to validate your config file to make sure it works, you may do so on the YAML Validator page. YAML
|
||||
validator page: [YAML Validation page](/mysterycheck)
|
||||
|
||||
## Generating a Single-Player Game
|
||||
|
||||
1. Navigate to the Player Settings page, configure your options, and click the "Generate Game" button.
|
||||
- Player Settings page: [SMZ3 Player Settings Page](/games/SMZ3/player-settings)
|
||||
2. You will be presented with a "Seed Info" page.
|
||||
3. Click the "Create New Room" link.
|
||||
4. You will be presented with a server page, from which you can download your patch file.
|
||||
5. Double-click on your patch file, and the SMZ3 Client will launch automatically, create your ROM from the
|
||||
patch file, and open your emulator for you.
|
||||
6. Since this is a single-player game, you will no longer need the client, so feel free to close it.
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
### Obtain your patch file and create your ROM
|
||||
|
||||
When you join a multiworld game, you will be asked to provide your config file to whoever is hosting. Once that is done,
|
||||
the host will provide you with either a link to download your patch file, or with a zip file containing everyone's patch
|
||||
files. Your patch file should have a `.apsmz` extension.
|
||||
|
||||
Put your patch file on your desktop or somewhere convenient, and double click it. This should automatically launch the
|
||||
client, and will also create your ROM in the same place as your patch file.
|
||||
|
||||
### Connect to the client
|
||||
|
||||
#### With an emulator
|
||||
|
||||
When the client launched automatically, SNI should have also automatically launched in the background. If this is its
|
||||
first time launching, you may be prompted to allow it to communicate through the Windows Firewall.
|
||||
|
||||
##### snes9x Multitroid
|
||||
|
||||
1. Load your ROM file if it hasn't already been loaded.
|
||||
2. Click on the File menu and hover on **Lua Scripting**
|
||||
3. Click on **New Lua Script Window...**
|
||||
4. In the new window, click **Browse...**
|
||||
5. Select the connector lua file included with your client
|
||||
- SuperNintendoClient users should download `sniConnector.lua` from the client download page
|
||||
- SNIClient users should look in their Archipelago folder for `/SNI/lua/x64` or `/SNI/lua/x86` depending on if the
|
||||
emulator is 64-bit or 32-bit.
|
||||
|
||||
##### BizHawk
|
||||
|
||||
1. Ensure you have the BSNES core loaded. You may do this by clicking on the Tools menu in BizHawk and following these
|
||||
menu options:
|
||||
`Config --> Cores --> SNES --> BSNES`
|
||||
Once you have changed the loaded core, you must restart BizHawk.
|
||||
2. Load your ROM file if it hasn't already been loaded.
|
||||
3. Click on the Tools menu and click on **Lua Console**
|
||||
4. Click the button to open a new Lua script.
|
||||
5. Select the `Connector.lua` file included with your client
|
||||
- SuperNintendoClient users should download `sniConnector.lua` from the client download page
|
||||
- SNIClient users should look in their Archipelago folder for `/SNI/lua/x64` or `/SNI/lua/x86` depending on if the
|
||||
emulator is 64-bit or 32-bit. Please note the most recent versions of BizHawk are 64-bit only.
|
||||
|
||||
##### RetroArch 1.10.3 or newer
|
||||
|
||||
You only have to do these steps once. Note, RetroArch 1.9.x will not work as it is older than 1.10.3.
|
||||
|
||||
1. Enter the RetroArch main menu screen.
|
||||
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
|
||||
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
|
||||
Network Command Port at 55355.
|
||||

|
||||
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
|
||||
Performance)".
|
||||
|
||||
When loading a ROM, be sure to select a **bsnes-mercury** core. These are the only cores that allow external tools to
|
||||
read ROM data.
|
||||
|
||||
#### With hardware
|
||||
|
||||
This guide assumes you have downloaded the correct firmware for your device. If you have not done so already, please do
|
||||
this now. SD2SNES and FXPak Pro users may download the appropriate firmware on the SD2SNES releases page. SD2SNES
|
||||
releases page: [SD2SNES Releases Page](https://github.com/RedGuyyyy/sd2snes/releases)
|
||||
|
||||
Other hardware may find helpful information on the usb2snes platforms
|
||||
page: [usb2snes Supported Platforms Page](http://usb2snes.com/#supported-platforms)
|
||||
|
||||
1. Close your emulator, which may have auto-launched.
|
||||
2. Power on your device and load the ROM.
|
||||
|
||||
### Connect to the Archipelago Server
|
||||
|
||||
The patch file which launched your client should have automatically connected you to the AP Server. There are a few
|
||||
reasons this may not happen however, including if the game is hosted on the website but was generated elsewhere. If the
|
||||
client window shows "Server Status: Not Connected", simply ask the host for the address of the server, and copy/paste it
|
||||
into the "Server" input field then press enter.
|
||||
|
||||
The client will attempt to reconnect to the new server address, and should momentarily show "Server Status: Connected".
|
||||
|
||||
### Play the game
|
||||
|
||||
When the client shows both SNES Device and Server as connected, you're ready to begin playing. Congratulations on
|
||||
successfully joining a multiworld game!
|
||||
|
||||
## Hosting a MultiWorld game
|
||||
|
||||
The recommended way to host a game is to use our hosting service. The process is relatively simple:
|
||||
|
||||
1. Collect config files from your players.
|
||||
2. Create a zip file containing your players' config files.
|
||||
3. Upload that zip file to the Generate page above.
|
||||
- Generate page: [WebHost Seed Generation Page](/generate)
|
||||
4. Wait a moment while the seed is generated.
|
||||
5. When the seed is generated, you will be redirected to a "Seed Info" page.
|
||||
6. Click "Create New Room". This will take you to the server page. Provide the link to this page to your players, so
|
||||
they may download their patch files from there.
|
||||
7. Note that a link to a MultiWorld Tracker is at the top of the room page. The tracker shows the progress of all
|
||||
players in the game. Any observers may also be given the link to this page.
|
||||
8. Once all players have joined, you may begin playing.
|
||||
@@ -9,6 +9,7 @@
|
||||
- snes9x-rr from: [snes9x-rr Releases Page](https://github.com/gocha/snes9x-rr/releases)
|
||||
- BizHawk from: [BizHawk Website](http://tasvideos.org/BizHawk.html)
|
||||
- bsnes-plus-nwa from: [bsnes-plus GitHub](https://github.com/black-sliver/bsnes-plus)
|
||||
- RetroArch from: [RetroArch Website](https://retroarch.com?page=platforms). Or,
|
||||
- Or SD2SNES, FXPak Pro ([FXPak Pro Store Page](https://krikzz.com/store/home/54-fxpak-pro.html)), or other
|
||||
compatible hardware.
|
||||
- Your legally obtained Secret of Evermore US ROM file, probably named `Secret of Evermore (USA).sfc`
|
||||
@@ -92,6 +93,21 @@ you may be prompted to allow it to communicate through the Windows Firewall.
|
||||
This should automatically connect to SNI. If this is its first time launching, you may be prompted to allow it to
|
||||
communicate through the Windows Firewall.
|
||||
|
||||
##### RetroArch
|
||||
|
||||
You only have to do these steps once.
|
||||
|
||||
1. Enter the RetroArch main menu screen.
|
||||
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
|
||||
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
|
||||
Network Command Port at 55355.
|
||||

|
||||
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
|
||||
Performance)".
|
||||
|
||||
When loading a ROM, be sure to select a **bsnes-mercury** core. These are the only cores that allow external tools to
|
||||
read ROM data.
|
||||
|
||||
#### With hardware
|
||||
|
||||
This guide assumes you have downloaded the correct firmware for your device. If you have not done so already, please do
|
||||
@@ -3,7 +3,7 @@
|
||||
## Required Software
|
||||
|
||||
- Super Mario 64 US Rom (Japanese may work also. Europe and Shindou not supported)
|
||||
- Either of [sm64pclauncher](https://github.com/N00byKing/sm64pclauncher/actions/workflows/ci.yml?query=branch%3Aarchipelago+event%3Apush) or
|
||||
- Either of [sm64pclauncher](https://github.com/N00byKing/sm64pclauncher/releases) or
|
||||
- Cloning and building [sm64ex](https://github.com/N00byKing/sm64ex) manually.
|
||||
|
||||
NOTE: The above linked sm64pclauncher is a special version designed to work with the Archipelago build of sm64ex.
|
||||
@@ -48,19 +48,26 @@ The Compiled binary will be in `build/REGION_pc/`.
|
||||
# Joining a MultiWorld Game
|
||||
|
||||
To join, set the following launch options: `--sm64ap_name YourName --sm64ap_ip ServerIP:Port`.
|
||||
Optionally, add `--sm64ap_passwd "YourPassword"` if the room you are using requires a password. All parameters without quotation marks.
|
||||
Optionally, add `--sm64ap_passwd "YourPassword"` if the room you are using requires a password.
|
||||
The Name in this case is the one specified in your generated .yaml file.
|
||||
In case you are using the Archipelago Website, the IP should be `archipelago.gg` and Port `38281`.
|
||||
In case you are using the Archipelago Website, the IP should be `archipelago.gg`.
|
||||
|
||||
If everything worked out, you will see a textbox informing you the connection has been established after the story intro.
|
||||
|
||||
# Playing offline
|
||||
|
||||
To play offline, first generate a seed on the game's settings page.
|
||||
Create a room and download the `.apsm64ex` file, and start the game with the `--sm64ap_file FileName` launch argument.
|
||||
|
||||
## Installation Troubleshooting
|
||||
|
||||
Start the game from the command line to view helpful messages regarding SM64EX.
|
||||
|
||||
### Game doesn't start after compiling
|
||||
|
||||
Most likely you forgot to set the launch options. `--sm64ap_name YourName` and `--sm64ap_ip ServerIP:Port` are required for startup.
|
||||
Most likely you forgot to set the launch options. `--sm64ap_name YourName` and `--sm64ap_ip ServerIP:Port` are required for startup for Multiworlds, and
|
||||
`--sm64ap_file FileName` is required for (offline) singleplayer.
|
||||
If your Name or Password have spaces in them, surround them in quotes.
|
||||
|
||||
## Game Troubleshooting
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- snes9x Multitroid
|
||||
from: [snes9x Multitroid Download](https://drive.google.com/drive/folders/1_ej-pwWtCAHYXIrvs5Hro16A1s9Hi3Jz),
|
||||
- BizHawk from: [BizHawk Website](http://tasvideos.org/BizHawk.html)
|
||||
- RetroArch 1.10.1 or newer from: [RetroArch Website](https://retroarch.com?page=platforms). Or,
|
||||
- An SD2SNES, FXPak Pro ([FXPak Pro Store Page](https://krikzz.com/store/home/54-fxpak-pro.html)), or other
|
||||
compatible hardware
|
||||
- Your legally obtained Super Metroid ROM file, probably named `Super Metroid (Japan, USA).sfc`
|
||||
@@ -107,6 +108,21 @@ first time launching, you may be prompted to allow it to communicate through the
|
||||
- SNIClient users should look in their Archipelago folder for `/SNI/lua/x64` or `/SNI/lua/x86` depending on if the
|
||||
emulator is 64-bit or 32-bit. Please note the most recent versions of BizHawk are 64-bit only.
|
||||
|
||||
##### RetroArch 1.10.1 or newer
|
||||
|
||||
You only have to do these steps once. Note, RetroArch 1.9.x will not work as it is older than 1.10.1.
|
||||
|
||||
1. Enter the RetroArch main menu screen.
|
||||
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
|
||||
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
|
||||
Network Command Port at 55355.
|
||||

|
||||
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
|
||||
Performance)".
|
||||
|
||||
When loading a ROM, be sure to select a **bsnes-mercury** core. These are the only cores that allow external tools to
|
||||
read ROM data.
|
||||
|
||||
#### With hardware
|
||||
|
||||
This guide assumes you have downloaded the correct firmware for your device. If you have not done so already, please do
|
||||
@@ -3,31 +3,37 @@
|
||||
## Required Software
|
||||
|
||||
- VVVVVV (Bought from the [Steam Store](https://store.steampowered.com/app/70300/VVVVVV/) or [GOG Store](https://www.gog.com/game/vvvvvv) Page, NOT Make and Play Edition!)
|
||||
- [V6AP](https://github.com/N00byKing/VVVVVV/actions/workflows/ci.yml?query=branch%3Aarchipelago)
|
||||
- [V6AP](https://github.com/N00byKing/VVVVVV/releases)
|
||||
|
||||
## Installation and Game Start Procedures
|
||||
|
||||
1. Install VVVVVV through either Steam or GOG
|
||||
2. Go to the page linked for V6AP, and press on the topmost entry
|
||||
3. Scroll down, and download the zip file corresponding to your platform (NOTE: Linux currently does not build automatically. Linux users will have to compile manually for now. Mac is unsupported, but may work if [APCpp](https://github.com/N00byKing/APCpp) is compiled and supplied)
|
||||
4. Unpack the zip file where you have VVVVVV installed.
|
||||
2. Go to the page linked for V6AP, and download the latest release
|
||||
3. Unpack the zip file where you have VVVVVV installed.
|
||||
|
||||
# Joining a MultiWorld Game
|
||||
|
||||
To join, set the following launch options: `-v6ap_name "YourName" -v6ap_ip "ServerIP"`.
|
||||
To join, set the following launch options: `-v6ap_name YourName -v6ap_ip ServerIP:Port`.
|
||||
Optionally, add `-v6ap_passwd "YourPassword"` if the room you are using requires a password. All parameters without quotation marks.
|
||||
The Name in this case is the one specified in your generated .yaml file.
|
||||
In case you are using the Archipelago Website, the IP should be `archipelago.gg`.
|
||||
|
||||
If everything worked out, you will see a textbox informing you the connection has been established after the story intro.
|
||||
|
||||
# Playing offline
|
||||
|
||||
To play offline, first generate a seed on the game's settings page.
|
||||
Create a room and download the `.apv6` file, and start the game with the `-v6ap_file FileName` launch argument.
|
||||
|
||||
## Installation Troubleshooting
|
||||
|
||||
Start the game from the command line to view helpful messages regarding V6AP. These will look something like "V6AP: Message"
|
||||
|
||||
### Game no longer starts after copying the .exe
|
||||
|
||||
Most likely you forgot to set the launch options. `-v6ap_name "YourName"` and `-v6ap_ip "ServerIP"` are required for startup.
|
||||
Most likely you forgot to set the launch options. `-v6ap_name YourName` and `-v6ap_ip ServerIP:Port` are required for startup for Multiworlds, and
|
||||
`-v6ap_file FileName` is required for (offline) singleplayer.
|
||||
If your Name or Password have spaces in them, surround them in quotes.
|
||||
|
||||
## Game Troubleshooting
|
||||
|
||||
BIN
WebHostLib/static/assets/tutorial/retroarch-network-commands-en.png
Executable file
|
After Width: | Height: | Size: 31 KiB |
@@ -8,8 +8,8 @@
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "archipelago/setup_en.md",
|
||||
"link": "archipelago/setup/en",
|
||||
"filename": "Archipelago/setup_en.md",
|
||||
"link": "Archipelago/setup/en",
|
||||
"authors": [
|
||||
"alwaysintreble"
|
||||
]
|
||||
@@ -22,8 +22,8 @@
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "archipelago/using_website.md",
|
||||
"link": "archipelago/using_website/en",
|
||||
"filename": "Archipelago/using_website.md",
|
||||
"link": "Archipelago/using_website/en",
|
||||
"authors": [
|
||||
"alwaysintreble"
|
||||
]
|
||||
@@ -36,8 +36,8 @@
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "archipelago/commands_en.md",
|
||||
"link": "archipelago/commands/en",
|
||||
"filename": "Archipelago/commands_en.md",
|
||||
"link": "Archipelago/commands/en",
|
||||
"authors": [
|
||||
"jat2980",
|
||||
"Ijwu"
|
||||
@@ -51,8 +51,8 @@
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "archipelago/advanced_settings_en.md",
|
||||
"link": "archipelago/advanced_settings/en",
|
||||
"filename": "Archipelago/advanced_settings_en.md",
|
||||
"link": "Archipelago/advanced_settings/en",
|
||||
"authors": [
|
||||
"alwaysintreble",
|
||||
"Alchav"
|
||||
@@ -66,8 +66,8 @@
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "archipelago/triggers_en.md",
|
||||
"link": "archipelago/triggers/en",
|
||||
"filename": "Archipelago/triggers_en.md",
|
||||
"link": "Archipelago/triggers/en",
|
||||
"authors": [
|
||||
"alwaysintreble"
|
||||
]
|
||||
@@ -80,8 +80,8 @@
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "archipelago/plando_en.md",
|
||||
"link": "archipelago/plando/en",
|
||||
"filename": "Archipelago/plando_en.md",
|
||||
"link": "Archipelago/plando/en",
|
||||
"authors": [
|
||||
"alwaysintreble",
|
||||
"Alchav"
|
||||
@@ -100,32 +100,32 @@
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "zelda3/multiworld_en.md",
|
||||
"link": "zelda3/multiworld/en",
|
||||
"filename": "A Link to the Past/multiworld_en.md",
|
||||
"link": "A Link to the Past/multiworld/en",
|
||||
"authors": [
|
||||
"Farrak Kilhn"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Deutsch",
|
||||
"filename": "zelda3/multiworld_de.md",
|
||||
"link": "zelda3/multiworld/de",
|
||||
"filename": "A Link to the Past/multiworld_de.md",
|
||||
"link": "A Link to the Past/multiworld/de",
|
||||
"authors": [
|
||||
"Fischfilet"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Español",
|
||||
"filename": "zelda3/multiworld_es.md",
|
||||
"link": "zelda3/multiworld/es",
|
||||
"filename": "A Link to the Past/multiworld_es.md",
|
||||
"link": "A Link to the Past/multiworld/es",
|
||||
"authors": [
|
||||
"Edos"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Français",
|
||||
"filename": "zelda3/multiworld_fr.md",
|
||||
"link": "zelda3/multiworld/fr",
|
||||
"filename": "A Link to the Past/multiworld_fr.md",
|
||||
"link": "A Link to the Past/multiworld/fr",
|
||||
"authors": [
|
||||
"Coxla"
|
||||
]
|
||||
@@ -138,24 +138,24 @@
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "zelda3/msu1_en.md",
|
||||
"link": "zelda3/msu1/en",
|
||||
"filename": "A Link to the Past/msu1_en.md",
|
||||
"link": "A Link to the Past/msu1/en",
|
||||
"authors": [
|
||||
"Farrak Kilhn"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Español",
|
||||
"filename": "zelda3/msu1_es.md",
|
||||
"link": "zelda3/msu1/es",
|
||||
"filename": "A Link to the Past/msu1_es.md",
|
||||
"link": "A Link to the Past/msu1/es",
|
||||
"authors": [
|
||||
"Edos"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Français",
|
||||
"filename": "msu1_fr.md",
|
||||
"link": "zelda3/msu1/fr",
|
||||
"filename": "A Link to the Past/msu1_fr.md",
|
||||
"link": "A Link to the Past/msu1/fr",
|
||||
"authors": [
|
||||
"Coxla"
|
||||
]
|
||||
@@ -168,8 +168,8 @@
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "zelda3/plando_en.md",
|
||||
"link": "zelda3/plando/en",
|
||||
"filename": "A Link to the Past/plando_en.md",
|
||||
"link": "A Link to the Past/plando/en",
|
||||
"authors": [
|
||||
"Berserker"
|
||||
]
|
||||
@@ -187,16 +187,16 @@
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "zelda5/setup_en.md",
|
||||
"link": "zelda5/setup/en",
|
||||
"filename": "Ocarina of Time/setup_en.md",
|
||||
"link": "Ocarina of Time/setup/en",
|
||||
"authors": [
|
||||
"Edos"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Spanish",
|
||||
"filename": "zelda5/setup_es.md",
|
||||
"link": "zelda5/setup/es",
|
||||
"filename": "Ocarina of Time/setup_es.md",
|
||||
"link": "Ocarina of Time/setup/es",
|
||||
"authors": [
|
||||
"Edos"
|
||||
]
|
||||
@@ -214,8 +214,8 @@
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "factorio/setup_en.md",
|
||||
"link": "factorio/setup/en",
|
||||
"filename": "Factorio/setup_en.md",
|
||||
"link": "Factorio/setup/en",
|
||||
"authors": [
|
||||
"Berserker",
|
||||
"Farrak Kilhn"
|
||||
@@ -225,6 +225,25 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Meritous",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Meritous Setup Tutorial",
|
||||
"description": "A guide to setting up the Archipelago Meritous software on your computer.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "Meritous/setup_en.md",
|
||||
"link": "Meritous/setup/en",
|
||||
"authors": [
|
||||
"KewlioMZX"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Minecraft",
|
||||
"tutorials": [
|
||||
@@ -234,24 +253,24 @@
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "minecraft/minecraft_en.md",
|
||||
"link": "minecraft/minecraft/en",
|
||||
"filename": "Minecraft/minecraft_en.md",
|
||||
"link": "Minecraft/minecraft/en",
|
||||
"authors": [
|
||||
"Kono Tyran"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Spanish",
|
||||
"filename": "minecraft/minecraft_es.md",
|
||||
"link": "minecraft/minecraft/es",
|
||||
"filename": "Minecraft/minecraft_es.md",
|
||||
"link": "Minecraft/minecraft/es",
|
||||
"authors": [
|
||||
"Edos"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Swedish",
|
||||
"filename": "minecraft/minecraft_sv.md",
|
||||
"link": "minecraft/minecraft/sv",
|
||||
"filename": "Minecraft/minecraft_sv.md",
|
||||
"link": "Minecraft/minecraft/sv",
|
||||
"authors": [
|
||||
"Albinum"
|
||||
]
|
||||
@@ -269,8 +288,8 @@
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "ror2/setup_en.md",
|
||||
"link": "ror2/setup/en",
|
||||
"filename": "Risk of Rain 2/setup_en.md",
|
||||
"link": "Risk of Rain 2/setup/en",
|
||||
"authors": [
|
||||
"Ijwu"
|
||||
]
|
||||
@@ -288,8 +307,8 @@
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "raft/setup_en.md",
|
||||
"link": "raft/setup/en",
|
||||
"filename": "Raft/setup_en.md",
|
||||
"link": "Raft/setup/en",
|
||||
"authors": [
|
||||
"SunnyBat",
|
||||
"Awareqwx"
|
||||
@@ -308,16 +327,16 @@
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "timespinner/setup_en.md",
|
||||
"link": "timespinner/setup/en",
|
||||
"filename": "Timespinner/setup_en.md",
|
||||
"link": "Timespinner/setup/en",
|
||||
"authors": [
|
||||
"Jarno"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "German",
|
||||
"filename": "timespinner/setup_de.md",
|
||||
"link": "timespinner/setup/de",
|
||||
"filename": "Timespinner/setup_de.md",
|
||||
"link": "Timespinner/setup/de",
|
||||
"authors": [
|
||||
"Grrmo",
|
||||
"Fynxes",
|
||||
@@ -328,6 +347,25 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "SMZ3",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to setting up the Archipelago Super Metroid and A Link to the Past Crossover randomizer on your computer. This guide covers single-player, multiworld, and related software.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "SMZ3/multiworld_en.md",
|
||||
"link": "SMZ3/multiworld/en",
|
||||
"authors": [
|
||||
"lordlou"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Subnautica",
|
||||
"tutorials": [
|
||||
@@ -356,8 +394,8 @@
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "super-metroid/multiworld_en.md",
|
||||
"link": "super-metroid/multiworld/en",
|
||||
"filename": "Super Metroid/multiworld_en.md",
|
||||
"link": "Super Metroid/multiworld/en",
|
||||
"authors": [
|
||||
"Farrak Kilhn"
|
||||
]
|
||||
@@ -375,8 +413,8 @@
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "secret-of-evermore/multiworld_en.md",
|
||||
"link": "secret-of-evermore/multiworld/en",
|
||||
"filename": "Secret of Evermore/multiworld_en.md",
|
||||
"link": "Secret of Evermore/multiworld/en",
|
||||
"authors": [
|
||||
"Black Sliver"
|
||||
]
|
||||
@@ -394,8 +432,8 @@
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "ff1/multiworld_en.md",
|
||||
"link": "ff1/multiworld/en",
|
||||
"filename": "Final Fantasy/multiworld_en.md",
|
||||
"link": "Final Fantasy/multiworld/en",
|
||||
"authors": [
|
||||
"jat2980"
|
||||
]
|
||||
@@ -413,8 +451,8 @@
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "rogue-legacy/rogue-legacy_en.md",
|
||||
"link": "rogue-legacy/rogue-legacy/en",
|
||||
"filename": "Rogue Legacy/rogue-legacy_en.md",
|
||||
"link": "Rogue Legacy/rogue-legacy/en",
|
||||
"authors": [
|
||||
"Phar"
|
||||
]
|
||||
@@ -432,8 +470,8 @@
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "slay-the-spire/slay-the-spire_en.md",
|
||||
"link": "slay-the-spire/slay-the-spire/en",
|
||||
"filename": "Slay the Spire/slay-the-spire_en.md",
|
||||
"link": "Slay the Spire/slay-the-spire/en",
|
||||
"authors": [
|
||||
"Phar"
|
||||
]
|
||||
@@ -451,8 +489,8 @@
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "sm64ex/setup_en.md",
|
||||
"link": "sm64ex/setup/en",
|
||||
"filename": "Super Mario 64/setup_en.md",
|
||||
"link": "Super Mario 64/setup/en",
|
||||
"authors": [
|
||||
"N00byKing"
|
||||
]
|
||||
@@ -470,8 +508,8 @@
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "v6/setup_en.md",
|
||||
"link": "v6/setup/en",
|
||||
"filename": "VVVVVV/setup_en.md",
|
||||
"link": "VVVVVV/setup/en",
|
||||
"authors": [
|
||||
"N00byKing"
|
||||
]
|
||||
@@ -479,5 +517,62 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "ChecksFinder",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Tutorial",
|
||||
"description": "A guide to setting up the Archipelago ChecksFinder software on your computer. This guide covers single-player, multiworld, and related software.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "ChecksFinder/checksfinder_en.md",
|
||||
"link": "ChecksFinder/checksfinder/en",
|
||||
"authors": [
|
||||
"Mewlif"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "ArchipIDLE",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Setup Guide",
|
||||
"description": "A guide to playing ArchipIDLE",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "ArchipIDLE/guide_en.md",
|
||||
"link": "ArchipIDLE/guide/en",
|
||||
"authors": [
|
||||
"Farrak Kilhn"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Hollow Knight",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Mod Setup and Use Guide",
|
||||
"description": "A guide to playing Hollow Knight with Archipelago.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "Hollow Knight/setup_en.md",
|
||||
"link": "Hollow Knight/setup/en",
|
||||
"authors": [
|
||||
"ijwu"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -66,6 +66,6 @@ window.addEventListener('load', () => {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/assets/tutorial/tutorials.json`, true);
|
||||
ajax.open('GET', `${window.location.origin}/static/generated/tutorials.json`, true);
|
||||
ajax.send();
|
||||
});
|
||||
|
||||
@@ -3,16 +3,16 @@ window.addEventListener('load', () => {
|
||||
let settingHash = localStorage.getItem('weighted-settings-hash');
|
||||
if (!settingHash) {
|
||||
// If no hash data has been set before, set it now
|
||||
localStorage.setItem('weighted-settings-hash', md5(results));
|
||||
localStorage.setItem('weighted-settings-hash', md5(JSON.stringify(results)));
|
||||
localStorage.removeItem('weighted-settings');
|
||||
settingHash = md5(results);
|
||||
settingHash = md5(JSON.stringify(results));
|
||||
}
|
||||
|
||||
if (settingHash !== md5(results)) {
|
||||
if (settingHash !== md5(JSON.stringify(results))) {
|
||||
const userMessage = document.getElementById('user-message');
|
||||
userMessage.innerText = "Your settings are out of date! Click here to update them! Be aware this will reset " +
|
||||
"them all to default.";
|
||||
userMessage.style.display = "block";
|
||||
userMessage.classList.add('visible');
|
||||
userMessage.addEventListener('click', resetSettings);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ const resetSettings = () => {
|
||||
|
||||
const fetchSettingData = () => new Promise((resolve, reject) => {
|
||||
fetch(new Request(`${window.location.origin}/static/generated/weighted-settings.json`)).then((response) => {
|
||||
try{ resolve(response.json()); }
|
||||
try{ response.json().then((jsonObj) => resolve(jsonObj)); }
|
||||
catch(error){ reject(error); }
|
||||
});
|
||||
});
|
||||
|
||||
BIN
WebHostLib/static/static/backgrounds/dirt.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
BIN
WebHostLib/static/static/backgrounds/grass-flowers.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
WebHostLib/static/static/backgrounds/grass.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |