Compare commits

..

5 Commits

Author SHA1 Message Date
CaitSith2
87152f17f5 Merge branch 'main' into show_all_hints 2022-07-02 06:56:15 -07:00
CaitSith2
c1b099d44e Merge branch 'main' into show_all_hints 2022-06-27 02:19:24 -07:00
CaitSith2
d20ade7ff8 Automatically allow spectator slots to see all hints. 2022-06-22 16:18:36 -07:00
CaitSith2
df90ff4ddb Don't need this line. 2022-06-22 04:53:38 -07:00
CaitSith2
a798e8aea2 Add a means to allow a client to opt into seeing ALL hints. 2022-06-22 04:49:31 -07:00
678 changed files with 32968 additions and 95863 deletions

View File

@@ -1,35 +0,0 @@
name: Bug Report
description: File a bug report.
title: "Bug: "
labels:
- bug / fix
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report! If this bug occurred during local generation check your
Archipelago install for a log (probably `C:\ProgramData\Archipelago\logs`)
and upload it with this report, as well as all yaml files used.
- type: textarea
id: what-happened
attributes:
label: What happened?
validations:
required: true
- type: textarea
id: expected-results
attributes:
label: What were the expected results?
validations:
required: true
- type: dropdown
id: version
attributes:
label: Software
description: Where did this bug occur?
options:
- Website
- Local generation
- While playing
validations:
required: true

View File

@@ -1,17 +0,0 @@
name: Feature Request
description: Request a feature!
title: "Category: "
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
Please replace `Category` in the title with what this feature will be targeting, such as Core generation,
website, documentation, or a game.
Note: this is not for requesting new games to be added. If you would like to request a game, the best place to
ask is about it is in the [discord](https://archipelago.gg/discord).
- type: textarea
id: feature
attributes:
label: What feature would you like to see?

View File

@@ -1,10 +0,0 @@
name: Task
description: Submit a task to be done. If this is not targeting core, it should likely be elsewhere.
title: "Core: "
labels:
- core
- enhancement
body:
- type: textarea
attributes:
label: What task needs to be completed?

View File

@@ -1,12 +0,0 @@
Please format your title with what portion of the project this pull request is
targeting and what it's changing.
ex. "MyGame4: implement new game" or "Docs: add new guide for customizing MyGame3"
## What is this fixing or adding?
## How was this tested?
## If this makes graphical changes, please attach screenshots.

View File

@@ -4,11 +4,6 @@ name: Build
on: workflow_dispatch on: workflow_dispatch
env:
SNI_VERSION: v0.0.84
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
jobs: jobs:
# build-release-macos: # LF volunteer # build-release-macos: # LF volunteer
@@ -22,15 +17,15 @@ jobs:
python-version: '3.8' python-version: '3.8'
- name: Download run-time dependencies - name: Download run-time dependencies
run: | run: |
Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/${Env:SNI_VERSION}/sni-${Env:SNI_VERSION}-windows-amd64.zip -OutFile sni.zip Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/v0.0.81/sni-v0.0.81-windows-amd64.zip -OutFile sni.zip
Expand-Archive -Path sni.zip -DestinationPath SNI -Force Expand-Archive -Path sni.zip -DestinationPath SNI -Force
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/7.0.1/win-x64.zip -OutFile enemizer.zip
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
- name: Build - name: Build
run: | run: |
python -m pip install --upgrade pip setuptools python -m pip install --upgrade pip setuptools
pip install -r requirements.txt pip install -r requirements.txt
python setup.py build_exe --yes python setup.py build --yes
$NAME="$(ls build)".Split('.',2)[1] $NAME="$(ls build)".Split('.',2)[1]
$ZIP_NAME="Archipelago_$NAME.7z" $ZIP_NAME="Archipelago_$NAME.7z"
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
@@ -48,7 +43,6 @@ jobs:
build-ubuntu1804: build-ubuntu1804:
runs-on: ubuntu-18.04 runs-on: ubuntu-18.04
steps: steps:
# - copy code below to release.yml -
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Install base dependencies - name: Install base dependencies
run: | run: |
@@ -62,18 +56,18 @@ jobs:
- name: Install build-time dependencies - name: Install build-time dependencies
run: | run: |
echo "PYTHON=python3.9" >> $GITHUB_ENV echo "PYTHON=python3.9" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage wget -nv https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
chmod a+rx appimagetool-x86_64.AppImage chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract ./appimagetool-x86_64.AppImage --appimage-extract
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
chmod a+rx appimagetool chmod a+rx appimagetool
- name: Download run-time dependencies - name: Download run-time dependencies
run: | run: |
wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-manylinux2014-amd64.tar.xz wget -nv https://github.com/alttpo/sni/releases/download/v0.0.81/sni-v0.0.81-manylinux2014-amd64.tar.xz
tar xf sni-*.tar.xz tar xf sni-*.tar.xz
rm sni-*.tar.xz rm sni-*.tar.xz
mv sni-* SNI mv sni-* SNI
wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z wget -nv https://github.com/Ijwu/Enemizer/releases/download/7.0.1/ubuntu.16.04-x64.7z
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z 7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
- name: Build - name: Build
run: | run: |
@@ -82,7 +76,7 @@ jobs:
"${{ env.PYTHON }}" -m venv venv "${{ env.PYTHON }}" -m venv venv
source venv/bin/activate source venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
python setup.py build_exe --yes bdist_appimage --yes python setup.py build --yes bdist_appimage --yes
echo -e "setup.py build output:\n `ls build`" echo -e "setup.py build output:\n `ls build`"
echo -e "setup.py dist output:\n `ls dist`" echo -e "setup.py dist output:\n `ls dist`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd .. cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
@@ -90,7 +84,6 @@ jobs:
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME") (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 "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - copy code above to release.yml -
- name: Store AppImage - name: Store AppImage
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v2
with: with:

View File

@@ -18,8 +18,8 @@ jobs:
python-version: 3.9 python-version: 3.9
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip wheel python -m pip install --upgrade pip
pip install flake8 pytest pytest-subtests pip install flake8 pytest
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8 - name: Lint with flake8
run: | run: |

View File

@@ -7,11 +7,6 @@ on:
tags: tags:
- '*.*.*' - '*.*.*'
env:
SNI_VERSION: v0.0.84
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
jobs: jobs:
create-release: create-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -49,23 +44,22 @@ jobs:
- name: Install build-time dependencies - name: Install build-time dependencies
run: | run: |
echo "PYTHON=python3.9" >> $GITHUB_ENV echo "PYTHON=python3.9" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage wget -nv https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
chmod a+rx appimagetool-x86_64.AppImage chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract ./appimagetool-x86_64.AppImage --appimage-extract
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
chmod a+rx appimagetool chmod a+rx appimagetool
- name: Download run-time dependencies - name: Download run-time dependencies
run: | run: |
wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-manylinux2014-amd64.tar.xz wget -nv https://github.com/alttpo/sni/releases/download/v0.0.81/sni-v0.0.81-manylinux2014-amd64.tar.xz
tar xf sni-*.tar.xz tar xf sni-*.tar.xz
rm sni-*.tar.xz rm sni-*.tar.xz
mv sni-* SNI mv sni-* SNI
wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z wget -nv https://github.com/Ijwu/Enemizer/releases/download/7.0.1/ubuntu.16.04-x64.7z
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z 7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
- name: Build - name: Build
run: | run: |
# pygobject is an optional dependency for kivy that's not in requirements "${{ env.PYTHON }}" -m pip install --upgrade pip setuptools virtualenv PyGObject # pygobject should probably move to requirements
"${{ env.PYTHON }}" -m pip install --upgrade pip virtualenv PyGObject setuptools
"${{ env.PYTHON }}" -m venv venv "${{ env.PYTHON }}" -m venv venv
source venv/bin/activate source venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt

View File

@@ -32,9 +32,9 @@ jobs:
python-version: ${{ matrix.python.version }} python-version: ${{ matrix.python.version }}
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip wheel python -m pip install --upgrade pip
pip install flake8 pytest pytest-subtests pip install flake8 pytest
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt" python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
- name: Unittests - name: Unittests
run: | run: |
pytest pytest test

25
.gitignore vendored
View File

@@ -4,7 +4,6 @@
*_Spoiler.txt *_Spoiler.txt
*.bmbp *.bmbp
*.apbp *.apbp
*.apl2ac
*.apm3 *.apm3
*.apmc *.apmc
*.apz5 *.apz5
@@ -14,10 +13,6 @@
*.z64 *.z64
*.n64 *.n64
*.nes *.nes
*.sms
*.gb
*.gbc
*.gba
*.wixobj *.wixobj
*.lck *.lck
*.db3 *.db3
@@ -33,7 +28,6 @@ README.html
.vs/ .vs/
EnemizerCLI/ EnemizerCLI/
/Players/ /Players/
/SNI/
/options.yaml /options.yaml
/config.yaml /config.yaml
/logs/ /logs/
@@ -49,7 +43,7 @@ Output Logs/
/freeze_requirements.txt /freeze_requirements.txt
/Archipelago.zip /Archipelago.zip
/setup.ini /setup.ini
/installdelete.iss
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
@@ -122,21 +116,17 @@ target/
profile_default/ profile_default/
ipython_config.py ipython_config.py
# vim editor
*.swp
# SageMath parsed files # SageMath parsed files
*.sage.py *.sage.py
# Environments # Environments
.env .env
.venv* .venv
env/ env/
venv/ venv/
ENV/ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
.code-workspace
# Spyder project settings # Spyder project settings
.spyderproject .spyderproject
@@ -162,17 +152,10 @@ dmypy.json
# Cython debug symbols # Cython debug symbols
cython_debug/ cython_debug/
# minecraft server stuff #minecraft server stuff
jdk*/ jdk*/
minecraft*/ minecraft*/
minecraft_versions.json minecraft_versions.json
# pyenv #pyenv
.python-version .python-version
# OS General Files
.DS_Store
.AppleDouble
.LSOverride
Thumbs.db
[Dd]esktop.ini

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,6 @@
from __future__ import annotations from __future__ import annotations
import os import os
import sys
import asyncio import asyncio
import shutil
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
@@ -34,24 +32,6 @@ class ChecksFinderContext(CommonContext):
self.send_index: int = 0 self.send_index: int = 0
self.syncing = False self.syncing = False
self.awaiting_bridge = False self.awaiting_bridge = False
# self.game_communication_path: files go in this path to pass data between us and the actual game
if "localappdata" in os.environ:
self.game_communication_path = os.path.expandvars(r"%localappdata%/ChecksFinder")
else:
# not windows. game is an exe so let's see if wine might be around to run it
if "WINEPREFIX" in os.environ:
wineprefix = os.environ["WINEPREFIX"]
elif shutil.which("wine") or shutil.which("wine-stable"):
wineprefix = os.path.expanduser("~/.wine") # default root of wine system data, deep in which is app data
else:
msg = "ChecksFinderClient couldn't detect system type. Unable to infer required game_communication_path"
logger.error("Error: " + msg)
Utils.messagebox("Error", msg, error=True)
sys.exit(1)
self.game_communication_path = os.path.join(
wineprefix,
"drive_c",
os.path.expandvars("users/$USER/Local Settings/Application Data/ChecksFinder"))
async def server_auth(self, password_requested: bool = False): async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password: if password_requested and not self.password:
@@ -61,7 +41,8 @@ class ChecksFinderContext(CommonContext):
async def connection_closed(self): async def connection_closed(self):
await super(ChecksFinderContext, self).connection_closed() await super(ChecksFinderContext, self).connection_closed()
for root, dirs, files in os.walk(self.game_communication_path): path = os.path.expandvars(r"%localappdata%/ChecksFinder")
for root, dirs, files in os.walk(path):
for file in files: for file in files:
if file.find("obtain") <= -1: if file.find("obtain") <= -1:
os.remove(root + "/" + file) os.remove(root + "/" + file)
@@ -75,25 +56,26 @@ class ChecksFinderContext(CommonContext):
async def shutdown(self): async def shutdown(self):
await super(ChecksFinderContext, self).shutdown() await super(ChecksFinderContext, self).shutdown()
for root, dirs, files in os.walk(self.game_communication_path): path = os.path.expandvars(r"%localappdata%/ChecksFinder")
for root, dirs, files in os.walk(path):
for file in files: for file in files:
if file.find("obtain") <= -1: if file.find("obtain") <= -1:
os.remove(root+"/"+file) os.remove(root+"/"+file)
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
if cmd in {"Connected"}: if cmd in {"Connected"}:
if not os.path.exists(self.game_communication_path): if not os.path.exists(os.path.expandvars(r"%localappdata%/ChecksFinder")):
os.makedirs(self.game_communication_path) os.mkdir(os.path.expandvars(r"%localappdata%/ChecksFinder"))
for ss in self.checked_locations: for ss in self.checked_locations:
filename = f"send{ss}" filename = f"send{ss}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.expandvars(r"%localappdata%/ChecksFinder/" + filename), 'w') as f:
f.close() f.close()
if cmd in {"ReceivedItems"}: if cmd in {"ReceivedItems"}:
start_index = args["index"] start_index = args["index"]
if start_index != len(self.items_received): if start_index != len(self.items_received):
for item in args['items']: for item in args['items']:
filename = f"AP_{str(NetworkItem(*item).location)}PLR{str(NetworkItem(*item).player)}.item" filename = f"AP_{str(NetworkItem(*item).location)}PLR{str(NetworkItem(*item).player)}.item"
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.expandvars(r"%localappdata%/ChecksFinder/" + filename), 'w') as f:
f.write(str(NetworkItem(*item).item)) f.write(str(NetworkItem(*item).item))
f.close() f.close()
@@ -101,7 +83,7 @@ class ChecksFinderContext(CommonContext):
if "checked_locations" in args: if "checked_locations" in args:
for ss in self.checked_locations: for ss in self.checked_locations:
filename = f"send{ss}" filename = f"send{ss}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.expandvars(r"%localappdata%/ChecksFinder/" + filename), 'w') as f:
f.close() f.close()
def run_gui(self): def run_gui(self):
@@ -127,9 +109,10 @@ async def game_watcher(ctx: ChecksFinderContext):
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)}) sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
await ctx.send_msgs(sync_msg) await ctx.send_msgs(sync_msg)
ctx.syncing = False ctx.syncing = False
path = os.path.expandvars(r"%localappdata%/ChecksFinder")
sending = [] sending = []
victory = False victory = False
for root, dirs, files in os.walk(ctx.game_communication_path): for root, dirs, files in os.walk(path):
for file in files: for file in files:
if file.find("send") > -1: if file.find("send") > -1:
st = file.split("send", -1)[1] st = file.split("send", -1)[1]

View File

@@ -5,7 +5,6 @@ import urllib.parse
import sys import sys
import typing import typing
import time import time
import functools
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
@@ -18,15 +17,11 @@ if __name__ == "__main__":
Utils.init_logging("TextClient", exception_logger="Client") Utils.init_logging("TextClient", exception_logger="Client")
from MultiServer import CommandProcessor from MultiServer import CommandProcessor
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \ from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot
ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser from Utils import Version, stream_input
from Utils import Version, stream_input, async_start
from worlds import network_data_package, AutoWorldRegister from worlds import network_data_package, AutoWorldRegister
import os import os
if typing.TYPE_CHECKING:
import kvui
logger = logging.getLogger("Client") logger = logging.getLogger("Client")
# without terminal, we have to use gui mode # without terminal, we have to use gui mode
@@ -47,18 +42,16 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_connect(self, address: str = "") -> bool: def _cmd_connect(self, address: str = "") -> bool:
"""Connect to a MultiWorld Server""" """Connect to a MultiWorld Server"""
if address: self.ctx.server_address = None
self.ctx.server_address = None self.ctx.username = None
self.ctx.username = None asyncio.create_task(self.ctx.connect(address if address else None), name="connecting")
elif not self.ctx.server_address:
self.output("Please specify an address.")
return False
async_start(self.ctx.connect(address if address else None), name="connecting")
return True return True
def _cmd_disconnect(self) -> bool: def _cmd_disconnect(self) -> bool:
"""Disconnect from a MultiWorld Server""" """Disconnect from a MultiWorld Server"""
async_start(self.ctx.disconnect(), name="disconnecting") self.ctx.server_address = None
self.ctx.username = None
asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
return True return True
def _cmd_received(self) -> bool: def _cmd_received(self) -> bool:
@@ -96,18 +89,12 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_items(self): def _cmd_items(self):
"""List all item names for the currently running game.""" """List all item names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing items.")
return False
self.output(f"Item Names for {self.ctx.game}") self.output(f"Item Names for {self.ctx.game}")
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id: for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
self.output(item_name) self.output(item_name)
def _cmd_locations(self): def _cmd_locations(self):
"""List all location names for the currently running game.""" """List all location names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing locations.")
return False
self.output(f"Location Names for {self.ctx.game}") self.output(f"Location Names for {self.ctx.game}")
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id: for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
self.output(location_name) self.output(location_name)
@@ -121,12 +108,16 @@ class ClientCommandProcessor(CommandProcessor):
else: else:
state = ClientStatus.CLIENT_CONNECTED state = ClientStatus.CLIENT_CONNECTED
self.output("Unreadied.") self.output("Unreadied.")
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate") asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
def _cmd_show_all_hints(self):
"""Allows the player to see all hints, not just the ones that apply to them."""
asyncio.create_task(self.ctx.update_show_all_hints("ShowAllHints" not in self.ctx.tags))
def default(self, raw: str): def default(self, raw: str):
raw = self.ctx.on_user_say(raw) raw = self.ctx.on_user_say(raw)
if raw: if raw:
async_start(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say") asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
class CommonContext: class CommonContext:
@@ -134,7 +125,6 @@ class CommonContext:
tags: typing.Set[str] = {"AP"} tags: typing.Set[str] = {"AP"}
game: typing.Optional[str] = None game: typing.Optional[str] = None
items_handling: typing.Optional[int] = None items_handling: typing.Optional[int] = None
want_slot_data: bool = True # should slot_data be retrieved via Connect
# datapackage # datapackage
# Contents in flux until connection to server is made, to download correct data for this multiworld. # Contents in flux until connection to server is made, to download correct data for this multiworld.
@@ -144,48 +134,37 @@ class CommonContext:
# defaults # defaults
starting_reconnect_delay: int = 5 starting_reconnect_delay: int = 5
current_reconnect_delay: int = starting_reconnect_delay current_reconnect_delay: int = starting_reconnect_delay
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor command_processor: type(CommandProcessor) = ClientCommandProcessor
ui = None ui = None
ui_task: typing.Optional["asyncio.Task[None]"] = None ui_task: typing.Optional[asyncio.Task] = None
input_task: typing.Optional["asyncio.Task[None]"] = None input_task: typing.Optional[asyncio.Task] = None
keep_alive_task: typing.Optional["asyncio.Task[None]"] = None keep_alive_task: typing.Optional[asyncio.Task] = None
server_task: typing.Optional["asyncio.Task[None]"] = None server_task: typing.Optional[asyncio.Task] = None
autoreconnect_task: typing.Optional["asyncio.Task[None]"] = None
disconnected_intentionally: bool = False
server: typing.Optional[Endpoint] = None server: typing.Optional[Endpoint] = None
server_version: Version = Version(0, 0, 0) server_version: Version = Version(0, 0, 0)
current_energy_link_value: typing.Optional[int] = None # to display in UI, gets set by server current_energy_link_value: int = 0 # to display in UI, gets set by server
last_death_link: float = time.time() # last send/received death link on AP layer last_death_link: float = time.time() # last send/received death link on AP layer
# remaining type info # remaining type info
slot_info: typing.Dict[int, NetworkSlot] slot_info: typing.Dict[int, NetworkSlot]
server_address: typing.Optional[str] server_address: str
password: typing.Optional[str] password: typing.Optional[str]
hint_cost: typing.Optional[int] hint_cost: typing.Optional[int]
player_names: typing.Dict[int, str] player_names: typing.Dict[int, str]
finished_game: bool
ready: bool
auth: typing.Optional[str]
seed_name: typing.Optional[str]
# locations # locations
locations_checked: typing.Set[int] # local state locations_checked: typing.Set[int] # local state
locations_scouted: typing.Set[int] locations_scouted: typing.Set[int]
items_received: typing.List[NetworkItem] missing_locations: typing.Set[int]
missing_locations: typing.Set[int] # server state
checked_locations: typing.Set[int] # server state checked_locations: typing.Set[int] # server state
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
locations_info: typing.Dict[int, NetworkItem] locations_info: typing.Dict[int, NetworkItem]
# internals # internals
# current message box through kvui # current message box through kvui
_messagebox: typing.Optional["kvui.MessageBox"] = None _messagebox = None
# message box reporting a loss of connection
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None: def __init__(self, server_address, password):
# server state # server state
self.server_address = server_address self.server_address = server_address
self.username = None self.username = None
@@ -209,9 +188,8 @@ class CommonContext:
self.locations_checked = set() # local state self.locations_checked = set() # local state
self.locations_scouted = set() self.locations_scouted = set()
self.items_received = [] self.items_received = []
self.missing_locations = set() # server state self.missing_locations = set()
self.checked_locations = set() # server state self.checked_locations = set() # server state
self.server_locations = set() # all locations the server knows of, missing_location | checked_locations
self.locations_info = {} self.locations_info = {}
self.input_queue = asyncio.Queue() self.input_queue = asyncio.Queue()
@@ -228,16 +206,6 @@ class CommonContext:
# execution # execution
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy") self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
@property
def suggested_address(self) -> str:
if self.server_address:
return self.server_address
return Utils.persistent_load().get("client", {}).get("last_server_address", "")
@functools.cached_property
def raw_text_parser(self) -> RawJSONtoTextParser:
return RawJSONtoTextParser(self)
@property @property
def total_locations(self) -> typing.Optional[int]: def total_locations(self) -> typing.Optional[int]:
"""Will return None until connected.""" """Will return None until connected."""
@@ -245,9 +213,9 @@ class CommonContext:
return len(self.checked_locations | self.missing_locations) return len(self.checked_locations | self.missing_locations)
async def connection_closed(self): async def connection_closed(self):
self.reset_server_state()
if self.server and self.server.socket is not None: if self.server and self.server.socket is not None:
await self.server.socket.close() await self.server.socket.close()
self.reset_server_state()
def reset_server_state(self): def reset_server_state(self):
self.auth = None self.auth = None
@@ -265,18 +233,13 @@ class CommonContext:
"remaining": "disabled", "remaining": "disabled",
} }
async def disconnect(self, allow_autoreconnect: bool = False): async def disconnect(self):
if not allow_autoreconnect:
self.disconnected_intentionally = True
if self.cancel_autoreconnect():
logger.info("Cancelled auto-reconnect.")
if self.server and not self.server.socket.closed: if self.server and not self.server.socket.closed:
await self.server.socket.close() await self.server.socket.close()
if self.server_task is not None: if self.server_task is not None:
await self.server_task await self.server_task
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None: async def send_msgs(self, msgs):
""" `msgs` JSON serializable """
if not self.server or not self.server.socket.open or self.server.socket.closed: if not self.server or not self.server.socket.open or self.server.socket.closed:
return return
await self.server.socket.send(encode(msgs)) await self.server.socket.send(encode(msgs))
@@ -304,36 +267,25 @@ class CommonContext:
logger.info('Enter slot name:') logger.info('Enter slot name:')
self.auth = await self.console_input() self.auth = await self.console_input()
async def send_connect(self, **kwargs: typing.Any) -> None: async def send_connect(self, **kwargs):
""" send `Connect` packet to log in to server """
payload = { payload = {
'cmd': 'Connect', 'cmd': 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple, 'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
'tags': self.tags, 'items_handling': self.items_handling, 'tags': self.tags, 'items_handling': self.items_handling,
'uuid': Utils.get_unique_identifier(), 'game': self.game, "slot_data": self.want_slot_data, 'uuid': Utils.get_unique_identifier(), 'game': self.game
} }
if kwargs: if kwargs:
payload.update(kwargs) payload.update(kwargs)
await self.send_msgs([payload]) await self.send_msgs([payload])
async def console_input(self) -> str: async def console_input(self):
if self.ui:
self.ui.focus_textinput()
self.input_requests += 1 self.input_requests += 1
return await self.input_queue.get() return await self.input_queue.get()
async def connect(self, address: typing.Optional[str] = None) -> None: async def connect(self, address=None):
""" disconnect any previous connection, and open new connection to the server """
await self.disconnect() await self.disconnect()
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop") self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
def cancel_autoreconnect(self) -> bool:
if self.autoreconnect_task:
self.autoreconnect_task.cancel()
self.autoreconnect_task = None
return True
return False
def slot_concerns_self(self, slot) -> bool: def slot_concerns_self(self, slot) -> bool:
if slot == self.slot: if slot == self.slot:
return True return True
@@ -341,12 +293,6 @@ class CommonContext:
return self.slot in self.slot_info[slot].group_members return self.slot in self.slot_info[slot].group_members
return False return False
def is_uninteresting_item_send(self, print_json_packet: dict) -> bool:
"""Helper function for filtering out ItemSend prints that do not concern the local player."""
return print_json_packet.get("type", "") == "ItemSend" \
and not self.slot_concerns_self(print_json_packet["receiving"]) \
and not self.slot_concerns_self(print_json_packet["item"].player)
def on_print(self, args: dict): def on_print(self, args: dict):
logger.info(args["text"]) logger.info(args["text"])
@@ -378,7 +324,6 @@ class CommonContext:
async def shutdown(self): async def shutdown(self):
self.server_address = "" self.server_address = ""
self.username = None self.username = None
self.cancel_autoreconnect()
if self.server and not self.server.socket.closed: if self.server and not self.server.socket.closed:
await self.server.socket.close() await self.server.socket.close()
if self.server_task: if self.server_task:
@@ -404,8 +349,6 @@ class CommonContext:
cache_package = Utils.persistent_load().get("datapackage", {}).get("games", {}) cache_package = Utils.persistent_load().get("datapackage", {}).get("games", {})
needed_updates: typing.Set[str] = set() needed_updates: typing.Set[str] = set()
for game in relevant_games: for game in relevant_games:
if game not in remote_datepackage_versions:
continue
remote_version: int = remote_datepackage_versions[game] remote_version: int = remote_datepackage_versions[game]
if remote_version == 0: # custom datapackage for this game if remote_version == 0: # custom datapackage for this game
@@ -441,7 +384,7 @@ class CommonContext:
# DeathLink hooks # DeathLink hooks
def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None: def on_deathlink(self, data: dict):
"""Gets dispatched when a new DeathLink is triggered by another linked player.""" """Gets dispatched when a new DeathLink is triggered by another linked player."""
self.last_death_link = max(data["time"], self.last_death_link) self.last_death_link = max(data["time"], self.last_death_link)
text = data.get("cause", "") text = data.get("cause", "")
@@ -463,6 +406,15 @@ class CommonContext:
} }
}]) }])
async def update_show_all_hints(self, show_all_hints: bool):
old_tags = self.tags.copy()
if show_all_hints:
self.tags.add("ShowAllHints")
else:
self.tags -= {"ShowAllHints"}
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 update_death_link(self, death_link: bool): async def update_death_link(self, death_link: bool):
old_tags = self.tags.copy() old_tags = self.tags.copy()
if death_link: if death_link:
@@ -472,10 +424,10 @@ class CommonContext:
if old_tags != self.tags and self.server and not self.server.socket.closed: if old_tags != self.tags and self.server and not self.server.socket.closed:
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}]) await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Optional["kvui.MessageBox"]: def gui_error(self, title: str, text: typing.Union[Exception, str]):
"""Displays an error messagebox""" """Displays an error messagebox"""
if not self.ui: if not self.ui:
return None return
title = title or "Error" title = title or "Error"
from kvui import MessageBox from kvui import MessageBox
if self._messagebox: if self._messagebox:
@@ -492,13 +444,6 @@ class CommonContext:
# display error # display error
self._messagebox = MessageBox(title, text, error=True) self._messagebox = MessageBox(title, text, error=True)
self._messagebox.open() self._messagebox.open()
return self._messagebox
def _handle_connection_loss(self, msg: str) -> None:
"""Helper for logging and displaying a loss of connection. Must be called from an except block."""
exc_info = sys.exc_info()
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
def run_gui(self): def run_gui(self):
"""Import kivy UI system and start running it as self.ui_task.""" """Import kivy UI system and start running it as self.ui_task."""
@@ -535,7 +480,7 @@ async def keep_alive(ctx: CommonContext, seconds_between_checks=100):
seconds_elapsed = 0 seconds_elapsed = 0
async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None) -> None: async def server_loop(ctx: CommonContext, address=None):
if ctx.server and ctx.server.socket: if ctx.server and ctx.server.socket:
logger.error('Already connected') logger.error('Already connected')
return return
@@ -548,11 +493,6 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
logger.info('Please connect to an Archipelago server.') logger.info('Please connect to an Archipelago server.')
return return
ctx.cancel_autoreconnect()
if ctx._messagebox_connection_loss:
ctx._messagebox_connection_loss.dismiss()
ctx._messagebox_connection_loss = None
address = f"ws://{address}" if "://" not in address \ address = f"ws://{address}" if "://" not in address \
else address.replace("archipelago://", "ws://") else address.replace("archipelago://", "ws://")
@@ -563,37 +503,39 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
ctx.password = server_url.password ctx.password = server_url.password
port = server_url.port or 38281 port = server_url.port or 38281
def reconnect_hint() -> str:
return ", type /connect to reconnect" if ctx.server_address else ""
logger.info(f'Connecting to Archipelago server at {address}') logger.info(f'Connecting to Archipelago server at {address}')
try: try:
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None) socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
if ctx.ui is not None: ctx.ui.update_address_bar(server_url.netloc)
ctx.ui.update_address_bar(server_url.netloc)
ctx.server = Endpoint(socket) ctx.server = Endpoint(socket)
logger.info('Connected') logger.info('Connected')
ctx.server_address = address ctx.server_address = address
ctx.current_reconnect_delay = ctx.starting_reconnect_delay ctx.current_reconnect_delay = ctx.starting_reconnect_delay
ctx.disconnected_intentionally = False
async for data in ctx.server.socket: async for data in ctx.server.socket:
for msg in decode(data): for msg in decode(data):
await process_server_cmd(ctx, msg) await process_server_cmd(ctx, msg)
logger.warning(f"Disconnected from multiworld server{reconnect_hint()}") logger.warning('Disconnected from multiworld server, type /connect to reconnect')
except ConnectionRefusedError: except ConnectionRefusedError as e:
ctx._handle_connection_loss("Connection refused by the server. May not be running Archipelago on that address or port.") msg = 'Connection refused by the server. May not be running Archipelago on that address or port.'
except websockets.InvalidURI: logger.exception(msg, extra={'compact_gui': True})
ctx._handle_connection_loss("Failed to connect to the multiworld server (invalid URI)") ctx.gui_error(msg, e)
except OSError: except websockets.InvalidURI as e:
ctx._handle_connection_loss("Failed to connect to the multiworld server") msg = 'Failed to connect to the multiworld server (invalid URI)'
except Exception: logger.exception(msg, extra={'compact_gui': True})
ctx._handle_connection_loss(f"Lost connection to the multiworld server{reconnect_hint()}") ctx.gui_error(msg, e)
except OSError as e:
msg = 'Failed to connect to the multiworld server'
logger.exception(msg, extra={'compact_gui': True})
ctx.gui_error(msg, e)
except Exception as e:
msg = 'Lost connection to the multiworld server, type /connect to reconnect'
logger.exception(msg, extra={'compact_gui': True})
ctx.gui_error(msg, e)
finally: finally:
await ctx.connection_closed() await ctx.connection_closed()
if ctx.server_address and ctx.username and not ctx.disconnected_intentionally: if ctx.server_address:
logger.info(f"... automatically reconnecting in {ctx.current_reconnect_delay} seconds") logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s")
assert ctx.autoreconnect_task is None asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
ctx.autoreconnect_task = asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
ctx.current_reconnect_delay *= 2 ctx.current_reconnect_delay *= 2
@@ -633,21 +575,18 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
f" for each location checked. Use !hint for more information.") f" for each location checked. Use !hint for more information.")
ctx.hint_cost = int(args['hint_cost']) ctx.hint_cost = int(args['hint_cost'])
ctx.check_points = int(args['location_check_points']) ctx.check_points = int(args['location_check_points'])
players = args.get("players", [])
if "players" in args: # TODO remove when servers sending this are outdated if len(players) < 1:
players = args.get("players", []) logger.info('No player connected')
if len(players) < 1: else:
logger.info('No player connected') players.sort()
else: current_team = -1
players.sort() logger.info('Connected Players:')
current_team = -1 for network_player in players:
logger.info('Connected Players:') if network_player.team != current_team:
for network_player in players: logger.info(f' Team #{network_player.team + 1}')
if network_player.team != current_team: current_team = network_player.team
logger.info(f' Team #{network_player.team + 1}') logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
current_team = network_player.team
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
# update datapackage # update datapackage
await ctx.prepare_datapackage(set(args["games"]), args["datapackage_versions"]) await ctx.prepare_datapackage(set(args["games"]), args["datapackage_versions"])
@@ -702,10 +641,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
# when /missing is used for the client side view of what is missing. # when /missing is used for the client side view of what is missing.
ctx.missing_locations = set(args["missing_locations"]) ctx.missing_locations = set(args["missing_locations"])
ctx.checked_locations = set(args["checked_locations"]) ctx.checked_locations = set(args["checked_locations"])
ctx.server_locations = ctx.missing_locations | ctx. checked_locations
server_url = urllib.parse.urlparse(ctx.server_address)
Utils.persistent_store("client", "last_server_address", server_url.netloc)
elif cmd == 'ReceivedItems': elif cmd == 'ReceivedItems':
start_index = args["index"] start_index = args["index"]
@@ -785,7 +720,7 @@ async def console_loop(ctx: CommonContext):
logger.exception(e) logger.exception(e)
def get_base_parser(description: typing.Optional[str] = None): def get_base_parser(description=None):
import argparse import argparse
parser = argparse.ArgumentParser(description=description) parser = argparse.ArgumentParser(description=description)
parser.add_argument('--connect', default=None, help='Address of the multiworld host.') parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
@@ -799,10 +734,9 @@ if __name__ == '__main__':
# Text Mode to use !hint and such with games that have no text entry # Text Mode to use !hint and such with games that have no text entry
class TextContext(CommonContext): class TextContext(CommonContext):
tags = {"AP", "TextOnly"} tags = {"AP", "IgnoreGame", "TextOnly"}
game = "" # empty matches any game since 0.3.2 game = "" # empty matches any game since 0.3.2
items_handling = 0b111 # receive all items for /received items_handling = 0 # don't receive any NetworkItems
want_slot_data = False # Can't use game specific slot_data
async def server_auth(self, password_requested: bool = False): async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password: if password_requested and not self.password:
@@ -818,6 +752,7 @@ if __name__ == '__main__':
async def main(args): async def main(args):
ctx = TextContext(args.connect, args.password) ctx = TextContext(args.connect, args.password)
ctx.auth = args.name ctx.auth = args.name
ctx.server_address = args.connect
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
if gui_enabled: if gui_enabled:

View File

@@ -1,5 +1,4 @@
import asyncio import asyncio
import copy
import json import json
import time import time
from asyncio import StreamReader, StreamWriter from asyncio import StreamReader, StreamWriter
@@ -7,8 +6,7 @@ from typing import List
import Utils import Utils
from Utils import async_start from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, ClientCommandProcessor, logger, \
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
get_base_parser get_base_parser
SYSTEM_MESSAGE_ID = 0 SYSTEM_MESSAGE_ID = 0
@@ -66,37 +64,41 @@ class FF1Context(CommonContext):
def _set_message(self, msg: str, msg_id: int): def _set_message(self, msg: str, msg_id: int):
if DISPLAY_MSGS: if DISPLAY_MSGS:
self.messages[time.time(), msg_id] = msg self.messages[(time.time(), msg_id)] = msg
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
if cmd == 'Connected': if cmd == 'Connected':
async_start(parse_locations(self.locations_array, self, True)) asyncio.create_task(parse_locations(self.locations_array, self, True))
elif cmd == 'Print': elif cmd == 'Print':
msg = args['text'] msg = args['text']
if ': !' not in msg: if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID) self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "ReceivedItems":
def on_print_json(self, args: dict): msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
if self.ui: self._set_message(msg, SYSTEM_MESSAGE_ID)
self.ui.print_json(copy.deepcopy(args["data"])) elif cmd == 'PrintJSON':
else: print_type = args['type']
text = self.jsontotextparser(copy.deepcopy(args["data"])) item = args['item']
logger.info(text) receiving_player_id = args['receiving']
relevant = args.get("type", None) in {"Hint", "ItemSend"} receiving_player_name = self.player_names[receiving_player_id]
if relevant: sending_player_id = item.player
item = args["item"] sending_player_name = self.player_names[item.player]
# goes to this world if print_type == 'Hint':
if self.slot_concerns_self(args["receiving"]): msg = f"Hint: Your {self.item_names[item.item]} is at" \
relevant = True f" {self.player_names[item.player]}'s {self.location_names[item.location]}"
# found in this world self._set_message(msg, item.item)
elif self.slot_concerns_self(item.player): elif print_type == 'ItemSend' and receiving_player_id != self.slot:
relevant = True if sending_player_id == self.slot:
# not related if receiving_player_id == self.slot:
else: msg = f"You found your own {self.item_names[item.item]}"
relevant = False else:
if relevant: msg = f"You sent {self.item_names[item.item]} to {receiving_player_name}"
item = args["item"] else:
msg = self.raw_text_parser(copy.deepcopy(args["data"])) if receiving_player_id == sending_player_id:
msg = f"{sending_player_name} found their {self.item_names[item.item]}"
else:
msg = f"{sending_player_name} sent {self.item_names[item.item]} to " \
f"{receiving_player_name}"
self._set_message(msg, item.item) self._set_message(msg, item.item)
def run_gui(self): def run_gui(self):
@@ -181,7 +183,7 @@ async def nes_sync_task(ctx: FF1Context):
# print(data_decoded) # print(data_decoded)
if ctx.game is not None and 'locations' in data_decoded: if ctx.game is not None and 'locations' in data_decoded:
# Not just a keep alive ping, parse # Not just a keep alive ping, parse
async_start(parse_locations(data_decoded['locations'], ctx, False)) asyncio.create_task(parse_locations(data_decoded['locations'], ctx, False))
if not ctx.auth: if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0]) ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '': if ctx.auth == '':

View File

@@ -4,12 +4,9 @@ import logging
import json import json
import string import string
import copy import copy
import re
import subprocess import subprocess
import sys
import time import time
import random import random
import typing
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
@@ -20,18 +17,13 @@ import asyncio
from queue import Queue from queue import Queue
import Utils import Utils
def check_stdin() -> None:
if Utils.is_windows and sys.stdin:
print("WARNING: Console input is not routed reliably on Windows, use the GUI instead.")
if __name__ == "__main__": if __name__ == "__main__":
Utils.init_logging("FactorioClient", exception_logger="Client") Utils.init_logging("FactorioClient", exception_logger="Client")
check_stdin()
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger, gui_enabled, \
get_base_parser
from MultiServer import mark_raw from MultiServer import mark_raw
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
from Utils import async_start
from worlds.factorio import Factorio from worlds.factorio import Factorio
@@ -39,10 +31,6 @@ from worlds.factorio import Factorio
class FactorioCommandProcessor(ClientCommandProcessor): class FactorioCommandProcessor(ClientCommandProcessor):
ctx: FactorioContext ctx: FactorioContext
def _cmd_energy_link(self):
"""Print the status of the energy link."""
self.output(f"Energy Link: {self.ctx.energy_link_status}")
@mark_raw @mark_raw
def _cmd_factorio(self, text: str) -> bool: def _cmd_factorio(self, text: str) -> bool:
"""Send the following command to the bound Factorio Server.""" """Send the following command to the bound Factorio Server."""
@@ -59,13 +47,6 @@ class FactorioCommandProcessor(ClientCommandProcessor):
"""Manually trigger a resync.""" """Manually trigger a resync."""
self.ctx.awaiting_bridge = True self.ctx.awaiting_bridge = True
def _cmd_toggle_send_filter(self):
"""Toggle filtering of item sends that get displayed in-game to only those that involve you."""
self.ctx.toggle_filter_item_sends()
def _cmd_toggle_chat(self):
"""Toggle sending of chat messages from players on the Factorio server to Archipelago."""
self.ctx.toggle_bridge_chat_out()
class FactorioContext(CommonContext): class FactorioContext(CommonContext):
command_processor = FactorioCommandProcessor command_processor = FactorioCommandProcessor
@@ -85,9 +66,6 @@ class FactorioContext(CommonContext):
self.factorio_json_text_parser = FactorioJSONtoTextParser(self) self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
self.energy_link_increment = 0 self.energy_link_increment = 0
self.last_deplete = 0 self.last_deplete = 0
self.filter_item_sends: bool = False
self.multiplayer: bool = False # whether multiple different players have connected
self.bridge_chat_out: bool = True
async def server_auth(self, password_requested: bool = False): async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password: if password_requested and not self.password:
@@ -104,15 +82,12 @@ class FactorioContext(CommonContext):
def on_print(self, args: dict): def on_print(self, args: dict):
super(FactorioContext, self).on_print(args) super(FactorioContext, self).on_print(args)
if self.rcon_client: if self.rcon_client:
if not args['text'].startswith(self.player_names[self.slot] + ":"): self.print_to_game(args['text'])
self.print_to_game(args['text'])
def on_print_json(self, args: dict): def on_print_json(self, args: dict):
if self.rcon_client: if self.rcon_client:
if not self.filter_item_sends or not self.is_uninteresting_item_send(args): text = self.factorio_json_text_parser(copy.deepcopy(args["data"]))
text = self.factorio_json_text_parser(copy.deepcopy(args["data"])) self.print_to_game(text)
if not text.startswith(self.player_names[self.slot] + ":"):
self.print_to_game(text)
super(FactorioContext, self).on_print_json(args) super(FactorioContext, self).on_print_json(args)
@property @property
@@ -123,15 +98,6 @@ class FactorioContext(CommonContext):
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] " self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
f"{text}") f"{text}")
@property
def energy_link_status(self) -> str:
if not self.energy_link_increment:
return "Disabled"
elif self.current_energy_link_value is None:
return "Standby"
else:
return f"{Utils.format_SI_prefix(self.current_energy_link_value)}J"
def on_deathlink(self, data: dict): def on_deathlink(self, data: dict):
if self.rcon_client: if self.rcon_client:
self.rcon_client.send_command(f"/ap-deathlink {data['source']}") self.rcon_client.send_command(f"/ap-deathlink {data['source']}")
@@ -144,7 +110,7 @@ class FactorioContext(CommonContext):
self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for
item_name in args["checked_locations"]}) item_name in args["checked_locations"]})
if cmd == "Connected" and self.energy_link_increment: if cmd == "Connected" and self.energy_link_increment:
async_start(self.send_msgs([{ asyncio.create_task(self.send_msgs([{
"cmd": "SetNotify", "keys": ["EnergyLink"] "cmd": "SetNotify", "keys": ["EnergyLink"]
}])) }]))
elif cmd == "SetReply": elif cmd == "SetReply":
@@ -158,45 +124,6 @@ class FactorioContext(CommonContext):
f"{Utils.format_SI_prefix(args['value'])}J remaining.") f"{Utils.format_SI_prefix(args['value'])}J remaining.")
self.rcon_client.send_command(f"/ap-energylink {gained}") self.rcon_client.send_command(f"/ap-energylink {gained}")
def on_user_say(self, text: str) -> typing.Optional[str]:
# Mirror chat sent from the UI to the Factorio server.
self.print_to_game(f"{self.player_names[self.slot]}: {text}")
return text
async def chat_from_factorio(self, user: str, message: str) -> None:
if not self.bridge_chat_out:
return
# Pass through commands
if message.startswith("!"):
await self.send_msgs([{"cmd": "Say", "text": message}])
return
# Omit messages that contain local coordinates
if "[gps=" in message:
return
prefix = f"({user}) " if self.multiplayer else ""
await self.send_msgs([{"cmd": "Say", "text": f"{prefix}{message}"}])
def toggle_filter_item_sends(self) -> None:
self.filter_item_sends = not self.filter_item_sends
if self.filter_item_sends:
announcement = "Item sends are now filtered."
else:
announcement = "Item sends are no longer filtered."
logger.info(announcement)
self.print_to_game(announcement)
def toggle_bridge_chat_out(self) -> None:
self.bridge_chat_out = not self.bridge_chat_out
if self.bridge_chat_out:
announcement = "Chat is now bridged to Archipelago."
else:
announcement = "Chat is no longer bridged to Archipelago."
logger.info(announcement)
self.print_to_game(announcement)
def run_gui(self): def run_gui(self):
from kvui import GameManager from kvui import GameManager
@@ -214,6 +141,7 @@ class FactorioContext(CommonContext):
async def game_watcher(ctx: FactorioContext): async def game_watcher(ctx: FactorioContext):
bridge_logger = logging.getLogger("FactorioWatcher") bridge_logger = logging.getLogger("FactorioWatcher")
from worlds.factorio.Technologies import lookup_id_to_name
next_bridge = time.perf_counter() + 1 next_bridge = time.perf_counter() + 1
try: try:
while not ctx.exit_event.is_set(): while not ctx.exit_event.is_set():
@@ -235,7 +163,6 @@ async def game_watcher(ctx: FactorioContext):
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data} research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
victory = data["victory"] victory = data["victory"]
await ctx.update_death_link(data["death_link"]) await ctx.update_death_link(data["death_link"])
ctx.multiplayer = data.get("multiplayer", False)
if not ctx.finished_game and victory: if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
@@ -244,14 +171,14 @@ async def game_watcher(ctx: FactorioContext):
if ctx.locations_checked != research_data: if ctx.locations_checked != research_data:
bridge_logger.debug( bridge_logger.debug(
f"New researches done: " f"New researches done: "
f"{[ctx.location_names[rid] for rid in research_data - ctx.locations_checked]}") f"{[lookup_id_to_name[rid] for rid in research_data - ctx.locations_checked]}")
ctx.locations_checked = research_data ctx.locations_checked = research_data
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}]) await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
death_link_tick = data.get("death_link_tick", 0) death_link_tick = data.get("death_link_tick", 0)
if death_link_tick != ctx.death_link_tick: if death_link_tick != ctx.death_link_tick:
ctx.death_link_tick = death_link_tick ctx.death_link_tick = death_link_tick
if "DeathLink" in ctx.tags: if "DeathLink" in ctx.tags:
async_start(ctx.send_death()) asyncio.create_task(ctx.send_death())
if ctx.energy_link_increment: if ctx.energy_link_increment:
in_world_bridges = data["energy_bridges"] in_world_bridges = data["energy_bridges"]
if in_world_bridges: if in_world_bridges:
@@ -259,7 +186,7 @@ async def game_watcher(ctx: FactorioContext):
if in_world_energy < (ctx.energy_link_increment * in_world_bridges): if in_world_energy < (ctx.energy_link_increment * in_world_bridges):
# attempt to refill # attempt to refill
ctx.last_deplete = time.time() ctx.last_deplete = time.time()
async_start(ctx.send_msgs([{ asyncio.create_task(ctx.send_msgs([{
"cmd": "Set", "key": "EnergyLink", "operations": "cmd": "Set", "key": "EnergyLink", "operations":
[{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges}, [{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges},
{"operation": "max", "value": 0}], {"operation": "max", "value": 0}],
@@ -269,7 +196,7 @@ async def game_watcher(ctx: FactorioContext):
elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \ elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \
ctx.energy_link_increment*in_world_bridges: ctx.energy_link_increment*in_world_bridges:
value = ctx.energy_link_increment * in_world_bridges value = ctx.energy_link_increment * in_world_bridges
async_start(ctx.send_msgs([{ asyncio.create_task(ctx.send_msgs([{
"cmd": "Set", "key": "EnergyLink", "operations": "cmd": "Set", "key": "EnergyLink", "operations":
[{"operation": "add", "value": value}] [{"operation": "add", "value": value}]
}])) }]))
@@ -285,8 +212,6 @@ async def game_watcher(ctx: FactorioContext):
def stream_factorio_output(pipe, queue, process): def stream_factorio_output(pipe, queue, process):
pipe.reconfigure(errors="replace")
def queuer(): def queuer():
while process.poll() is None: while process.poll() is None:
text = pipe.readline().strip() text = pipe.readline().strip()
@@ -319,7 +244,7 @@ async def factorio_server_watcher(ctx: FactorioContext):
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process) stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
try: try:
while not ctx.exit_event.is_set(): while not ctx.exit_event.is_set():
if factorio_process.poll() is not None: if factorio_process.poll():
factorio_server_logger.info("Factorio server has exited.") factorio_server_logger.info("Factorio server has exited.")
ctx.exit_event.set() ctx.exit_event.set()
@@ -332,25 +257,12 @@ async def factorio_server_watcher(ctx: FactorioContext):
if not ctx.server: if not ctx.server:
logger.info("Established bridge to Factorio Server. " logger.info("Established bridge to Factorio Server. "
"Ready to connect to Archipelago via /connect") "Ready to connect to Archipelago via /connect")
check_stdin()
if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg: if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg:
ctx.awaiting_bridge = True ctx.awaiting_bridge = True
factorio_server_logger.debug(msg) factorio_server_logger.debug(msg)
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command energy-link$", msg):
factorio_server_logger.debug(msg)
ctx.print_to_game(f"Energy Link: {ctx.energy_link_status}")
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-send-filter$", msg):
factorio_server_logger.debug(msg)
ctx.toggle_filter_item_sends()
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-chat$", msg):
factorio_server_logger.debug(msg)
ctx.toggle_bridge_chat_out()
else: else:
factorio_server_logger.info(msg) factorio_server_logger.info(msg)
match = re.match(r"^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d \[CHAT\] ([^:]+): (.*)$", msg)
if match:
await ctx.chat_from_factorio(match.group(1), match.group(2))
if ctx.rcon_client: if ctx.rcon_client:
commands = {} commands = {}
while ctx.send_index < len(ctx.items_received): while ctx.send_index < len(ctx.items_received):
@@ -371,34 +283,12 @@ async def factorio_server_watcher(ctx: FactorioContext):
except Exception as e: except Exception as e:
logging.exception(e) logging.exception(e)
logging.error("Aborted Factorio Server Bridge") logging.error("Aborted Factorio Server Bridge")
ctx.rcon_client = None
ctx.exit_event.set() ctx.exit_event.set()
finally: finally:
if factorio_process.poll() is not None: factorio_process.terminate()
if ctx.rcon_client: factorio_process.wait(5)
ctx.rcon_client.close()
ctx.rcon_client = None
return
sent_quit = False
if ctx.rcon_client:
# Attempt clean quit through RCON.
try:
ctx.rcon_client.send_command("/quit")
except factorio_rcon.RCONNetworkError:
pass
else:
sent_quit = True
ctx.rcon_client.close()
ctx.rcon_client = None
if not sent_quit:
# Attempt clean quit using SIGTERM. (Note that on Windows this kills the process instead.)
factorio_process.terminate()
try:
factorio_process.wait(10)
except subprocess.TimeoutExpired:
factorio_process.kill()
async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient): async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient):
@@ -472,8 +362,6 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
async def main(args): async def main(args):
ctx = FactorioContext(args.connect, args.password) ctx = FactorioContext(args.connect, args.password)
ctx.filter_item_sends = initial_filter_item_sends
ctx.bridge_chat_out = initial_bridge_chat_out
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled: if gui_enabled:
@@ -512,7 +400,6 @@ if __name__ == '__main__':
"Refer to Factorio --help for those.") "Refer to Factorio --help for those.")
parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio') parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio')
parser.add_argument('--rcon-password', help='Password to authenticate with RCON.') parser.add_argument('--rcon-password', help='Password to authenticate with RCON.')
parser.add_argument('--server-settings', help='Factorio server settings configuration file.')
args, rest = parser.parse_known_args() args, rest = parser.parse_known_args()
colorama.init() colorama.init()
@@ -523,15 +410,6 @@ if __name__ == '__main__':
factorio_server_logger = logging.getLogger("FactorioServer") factorio_server_logger = logging.getLogger("FactorioServer")
options = Utils.get_options() options = Utils.get_options()
executable = options["factorio_options"]["executable"] executable = options["factorio_options"]["executable"]
server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None)
if server_settings:
server_settings = os.path.abspath(server_settings)
if not isinstance(options["factorio_options"]["filter_item_sends"], bool):
logging.warning(f"Warning: Option filter_item_sends should be a bool.")
initial_filter_item_sends = bool(options["factorio_options"]["filter_item_sends"])
if not isinstance(options["factorio_options"]["bridge_chat_out"], bool):
logging.warning(f"Warning: Option bridge_chat_out should be a bool.")
initial_bridge_chat_out = bool(options["factorio_options"]["bridge_chat_out"])
if not os.path.exists(os.path.dirname(executable)): if not os.path.exists(os.path.dirname(executable)):
raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.") raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.")
@@ -543,10 +421,7 @@ if __name__ == '__main__':
else: else:
raise FileNotFoundError(f"Path {executable} is not an executable file.") raise FileNotFoundError(f"Path {executable} is not an executable file.")
if server_settings and os.path.isfile(server_settings): server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, "--server-settings", server_settings, *rest)
else:
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
asyncio.run(main(args)) asyncio.run(main(args))
colorama.deinit() colorama.deinit()

490
Fill.py
View File

@@ -4,10 +4,9 @@ import collections
import itertools import itertools
from collections import Counter, deque from collections import Counter, deque
from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item, ItemClassification from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item
from worlds.AutoWorld import call_all from worlds.AutoWorld import call_all
from worlds.generic.Rules import add_item_rule
class FillError(RuntimeError): class FillError(RuntimeError):
@@ -23,9 +22,7 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location], def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False, itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False) -> None:
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
allow_partial: bool = False) -> None:
unplaced_items: typing.List[Item] = [] unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = [] placements: typing.List[Location] = []
@@ -45,16 +42,8 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
has_beaten_game = world.has_beaten_game(maximum_exploration_state) has_beaten_game = world.has_beaten_game(maximum_exploration_state)
while items_to_place: for item_to_place in items_to_place:
# if we have run out of locations to fill,break out of this loop
if not locations:
unplaced_items += items_to_place
break
item_to_place = items_to_place.pop(0)
spot_to_fill: typing.Optional[Location] = None spot_to_fill: typing.Optional[Location] = None
# if minimal accessibility, only check whether location is reachable if game not beatable
if world.accessibility[item_to_place.player] == 'minimal': if world.accessibility[item_to_place.player] == 'minimal':
perform_access_check = not world.has_beaten_game(maximum_exploration_state, perform_access_check = not world.has_beaten_game(maximum_exploration_state,
item_to_place.player) \ item_to_place.player) \
@@ -65,75 +54,69 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
for i, location in enumerate(locations): for i, location in enumerate(locations):
if (not single_player_placement or location.player == item_to_place.player) \ if (not single_player_placement or location.player == item_to_place.player) \
and location.can_fill(maximum_exploration_state, item_to_place, perform_access_check): and location.can_fill(maximum_exploration_state, item_to_place, perform_access_check):
# popping by index is faster than removing by content, # poping by index is faster than removing by content,
spot_to_fill = locations.pop(i) spot_to_fill = locations.pop(i)
# skipping a scan for the element # skipping a scan for the element
break break
else: else:
# we filled all reachable spots. # we filled all reachable spots.
if swap: # try swapping this item with previously placed items
# try swapping this item with previously placed items for (i, location) in enumerate(placements):
for (i, location) in enumerate(placements): placed_item = location.item
placed_item = location.item # Unplaceable items can sometimes be swapped infinitely. Limit the
# Unplaceable items can sometimes be swapped infinitely. Limit the # number of times we will swap an individual item to prevent this
# number of times we will swap an individual item to prevent this swap_count = swapped_items[placed_item.player,
swap_count = swapped_items[placed_item.player, placed_item.name]
placed_item.name] if swap_count > 1:
if swap_count > 1:
continue
location.item = None
placed_item.location = None
swap_state = sweep_from_pool(base_state, [placed_item])
# swap_state assumes we can collect placed item before item_to_place
if (not single_player_placement or location.player == item_to_place.player) \
and location.can_fill(swap_state, item_to_place, perform_access_check):
# Verify that placing this item won't reduce available locations, which could happen with rules
# that want to not have both items. Left in until removal is proven useful.
prev_state = swap_state.copy()
prev_loc_count = len(
world.get_reachable_locations(prev_state))
swap_state.collect(item_to_place, True)
new_loc_count = len(
world.get_reachable_locations(swap_state))
if new_loc_count >= prev_loc_count:
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
swap_count += 1
swapped_items[placed_item.player,
placed_item.name] = swap_count
reachable_items[placed_item.player].appendleft(
placed_item)
itempool.append(placed_item)
break
# Item can't be placed here, restore original item
location.item = placed_item
placed_item.location = location
if spot_to_fill is None:
# Can't place this item, move on to the next
unplaced_items.append(item_to_place)
continue continue
else:
location.item = None
placed_item.location = None
swap_state = sweep_from_pool(base_state)
if (not single_player_placement or location.player == item_to_place.player) \
and location.can_fill(swap_state, item_to_place, perform_access_check):
# Verify that placing this item won't reduce available locations
prev_state = swap_state.copy()
prev_state.collect(placed_item)
prev_loc_count = len(
world.get_reachable_locations(prev_state))
swap_state.collect(item_to_place, True)
new_loc_count = len(
world.get_reachable_locations(swap_state))
if new_loc_count >= prev_loc_count:
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
swap_count += 1
swapped_items[placed_item.player,
placed_item.name] = swap_count
reachable_items[placed_item.player].appendleft(
placed_item)
itempool.append(placed_item)
break
# Item can't be placed here, restore original item
location.item = placed_item
placed_item.location = location
if spot_to_fill is None:
# Can't place this item, move on to the next
unplaced_items.append(item_to_place) unplaced_items.append(item_to_place)
continue continue
world.push_item(spot_to_fill, item_to_place, False) world.push_item(spot_to_fill, item_to_place, False)
spot_to_fill.locked = lock spot_to_fill.locked = lock
placements.append(spot_to_fill) placements.append(spot_to_fill)
spot_to_fill.event = item_to_place.advancement spot_to_fill.event = item_to_place.advancement
if on_place:
on_place(spot_to_fill)
if not allow_partial and len(unplaced_items) > 0 and len(locations) > 0: if len(unplaced_items) > 0 and len(locations) > 0:
# There are leftover unplaceable items and locations that won't accept them # There are leftover unplaceable items and locations that won't accept them
if world.can_beat_game(): if world.can_beat_game():
logging.warning( logging.warning(
@@ -145,216 +128,33 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
itempool.extend(unplaced_items) itempool.extend(unplaced_items)
def remaining_fill(world: MultiWorld,
locations: typing.List[Location],
itempool: typing.List[Item]) -> None:
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
while locations and itempool:
item_to_place = itempool.pop()
spot_to_fill: typing.Optional[Location] = None
for i, location in enumerate(locations):
if location.item_rule(item_to_place):
# popping by index is faster than removing by content,
spot_to_fill = locations.pop(i)
# skipping a scan for the element
break
else:
# we filled all reachable spots.
# try swapping this item with previously placed items
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
if swapped_items[placed_item.player,
placed_item.name] > 1:
continue
location.item = None
placed_item.location = None
if location.item_rule(item_to_place):
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
swapped_items[placed_item.player,
placed_item.name] += 1
itempool.append(placed_item)
break
# Item can't be placed here, restore original item
location.item = placed_item
placed_item.location = location
if spot_to_fill is None:
# Can't place this item, move on to the next
unplaced_items.append(item_to_place)
continue
world.push_item(spot_to_fill, item_to_place, False)
placements.append(spot_to_fill)
if unplaced_items and locations:
# There are leftover unplaceable items and locations that won't accept them
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 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 accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]):
maximum_exploration_state = sweep_from_pool(state, pool)
minimal_players = {player for player in world.player_ids if world.accessibility[player] == "minimal"}
unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and
not location.can_reach(maximum_exploration_state)]
for location in unreachable_locations:
if (location.item is not None and location.item.advancement and location.address is not None and not
location.locked and location.item.player not in minimal_players):
pool.append(location.item)
state.remove(location.item)
location.item = None
location.event = False
if location in state.events:
state.events.remove(location)
locations.append(location)
if pool and locations:
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
fill_restrictive(world, state, locations, pool)
def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations):
maximum_exploration_state = sweep_from_pool(state)
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
if unreachable_locations:
def forbid_important_item_rule(item: Item):
return not ((item.classification & 0b0011) and world.accessibility[item.player] != 'minimal')
for location in unreachable_locations:
add_item_rule(location, forbid_important_item_rule)
def distribute_early_items(world: MultiWorld,
fill_locations: typing.List[Location],
itempool: typing.List[Item]) -> typing.Tuple[typing.List[Location], typing.List[Item]]:
""" returns new fill_locations and itempool """
early_items_count: typing.Dict[typing.Tuple[str, int], typing.List[int]] = {}
for player in world.player_ids:
items = itertools.chain(world.early_items[player], world.local_early_items[player])
for item in items:
early_items_count[item, player] = [world.early_items[player].get(item, 0),
world.local_early_items[player].get(item, 0)]
if early_items_count:
early_locations: typing.List[Location] = []
early_priority_locations: typing.List[Location] = []
loc_indexes_to_remove: typing.Set[int] = set()
base_state = world.state.copy()
base_state.sweep_for_events(locations=(loc for loc in world.get_filled_locations() if loc.address is None))
for i, loc in enumerate(fill_locations):
if loc.can_reach(base_state):
if loc.progress_type == LocationProgressType.PRIORITY:
early_priority_locations.append(loc)
else:
early_locations.append(loc)
loc_indexes_to_remove.add(i)
fill_locations = [loc for i, loc in enumerate(fill_locations) if i not in loc_indexes_to_remove]
early_prog_items: typing.List[Item] = []
early_rest_items: typing.List[Item] = []
early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
item_indexes_to_remove: typing.Set[int] = set()
for i, item in enumerate(itempool):
if (item.name, item.player) in early_items_count:
if item.advancement:
if early_items_count[item.name, item.player][1]:
early_local_prog_items[item.player].append(item)
early_items_count[item.name, item.player][1] -= 1
else:
early_prog_items.append(item)
early_items_count[item.name, item.player][0] -= 1
else:
if early_items_count[item.name, item.player][1]:
early_local_rest_items[item.player].append(item)
early_items_count[item.name, item.player][1] -= 1
else:
early_rest_items.append(item)
early_items_count[item.name, item.player][0] -= 1
item_indexes_to_remove.add(i)
if early_items_count[item.name, item.player] == [0, 0]:
del early_items_count[item.name, item.player]
if len(early_items_count) == 0:
break
itempool = [item for i, item in enumerate(itempool) if i not in item_indexes_to_remove]
for player in world.player_ids:
player_local = early_local_rest_items[player]
fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player],
player_local, lock=True, allow_partial=True)
if player_local:
logging.warning(f"Could not fulfill rules of early items: {player_local}")
early_rest_items.extend(early_local_rest_items[player])
early_locations = [loc for loc in early_locations if not loc.item]
fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True, allow_partial=True)
early_locations += early_priority_locations
for player in world.player_ids:
player_local = early_local_prog_items[player]
fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player],
player_local, lock=True, allow_partial=True)
if player_local:
logging.warning(f"Could not fulfill rules of early items: {player_local}")
early_prog_items.extend(player_local)
early_locations = [loc for loc in early_locations if not loc.item]
fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True, allow_partial=True)
unplaced_early_items = early_rest_items + early_prog_items
if unplaced_early_items:
logging.warning("Ran out of early locations for early items. Failed to place "
f"{unplaced_early_items} early.")
itempool += unplaced_early_items
fill_locations.extend(early_locations)
world.random.shuffle(fill_locations)
return fill_locations, itempool
def distribute_items_restrictive(world: MultiWorld) -> None: def distribute_items_restrictive(world: MultiWorld) -> None:
fill_locations = sorted(world.get_unfilled_locations()) fill_locations = sorted(world.get_unfilled_locations())
world.random.shuffle(fill_locations) world.random.shuffle(fill_locations)
# get items to distribute # get items to distribute
itempool = sorted(world.itempool) itempool = sorted(world.itempool)
world.random.shuffle(itempool) world.random.shuffle(itempool)
fill_locations, itempool = distribute_early_items(world, fill_locations, itempool)
progitempool: typing.List[Item] = [] progitempool: typing.List[Item] = []
usefulitempool: typing.List[Item] = [] nonexcludeditempool: typing.List[Item] = []
filleritempool: 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: for item in itempool:
if item.advancement: if item.advancement:
progitempool.append(item) progitempool.append(item)
elif item.useful: elif item.useful: # this only gets nonprogression items which should not appear in excluded locations
usefulitempool.append(item) nonexcludeditempool.append(item)
elif item.name in world.local_items[item.player].value:
localrestitempool[item.player].append(item)
elif item.name in world.non_local_items[item.player].value:
nonlocalrestitempool.append(item)
else: else:
filleritempool.append(item) restitempool.append(item)
call_all(world, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations) call_all(world, "fill_hook", progitempool, nonexcludeditempool,
localrestitempool, nonlocalrestitempool, restitempool, fill_locations)
locations: typing.Dict[LocationProgressType, typing.List[Location]] = { locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
loc_type: [] for loc_type in LocationProgressType} loc_type: [] for loc_type in LocationProgressType}
@@ -366,44 +166,60 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
defaultlocations = locations[LocationProgressType.DEFAULT] defaultlocations = locations[LocationProgressType.DEFAULT]
excludedlocations = locations[LocationProgressType.EXCLUDED] excludedlocations = locations[LocationProgressType.EXCLUDED]
# can't lock due to accessibility corrections touching things, so we remember which ones got placed and lock later fill_restrictive(world, world.state, prioritylocations, progitempool, lock=True)
lock_later = []
def mark_for_locking(location: Location):
nonlocal lock_later
lock_later.append(location)
if prioritylocations: if prioritylocations:
# "priority fill"
fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking)
accessibility_corrections(world, world.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations defaultlocations = prioritylocations + defaultlocations
if progitempool: if progitempool:
# "progression fill"
fill_restrictive(world, world.state, defaultlocations, progitempool) fill_restrictive(world, world.state, defaultlocations, progitempool)
if progitempool: if progitempool:
raise FillError( raise FillError(
f'Not enough locations for progress items. There are {len(progitempool)} more items than locations') f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
accessibility_corrections(world, world.state, defaultlocations)
for location in lock_later: if nonexcludeditempool:
if location.item: world.random.shuffle(defaultlocations)
location.locked = True # needs logical fill to not conflict with local items
del mark_for_locking, lock_later 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')
inaccessible_location_rules(world, world.state, defaultlocations) defaultlocations = defaultlocations + excludedlocations
world.random.shuffle(defaultlocations)
remaining_fill(world, excludedlocations, filleritempool) if any(localrestitempool.values()): # we need to make sure some fills are limited to certain worlds
if excludedlocations: local_locations: typing.Dict[int, typing.List[Location]] = {player: [] for player in world.player_ids}
raise FillError( for location in defaultlocations:
f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items") local_locations[location.player].append(location)
for player_locations in local_locations.values():
world.random.shuffle(player_locations)
restitempool = usefulitempool + filleritempool for player, items in localrestitempool.items(): # items already shuffled
player_local_locations = local_locations[player]
for item_to_place in items:
if not player_local_locations:
logging.warning(f"Ran out of local locations for player {player}, "
f"cannot place {item_to_place}.")
break
spot_to_fill = player_local_locations.pop()
world.push_item(spot_to_fill, item_to_place, False)
defaultlocations.remove(spot_to_fill)
remaining_fill(world, defaultlocations, restitempool) for item_to_place in nonlocalrestitempool:
for i, location in enumerate(defaultlocations):
if location.player != item_to_place.player:
world.push_item(defaultlocations.pop(i), item_to_place, False)
break
else:
logging.warning(
f"Could not place non_local_item {item_to_place} among {defaultlocations}, tossing.")
unplaced = restitempool world.random.shuffle(defaultlocations)
restitempool, defaultlocations = fast_fill(
world, restitempool, defaultlocations)
unplaced = progitempool + restitempool
unfilled = defaultlocations unfilled = defaultlocations
if unplaced or unfilled: if unplaced or unfilled:
@@ -417,6 +233,15 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
logging.info(f'Per-Player counts: {print_data})') logging.info(f'Per-Player counts: {print_data})')
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) -> None: def flood_items(world: MultiWorld) -> None:
# get items to distribute # get items to distribute
world.random.shuffle(world.itempool) world.random.shuffle(world.itempool)
@@ -693,17 +518,6 @@ def distribute_planned(world: MultiWorld) -> None:
else: else:
warn(warning, force) warn(warning, force)
swept_state = world.state.copy()
swept_state.sweep_for_events()
reachable = frozenset(world.get_reachable_locations(swept_state))
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
for loc in world.get_unfilled_locations():
if loc in reachable:
early_locations[loc.player].append(loc.name)
else: # not reachable with swept state
non_early_locations[loc.player].append(loc.name)
# TODO: remove. Preferably by implementing key drop # TODO: remove. Preferably by implementing key drop
from worlds.alttp.Regions import key_drop_data from worlds.alttp.Regions import key_drop_data
world_name_lookup = world.world_name_lookup world_name_lookup = world.world_name_lookup
@@ -719,39 +533,7 @@ def distribute_planned(world: MultiWorld) -> None:
if 'from_pool' not in block: if 'from_pool' not in block:
block['from_pool'] = True block['from_pool'] = True
if 'world' not in block: if 'world' not in block:
target_world = False block['world'] = False
else:
target_world = block['world']
if target_world is False or world.players == 1: # target own world
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 = 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.",
block['force'])
continue
worlds.add(world_name_lookup[listed_world])
elif type(target_world) == int: # target world by slot number
if target_world not in range(1, world.players + 1):
failed(
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
block['force'])
continue
worlds = {target_world}
else: # target world by slot name
if target_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
block['force'])
continue
worlds = {world_name_lookup[target_world]}
block['world'] = worlds
items: block_value = [] items: block_value = []
if "items" in block: if "items" in block:
items = block["items"] items = block["items"]
@@ -788,17 +570,6 @@ def distribute_planned(world: MultiWorld) -> None:
for key, value in locations.items(): for key, value in locations.items():
location_list += [key] * value location_list += [key] * value
locations = location_list locations = location_list
if "early_locations" in locations:
locations.remove("early_locations")
for player in worlds:
locations += early_locations[player]
if "non_early_locations" in locations:
locations.remove("non_early_locations")
for player in worlds:
locations += non_early_locations[player]
block['locations'] = locations block['locations'] = locations
if not block['count']: if not block['count']:
@@ -834,11 +605,38 @@ def distribute_planned(world: MultiWorld) -> None:
for placement in plando_blocks: for placement in plando_blocks:
player = placement['player'] player = placement['player']
try: try:
worlds = placement['world'] target_world = placement['world']
locations = placement['locations'] locations = placement['locations']
items = placement['items'] items = placement['items']
maxcount = placement['count']['target'] maxcount = placement['count']['target']
from_pool = placement['from_pool'] from_pool = placement['from_pool']
if target_world is False or world.players == 1: # target own world
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 = 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.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(
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
placement['force'])
continue
worlds = {target_world}
else: # target world by slot name
if target_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
placement['force'])
continue
worlds = {world_name_lookup[target_world]}
candidates = list(location for location in world.get_unfilled_locations_for_players(locations, candidates = list(location for location in world.get_unfilled_locations_for_players(locations,
worlds)) worlds))

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import argparse import argparse
import logging import logging
import random import random
@@ -7,9 +5,8 @@ import urllib.request
import urllib.parse import urllib.parse
from typing import Set, Dict, Tuple, Callable, Any, Union from typing import Set, Dict, Tuple, Callable, Any, Union
import os import os
from collections import Counter, ChainMap from collections import Counter
import string import string
import enum
import ModuleUpdate import ModuleUpdate
@@ -23,47 +20,12 @@ from worlds.alttp.EntranceRandomizer import parse_arguments
from Main import main as ERmain from Main import main as ERmain
from BaseClasses import seeddigits, get_seed from BaseClasses import seeddigits, get_seed
import Options import Options
from worlds.alttp import Bosses
from worlds.alttp.Text import TextTable from worlds.alttp.Text import TextTable
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
import copy import copy
categories = set(AutoWorldRegister.world_types)
class PlandoSettings(enum.IntFlag):
items = 0b0001
connections = 0b0010
texts = 0b0100
bosses = 0b1000
@classmethod
def from_option_string(cls, option_string: str) -> PlandoSettings:
result = cls(0)
for part in option_string.split(","):
part = part.strip().lower()
if part:
result = cls._handle_part(part, result)
return result
@classmethod
def from_set(cls, option_set: Set[str]) -> PlandoSettings:
result = cls(0)
for part in option_set:
result = cls._handle_part(part, result)
return result
@classmethod
def _handle_part(cls, part: str, base: PlandoSettings) -> PlandoSettings:
try:
part = cls[part]
except Exception as e:
raise KeyError(f"{part} is not a recognized name for a plando module. "
f"Known options: {', '.join(flag.name for flag in cls)}") from e
else:
return base | part
def __str__(self) -> str:
if self.value:
return ", ".join(flag.name for flag in PlandoSettings if self.value & flag.value)
return "Off"
def mystery_argparse(): def mystery_argparse():
@@ -83,6 +45,11 @@ def mystery_argparse():
parser.add_argument('--seed', help='Define seed number to generate.', type=int) 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('--multi', default=defaults["players"], type=lambda value: max(int(value), 1))
parser.add_argument('--spoiler', type=int, default=defaults["spoiler"]) 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.") # 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), 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 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('--race', action='store_true', default=defaults["race"])
@@ -97,7 +64,7 @@ def mystery_argparse():
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path) args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
if not os.path.isabs(args.meta_file_path): if not os.path.isabs(args.meta_file_path):
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path) args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
args.plando: PlandoSettings = PlandoSettings.from_option_string(args.plando) args.plando: Set[str] = {arg.strip().lower() for arg in args.plando.split(",")}
return args, options return args, options
@@ -127,14 +94,12 @@ def main(args=None, callback=ERmain):
if args.meta_file_path and os.path.exists(args.meta_file_path): if args.meta_file_path and os.path.exists(args.meta_file_path):
try: try:
meta_weights = read_weights_yamls(args.meta_file_path)[-1] weights_cache[args.meta_file_path] = read_weights_yamls(args.meta_file_path)
except Exception as e: except Exception as e:
raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
meta_weights = weights_cache[args.meta_file_path][-1]
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}") print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
try: # meta description allows us to verify that the file named meta.yaml is intentionally a meta file del(meta_weights["meta_description"])
del(meta_weights["meta_description"])
except Exception as e:
raise ValueError("No meta description found for meta.yaml. Unable to verify.") from e
if args.samesettings: if args.samesettings:
raise Exception("Cannot mix --samesettings with --meta") raise Exception("Cannot mix --samesettings with --meta")
else: else:
@@ -154,16 +119,15 @@ def main(args=None, callback=ERmain):
# sort dict for consistent results across platforms: # sort dict for consistent results across platforms:
weights_cache = {key: value for key, value in sorted(weights_cache.items())} weights_cache = {key: value for key, value in sorted(weights_cache.items())}
for filename, yaml_data in weights_cache.items(): for filename, yaml_data in weights_cache.items():
if filename not in {args.meta_file_path, args.weights_file_path}: for yaml in yaml_data:
for yaml in yaml_data: print(f"P{player_id} Weights: {filename} >> "
print(f"P{player_id} Weights: {filename} >> " f"{get_choice('description', yaml, 'No description specified')}")
f"{get_choice('description', yaml, 'No description specified')}") player_files[player_id] = filename
player_files[player_id] = filename player_id += 1
player_id += 1
args.multi = max(player_id - 1, args.multi) args.multi = max(player_id-1, args.multi)
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: " print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: "
f"{args.plando}") f"{', '.join(args.plando)}")
if not weights_cache: if not weights_cache:
raise Exception(f"No weights found. Provide a general weights file ({args.weights_file_path}) or individual player files. " raise Exception(f"No weights found. Provide a general weights file ({args.weights_file_path}) or individual player files. "
@@ -178,29 +142,31 @@ def main(args=None, callback=ERmain):
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level) Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
erargs.lttp_rom = args.lttp_rom
erargs.sm_rom = args.sm_rom
erargs.enemizercli = args.enemizercli
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \ settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None) {fname: ( tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
for fname, yamls in weights_cache.items()} for fname, yamls in weights_cache.items()}
player_path_cache = {}
for player in range(1, args.multi + 1):
player_path_cache[player] = player_files.get(player, args.weights_file_path)
if meta_weights: if meta_weights:
for category_name, category_dict in meta_weights.items(): for category_name, category_dict in meta_weights.items():
for key in category_dict: for key in category_dict:
option = roll_meta_option(key, category_name, category_dict) option = get_choice(key, category_dict)
if option is not None: if option is not None:
for path in weights_cache: for player, path in player_path_cache.items():
for yaml in weights_cache[path]: for yaml in weights_cache[path]:
if category_name is None: if category_name is None:
for category in yaml: yaml[key] = option
if category in AutoWorldRegister.world_types and key in Options.common_options:
yaml[category][key] = option
elif category_name not in yaml: elif category_name not in yaml:
logging.warning(f"Meta: Category {category_name} is not present in {path}.") logging.warning(f"Meta: Category {category_name} is not present in {path}.")
else: else:
yaml[category_name][key] = option yaml[category_name][key] = option
player_path_cache = {}
for player in range(1, args.multi + 1):
player_path_cache[player] = player_files.get(player, args.weights_file_path)
name_counter = Counter() name_counter = Counter()
erargs.player_settings = {} erargs.player_settings = {}
@@ -233,8 +199,8 @@ def main(args=None, callback=ERmain):
else: else:
raise RuntimeError(f'No weights specified for player {player}') raise RuntimeError(f'No weights specified for player {player}')
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name): if len(set(erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}") raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
if args.yaml_output: if args.yaml_output:
import yaml import yaml
@@ -317,11 +283,11 @@ class SafeDict(dict):
def handle_name(name: str, player: int, name_counter: Counter): def handle_name(name: str, player: int, name_counter: Counter):
name_counter[name.lower()] += 1 name_counter[name] += 1
number = name_counter[name.lower()]
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")]) new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number, new_name = string.Formatter().vformat(new_name, (), SafeDict(number=name_counter[name],
NUMBER=(number if number > 1 else ''), NUMBER=(name_counter[name] if name_counter[
name] > 1 else ''),
player=player, player=player,
PLAYER=(player if player > 1 else ''))) PLAYER=(player if player > 1 else '')))
new_name = new_name.strip()[:16] new_name = new_name.strip()[:16]
@@ -337,6 +303,19 @@ def prefer_int(input_data: str) -> Union[str, int]:
return input_data return input_data
available_boss_names: Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
{'Agahnim', 'Agahnim2', 'Ganon'}}
available_boss_locations: Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in
Bosses.boss_location_table}
boss_shuffle_options = {None: 'none',
'none': 'none',
'basic': 'basic',
'full': 'full',
'chaos': 'chaos',
'singularity': 'singularity'
}
goals = { goals = {
'ganon': 'ganon', 'ganon': 'ganon',
'crystals': 'crystals', 'crystals': 'crystals',
@@ -369,28 +348,6 @@ def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> di
return weights return weights
def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
if not game:
return get_choice(option_key, category_dict)
if game in AutoWorldRegister.world_types:
game_world = AutoWorldRegister.world_types[game]
options = ChainMap(game_world.option_definitions, Options.per_game_common_options)
if option_key in options:
if options[option_key].supports_weighting:
return get_choice(option_key, category_dict)
return category_dict[option_key]
if game == "A Link to the Past": # TODO wow i hate this
if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode",
"triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra",
"triforce_pieces_required", "shop_shuffle", "mode", "item_pool", "item_functionality",
"boss_shuffle", "enemy_damage", "enemy_health", "timer", "countdown_start_time",
"red_clock_time", "blue_clock_time", "green_clock_time", "dungeon_counters", "shuffle_prizes",
"misery_mire_medallion", "turtle_rock_medallion", "sprite_pool", "sprite",
"random_sprite_on_event"}:
return get_choice(option_key, category_dict)
raise Exception(f"Error generating meta option {option_key} for {game}.")
def roll_linked_options(weights: dict) -> dict: def roll_linked_options(weights: dict) -> dict:
weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings
for option_set in weights["linked_options"]: for option_set in weights["linked_options"]:
@@ -443,7 +400,42 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
return weights return weights
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoSettings): def get_plando_bosses(boss_shuffle: str, plando_options: Set[str]) -> str:
if boss_shuffle in boss_shuffle_options:
return boss_shuffle_options[boss_shuffle]
elif "bosses" in plando_options:
options = boss_shuffle.lower().split(";")
remainder_shuffle = "none" # vanilla
bosses = []
for boss in options:
if boss in boss_shuffle_options:
remainder_shuffle = boss_shuffle_options[boss]
elif "-" in boss:
loc, boss_name = boss.split("-")
if boss_name not in available_boss_names:
raise ValueError(f"Unknown Boss name {boss_name}")
if loc not in available_boss_locations:
raise ValueError(f"Unknown Boss Location {loc}")
level = ''
if loc.split(" ")[-1] in {"top", "middle", "bottom"}:
# split off level
loc = loc.split(" ")
level = f" {loc[-1]}"
loc = " ".join(loc[:-1])
loc = loc.title().replace("Of", "of")
if not Bosses.can_place_boss(boss_name.title(), loc, level):
raise ValueError(f"Cannot place {boss_name} at {loc}{level}")
bosses.append(boss)
elif boss not in available_boss_names:
raise ValueError(f"Unknown Boss name or Boss shuffle option {boss}.")
else:
bosses.append(boss)
return ";".join(bosses + [remainder_shuffle])
else:
raise Exception(f"Boss Shuffle {boss_shuffle} is unknown and boss plando is turned off.")
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option)):
if option_key in game_weights: if option_key in game_weights:
try: try:
if not option.supports_weighting: if not option.supports_weighting:
@@ -454,12 +446,13 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
except Exception as e: except Exception as e:
raise Exception(f"Error generating option {option_key} in {ret.game}") from e raise Exception(f"Error generating option {option_key} in {ret.game}") from e
else: else:
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options) if hasattr(player_option, "verify"):
player_option.verify(AutoWorldRegister.world_types[ret.game])
else: else:
setattr(ret, option_key, option.from_any(option.default)) # call the from_any here to support default "random" setattr(ret, option_key, option(option.default))
def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings.bosses): def roll_settings(weights: dict, plando_options: Set[str] = frozenset(("bosses",))):
if "linked_options" in weights: if "linked_options" in weights:
weights = roll_linked_options(weights) weights = roll_linked_options(weights)
@@ -472,11 +465,17 @@ def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings
if tuplize_version(version) > version_tuple: if tuplize_version(version) > version_tuple:
raise Exception(f"Settings reports required version of generator is at least {version}, " raise Exception(f"Settings reports required version of generator is at least {version}, "
f"however generator is of version {__version__}") f"however generator is of version {__version__}")
required_plando_options = PlandoSettings.from_option_string(requirements.get("plando", "")) required_plando_options = requirements.get("plando", "")
if required_plando_options not in plando_options: if required_plando_options:
required_plando_options = set(option.strip() for option in required_plando_options.split(","))
required_plando_options -= plando_options
if required_plando_options: if required_plando_options:
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, " if len(required_plando_options) == 1:
f"which is not enabled.") raise Exception(f"Settings reports required plando module {', '.join(required_plando_options)}, "
f"which is not enabled.")
else:
raise Exception(f"Settings reports required plando modules {', '.join(required_plando_options)}, "
f"which are not enabled.")
ret = argparse.Namespace() ret = argparse.Namespace()
for option_key in Options.per_game_common_options: for option_key in Options.per_game_common_options:
@@ -499,19 +498,18 @@ def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default))) setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
if ret.game in AutoWorldRegister.world_types: if ret.game in AutoWorldRegister.world_types:
for option_key, option in world_type.option_definitions.items(): for option_key, option in world_type.options.items():
handle_option(ret, game_weights, option_key, option, plando_options) handle_option(ret, game_weights, option_key, option)
for option_key, option in Options.per_game_common_options.items(): for option_key, option in Options.per_game_common_options.items():
# skip setting this option if already set from common_options, defaulting to root option # skip setting this option if already set from common_options, defaulting to root option
if option_key not in world_type.option_definitions and \ if not (option_key in Options.common_options and option_key not in game_weights):
(option_key not in Options.common_options or option_key in game_weights): handle_option(ret, game_weights, option_key, option)
handle_option(ret, game_weights, option_key, option, plando_options) if "items" in plando_options:
if PlandoSettings.items in plando_options:
ret.plando_items = game_weights.get("plando_items", []) ret.plando_items = game_weights.get("plando_items", [])
if ret.game == "Minecraft" or ret.game == "Ocarina of Time": if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
# bad hardcoded behavior to make this work for now # bad hardcoded behavior to make this work for now
ret.plando_connections = [] ret.plando_connections = []
if PlandoSettings.connections in plando_options: if "connections" in plando_options:
options = game_weights.get("plando_connections", []) options = game_weights.get("plando_connections", [])
for placement in options: for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)): if roll_percentage(get_choice("percentage", placement, 100)):
@@ -557,6 +555,9 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
ret.goal = goals[goal] ret.goal = goals[goal]
# TODO consider moving open_pyramid to an automatic variable in the core roller, set to True when
# fast ganon + ganon at hole
ret.open_pyramid = get_choice_legacy('open_pyramid', weights, 'goal')
extra_pieces = get_choice_legacy('triforce_pieces_mode', weights, 'available') extra_pieces = get_choice_legacy('triforce_pieces_mode', weights, 'available')
@@ -588,6 +589,8 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
ret.item_functionality = get_choice_legacy('item_functionality', weights) ret.item_functionality = get_choice_legacy('item_functionality', weights)
boss_shuffle = get_choice_legacy('boss_shuffle', weights)
ret.shufflebosses = get_plando_bosses(boss_shuffle, plando_options)
ret.enemy_damage = {None: 'default', ret.enemy_damage = {None: 'default',
'default': 'default', 'default': 'default',
@@ -626,7 +629,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}") raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
ret.plando_texts = {} ret.plando_texts = {}
if PlandoSettings.texts in plando_options: if "texts" in plando_options:
tt = TextTable() tt = TextTable()
tt.removeUnwantedText() tt.removeUnwantedText()
options = weights.get("plando_texts", []) options = weights.get("plando_texts", [])
@@ -638,7 +641,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
ret.plando_texts[at] = str(get_choice_legacy("text", placement)) ret.plando_texts[at] = str(get_choice_legacy("text", placement))
ret.plando_connections = [] ret.plando_connections = []
if PlandoSettings.connections in plando_options: if "connections" in plando_options:
options = weights.get("plando_connections", []) options = weights.get("plando_connections", [])
for placement in options: for placement in options:
if roll_percentage(get_choice_legacy("percentage", placement, 100)): if roll_percentage(get_choice_legacy("percentage", placement, 100)):

View File

@@ -10,21 +10,16 @@ Scroll down to components= to add components to the launcher as well as setup.py
import argparse import argparse
import itertools
import shlex
import subprocess
import sys
from enum import Enum, auto
from os.path import isfile from os.path import isfile
from shutil import which import sys
from typing import Iterable, Sequence, Callable, Union, Optional from typing import Iterable, Sequence, Callable, Union, Optional
import subprocess
if __name__ == "__main__": import itertools
import ModuleUpdate from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox,\
ModuleUpdate.update()
from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox, \
is_windows, is_macos, is_linux is_windows, is_macos, is_linux
from shutil import which
import shlex
from enum import Enum, auto
def open_host_yaml(): def open_host_yaml():
@@ -70,7 +65,6 @@ def browse_files():
webbrowser.open(file) webbrowser.open(file)
# noinspection PyArgumentList
class Type(Enum): class Type(Enum):
TOOL = auto() TOOL = auto()
FUNC = auto() # not a real component FUNC = auto() # not a real component
@@ -132,7 +126,7 @@ components: Iterable[Component] = (
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'), Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
# SNI # SNI
Component('SNI Client', 'SNIClient', Component('SNI Client', 'SNIClient',
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3', '.apsmw')), file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3')),
Component('LttP Adjuster', 'LttPAdjuster'), Component('LttP Adjuster', 'LttPAdjuster'),
# Factorio # Factorio
Component('Factorio Client', 'FactorioClient'), Component('Factorio Client', 'FactorioClient'),
@@ -145,15 +139,10 @@ components: Iterable[Component] = (
Component('OoT Adjuster', 'OoTAdjuster'), Component('OoT Adjuster', 'OoTAdjuster'),
# FF1 # FF1
Component('FF1 Client', 'FF1Client'), Component('FF1 Client', 'FF1Client'),
# Pokémon
Component('Pokemon Client', 'PokemonClient', file_identifier=SuffixIdentifier('.apred', '.apblue')),
# ChecksFinder # ChecksFinder
Component('ChecksFinder Client', 'ChecksFinderClient'), Component('ChecksFinder Client', 'ChecksFinderClient'),
# Starcraft 2 # Starcraft 2
Component('Starcraft 2 Client', 'Starcraft2Client'), Component('Starcraft 2 Client', 'Starcraft2Client'),
# Zillion
Component('Zillion Client', 'ZillionClient',
file_identifier=SuffixIdentifier('.apzl')),
# Functions # Functions
Component('Open host.yaml', func=open_host_yaml), Component('Open host.yaml', func=open_host_yaml),
Component('Open Patch', func=open_patch), Component('Open Patch', func=open_patch),

View File

@@ -26,9 +26,7 @@ ModuleUpdate.update()
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \ from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
get_adjuster_settings, tkinter_center_window, init_logging get_adjuster_settings, tkinter_center_window, init_logging
from Patch import GAME_ALTTP
GAME_ALTTP = "A Link to the Past"
class AdjusterWorld(object): class AdjusterWorld(object):
@@ -85,9 +83,9 @@ def main():
parser.add_argument('--ow_palettes', default='default', parser.add_argument('--ow_palettes', default='default',
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy', choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
'sick']) 'sick'])
# parser.add_argument('--link_palettes', default='default', parser.add_argument('--link_palettes', default='default',
# choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy', choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
# 'sick']) 'sick'])
parser.add_argument('--shield_palettes', default='default', parser.add_argument('--shield_palettes', default='default',
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy', choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
'sick']) 'sick'])
@@ -141,7 +139,7 @@ def adjust(args):
vanillaRom = args.baserom vanillaRom = args.baserom
if not os.path.exists(vanillaRom) and not os.path.isabs(vanillaRom): if not os.path.exists(vanillaRom) and not os.path.isabs(vanillaRom):
vanillaRom = local_path(vanillaRom) vanillaRom = local_path(vanillaRom)
if os.path.splitext(args.rom)[-1].lower() == '.aplttp': if os.path.splitext(args.rom)[-1].lower() in {'.apbp', '.aplttp'}:
import Patch import Patch
meta, args.rom = Patch.create_rom_file(args.rom) meta, args.rom = Patch.create_rom_file(args.rom)
@@ -197,7 +195,7 @@ def adjustGUI():
romEntry2 = Entry(romDialogFrame, textvariable=romVar2) romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
def RomSelect2(): def RomSelect2():
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc", ".aplttp")), ("All Files", "*")]) rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc", ".apbp")), ("All Files", "*")])
romVar2.set(rom) romVar2.set(rom)
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2) romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
@@ -291,7 +289,7 @@ def run_sprite_update():
else: else:
top.withdraw() top.withdraw()
task = BackgroundTaskProgress(top, update_sprites, "Updating Sprites", lambda succesful, resultmessage: done.set()) task = BackgroundTaskProgress(top, update_sprites, "Updating Sprites", lambda succesful, resultmessage: done.set())
while not done.is_set(): while not done.isSet():
task.do_events() task.do_events()
logging.info("Done updating sprites") logging.info("Done updating sprites")
@@ -302,7 +300,6 @@ def update_sprites(task, on_finish=None):
sprite_dir = user_path("data", "sprites", "alttpr") sprite_dir = user_path("data", "sprites", "alttpr")
os.makedirs(sprite_dir, exist_ok=True) os.makedirs(sprite_dir, exist_ok=True)
ctx = get_cert_none_ssl_context() ctx = get_cert_none_ssl_context()
def finished(): def finished():
task.close_window() task.close_window()
if on_finish: if on_finish:
@@ -727,7 +724,7 @@ def get_rom_options_frame(parent=None):
vars.auto_apply = StringVar(value=adjuster_settings.auto_apply) vars.auto_apply = StringVar(value=adjuster_settings.auto_apply)
autoApplyFrame = Frame(romOptionsFrame) autoApplyFrame = Frame(romOptionsFrame)
autoApplyFrame.grid(row=9, column=0, columnspan=2, sticky=W) autoApplyFrame.grid(row=9, column=0, columnspan=2, sticky=W)
filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .aplttp files") filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .apbp files")
filler.pack(side=TOP, expand=True, fill=X) filler.pack(side=TOP, expand=True, fill=X)
askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask') askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask')
askRadio.pack(side=LEFT, padx=5, pady=5) askRadio.pack(side=LEFT, padx=5, pady=5)
@@ -754,7 +751,6 @@ class SpriteSelector():
self.window['pady'] = 5 self.window['pady'] = 5
self.spritesPerRow = 32 self.spritesPerRow = 32
self.all_sprites = [] self.all_sprites = []
self.invalid_sprites = []
self.sprite_pool = spritePool self.sprite_pool = spritePool
def open_custom_sprite_dir(_evt): def open_custom_sprite_dir(_evt):
@@ -836,13 +832,6 @@ class SpriteSelector():
self.window.focus() self.window.focus()
tkinter_center_window(self.window) tkinter_center_window(self.window)
if self.invalid_sprites:
invalid = sorted(self.invalid_sprites)
logging.warning(f"The following sprites are invalid: {', '.join(invalid)}")
msg = f"{invalid[0]} "
msg += f"and {len(invalid)-1} more are invalid" if len(invalid) > 1 else "is invalid"
messagebox.showerror("Invalid sprites detected", msg, parent=self.window)
def remove_from_sprite_pool(self, button, spritename): def remove_from_sprite_pool(self, button, spritename):
self.callback(("remove", spritename)) self.callback(("remove", spritename))
self.spritePoolButtons.buttons.remove(button) self.spritePoolButtons.buttons.remove(button)
@@ -907,13 +896,7 @@ class SpriteSelector():
sprites = [] sprites = []
for file in os.listdir(path): for file in os.listdir(path):
if file == '.gitignore': sprites.append((file, Sprite(os.path.join(path, file))))
continue
sprite = Sprite(os.path.join(path, file))
if sprite.valid:
sprites.append((file, sprite))
else:
self.invalid_sprites.append(file)
sprites.sort(key=lambda s: str.lower(s[1].name or "").strip()) sprites.sort(key=lambda s: str.lower(s[1].name or "").strip())

302
Main.py
View File

@@ -1,4 +1,5 @@
import collections import collections
from itertools import zip_longest, chain
import logging import logging
import os import os
import time import time
@@ -7,15 +8,15 @@ import concurrent.futures
import pickle import pickle
import tempfile import tempfile
import zipfile import zipfile
from typing import Dict, List, Tuple, Optional, Set from typing import Dict, Tuple, Optional, Set
from BaseClasses import Item, MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location from BaseClasses import MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location
import worlds from worlds.alttp.Items import item_name_groups
from worlds.alttp.Regions import is_main_entrance from worlds.alttp.Regions import lookup_vanilla_location_to_entrance
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
from Utils import output_path, get_options, __version__, version_tuple from Utils import output_path, get_options, __version__, version_tuple
from worlds.generic.Rules import locality_rules, exclusion_rules from worlds.generic.Rules import locality_rules, exclusion_rules, group_locality_rules
from worlds import AutoWorld from worlds import AutoWorld
ordered_areas = ( ordered_areas = (
@@ -46,6 +47,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.item_functionality = args.item_functionality.copy() world.item_functionality = args.item_functionality.copy()
world.timer = args.timer.copy() world.timer = args.timer.copy()
world.goal = args.goal.copy() world.goal = args.goal.copy()
world.open_pyramid = args.open_pyramid.copy()
world.boss_shuffle = args.shufflebosses.copy() world.boss_shuffle = args.shufflebosses.copy()
world.enemy_health = args.enemy_health.copy() world.enemy_health = args.enemy_health.copy()
world.enemy_damage = args.enemy_damage.copy() world.enemy_damage = args.enemy_damage.copy()
@@ -69,6 +71,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.required_medallions = args.required_medallions.copy() world.required_medallions = args.required_medallions.copy()
world.game = args.game.copy() world.game = args.game.copy()
world.player_name = args.name.copy() world.player_name = args.name.copy()
world.enemizer = args.enemizercli
world.sprite = args.sprite.copy() world.sprite = args.sprite.copy()
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option. world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
@@ -79,30 +82,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.info("Found World Types:") logger.info("Found World Types:")
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types) longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
numlength = 8
max_item = 0
max_location = 0
for cls in AutoWorld.AutoWorldRegister.world_types.values():
if cls.item_id_to_name:
max_item = max(max_item, max(cls.item_id_to_name))
max_location = max(max_location, max(cls.location_id_to_name))
item_digits = len(str(max_item))
location_digits = len(str(max_location))
item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
del max_item, max_location
for name, cls in AutoWorld.AutoWorldRegister.world_types.items(): for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
if not cls.hidden and len(cls.item_names) > 0: if not cls.hidden:
logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} " logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} "
f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - " f"Items (IDs: {min(cls.item_id_to_name):{numlength}} - "
f"{max(cls.item_id_to_name):{item_digits}}) | " f"{max(cls.item_id_to_name):{numlength}}) | "
f"{len(cls.location_names):{location_count}} " f"{len(cls.location_names):3} "
f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - " f"Locations (IDs: {min(cls.location_id_to_name):{numlength}} - "
f"{max(cls.location_id_to_name):{location_digits}})") f"{max(cls.location_id_to_name):{numlength}})")
del item_digits, location_digits, item_count, location_count
AutoWorld.call_stage(world, "assert_generate") AutoWorld.call_stage(world, "assert_generate")
@@ -115,6 +103,19 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for _ in range(count): for _ in range(count):
world.push_precollected(world.create_item(item_name, player)) world.push_precollected(world.create_item(item_name, player))
for player in world.player_ids:
if player in world.get_game_players("A Link to the Past"):
# enforce pre-defined local items.
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
world.local_items[player].value.add('Triforce Piece')
# Not possible to place pendants/crystals out side of boss prizes yet.
world.non_local_items[player].value -= item_name_groups['Pendants']
world.non_local_items[player].value -= item_name_groups['Crystals']
# items can't be both local and non-local, prefer local
world.non_local_items[player].value -= world.local_items[player].value
logger.info('Creating World.') logger.info('Creating World.')
AutoWorld.call_all(world, "create_regions") AutoWorld.call_all(world, "create_regions")
@@ -122,14 +123,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
AutoWorld.call_all(world, "create_items") AutoWorld.call_all(world, "create_items")
logger.info('Calculating Access Rules.') logger.info('Calculating Access Rules.')
for player in world.player_ids:
# items can't be both local and non-local, prefer local
world.non_local_items[player].value -= world.local_items[player].value
world.non_local_items[player].value -= set(world.local_early_items[player])
if world.players > 1: if world.players > 1:
locality_rules(world) for player in world.player_ids:
locality_rules(world, player)
group_locality_rules(world)
else: else:
world.non_local_items[1].value = set() world.non_local_items[1].value = set()
world.local_items[1].value = set() world.local_items[1].value = set()
@@ -146,10 +143,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
# temporary home for item links, should be moved out of Main # temporary home for item links, should be moved out of Main
for group_id, group in world.groups.items(): for group_id, group in world.groups.items():
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[ def find_common_pool(players: Set[int], shared_pool: Set[str]):
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]] classifications = collections.defaultdict(int)
]:
classifications: Dict[str, int] = collections.defaultdict(int)
counters = {player: {name: 0 for name in shared_pool} for player in players} counters = {player: {name: 0 for name in shared_pool} for player in players}
for item in world.itempool: for item in world.itempool:
if item.player in counters and item.name in shared_pool: if item.player in counters and item.name in shared_pool:
@@ -159,7 +154,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player in players.copy(): for player in players.copy():
if all([counters[player][item] == 0 for item in shared_pool]): if all([counters[player][item] == 0 for item in shared_pool]):
players.remove(player) players.remove(player)
del (counters[player]) del(counters[player])
if not players: if not players:
return None, None return None, None
@@ -171,14 +166,14 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
counters[player][item] = count counters[player][item] = count
else: else:
for player in players: for player in players:
del (counters[player][item]) del(counters[player][item])
return counters, classifications return counters, classifications
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"]) common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
if not common_item_count: if not common_item_count:
continue continue
new_itempool: List[Item] = [] new_itempool = []
for item_name, item_count in next(iter(common_item_count.values())).items(): for item_name, item_count in next(iter(common_item_count.values())).items():
for _ in range(item_count): for _ in range(item_count):
new_item = group["world"].create_item(item_name) new_item = group["world"].create_item(item_name)
@@ -209,15 +204,11 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
while itemcount > len(world.itempool): while itemcount > len(world.itempool):
items_to_add = [] items_to_add = []
for player in group["players"]: for player in group["players"]:
if group["link_replacement"]:
item_player = group_id
else:
item_player = player
if group["replacement_items"][player]: if group["replacement_items"][player]:
items_to_add.append(AutoWorld.call_single(world, "create_item", item_player, items_to_add.append(AutoWorld.call_single(world, "create_item", player,
group["replacement_items"][player])) group["replacement_items"][player]))
else: else:
items_to_add.append(AutoWorld.call_single(world, "create_filler", item_player)) items_to_add.append(AutoWorld.call_single(world, "create_filler", player))
world.random.shuffle(items_to_add) world.random.shuffle(items_to_add)
world.itempool.extend(items_to_add[:itemcount - len(world.itempool)]) world.itempool.extend(items_to_add[:itemcount - len(world.itempool)])
@@ -227,6 +218,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.info("Running Item Plando") logger.info("Running Item Plando")
for item in world.itempool:
item.world = world
distribute_planned(world) distribute_planned(world)
logger.info('Running Pre Main Fill.') logger.info('Running Pre Main Fill.')
@@ -260,9 +254,24 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
output_file_futures.append( output_file_futures.append(
pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir)) pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
def get_entrance_to_region(region: Region):
for entrance in region.entrances:
if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld, RegionType.Generic):
return entrance
for entrance in region.entrances: # BFS might be better here, trying DFS for now.
return get_entrance_to_region(entrance.parent_region)
# collect ER hint info # collect ER hint info
er_hint_data: Dict[int, Dict[int, str]] = {} er_hint_data = {player: {} for player in world.get_game_players("A Link to the Past") if
AutoWorld.call_all(world, 'extend_hint_information', er_hint_data) world.shuffle[player] != "vanilla" or world.retro_caves[player]}
for region in world.regions:
if region.player in er_hint_data and region.locations:
main_entrance = get_entrance_to_region(region)
for location in region.locations:
if type(location.address) == int: # skips events and crystals
if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name:
er_hint_data[region.player][location.address] = main_entrance.name
checks_in_area = {player: {area: list() for area in ordered_areas} checks_in_area = {player: {area: list() for area in ordered_areas}
for player in range(1, world.players + 1)} for player in range(1, world.players + 1)}
@@ -272,23 +281,22 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for location in world.get_filled_locations(): for location in world.get_filled_locations():
if type(location.address) is int: if type(location.address) is int:
main_entrance = get_entrance_to_region(location.parent_region)
if location.game != "A Link to the Past": if location.game != "A Link to the Past":
checks_in_area[location.player]["Light World"].append(location.address) checks_in_area[location.player]["Light World"].append(location.address)
else: elif location.parent_region.dungeon:
main_entrance = location.parent_region.get_connecting_entrance(is_main_entrance) dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
if location.parent_region.dungeon: 'Inverted Ganons Tower': 'Ganons Tower'} \
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower', .get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
'Inverted Ganons Tower': 'Ganons Tower'} \ checks_in_area[location.player][dungeonname].append(location.address)
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name) elif location.parent_region.type == RegionType.LightWorld:
checks_in_area[location.player][dungeonname].append(location.address) checks_in_area[location.player]["Light World"].append(location.address)
elif location.parent_region.type == RegionType.LightWorld: elif location.parent_region.type == RegionType.DarkWorld:
checks_in_area[location.player]["Light World"].append(location.address) checks_in_area[location.player]["Dark World"].append(location.address)
elif location.parent_region.type == RegionType.DarkWorld: elif main_entrance.parent_region.type == RegionType.LightWorld:
checks_in_area[location.player]["Dark World"].append(location.address) checks_in_area[location.player]["Light World"].append(location.address)
elif main_entrance.parent_region.type == RegionType.LightWorld: elif main_entrance.parent_region.type == RegionType.DarkWorld:
checks_in_area[location.player]["Light World"].append(location.address) checks_in_area[location.player]["Dark World"].append(location.address)
elif main_entrance.parent_region.type == RegionType.DarkWorld:
checks_in_area[location.player]["Dark World"].append(location.address)
checks_in_area[location.player]["Total"] += 1 checks_in_area[location.player]["Total"] += 1
oldmancaves = [] oldmancaves = []
@@ -302,7 +310,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
player = region.player player = region.player
location_id = SHOP_ID_START + total_shop_slots + index location_id = SHOP_ID_START + total_shop_slots + index
main_entrance = region.get_connecting_entrance(is_main_entrance) main_entrance = get_entrance_to_region(region)
if main_entrance.parent_region.type == RegionType.LightWorld: if main_entrance.parent_region.type == RegionType.LightWorld:
checks_in_area[player]["Light World"].append(location_id) checks_in_area[player]["Light World"].append(location_id)
else: else:
@@ -337,6 +345,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player, world_precollected in world.precollected_items.items()} for player, world_precollected in world.precollected_items.items()}
precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))} precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))}
for slot in world.player_ids: for slot in world.player_ids:
slot_data[slot] = world.worlds[slot].fill_slot_data() slot_data[slot] = world.worlds[slot].fill_slot_data()
@@ -355,8 +364,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for location in world.get_filled_locations(): for location in world.get_filled_locations():
if type(location.address) == int: if type(location.address) == int:
assert location.item.code is not None, "item code None should be event, " \ assert location.item.code is not None, "item code None should be event, " \
"location.address should then also be None. Location: " \ "location.address should then also be None"
f" {location}"
locations_data[location.player][location.address] = \ locations_data[location.player][location.address] = \
location.item.code, location.item.player, location.item.flags location.item.code, location.item.player, location.item.flags
if location.name in world.start_location_hints[location.player]: if location.name in world.start_location_hints[location.player]:
@@ -367,19 +375,16 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player in world.groups.get(location.item.player, {}).get("players", [])]): for player in world.groups.get(location.item.player, {}).get("players", [])]):
precollect_hint(location) precollect_hint(location)
# custom datapackage
datapackage = {}
for game_world in world.worlds.values():
if game_world.data_version == 0 and game_world.game not in datapackage:
datapackage[game_world.game] = worlds.network_data_package["games"][game_world.game]
datapackage[game_world.game]["item_name_groups"] = game_world.item_name_groups
multidata = { multidata = {
"slot_data": slot_data, "slot_data": slot_data,
"slot_info": slot_info, "slot_info": slot_info,
"names": names, # TODO: remove around 0.2.5 in favor of 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 "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()}, "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},
"remote_start_inventory": {player for player in world.player_ids if
world.worlds[player].remote_start_inventory},
"locations": locations_data, "locations": locations_data,
"checks_in_area": checks_in_area, "checks_in_area": checks_in_area,
"server_options": baked_server_options, "server_options": baked_server_options,
@@ -389,8 +394,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
"version": tuple(version_tuple), "version": tuple(version_tuple),
"tags": ["AP"], "tags": ["AP"],
"minimum_versions": minimum_versions, "minimum_versions": minimum_versions,
"seed_name": world.seed_name, "seed_name": world.seed_name
"datapackage": datapackage,
} }
AutoWorld.call_all(world, "modify_multidata", multidata) AutoWorld.call_all(world, "modify_multidata", multidata)
@@ -416,13 +420,13 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
if args.spoiler > 1: if args.spoiler > 1:
logger.info('Calculating playthrough.') logger.info('Calculating playthrough.')
world.spoiler.create_playthrough(create_paths=args.spoiler > 2) create_playthrough(world)
if args.spoiler: if args.spoiler:
world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase)) world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
zipfilename = output_path(f"AP_{world.seed_name}.zip") zipfilename = output_path(f"AP_{world.seed_name}.zip")
logger.info(f"Creating final archive at {zipfilename}") logger.info(f'Creating final archive at {zipfilename}.')
with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED, with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED,
compresslevel=9) as zf: compresslevel=9) as zf:
for file in os.scandir(temp_dir): for file in os.scandir(temp_dir):
@@ -430,3 +434,143 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start) logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start)
return world return world
def create_playthrough(world):
"""Destructive to the world while it is run, damage gets repaired afterwards."""
# get locations containing progress items
prog_locations = {location for location in world.get_filled_locations() if location.item.advancement}
state_cache = [None]
collection_spheres = []
state = CollectionState(world)
sphere_candidates = set(prog_locations)
logging.debug('Building up collection spheres.')
while sphere_candidates:
# build up spheres of collection radius.
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
sphere = {location for location in sphere_candidates if state.can_reach(location)}
for location in sphere:
state.collect(location.item, True, location)
sphere_candidates -= sphere
collection_spheres.append(sphere)
state_cache.append(state.copy())
logging.debug('Calculated sphere %i, containing %i of %i progress items.', len(collection_spheres), len(sphere),
len(prog_locations))
if not sphere:
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
location.item.name, location.item.player, location.name, location.player) for location in
sphere_candidates])
if any([world.accessibility[location.item.player] != 'minimal' for location in sphere_candidates]):
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
f'Something went terribly wrong here.')
else:
world.spoiler.unreachables = sphere_candidates
break
# in the second phase, we cull each sphere such that the game is still beatable,
# reducing each range of influence to the bare minimum required inside it
restore_later = {}
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
to_delete = set()
for location in sphere:
# we remove the item at location and check if game is still beatable
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
location.item.player)
old_item = location.item
location.item = None
if world.can_beat_game(state_cache[num]):
to_delete.add(location)
restore_later[location] = old_item
else:
# still required, got to keep it around
location.item = old_item
# cull entries in spheres for spoiler walkthrough at end
sphere -= to_delete
# second phase, sphere 0
removed_precollected = []
for item in (i for i in chain.from_iterable(world.precollected_items.values()) if i.advancement):
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
world.precollected_items[item.player].remove(item)
world.state.remove(item)
if not world.can_beat_game():
world.push_precollected(item)
else:
removed_precollected.append(item)
# we are now down to just the required progress items in collection_spheres. Unfortunately
# the previous pruning stage could potentially have made certain items dependant on others
# in the same or later sphere (because the location had 2 ways to access but the item originally
# used to access it was deemed not required.) So we need to do one final sphere collection pass
# to build up the correct spheres
required_locations = {item for sphere in collection_spheres for item in sphere}
state = CollectionState(world)
collection_spheres = []
while required_locations:
state.sweep_for_events(key_only=True)
sphere = set(filter(state.can_reach, required_locations))
for location in sphere:
state.collect(location.item, True, location)
required_locations -= sphere
collection_spheres.append(sphere)
logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres),
len(sphere), len(required_locations))
if not sphere:
raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}')
def flist_to_iter(node):
while node:
value, node = node
yield value
def get_path(state, region):
reversed_path_as_flist = state.path.get(region, (region, None))
string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist))))
# Now we combine the flat string list into (region, exit) pairs
pathsiter = iter(string_path_flat)
pathpairs = zip_longest(pathsiter, pathsiter)
return list(pathpairs)
world.spoiler.paths = {}
topology_worlds = (player for player in world.player_ids if world.worlds[player].topology_present)
for player in topology_worlds:
world.spoiler.paths.update(
{str(location): get_path(state, location.parent_region) for sphere in collection_spheres for location in
sphere if location.player == player})
if player in world.get_game_players("A Link to the Past"):
# If Pyramid Fairy Entrance needs to be reached, also path to Big Bomb Shop
# Maybe move the big bomb over to the Event system instead?
if any(exit_path == 'Pyramid Fairy' for path in world.spoiler.paths.values() for (_, exit_path) in path):
if world.mode[player] != 'inverted':
world.spoiler.paths[str(world.get_region('Big Bomb Shop', player))] = \
get_path(state, world.get_region('Big Bomb Shop', player))
else:
world.spoiler.paths[str(world.get_region('Inverted Big Bomb Shop', player))] = \
get_path(state, world.get_region('Inverted Big Bomb Shop', player))
# we can finally output our playthrough
world.spoiler.playthrough = {"0": sorted([str(item) for item in
chain.from_iterable(world.precollected_items.values())
if item.advancement])}
for i, sphere in enumerate(collection_spheres):
world.spoiler.playthrough[str(i + 1)] = {str(location): str(location.item) for location in sorted(sphere)}
# repair the world again
for location, item in restore_later.items():
location.item = item
for item in removed_precollected:
world.push_precollected(item)

View File

@@ -13,12 +13,10 @@ update_ran = getattr(sys, "frozen", False) # don't run update if environment is
if not update_ran: if not update_ran:
for entry in os.scandir(os.path.join(local_dir, "worlds")): for entry in os.scandir(os.path.join(local_dir, "worlds")):
# skip .* (hidden / disabled) folders if entry.is_dir():
if not entry.name.startswith("."): req_file = os.path.join(entry.path, "requirements.txt")
if entry.is_dir(): if os.path.exists(req_file):
req_file = os.path.join(entry.path, "requirements.txt") requirements_files.add(req_file)
if os.path.exists(req_file):
requirements_files.add(req_file)
def update_command(): def update_command():
@@ -39,25 +37,11 @@ def update(yes=False, force=False):
path = os.path.join(os.path.dirname(__file__), req_file) path = os.path.join(os.path.dirname(__file__), req_file)
with open(path) as requirementsfile: with open(path) as requirementsfile:
for line in requirementsfile: for line in requirementsfile:
if line.startswith(("https://", "git+https://")): if line.startswith('https://'):
# extract name and version for url # extract name and version from url
rest = line.split('/')[-1] wheel = line.split('/')[-1]
line = "" name, version, _ = wheel.split('-', 2)
if "#egg=" in rest: line = f'{name}=={version}'
# from egg info
rest, egg = rest.split("#egg=", 1)
egg = egg.split(";", 1)[0]
if any(compare in egg for compare in ("==", ">=", ">", "<", "<=", "!=")):
line = egg
else:
egg = ""
if "@" in rest and not line:
raise ValueError("Can't deduce version from requirement")
elif not line:
# from filename
rest = rest.replace(".zip", "-").replace(".tar.gz", "-")
name, version, _ = rest.split("-", 2)
line = f'{egg or name}=={version}'
requirements = pkg_resources.parse_requirements(line) requirements = pkg_resources.parse_requirements(line)
for requirement in requirements: for requirement in requirements:
requirement = str(requirement) requirement = str(requirement)

File diff suppressed because it is too large Load Diff

View File

@@ -100,7 +100,7 @@ _encode = JSONEncoder(
).encode ).encode
def encode(obj: typing.Any) -> str: def encode(obj):
return _encode(_scan_for_TypedTuples(obj)) return _encode(_scan_for_TypedTuples(obj))
@@ -270,7 +270,7 @@ class RawJSONtoTextParser(JSONtoTextParser):
color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34, color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34,
'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43, 'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43,
'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47} 'blue_bg': 44, 'purple_bg': 45, 'cyan_bg': 46, 'white_bg': 47}
def color_code(*args): def color_code(*args):

View File

@@ -3,7 +3,6 @@ import argparse
import logging import logging
import random import random
import os import os
import zipfile
from itertools import chain from itertools import chain
from BaseClasses import MultiWorld from BaseClasses import MultiWorld
@@ -218,18 +217,13 @@ def adjust(args):
# Load up the ROM # Load up the ROM
rom = Rom(file=args.rom, force_use=True) rom = Rom(file=args.rom, force_use=True)
delete_zootdec = True delete_zootdec = True
elif os.path.splitext(args.rom)[-1] in ['.apz5', '.zpf']: elif os.path.splitext(args.rom)[-1] == '.apz5':
# Load vanilla ROM # Load vanilla ROM
rom = Rom(file=args.vanilla_rom, force_use=True) rom = Rom(file=args.vanilla_rom, force_use=True)
apz5_file = args.rom
base_name = os.path.splitext(apz5_file)[0]
# Patch file # Patch file
apply_patch_file(rom, apz5_file, apply_patch_file(rom, args.rom)
sub_file=(os.path.basename(base_name) + '.zpf'
if zipfile.is_zipfile(apz5_file)
else None))
else: else:
raise Exception("Invalid file extension; requires .n64, .z64, .apz5, .zpf") raise Exception("Invalid file extension; requires .n64, .z64, .apz5")
# Call patch_cosmetics # Call patch_cosmetics
try: try:
patch_cosmetics(ootworld, rom) patch_cosmetics(ootworld, rom)

View File

@@ -3,14 +3,11 @@ import json
import os import os
import multiprocessing import multiprocessing
import subprocess import subprocess
import zipfile
from asyncio import StreamReader, StreamWriter from asyncio import StreamReader, StreamWriter
# CommonClient import first to trigger ModuleUpdater from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, \
from CommonClient import CommonContext, server_loop, gui_enabled, \
ClientCommandProcessor, logger, get_base_parser ClientCommandProcessor, logger, get_base_parser
import Utils import Utils
from Utils import async_start
from worlds import network_data_package from worlds import network_data_package
from worlds.oot.Rom import Rom, compress_rom_file from worlds.oot.Rom import Rom, compress_rom_file
from worlds.oot.N64Patch import apply_patch_file from worlds.oot.N64Patch import apply_patch_file
@@ -51,7 +48,7 @@ deathlink_sent_this_death: we interacted with the multiworld on this death, wait
oot_loc_name_to_id = network_data_package["games"]["Ocarina of Time"]["location_name_to_id"] oot_loc_name_to_id = network_data_package["games"]["Ocarina of Time"]["location_name_to_id"]
script_version: int = 3 script_version: int = 1
def get_item_value(ap_id): def get_item_value(ap_id):
return ap_id - 66000 return ap_id - 66000
@@ -71,7 +68,7 @@ class OoTCommandProcessor(ClientCommandProcessor):
if isinstance(self.ctx, OoTContext): if isinstance(self.ctx, OoTContext):
self.ctx.deathlink_client_override = True self.ctx.deathlink_client_override = True
self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled
async_start(self.ctx.update_death_link(self.ctx.deathlink_enabled), name="Update Deathlink") asyncio.create_task(self.ctx.update_death_link(self.ctx.deathlink_enabled), name="Update Deathlink")
class OoTContext(CommonContext): class OoTContext(CommonContext):
@@ -86,9 +83,6 @@ class OoTContext(CommonContext):
self.n64_status = CONNECTION_INITIAL_STATUS self.n64_status = CONNECTION_INITIAL_STATUS
self.awaiting_rom = False self.awaiting_rom = False
self.location_table = {} self.location_table = {}
self.collectible_table = {}
self.collectible_override_flags_address = 0
self.collectible_offsets = {}
self.deathlink_enabled = False self.deathlink_enabled = False
self.deathlink_pending = False self.deathlink_pending = False
self.deathlink_sent_this_death = False self.deathlink_sent_this_death = False
@@ -121,13 +115,6 @@ class OoTContext(CommonContext):
self.ui = OoTManager(self) self.ui = OoTManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def on_package(self, cmd, args):
if cmd == 'Connected':
slot_data = args.get('slot_data', None)
if slot_data:
self.collectible_override_flags_address = slot_data.get('collectible_override_flags', 0)
self.collectible_offsets = slot_data.get('collectible_flag_offsets', {})
def get_payload(ctx: OoTContext): def get_payload(ctx: OoTContext):
if ctx.deathlink_enabled and ctx.deathlink_pending: if ctx.deathlink_enabled and ctx.deathlink_pending:
@@ -136,32 +123,15 @@ def get_payload(ctx: OoTContext):
else: else:
trigger_death = False trigger_death = False
payload = json.dumps({ return json.dumps({
"items": [get_item_value(item.item) for item in ctx.items_received], "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], "playerNames": [name for (i, name) in ctx.player_names.items() if i != 0],
"triggerDeath": trigger_death, "triggerDeath": trigger_death
"collectibleOverrides": ctx.collectible_override_flags_address,
"collectibleOffsets": ctx.collectible_offsets
}) })
return payload
async def parse_payload(payload: dict, ctx: OoTContext, force: bool): async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
# Refuse to do anything if ROM is detected as changed
if ctx.auth and payload['playerName'] != ctx.auth:
logger.warning("ROM change detected. Disconnecting and reconnecting...")
ctx.deathlink_enabled = False
ctx.deathlink_client_override = False
ctx.finished_game = False
ctx.location_table = {}
ctx.collectible_table = {}
ctx.deathlink_pending = False
ctx.deathlink_sent_this_death = False
ctx.auth = payload['playerName']
await ctx.send_connect()
return
# Turn on deathlink if it is on, and if the client hasn't overriden it # Turn on deathlink if it is on, and if the client hasn't overriden it
if payload['deathlinkActive'] and not ctx.deathlink_enabled and not ctx.deathlink_client_override: if payload['deathlinkActive'] and not ctx.deathlink_enabled and not ctx.deathlink_client_override:
await ctx.update_death_link(True) await ctx.update_death_link(True)
@@ -176,17 +146,11 @@ async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
ctx.finished_game = True ctx.finished_game = True
# Locations handling # Locations handling
locations = payload['locations'] if ctx.location_table != payload['locations']:
collectibles = payload['collectibles'] ctx.location_table = payload['locations']
if ctx.location_table != locations or ctx.collectible_table != collectibles:
ctx.location_table = locations
ctx.collectible_table = collectibles
locs1 = [oot_loc_name_to_id[loc] for loc, b in ctx.location_table.items() if b]
locs2 = [int(loc) for loc, b in ctx.collectible_table.items() if b]
await ctx.send_msgs([{ await ctx.send_msgs([{
"cmd": "LocationChecks", "cmd": "LocationChecks",
"locations": locs1 + locs2 "locations": [oot_loc_name_to_id[loc] for loc in ctx.location_table if ctx.location_table[loc]]
}]) }])
# Deathlink handling # Deathlink handling
@@ -212,13 +176,20 @@ async def n64_sync_task(ctx: OoTContext):
try: try:
await asyncio.wait_for(writer.drain(), timeout=1.5) await asyncio.wait_for(writer.drain(), timeout=1.5)
try: try:
# Data will return a dict with up to six fields:
# 1. str: player name (always)
# 2. int: script version (always)
# 3. bool: deathlink active (always)
# 4. dict[str, bool]: checked locations
# 5. bool: whether Link is currently at 0 HP
# 6. bool: whether the game currently registers as complete
data = await asyncio.wait_for(reader.readline(), timeout=10) data = await asyncio.wait_for(reader.readline(), timeout=10)
data_decoded = json.loads(data.decode()) data_decoded = json.loads(data.decode())
reported_version = data_decoded.get('scriptVersion', 0) reported_version = data_decoded.get('scriptVersion', 0)
if reported_version >= script_version: if reported_version == script_version:
if ctx.game is not None and 'locations' in data_decoded: if ctx.game is not None and 'locations' in data_decoded:
# Not just a keep alive ping, parse # Not just a keep alive ping, parse
async_start(parse_payload(data_decoded, ctx, False)) asyncio.create_task(parse_payload(data_decoded, ctx, False))
if not ctx.auth: if not ctx.auth:
ctx.auth = data_decoded['playerName'] ctx.auth = data_decoded['playerName']
if ctx.awaiting_rom: if ctx.awaiting_rom:
@@ -284,21 +255,17 @@ async def run_game(romfile):
async def patch_and_run_game(apz5_file): async def patch_and_run_game(apz5_file):
apz5_file = os.path.abspath(apz5_file)
base_name = os.path.splitext(apz5_file)[0] base_name = os.path.splitext(apz5_file)[0]
decomp_path = base_name + '-decomp.z64' decomp_path = base_name + '-decomp.z64'
comp_path = base_name + '.z64' comp_path = base_name + '.z64'
# Load vanilla ROM, patch file, compress ROM # Load vanilla ROM, patch file, compress ROM
rom = Rom(Utils.local_path(Utils.get_options()["oot_options"]["rom_file"])) rom = Rom(Utils.local_path(Utils.get_options()["oot_options"]["rom_file"]))
apply_patch_file(rom, apz5_file, apply_patch_file(rom, apz5_file)
sub_file=(os.path.basename(base_name) + '.zpf'
if zipfile.is_zipfile(apz5_file)
else None))
rom.write_to_file(decomp_path) rom.write_to_file(decomp_path)
os.chdir(data_path("Compress")) os.chdir(data_path("Compress"))
compress_rom_file(decomp_path, comp_path) compress_rom_file(decomp_path, comp_path)
os.remove(decomp_path) os.remove(decomp_path)
async_start(run_game(comp_path)) asyncio.create_task(run_game(comp_path))
if __name__ == '__main__': if __name__ == '__main__':
@@ -314,7 +281,7 @@ if __name__ == '__main__':
if args.apz5_file: if args.apz5_file:
logger.info("APZ5 file supplied, beginning patching process...") logger.info("APZ5 file supplied, beginning patching process...")
async_start(patch_and_run_game(args.apz5_file)) asyncio.create_task(patch_and_run_game(args.apz5_file))
ctx = OoTContext(args.connect, args.password) ctx = OoTContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop") ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import abc import abc
from copy import deepcopy
import math import math
import numbers import numbers
import typing import typing
@@ -27,31 +26,15 @@ class AssembleOptions(abc.ABCMeta):
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()}) attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
options.update(new_options) options.update(new_options)
# apply aliases, without name_lookup # apply aliases, without name_lookup
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("alias_")} name.startswith("alias_")}
assert "random" not in aliases, "Choice option 'random' cannot be manually assigned." assert "random" not in aliases, "Choice option 'random' cannot be manually assigned."
# auto-alias Off and On being parsed as True and False
if "off" in options:
options["false"] = options["off"]
if "on" in options:
options["true"] = options["on"]
options.update(aliases) options.update(aliases)
if "verify" not in attrs:
# not overridden by class -> look up bases
verifiers = [f for f in (getattr(base, "verify", None) for base in bases) if f]
if len(verifiers) > 1: # verify multiple bases/mixins
def verify(self, *args, **kwargs) -> None:
for f in verifiers:
f(self, *args, **kwargs)
attrs["verify"] = verify
else:
assert verifiers, "class Option is supposed to implement def verify"
# auto-validate schema on __init__ # auto-validate schema on __init__
if "schema" in attrs.keys(): if "schema" in attrs.keys():
@@ -79,9 +62,6 @@ class AssembleOptions(abc.ABCMeta):
return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs) return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
@abc.abstractclassmethod
def from_any(cls, value: typing.Any) -> "Option[typing.Any]": ...
T = typing.TypeVar('T') T = typing.TypeVar('T')
@@ -132,44 +112,8 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
def from_any(cls, data: typing.Any) -> Option[T]: def from_any(cls, data: typing.Any) -> Option[T]:
raise NotImplementedError raise NotImplementedError
if typing.TYPE_CHECKING:
from Generate import PlandoSettings
from worlds.AutoWorld import World
def verify(self, world: World, player_name: str, plando_options: PlandoSettings) -> None:
pass
else:
def verify(self, *args, **kwargs) -> None:
pass
class FreeText(Option):
"""Text option that allows users to enter strings.
Needs to be validated by the world or option definition."""
def __init__(self, value: str):
assert isinstance(value, str), "value of FreeText must be a string"
self.value = value
@property
def current_key(self) -> str:
return self.value
@classmethod
def from_text(cls, text: str) -> FreeText:
return cls(text)
@classmethod
def from_any(cls, data: typing.Any) -> FreeText:
return cls.from_text(str(data))
@classmethod
def get_option_name(cls, value: T) -> str:
return value
class NumericOption(Option[int], numbers.Integral): class NumericOption(Option[int], numbers.Integral):
default = 0
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards # note: some of the `typing.Any`` here is a result of unresolved issue in python standards
# `int` is not a `numbers.Integral` according to the official typestubs # `int` is not a `numbers.Integral` according to the official typestubs
# (even though isinstance(5, numbers.Integral) == True) # (even though isinstance(5, numbers.Integral) == True)
@@ -354,7 +298,7 @@ class Toggle(NumericOption):
if type(data) == str: if type(data) == str:
return cls.from_text(data) return cls.from_text(data)
else: else:
return cls(int(data)) return cls(data)
@classmethod @classmethod
def get_option_name(cls, value): def get_option_name(cls, value):
@@ -424,170 +368,6 @@ class Choice(NumericOption):
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__ __hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
class TextChoice(Choice):
"""Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string"""
def __init__(self, value: typing.Union[str, int]):
assert isinstance(value, str) or isinstance(value, int), \
f"{value} is not a valid option for {self.__class__.__name__}"
self.value = value
@property
def current_key(self) -> str:
if isinstance(self.value, str):
return self.value
else:
return self.name_lookup[self.value]
@classmethod
def from_text(cls, text: str) -> TextChoice:
if text.lower() == "random": # chooses a random defined option but won't use any free text options
return cls(random.choice(list(cls.name_lookup)))
for option_name, value in cls.options.items():
if option_name.lower() == text.lower():
return cls(value)
return cls(text)
@classmethod
def get_option_name(cls, value: T) -> str:
if isinstance(value, str):
return value
return cls.name_lookup[value]
def __eq__(self, other: typing.Any):
if isinstance(other, self.__class__):
return other.value == self.value
elif isinstance(other, str):
if other in self.options:
return other == self.current_key
return other == self.value
elif isinstance(other, int):
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)
else:
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
class BossMeta(AssembleOptions):
def __new__(mcs, name, bases, attrs):
if name != "PlandoBosses":
assert "bosses" in attrs, f"Please define valid bosses for {name}"
attrs["bosses"] = frozenset((boss.lower() for boss in attrs["bosses"]))
assert "locations" in attrs, f"Please define valid locations for {name}"
attrs["locations"] = frozenset((location.lower() for location in attrs["locations"]))
cls = super().__new__(mcs, name, bases, attrs)
assert not cls.duplicate_bosses or "singularity" in cls.options, f"Please define option_singularity for {name}"
return cls
class PlandoBosses(TextChoice, metaclass=BossMeta):
"""Generic boss shuffle option that supports plando. Format expected is
'location1-boss1;location2-boss2;shuffle_mode'.
If shuffle_mode is not provided in the string, this will be the default shuffle mode. Must override can_place_boss,
which passes a plando boss and location. Check if the placement is valid for your game here."""
bosses: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]]
locations: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]]
duplicate_bosses: bool = False
@classmethod
def from_text(cls, text: str):
# set all of our text to lower case for name checking
text = text.lower()
if text == "random":
return cls(random.choice(list(cls.options.values())))
for option_name, value in cls.options.items():
if option_name == text:
return cls(value)
options = text.split(";")
# since plando exists in the option verify the plando values given are valid
cls.validate_plando_bosses(options)
return cls.get_shuffle_mode(options)
@classmethod
def get_shuffle_mode(cls, option_list: typing.List[str]):
# find out what mode of boss shuffle we should use for placing bosses after plando
# and add as a string to look nice in the spoiler
if "random" in option_list:
shuffle = random.choice(list(cls.options))
option_list.remove("random")
options = ";".join(option_list) + f";{shuffle}"
boss_class = cls(options)
else:
for option in option_list:
if option in cls.options:
options = ";".join(option_list)
break
else:
if cls.duplicate_bosses and len(option_list) == 1:
if cls.valid_boss_name(option_list[0]):
# this doesn't exist in this class but it's a forced option for classes where this is called
options = option_list[0] + ";singularity"
else:
options = option_list[0] + f";{cls.name_lookup[cls.default]}"
else:
options = ";".join(option_list) + f";{cls.name_lookup[cls.default]}"
boss_class = cls(options)
return boss_class
@classmethod
def validate_plando_bosses(cls, options: typing.List[str]) -> None:
used_locations = []
used_bosses = []
for option in options:
# check if a shuffle mode was provided in the incorrect location
if option == "random" or option in cls.options:
if option != options[-1]:
raise ValueError(f"{option} option must be at the end of the boss_shuffle options!")
elif "-" in option:
location, boss = option.split("-")
if location in used_locations:
raise ValueError(f"Duplicate Boss Location {location} not allowed.")
if not cls.duplicate_bosses and boss in used_bosses:
raise ValueError(f"Duplicate Boss {boss} not allowed.")
used_locations.append(location)
used_bosses.append(boss)
if not cls.valid_boss_name(boss):
raise ValueError(f"{boss.title()} is not a valid boss name.")
if not cls.valid_location_name(location):
raise ValueError(f"{location.title()} is not a valid boss location name.")
if not cls.can_place_boss(boss, location):
raise ValueError(f"{location.title()} is not a valid location for {boss.title()} to be placed.")
else:
if cls.duplicate_bosses:
if not cls.valid_boss_name(option):
raise ValueError(f"{option} is not a valid boss name.")
else:
raise ValueError(f"{option.title()} is not formatted correctly.")
@classmethod
def can_place_boss(cls, boss: str, location: str) -> bool:
raise NotImplementedError
@classmethod
def valid_boss_name(cls, value: str) -> bool:
return value in cls.bosses
@classmethod
def valid_location_name(cls, value: str) -> bool:
return value in cls.locations
def verify(self, world, player_name: str, plando_options) -> None:
if isinstance(self.value, int):
return
from Generate import PlandoSettings
if not(PlandoSettings.bosses & plando_options):
import logging
# plando is disabled but plando options were given so pull the option and change it to an int
option = self.value.split(";")[-1]
self.value = self.options[option]
logging.warning(f"The plando bosses module is turned off, so {self.name_lookup[self.value].title()} "
f"boss shuffle will be used for player {player_name}.")
class Range(NumericOption): class Range(NumericOption):
range_start = 0 range_start = 0
range_end = 1 range_end = 1
@@ -605,7 +385,7 @@ class Range(NumericOption):
if text.startswith("random"): if text.startswith("random"):
return cls.weighted_range(text) return cls.weighted_range(text)
elif text == "default" and hasattr(cls, "default"): elif text == "default" and hasattr(cls, "default"):
return cls.from_any(cls.default) return cls(cls.default)
elif text == "high": elif text == "high":
return cls(cls.range_end) return cls(cls.range_end)
elif text == "low": elif text == "low":
@@ -616,7 +396,7 @@ class Range(NumericOption):
and text in ("true", "false"): and text in ("true", "false"):
# these are the conditions where "true" and "false" make sense # these are the conditions where "true" and "false" make sense
if text == "true": if text == "true":
return cls.from_any(cls.default) return cls(cls.default)
else: # "false" else: # "false"
return cls(0) return cls(0)
return cls(int(text)) return cls(int(text))
@@ -727,7 +507,7 @@ class VerifyKeys:
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. " raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
f"Allowed keys: {cls.valid_keys}.") f"Allowed keys: {cls.valid_keys}.")
def verify(self, world, player_name: str, plando_options) -> None: def verify(self, world):
if self.convert_name_groups and self.verify_item_name: if self.convert_name_groups and self.verify_item_name:
new_value = type(self.value)() # empty container of whatever value is new_value = type(self.value)() # empty container of whatever value is
for item_name in self.value: for item_name in self.value:
@@ -750,11 +530,11 @@ class VerifyKeys:
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys): class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
default: typing.Dict[str, typing.Any] = {} default = {}
supports_weighting = False supports_weighting = False
def __init__(self, value: typing.Dict[str, typing.Any]): def __init__(self, value: typing.Dict[str, typing.Any]):
self.value = deepcopy(value) self.value = value
@classmethod @classmethod
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict: def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
@@ -781,11 +561,11 @@ class ItemDict(OptionDict):
class OptionList(Option[typing.List[typing.Any]], VerifyKeys): class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
default: typing.List[typing.Any] = [] default = []
supports_weighting = False supports_weighting = False
def __init__(self, value: typing.List[typing.Any]): def __init__(self, value: typing.List[typing.Any]):
self.value = deepcopy(value) self.value = value or []
super(OptionList, self).__init__() super(OptionList, self).__init__()
@classmethod @classmethod
@@ -807,11 +587,11 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
class OptionSet(Option[typing.Set[str]], VerifyKeys): class OptionSet(Option[typing.Set[str]], VerifyKeys):
default: typing.Union[typing.Set[str], typing.FrozenSet[str]] = frozenset() default = frozenset()
supports_weighting = False supports_weighting = False
def __init__(self, value: typing.Iterable[str]): def __init__(self, value: typing.Union[typing.Set[str, typing.Any], typing.List[str, typing.Any]]):
self.value = set(deepcopy(value)) self.value = set(value)
super(OptionSet, self).__init__() super(OptionSet, self).__init__()
@classmethod @classmethod
@@ -820,7 +600,10 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
@classmethod @classmethod
def from_any(cls, data: typing.Any): def from_any(cls, data: typing.Any):
if isinstance(data, (list, set, frozenset)): if type(data) == list:
cls.verify_keys(data)
return cls(data)
elif type(data) == set:
cls.verify_keys(data) cls.verify_keys(data)
return cls(data) return cls(data)
return cls.from_text(str(data)) return cls.from_text(str(data))
@@ -850,7 +633,7 @@ class Accessibility(Choice):
class ProgressionBalancing(SpecialRange): class ProgressionBalancing(SpecialRange):
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early. """A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
A lower setting means more getting stuck. A higher setting means less getting stuck.""" [0-99, default 50] A lower setting means more getting stuck. A higher setting means less getting stuck."""
default = 50 default = 50
range_start = 0 range_start = 0
range_end = 99 range_end = 99
@@ -927,8 +710,7 @@ class ItemLinks(OptionList):
Optional("exclude"): [And(str, len)], Optional("exclude"): [And(str, len)],
"replacement_item": Or(And(str, len), None), "replacement_item": Or(And(str, len), None),
Optional("local_items"): [And(str, len)], Optional("local_items"): [And(str, len)],
Optional("non_local_items"): [And(str, len)], Optional("non_local_items"): [And(str, len)]
Optional("link_replacement"): Or(None, bool),
} }
]) ])
@@ -950,9 +732,8 @@ class ItemLinks(OptionList):
pool |= {item_name} pool |= {item_name}
return pool return pool
def verify(self, world, player_name: str, plando_options) -> None: def verify(self, world):
link: dict super(ItemLinks, self).verify(world)
super(ItemLinks, self).verify(world, player_name, plando_options)
existing_links = set() existing_links = set()
for link in self.value: for link in self.value:
if link["name"] in existing_links: if link["name"] in existing_links:
@@ -976,9 +757,7 @@ class ItemLinks(OptionList):
intersection = local_items.intersection(non_local_items) intersection = local_items.intersection(non_local_items)
if intersection: if intersection:
raise Exception(f"item_link {link['name']} has {intersection} " raise Exception(f"item_link {link['name']} has {intersection} items in both its local_items and non_local_items pool.")
f"items in both its local_items and non_local_items pool.")
link.setdefault("link_replacement", None)
per_game_common_options = { per_game_common_options = {

409
Patch.py
View File

@@ -1,23 +1,256 @@
from __future__ import annotations from __future__ import annotations
import shutil
import json
import bsdiff4
import yaml
import os import os
import lzma
import threading
import concurrent.futures
import zipfile
import sys import sys
from typing import Tuple, Optional, TypedDict from typing import Tuple, Optional, Dict, Any, Union, BinaryIO
if __name__ == "__main__": import ModuleUpdate
import ModuleUpdate ModuleUpdate.update()
ModuleUpdate.update()
from worlds.Files import AutoPatchRegister, APDeltaPatch import Utils
current_patch_version = 4
class RomMeta(TypedDict): class AutoPatchRegister(type):
server: str 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: Optional[int]
player_name: str 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,
}
def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]: 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"
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_SMZ3: "apsmz"
}
def generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
if game == GAME_ALTTP:
from worlds.alttp.Rom import LTTPJPN10HASH as HASH
elif game == GAME_SM:
from worlds.sm.Rom import SMJUHASH as HASH
elif game == GAME_SOE:
from worlds.soe.Patch import USHASH as HASH
elif game == GAME_SMZ3:
from worlds.alttp.Rom import LTTPJPN10HASH as ALTTPHASH
from worlds.sm.Rom import SMJUHASH as SMHASH
HASH = ALTTPHASH + SMHASH
else:
raise RuntimeError(f"Selected game {game} for base rom not found.")
patch = yaml.dump({"meta": metadata,
"patch": patch,
"game": game,
# minimum version of patch system expected for patching to be successful
"compatible_version": 3,
"version": current_patch_version,
"base_checksum": HASH})
return patch.encode(encoding="utf-8-sig")
def generate_patch(rom: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
if metadata is None:
metadata = {}
patch = bsdiff4.diff(get_base_rom_data(game), rom)
return generate_yaml(patch, metadata, game)
def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str = None,
player: int = 0, player_name: str = "", game: str = GAME_ALTTP) -> str:
meta = {"server": server, # allow immediate connection to server in multiworld. Empty string otherwise
"player_id": player,
"player_name": player_name}
bytes = generate_patch(load_bytes(rom_file_to_patch),
meta,
game)
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + (
".apbp" if game == GAME_ALTTP else ".apsmz" if game == GAME_SMZ3 else ".apm3")
write_lzma(bytes, target)
return target
def create_rom_bytes(patch_file: str, ignore_version: bool = False) -> Tuple[dict, str, bytearray]:
data = Utils.parse_yaml(lzma.decompress(load_bytes(patch_file)).decode("utf-8-sig"))
game_name = data["game"]
if not ignore_version and data["compatible_version"] > current_patch_version:
raise RuntimeError("Patch file is incompatible with this patcher, likely an update is required.")
patched_data = bsdiff4.patch(get_base_rom_data(game_name), data["patch"])
rom_hash = patched_data[int(0x7FC0):int(0x7FD5)]
data["meta"]["hash"] = "".join(chr(x) for x in rom_hash)
target = os.path.splitext(patch_file)[0] + ".sfc"
return data["meta"], target, patched_data
def get_base_rom_data(game: str):
if game == GAME_ALTTP:
from worlds.alttp.Rom import get_base_rom_bytes
elif game == "alttp": # old version for A Link to the Past
from worlds.alttp.Rom import get_base_rom_bytes
elif game == GAME_SM:
from worlds.sm.Rom import get_base_rom_bytes
elif game == GAME_SOE:
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]:
auto_handler = AutoPatchRegister.get_handler(patch_file) auto_handler = AutoPatchRegister.get_handler(patch_file)
if auto_handler: if auto_handler:
handler: APDeltaPatch = auto_handler(patch_file) handler: APDeltaPatch = auto_handler(patch_file)
@@ -26,10 +259,162 @@ def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]:
return {"server": handler.server, return {"server": handler.server,
"player": handler.player, "player": handler.player,
"player_name": handler.player_name}, target "player_name": handler.player_name}, target
raise NotImplementedError(f"No Handler for {patch_file} found.") 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:
data = Utils.parse_yaml(lzma.decompress(patch_data).decode("utf-8-sig"))
data["meta"]["server"] = server
bytes = generate_yaml(data["patch"], data["meta"], data["game"])
return lzma.compress(bytes)
def load_bytes(path: str) -> bytes:
with open(path, "rb") as f:
return f.read()
def write_lzma(data: bytes, path: str):
with lzma.LZMAFile(path, 'wb') as f:
f.write(data)
def read_rom(stream, strip_header=True) -> bytearray:
"""Reads rom into bytearray and optionally strips off any smc header"""
buffer = bytearray(stream.read())
if strip_header and len(buffer) % 0x400 == 0x200:
return buffer[0x200:]
return buffer
if __name__ == "__main__": if __name__ == "__main__":
for file in sys.argv[1:]: host = Utils.get_public_ipv4()
meta_data, result_file = create_rom_file(file) options = Utils.get_options()['server_options']
print(f"Patch with meta-data {meta_data} was written to {result_file}") if options['host']:
host = options['host']
address = f"{host}:{options['port']}"
ziplock = threading.Lock()
print(f"Host for patches to be created is {address}")
with concurrent.futures.ThreadPoolExecutor() as pool:
for rom in sys.argv:
try:
if rom.endswith(".sfc"):
print(f"Creating patch for {rom}")
result = pool.submit(create_patch_file, rom, address)
result.add_done_callback(lambda task: print(f"Created patch {task.result()}"))
elif rom.endswith(".apbp"):
print(f"Applying patch {rom}")
data, target = create_rom_file(rom)
#romfile, adjusted = Utils.get_adjuster_settings(target)
adjuster_settings = Utils.get_adjuster_settings(GAME_ALTTP)
adjusted = False
if adjuster_settings:
import pprint
from worlds.alttp.Rom import get_base_rom_path
adjuster_settings.rom = target
adjuster_settings.baserom = get_base_rom_path()
adjuster_settings.world = None
whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
"uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes",
"reduceflashing", "deathlink"}
printed_options = {name: value for name, value in vars(adjuster_settings).items() if name in whitelist}
if hasattr(adjuster_settings, "sprite_pool"):
sprite_pool = {}
for sprite in getattr(adjuster_settings, "sprite_pool"):
if sprite in sprite_pool:
sprite_pool[sprite] += 1
else:
sprite_pool[sprite] = 1
if sprite_pool:
printed_options["sprite_pool"] = sprite_pool
adjust_wanted = str('no')
if not hasattr(adjuster_settings, 'auto_apply') or 'ask' in adjuster_settings.auto_apply:
adjust_wanted = 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: ")
if adjuster_settings.auto_apply == 'never': # never adjust, per user request
adjust_wanted = 'no'
elif adjuster_settings.auto_apply == 'always':
adjust_wanted = 'yes'
if adjust_wanted and "never" in adjust_wanted:
adjuster_settings.auto_apply = 'never'
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
elif adjust_wanted and "always" in adjust_wanted:
adjuster_settings.auto_apply = 'always'
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
if adjust_wanted and adjust_wanted.startswith("y"):
if hasattr(adjuster_settings, "sprite_pool"):
from LttPAdjuster import AdjusterWorld
adjuster_settings.world = AdjusterWorld(getattr(adjuster_settings, "sprite_pool"))
adjusted = True
import LttPAdjuster
_, romfile = LttPAdjuster.adjust(adjuster_settings)
if hasattr(adjuster_settings, "world"):
delattr(adjuster_settings, "world")
else:
adjusted = False
if adjusted:
try:
shutil.move(romfile, target)
romfile = target
except Exception as e:
print(e)
print(f"Created rom {romfile if adjusted else target}.")
if 'server' in data:
Utils.persistent_store("servers", data['hash'], data['server'])
print(f"Host is {data['server']}")
elif rom.endswith(".apm3"):
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(".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}")
def _handle_zip_file_entry(zfinfo: zipfile.ZipInfo, server: str):
data = zfr.read(zfinfo)
if zfinfo.filename.endswith(".apbp") or zfinfo.filename.endswith(".apm3"):
data = update_patch_data(data, server)
with ziplock:
zfw.writestr(zfinfo, data)
return zfinfo.filename
futures = []
with zipfile.ZipFile(rom, "r") as zfr:
updated_zip = os.path.splitext(rom)[0] + "_updated.zip"
with zipfile.ZipFile(updated_zip, "w", compression=zipfile.ZIP_DEFLATED,
compresslevel=9) as zfw:
for zfname in zfr.namelist():
futures.append(pool.submit(_handle_zip_file_entry, zfr.getinfo(zfname), address))
for future in futures:
print(f"File {future.result()} added to {os.path.split(updated_zip)[1]}")
except:
import traceback
traceback.print_exc()
input("Press enter to close.")

View File

@@ -1,335 +0,0 @@
import asyncio
import json
import time
import os
import bsdiff4
import subprocess
import zipfile
from asyncio import StreamReader, StreamWriter
from typing import List
import Utils
from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
get_base_parser
from worlds.pokemon_rb.locations import location_data
from worlds.pokemon_rb.rom import RedDeltaPatch, BlueDeltaPatch
location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}}
location_bytes_bits = {}
for location in location_data:
if location.ram_address is not None:
if type(location.ram_address) == list:
location_map[type(location.ram_address).__name__][(location.ram_address[0].flag, location.ram_address[1].flag)] = location.address
location_bytes_bits[location.address] = [{'byte': location.ram_address[0].byte, 'bit': location.ram_address[0].bit},
{'byte': location.ram_address[1].byte, 'bit': location.ram_address[1].bit}]
else:
location_map[type(location.ram_address).__name__][location.ram_address.flag] = location.address
location_bytes_bits[location.address] = {'byte': location.ram_address.byte, 'bit': location.ram_address.bit}
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart pkmn_rb.lua"
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure pkmn_rb.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart pkmn_rb.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
DISPLAY_MSGS = True
SCRIPT_VERSION = 1
class GBCommandProcessor(ClientCommandProcessor):
def __init__(self, ctx: CommonContext):
super().__init__(ctx)
def _cmd_gb(self):
"""Check Gameboy Connection State"""
if isinstance(self.ctx, GBContext):
logger.info(f"Gameboy Status: {self.ctx.gb_status}")
class GBContext(CommonContext):
command_processor = GBCommandProcessor
game = 'Pokemon Red and Blue'
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.gb_streams: (StreamReader, StreamWriter) = None
self.gb_sync_task = None
self.messages = {}
self.locations_array = None
self.gb_status = CONNECTION_INITIAL_STATUS
self.awaiting_rom = False
self.display_msgs = True
self.deathlink_pending = False
self.set_deathlink = False
self.client_compatibility_mode = 0
self.items_handling = 0b001
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(GBContext, 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 _set_message(self, msg: str, msg_id: int):
if DISPLAY_MSGS:
self.messages[(time.time(), msg_id)] = msg
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
self.locations_array = None
if 'death_link' in args['slot_data'] and args['slot_data']['death_link']:
self.set_deathlink = True
elif cmd == "RoomInfo":
self.seed_name = args['seed_name']
elif cmd == 'Print':
msg = args['text']
if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "ReceivedItems":
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
self._set_message(msg, SYSTEM_MESSAGE_ID)
def on_deathlink(self, data: dict):
self.deathlink_pending = True
super().on_deathlink(data)
def run_gui(self):
from kvui import GameManager
class GBManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Pokémon Client"
self.ui = GBManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def get_payload(ctx: GBContext):
current_time = time.time()
ret = json.dumps(
{
"items": [item.item for item in ctx.items_received],
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
if key[0] > current_time - 10},
"deathlink": ctx.deathlink_pending
}
)
ctx.deathlink_pending = False
return ret
async def parse_locations(data: List, ctx: GBContext):
locations = []
flags = {"EventFlag": data[:0x140], "Missable": data[0x140:0x140 + 0x20],
"Hidden": data[0x140 + 0x20: 0x140 + 0x20 + 0x0E], "Rod": data[0x140 + 0x20 + 0x0E:]}
if len(flags['Rod']) > 1:
return
for flag_type, loc_map in location_map.items():
for flag, loc_id in loc_map.items():
if flag_type == "list":
if (flags["EventFlag"][location_bytes_bits[loc_id][0]['byte']] & 1 << location_bytes_bits[loc_id][0]['bit']
and flags["Missable"][location_bytes_bits[loc_id][1]['byte']] & 1 << location_bytes_bits[loc_id][1]['bit']):
locations.append(loc_id)
elif flags[flag_type][location_bytes_bits[loc_id]['byte']] & 1 << location_bytes_bits[loc_id]['bit']:
locations.append(loc_id)
if flags["EventFlag"][280] & 1 and not ctx.finished_game:
await ctx.send_msgs([
{"cmd": "StatusUpdate",
"status": 30}
])
ctx.finished_game = True
if locations == ctx.locations_array:
return
ctx.locations_array = locations
if locations is not None:
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": locations}])
async def gb_sync_task(ctx: GBContext):
logger.info("Starting GB connector. Use /gb for status information")
while not ctx.exit_event.is_set():
error_status = None
if ctx.gb_streams:
(reader, writer) = ctx.gb_streams
msg = get_payload(ctx).encode()
writer.write(msg)
writer.write(b'\n')
try:
await asyncio.wait_for(writer.drain(), timeout=1.5)
try:
# Data will return a dict with up to two fields:
# 1. A keepalive response of the Players Name (always)
# 2. An array representing the memory values of the locations area (if in game)
data = await asyncio.wait_for(reader.readline(), timeout=5)
data_decoded = json.loads(data.decode())
if 'scriptVersion' not in data_decoded or data_decoded['scriptVersion'] != SCRIPT_VERSION:
msg = "You are connecting with an incompatible Lua script version. Ensure your connector Lua " \
"and PokemonClient are from the same Archipelago installation."
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
ctx.client_compatibility_mode = data_decoded['clientCompatibilityVersion']
if ctx.client_compatibility_mode == 0:
ctx.items_handling = 0b101 # old patches will not have local start inventory, must be requested
if ctx.seed_name and ctx.seed_name != ''.join([chr(i) for i in data_decoded['seedName'] if i != 0]):
msg = "The server is running a different multiworld than your client is. (invalid seed_name)"
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
ctx.seed_name = ''.join([chr(i) for i in data_decoded['seedName'] if i != 0])
if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '':
msg = "Invalid ROM detected. No player name built into the ROM."
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
if ctx.awaiting_rom:
await ctx.server_auth(False)
if 'locations' in data_decoded and ctx.game and ctx.gb_status == CONNECTION_CONNECTED_STATUS \
and not error_status and ctx.auth:
# Not just a keep alive ping, parse
async_start(parse_locations(data_decoded['locations'], ctx))
if 'deathLink' in data_decoded and data_decoded['deathLink'] and 'DeathLink' in ctx.tags:
await ctx.send_death(ctx.auth + " is out of usable Pokémon! " + ctx.auth + " blacked out!")
if ctx.set_deathlink:
await ctx.update_death_link(True)
except asyncio.TimeoutError:
logger.debug("Read Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.gb_streams = None
except ConnectionResetError as e:
logger.debug("Read failed due to Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.gb_streams = None
except TimeoutError:
logger.debug("Connection Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.gb_streams = None
except ConnectionResetError:
logger.debug("Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.gb_streams = None
if ctx.gb_status == CONNECTION_TENTATIVE_STATUS:
if not error_status:
logger.info("Successfully Connected to Gameboy")
ctx.gb_status = CONNECTION_CONNECTED_STATUS
else:
ctx.gb_status = f"Was tentatively connected but error occured: {error_status}"
elif error_status:
ctx.gb_status = error_status
logger.info("Lost connection to Gameboy and attempting to reconnect. Use /gb for status updates")
else:
try:
logger.debug("Attempting to connect to Gameboy")
ctx.gb_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 17242), timeout=10)
ctx.gb_status = CONNECTION_TENTATIVE_STATUS
except TimeoutError:
logger.debug("Connection Timed Out, Trying Again")
ctx.gb_status = CONNECTION_TIMING_OUT_STATUS
continue
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.gb_status = CONNECTION_REFUSED_STATUS
continue
async def run_game(romfile):
auto_start = Utils.get_options()["pokemon_rb_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(game_version, patch_file, ctx):
base_name = os.path.splitext(patch_file)[0]
comp_path = base_name + '.gb'
if game_version == "blue":
delta_patch = BlueDeltaPatch
else:
delta_patch = RedDeltaPatch
try:
base_rom = delta_patch.get_source_data()
except Exception as msg:
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
with zipfile.ZipFile(patch_file, 'r') as patch_archive:
with patch_archive.open('delta.bsdiff4', 'r') as stream:
patch = stream.read()
patched_rom_data = bsdiff4.patch(base_rom, patch)
with open(comp_path, "wb") as patched_rom_file:
patched_rom_file.write(patched_rom_data)
async_start(run_game(comp_path))
if __name__ == '__main__':
Utils.init_logging("PokemonClient")
options = Utils.get_options()
async def main():
parser = get_base_parser()
parser.add_argument('patch_file', default="", type=str, nargs="?",
help='Path to an APRED or APBLUE patch file')
args = parser.parse_args()
ctx = GBContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
ctx.gb_sync_task = asyncio.create_task(gb_sync_task(ctx), name="GB Sync")
if args.patch_file:
ext = args.patch_file.split(".")[len(args.patch_file.split(".")) - 1].lower()
if ext == "apred":
logger.info("APRED file supplied, beginning patching process...")
async_start(patch_and_run_game("red", args.patch_file, ctx))
elif ext == "apblue":
logger.info("APBLUE file supplied, beginning patching process...")
async_start(patch_and_run_game("blue", args.patch_file, ctx))
else:
logger.warning(f"Unknown patch file extension {ext}")
await ctx.exit_event.wait()
ctx.server_address = None
await ctx.shutdown()
if ctx.gb_sync_task:
await ctx.gb_sync_task
import colorama
colorama.init()
asyncio.run(main())
colorama.deinit()

View File

@@ -26,14 +26,6 @@ Currently, the following games are supported:
* The Witness * The Witness
* Sonic Adventure 2: Battle * Sonic Adventure 2: Battle
* Starcraft 2: Wings of Liberty * Starcraft 2: Wings of Liberty
* Donkey Kong Country 3
* Dark Souls 3
* Super Mario World
* Pokémon Red and Blue
* Hylics 2
* Overcooked! 2
* Zillion
* Lufia II Ancient Cave
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). 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 Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
@@ -57,7 +49,7 @@ Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEnt
## Running Archipelago ## Running Archipelago
For most people all you need to do is head over to the [releases](https://github.com/ArchipelagoMW/Archipelago/releases) page then download and run the appropriate installer. The installers function on Windows only. For most people all you need to do is head over to the [releases](https://github.com/ArchipelagoMW/Archipelago/releases) page then download and run the appropriate installer. The installers function on Windows only.
If you are running Archipelago from a non-Windows system then the likely scenario is that you are comfortable running source code directly. Please see our doc on [running Archipelago from source](docs/running%20from%20source.md). If you are running Archipelago from a non-Windows system then the likely scenario is that you are comfortable running source code directly. Please see our wiki page on [running Archipelago from source](https://github.com/ArchipelagoMW/Archipelago/wiki/Running-from-source).
## Related Repositories ## Related Repositories
This project makes use of multiple other projects. We wouldn't be here without these other repositories and the contributions of their developers, past and present. This project makes use of multiple other projects. We wouldn't be here without these other repositories and the contributions of their developers, past and present.
@@ -67,10 +59,26 @@ This project makes use of multiple other projects. We wouldn't be here without t
* [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer) * [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer)
## Contributing ## Contributing
For contribution guidelines, please see our [Contributing doc.](/docs/contributing.md) Contributions are welcome. We have a few asks of any new contributors.
* Ensure that all changes which affect logic are covered by unit tests.
* Do not introduce any unit test failures/regressions.
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 and other documentation on how Archipelago functions, please see the docs folder for the relevant information and feel free to ask any questions in the #archipelago-dev channel in our discord.
## FAQ ## FAQ
For Frequently asked questions, please see the website's [FAQ Page.](https://archipelago.gg/faq/en/) For frequently asked questions see the website's [FAQ Page](https://archipelago.gg/faq/en/)
## Code of Conduct ## Code of Conduct
Please refer to our [code of conduct.](/docs/code_of_conduct.md) 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:
* Be welcoming and inclusive in tone and language.
* Be respectful of others and their abilities.
* Show empathy when speaking with others.
* Be gracious and accept feedback and constructive criticism.
These guidelines apply to all channels of communication within this GitHub repository. Please be respectful in both public channels, such as issues, and private, such as private messaging or emails.
Any incidents of abuse may be reported directly to Ijwu at hmfarran@gmail.com.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

300
Utils.py
View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio import shutil
import typing import typing
import builtins import builtins
import os import os
@@ -12,20 +12,12 @@ import io
import collections import collections
import importlib import importlib
import logging import logging
from typing import BinaryIO, ClassVar, Coroutine, Optional, Set import decimal
from yaml import load, load_all, dump, SafeLoader
try:
from yaml import CLoader as UnsafeLoader
from yaml import CDumper as Dumper
except ImportError:
from yaml import Loader as UnsafeLoader
from yaml import Dumper
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
import tkinter from tkinter import Tk
import pathlib else:
Tk = typing.Any
def tuplize_version(version: str) -> Version: def tuplize_version(version: str) -> Version:
@@ -38,13 +30,21 @@ class Version(typing.NamedTuple):
build: int build: int
__version__ = "0.3.7" __version__ = "0.3.3"
version_tuple = tuplize_version(__version__) version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux") is_linux = sys.platform.startswith('linux')
is_macos = sys.platform == "darwin" is_macos = sys.platform == 'darwin'
is_windows = sys.platform in ("win32", "cygwin", "msys") is_windows = sys.platform in ("win32", "cygwin", "msys")
import jellyfish
from yaml import load, load_all, dump, SafeLoader
try:
from yaml import CLoader as Loader
except ImportError:
from yaml import Loader
def int16_as_bytes(value: int) -> typing.List[int]: def int16_as_bytes(value: int) -> typing.List[int]:
value = value & 0xFFFF value = value & 0xFFFF
@@ -99,7 +99,7 @@ def local_path(*path: str) -> str:
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0])) local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
else: else:
import __main__ import __main__
if hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__): if hasattr(__main__, "__file__"):
# we are running in a normal Python environment # we are running in a normal Python environment
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__)) local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
else: else:
@@ -125,24 +125,23 @@ def home_path(*path: str) -> str:
def user_path(*path: str) -> str: def user_path(*path: str) -> str:
"""Returns either local_path or home_path based on write permissions.""" """Returns either local_path or home_path based on write permissions."""
if hasattr(user_path, "cached_path"): if hasattr(user_path, 'cached_path'):
pass pass
elif os.access(local_path(), os.W_OK): elif os.access(local_path(), os.W_OK):
user_path.cached_path = local_path() user_path.cached_path = local_path()
else: else:
user_path.cached_path = home_path() user_path.cached_path = home_path()
# populate home from local - TODO: upgrade feature # populate home from local - TODO: upgrade feature
if user_path.cached_path != local_path() and not os.path.exists(user_path("host.yaml")): if user_path.cached_path != local_path() and not os.path.exists(user_path('host.yaml')):
import shutil for dn in ('Players', 'data/sprites'):
for dn in ("Players", "data/sprites"):
shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True) shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True)
for fn in ("manifest.json", "host.yaml"): for fn in ('manifest.json', 'host.yaml'):
shutil.copy2(local_path(fn), user_path(fn)) shutil.copy2(local_path(fn), user_path(fn))
return os.path.join(user_path.cached_path, *path) return os.path.join(user_path.cached_path, *path)
def output_path(*path: str) -> str: def output_path(*path: str):
if hasattr(output_path, 'cached_path'): if hasattr(output_path, 'cached_path'):
return os.path.join(output_path.cached_path, *path) return os.path.join(output_path.cached_path, *path)
output_path.cached_path = user_path(get_options()["general_options"]["output_path"]) output_path.cached_path = user_path(get_options()["general_options"]["output_path"])
@@ -151,12 +150,11 @@ def output_path(*path: str) -> str:
return path return path
def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None: def open_file(filename):
if is_windows: if sys.platform == 'win32':
os.startfile(filename) os.startfile(filename)
else: else:
from shutil import which open_command = 'open' if sys.platform == 'darwin' else 'xdg-open'
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
subprocess.call([open_command, filename]) subprocess.call([open_command, filename])
@@ -175,9 +173,7 @@ class UniqueKeyLoader(SafeLoader):
parse_yaml = functools.partial(load, Loader=UniqueKeyLoader) parse_yaml = functools.partial(load, Loader=UniqueKeyLoader)
parse_yamls = functools.partial(load_all, Loader=UniqueKeyLoader) parse_yamls = functools.partial(load_all, Loader=UniqueKeyLoader)
unsafe_parse_yaml = functools.partial(load, Loader=UnsafeLoader) unsafe_parse_yaml = functools.partial(load, Loader=Loader)
del load, load_all # should not be used. don't leak their names
def get_cert_none_ssl_context(): def get_cert_none_ssl_context():
@@ -195,12 +191,11 @@ def get_public_ipv4() -> str:
ip = socket.gethostbyname(socket.gethostname()) ip = socket.gethostbyname(socket.gethostname())
ctx = get_cert_none_ssl_context() ctx = get_cert_none_ssl_context()
try: try:
ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx).read().decode("utf8").strip() ip = urllib.request.urlopen('https://checkip.amazonaws.com/', context=ctx).read().decode('utf8').strip()
except Exception as e: except Exception as e:
# noinspection PyBroadException
try: try:
ip = urllib.request.urlopen("https://v4.ident.me", context=ctx).read().decode("utf8").strip() ip = urllib.request.urlopen('https://v4.ident.me', context=ctx).read().decode('utf8').strip()
except Exception: except:
logging.exception(e) logging.exception(e)
pass # we could be offline, in a local game, so no point in erroring out pass # we could be offline, in a local game, so no point in erroring out
return ip return ip
@@ -213,18 +208,15 @@ def get_public_ipv6() -> str:
ip = socket.gethostbyname(socket.gethostname()) ip = socket.gethostbyname(socket.gethostname())
ctx = get_cert_none_ssl_context() ctx = get_cert_none_ssl_context()
try: try:
ip = urllib.request.urlopen("https://v6.ident.me", context=ctx).read().decode("utf8").strip() ip = urllib.request.urlopen('https://v6.ident.me', context=ctx).read().decode('utf8').strip()
except Exception as e: except Exception as e:
logging.exception(e) logging.exception(e)
pass # we could be offline, in a local game, or ipv6 may not be available pass # we could be offline, in a local game, or ipv6 may not be available
return ip return ip
OptionsType = typing.Dict[str, typing.Dict[str, typing.Any]]
@cache_argsless @cache_argsless
def get_default_options() -> OptionsType: def get_default_options() -> dict:
# Refer to host.yaml for comments as to what all these options mean. # Refer to host.yaml for comments as to what all these options mean.
options = { options = {
"general_options": { "general_options": {
@@ -232,21 +224,20 @@ def get_default_options() -> OptionsType:
}, },
"factorio_options": { "factorio_options": {
"executable": os.path.join("factorio", "bin", "x64", "factorio"), "executable": os.path.join("factorio", "bin", "x64", "factorio"),
"filter_item_sends": False,
"bridge_chat_out": True,
},
"sni_options": {
"sni_path": "SNI",
"snes_rom_start": True,
}, },
"sm_options": { "sm_options": {
"rom_file": "Super Metroid (JU).sfc", "rom_file": "Super Metroid (JU).sfc",
"sni": "SNI",
"rom_start": True,
}, },
"soe_options": { "soe_options": {
"rom_file": "Secret of Evermore (USA).sfc", "rom_file": "Secret of Evermore (USA).sfc",
}, },
"lttp_options": { "lttp_options": {
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc", "rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
"sni": "SNI",
"rom_start": True,
}, },
"server_options": { "server_options": {
"host": None, "host": None,
@@ -268,12 +259,13 @@ def get_default_options() -> OptionsType:
"log_network": 0 "log_network": 0
}, },
"generator": { "generator": {
"teams": 1,
"enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core"), "enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core"),
"player_files_path": "Players", "player_files_path": "Players",
"players": 0, "players": 0,
"weights_file_path": "weights.yaml", "weights_file_path": "weights.yaml",
"meta_file_path": "meta.yaml", "meta_file_path": "meta.yaml",
"spoiler": 3, "spoiler": 2,
"glitch_triforce_room": 1, "glitch_triforce_room": 1,
"race": 0, "race": 0,
"plando_options": "bosses", "plando_options": "bosses",
@@ -285,36 +277,13 @@ def get_default_options() -> OptionsType:
}, },
"oot_options": { "oot_options": {
"rom_file": "The Legend of Zelda - Ocarina of Time.z64", "rom_file": "The Legend of Zelda - Ocarina of Time.z64",
"rom_start": True }
},
"dkc3_options": {
"rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc",
},
"smw_options": {
"rom_file": "Super Mario World (USA).sfc",
},
"zillion_options": {
"rom_file": "Zillion (UE) [!].sms",
# RetroArch doesn't make it easy to launch a game from the command line.
# You have to know the path to the emulator core library on the user's computer.
"rom_start": "retroarch",
},
"pokemon_rb_options": {
"red_rom_file": "Pokemon Red (UE) [S][!].gb",
"blue_rom_file": "Pokemon Blue (UE) [S][!].gb",
"rom_start": True
},
"ffr_options": {
"display_msgs": True,
},
"lufia2ac_options": {
"rom_file": "Lufia II - Rise of the Sinistrals (USA).sfc",
},
} }
return options return options
def update_options(src: dict, dest: dict, filename: str, keys: list) -> OptionsType: def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
for key, value in src.items(): for key, value in src.items():
new_keys = keys.copy() new_keys = keys.copy()
new_keys.append(key) new_keys.append(key)
@@ -334,20 +303,34 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> OptionsT
@cache_argsless @cache_argsless
def get_options() -> OptionsType: def get_options() -> dict:
filenames = ("options.yaml", "host.yaml") if not hasattr(get_options, "options"):
locations: typing.List[str] = [] filenames = ("options.yaml", "host.yaml")
if os.path.join(os.getcwd()) != local_path(): locations = []
locations += filenames # use files from cwd only if it's not the local_path if os.path.join(os.getcwd()) != local_path():
locations += [user_path(filename) for filename in filenames] 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: for location in locations:
if os.path.exists(location): if os.path.exists(location):
with open(location) as f: with open(location) as f:
options = parse_yaml(f.read()) options = parse_yaml(f.read())
return update_options(get_default_options(), options, location, list())
raise FileNotFoundError(f"Could not find {filenames[1]} to load options.") get_options.options = update_options(get_default_options(), options, location, list())
break
else:
raise FileNotFoundError(f"Could not find {filenames[1]} to load options.")
return get_options.options
def get_item_name_from_id(code: int) -> str:
from worlds import lookup_any_item_id_to_name
return lookup_any_item_id_to_name.get(code, f'Unknown item (ID:{code})')
def get_location_name_from_id(code: int) -> str:
from worlds import lookup_any_location_id_to_name
return lookup_any_location_id_to_name.get(code, f'Unknown location (ID:{code})')
def persistent_store(category: str, key: typing.Any, value: typing.Any): def persistent_store(category: str, key: typing.Any, value: typing.Any):
@@ -356,10 +339,10 @@ def persistent_store(category: str, key: typing.Any, value: typing.Any):
category = storage.setdefault(category, {}) category = storage.setdefault(category, {})
category[key] = value category[key] = value
with open(path, "wt") as f: with open(path, "wt") as f:
f.write(dump(storage, Dumper=Dumper)) f.write(dump(storage))
def persistent_load() -> typing.Dict[str, dict]: def persistent_load() -> typing.Dict[dict]:
storage = getattr(persistent_load, "storage", None) storage = getattr(persistent_load, "storage", None)
if storage: if storage:
return storage return storage
@@ -377,8 +360,8 @@ def persistent_load() -> typing.Dict[str, dict]:
return storage return storage
def get_adjuster_settings(game_name: str) -> typing.Dict[str, typing.Any]: def get_adjuster_settings(gameName: str):
adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {}) adjuster_settings = persistent_load().get("adjuster", {}).get(gameName, {})
return adjuster_settings return adjuster_settings
@@ -394,10 +377,10 @@ def get_unique_identifier():
return uuid return uuid
safe_builtins = frozenset(( safe_builtins = {
'set', 'set',
'frozenset', 'frozenset',
)) }
class RestrictedUnpickler(pickle.Unpickler): class RestrictedUnpickler(pickle.Unpickler):
@@ -416,8 +399,7 @@ class RestrictedUnpickler(pickle.Unpickler):
# Options and Plando are unpickled by WebHost -> Generate # Options and Plando are unpickled by WebHost -> Generate
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}: if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
return getattr(self.generic_properties_module, name) return getattr(self.generic_properties_module, name)
# pep 8 specifies that modules should have "all-lowercase names" (options, not Options) if module.endswith("Options"):
if module.lower().endswith("options"):
if module == "Options": if module == "Options":
mod = self.options_module mod = self.options_module
else: else:
@@ -426,7 +408,8 @@ class RestrictedUnpickler(pickle.Unpickler):
if issubclass(obj, self.options_module.Option): if issubclass(obj, self.options_module.Option):
return obj return obj
# Forbid everything else. # Forbid everything else.
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden") raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
(module, name))
def restricted_loads(s): def restricted_loads(s):
@@ -435,9 +418,6 @@ def restricted_loads(s):
class KeyedDefaultDict(collections.defaultdict): class KeyedDefaultDict(collections.defaultdict):
"""defaultdict variant that uses the missing key as argument to default_factory"""
default_factory: typing.Callable[[typing.Any], typing.Any]
def __missing__(self, key): def __missing__(self, key):
self[key] = value = self.default_factory(key) self[key] = value = self.default_factory(key)
return value return value
@@ -447,17 +427,12 @@ def get_text_between(text: str, start: str, end: str) -> str:
return text[text.index(start) + len(start): text.rindex(end)] return text[text.index(start) + len(start): text.rindex(end)]
def get_text_after(text: str, start: str) -> str:
return text[text.index(start) + len(start):]
loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG} loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w", def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
log_format: str = "[%(name)s at %(asctime)s]: %(message)s", log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
exception_logger: typing.Optional[str] = None): exception_logger: typing.Optional[str] = None):
import datetime
loglevel: int = loglevel_mapping.get(loglevel, loglevel) loglevel: int = loglevel_mapping.get(loglevel, loglevel)
log_folder = user_path("logs") log_folder = user_path("logs")
os.makedirs(log_folder, exist_ok=True) os.makedirs(log_folder, exist_ok=True)
@@ -466,8 +441,6 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
root_logger.removeHandler(handler) root_logger.removeHandler(handler)
handler.close() handler.close()
root_logger.setLevel(loglevel) root_logger.setLevel(loglevel)
if "a" not in write_mode:
name += f"_{datetime.datetime.now().strftime('%Y_%m_%d_%H_%M_%S')}"
file_handler = logging.FileHandler( file_handler = logging.FileHandler(
os.path.join(log_folder, f"{name}.txt"), os.path.join(log_folder, f"{name}.txt"),
write_mode, write_mode,
@@ -495,37 +468,15 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
sys.excepthook = handle_exception sys.excepthook = handle_exception
def _cleanup(): logging.info(f"Archipelago ({__version__}) logging initialized.")
for file in os.scandir(log_folder):
if file.name.endswith(".txt"):
last_change = datetime.datetime.fromtimestamp(file.stat().st_mtime)
if datetime.datetime.now() - last_change > datetime.timedelta(days=7):
try:
os.unlink(file.path)
except Exception as e:
logging.exception(e)
else:
logging.info(f"Deleted old logfile {file.path}")
import threading
threading.Thread(target=_cleanup, name="LogCleaner").start()
import platform
logging.info(
f"Archipelago ({__version__}) logging initialized"
f" on {platform.platform()}"
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
)
def stream_input(stream, queue): def stream_input(stream, queue):
def queuer(): def queuer():
while 1: while 1:
try: text = stream.readline().strip()
text = stream.readline().strip() if text:
except UnicodeDecodeError as e: queue.put_nowait(text)
logging.exception(e)
else:
if text:
queue.put_nowait(text)
from threading import Thread from threading import Thread
thread = Thread(target=queuer, name=f"Stream handler for {stream.name}", daemon=True) thread = Thread(target=queuer, name=f"Stream handler for {stream.name}", daemon=True)
@@ -533,11 +484,11 @@ def stream_input(stream, queue):
return thread return thread
def tkinter_center_window(window: "tkinter.Tk") -> None: def tkinter_center_window(window: Tk):
window.update() window.update()
x = int(window.winfo_screenwidth() / 2 - window.winfo_reqwidth() / 2) xPos = int(window.winfo_screenwidth() / 2 - window.winfo_reqwidth() / 2)
y = int(window.winfo_screenheight() / 2 - window.winfo_reqheight() / 2) yPos = int(window.winfo_screenheight() / 2 - window.winfo_reqheight() / 2)
window.geometry(f"+{x}+{y}") window.geometry("+{}+{}".format(xPos, yPos))
class VersionException(Exception): class VersionException(Exception):
@@ -554,27 +505,24 @@ def chaining_prefix(index: int, labels: typing.Tuple[str]) -> str:
# noinspection PyPep8Naming # noinspection PyPep8Naming
def format_SI_prefix(value, power=1000, power_labels=("", "k", "M", "G", "T", "P", "E", "Z", "Y")) -> str: def format_SI_prefix(value, power=1000, power_labels=('', 'k', 'M', 'G', 'T', "P", "E", "Z", "Y")) -> str:
"""Formats a value into a value + metric/si prefix. More info at https://en.wikipedia.org/wiki/Metric_prefix""" """Formats a value into a value + metric/si prefix. More info at https://en.wikipedia.org/wiki/Metric_prefix"""
import decimal
n = 0 n = 0
value = decimal.Decimal(value) value = decimal.Decimal(value)
limit = power - decimal.Decimal("0.005") while value >= power:
while value >= limit:
value /= power value /= power
n += 1 n += 1
return f"{value.quantize(decimal.Decimal('1.00'))} {chaining_prefix(n, power_labels)}" return f"{value.quantize(decimal.Decimal('1.00'))} {chaining_prefix(n, power_labels)}"
def get_fuzzy_ratio(word1: str, word2: str) -> float:
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
/ max(len(word1), len(word2)))
def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: typing.Optional[int] = None) \ def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: typing.Optional[int] = None) \
-> typing.List[typing.Tuple[str, int]]: -> typing.List[typing.Tuple[str, int]]:
import jellyfish
def get_fuzzy_ratio(word1: str, word2: str) -> float:
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
/ max(len(word1), len(word2)))
limit: int = limit if limit else len(wordlist) limit: int = limit if limit else len(wordlist)
return list( return list(
map( map(
@@ -592,19 +540,18 @@ def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: ty
def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]) \ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]) \
-> typing.Optional[str]: -> typing.Optional[str]:
def run(*args: str): def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None return subprocess.run(args, capture_output=True, text=True).stdout.split('\n', 1)[0] or None
if is_linux: if is_linux:
# prefer native dialog # prefer native dialog
from shutil import which kdialog = shutil.which('kdialog')
kdialog = which("kdialog")
if kdialog: if kdialog:
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes)) k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
return run(kdialog, f"--title={title}", "--getopenfilename", ".", k_filters) return run(kdialog, f'--title={title}', '--getopenfilename', '.', k_filters)
zenity = which("zenity") zenity = shutil.which('zenity')
if zenity: if zenity:
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes) z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
return run(zenity, f"--title={title}", "--file-selection", *z_filters) return run(zenity, f'--title={title}', '--file-selection', *z_filters)
# fall back to tk # fall back to tk
try: try:
@@ -622,10 +569,10 @@ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typin
def messagebox(title: str, text: str, error: bool = False) -> None: def messagebox(title: str, text: str, error: bool = False) -> None:
def run(*args: str): def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None return subprocess.run(args, capture_output=True, text=True).stdout.split('\n', 1)[0] or None
def is_kivy_running(): def is_kivy_running():
if "kivy" in sys.modules: if 'kivy' in sys.modules:
from kivy.app import App from kivy.app import App
return App.get_running_app() is not None return App.get_running_app() is not None
return False return False
@@ -635,15 +582,14 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
MessageBox(title, text, error).open() MessageBox(title, text, error).open()
return return
if is_linux and "tkinter" not in sys.modules: if is_linux and not 'tkinter' in sys.modules:
# prefer native dialog # prefer native dialog
from shutil import which kdialog = shutil.which('kdialog')
kdialog = which("kdialog")
if kdialog: if kdialog:
return run(kdialog, f"--title={title}", "--error" if error else "--msgbox", text) return run(kdialog, f'--title={title}', '--error' if error else '--msgbox', text)
zenity = which("zenity") zenity = shutil.which('zenity')
if zenity: if zenity:
return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info") return run(zenity, f'--title={title}', f'--text={text}', '--error' if error else '--info')
# fall back to tk # fall back to tk
try: try:
@@ -658,43 +604,3 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
root.withdraw() root.withdraw()
showerror(title, text) if error else showinfo(title, text) showerror(title, text) if error else showinfo(title, text)
root.update() root.update()
def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset(("a", "the"))):
"""Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning."""
def sorter(element: str) -> str:
parts = element.split(maxsplit=1)
if parts[0].lower() in ignore:
return parts[1].lower()
else:
return element.lower()
return sorted(data, key=lambda i: sorter(key(i)) if key else sorter(i))
def read_snes_rom(stream: BinaryIO, strip_header: bool = True) -> bytearray:
"""Reads rom into bytearray and optionally strips off any smc header"""
buffer = bytearray(stream.read())
if strip_header and len(buffer) % 0x400 == 0x200:
return buffer[0x200:]
return buffer
_faf_tasks: "Set[asyncio.Task[None]]" = set()
def async_start(co: Coroutine[typing.Any, typing.Any, bool], name: Optional[str] = None) -> None:
"""
Use this to start a task when you don't keep a reference to it or immediately await it,
to prevent early garbage collection. "fire-and-forget"
"""
# https://docs.python.org/3.10/library/asyncio-task.html#asyncio.create_task
# Python docs:
# ```
# Important: Save a reference to the result of [asyncio.create_task],
# to avoid a task disappearing mid-execution.
# ```
# This implementation follows the pattern given in that documentation.
task = asyncio.create_task(co, name=name)
_faf_tasks.add(task)
task.add_done_callback(_faf_tasks.discard)

View File

@@ -1,4 +1,5 @@
import os import os
import sys
import multiprocessing import multiprocessing
import logging import logging
import typing import typing
@@ -11,9 +12,9 @@ ModuleUpdate.update()
# in case app gets imported by something like gunicorn # in case app gets imported by something like gunicorn
import Utils import Utils
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8 Utils.local_path.cached_path = os.path.dirname(__file__)
from WebHostLib import register, app as raw_app from WebHostLib import app as raw_app
from waitress import serve from waitress import serve
from WebHostLib.models import db from WebHostLib.models import db
@@ -21,13 +22,14 @@ from WebHostLib.autolauncher import autohost, autogen
from WebHostLib.lttpsprites import update_sprites_lttp from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.options import create as create_options_files from WebHostLib.options import create as create_options_files
from worlds.AutoWorld import AutoWorldRegister
configpath = os.path.abspath("config.yaml") configpath = os.path.abspath("config.yaml")
if not os.path.exists(configpath): # fall back to config.yaml in home if not os.path.exists(configpath): # fall back to config.yaml in home
configpath = os.path.abspath(Utils.user_path('config.yaml')) configpath = os.path.abspath(Utils.user_path('config.yaml'))
def get_app(): def get_app():
register()
app = raw_app app = raw_app
if os.path.exists(configpath): if os.path.exists(configpath):
import yaml import yaml
@@ -41,39 +43,19 @@ def get_app():
def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]: def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
import json import json
import shutil import shutil
import zipfile
zfile: zipfile.ZipInfo
from worlds.AutoWorld import AutoWorldRegister
worlds = {} worlds = {}
data = [] data = []
for game, world in AutoWorldRegister.world_types.items(): for game, world in AutoWorldRegister.world_types.items():
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'): if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
worlds[game] = world worlds[game] = world
base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs")
for game, world in worlds.items(): for game, world in worlds.items():
# copy files from world's docs folder to the generated folder # copy files from world's docs folder to the generated folder
target_path = os.path.join(base_target_path, game) source_path = Utils.local_path(os.path.dirname(sys.modules[world.__module__].__file__), 'docs')
os.makedirs(target_path, exist_ok=True) target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", game)
files = os.listdir(source_path)
if world.zip_path: for file in files:
zipfile_path = world.zip_path os.makedirs(os.path.dirname(Utils.local_path(target_path, file)), exist_ok=True)
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
assert os.path.isfile(zipfile_path), f"{zipfile_path} is not a valid file(path)."
assert zipfile.is_zipfile(zipfile_path), f"{zipfile_path} is not a valid zipfile."
with zipfile.ZipFile(zipfile_path) as zf:
for zfile in zf.infolist():
if not zfile.is_dir() and "/docs/" in zfile.filename:
zf.extract(zfile, target_path)
else:
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
files = os.listdir(source_path)
for file in files:
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
# build a json tutorial dict per game # build a json tutorial dict per game
game_data = {'gameTitle': game, 'tutorials': []} game_data = {'gameTitle': game, 'tutorials': []}
for tutorial in world.web.tutorials: for tutorial in world.web.tutorials:
@@ -103,7 +85,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
for games in data: for games in data:
if 'Archipelago' in games['gameTitle']: if 'Archipelago' in games['gameTitle']:
generic_data = data.pop(data.index(games)) generic_data = data.pop(data.index(games))
sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"]) sorted_data = [generic_data] + sorted(data, key=lambda entry: entry["gameTitle"].lower())
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False) json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
return sorted_data return sorted_data

View File

@@ -1,46 +0,0 @@
# WebHost
## Contribution Guidelines
**Thank you for your interest in contributing to the Archipelago website!**
Much of the content on the website is generated automatically, but there are some things
that need a personal touch. For those things, we rely on contributions from both the core
team and the community. The current primary maintainer of the website is Farrak Kilhn.
He may be found on Discord as `Farrak Kilhn#0418`, or on GitHub as `LegendaryLinux`.
### Small Changes
Little changes like adding a button or a couple new select elements are perfectly fine.
Tweaks to style specific to a PR's content are also probably not a problem. For example, if
you build a new page which needs two side by side tables, and you need to write a CSS file
specific to your page, that is perfectly reasonable.
### Content Additions
Once you develop a new feature or add new content the website, make a pull request. It will
be reviewed by the community and there will probably be some discussion around it. Depending
on the size of the feature, and if new styles are required, there may be an additional step
before the PR is accepted wherein Farrak works with the designer to implement styles.
### Restrictions on Style Changes
A professional designer is paid to develop the styles and assets for the Archipelago website.
In an effort to maintain a consistent look and feel, pull requests which *exclusively*
change site styles are rejected. Please note this applies to code which changes the overall
look and feel of the site, not to small tweaks to CSS for your custom page. The intention
behind these restrictions is to maintain a curated feel for the design of the site. If
any PR affects the overall feel of the site but includes additive changes, there will
likely be a conversation about how to implement those changes without compromising the
curated site style. It is therefore worth noting there are a couple files which, if
changed in your pull request, will cause it to draw additional scrutiny.
These closely guarded files are:
- `globalStyles.css`
- `islandFooter.css`
- `landing.css`
- `markdown.css`
- `tooltip.css`
### Site Themes
There are several themes available for game pages. It is possible to request a new theme in
the `#art-and-design` channel on Discord. Because themes are created by the designer, they
are not free, and take some time to create. Farrak works closely with the designer to implement
these themes, and pays for the assets out of pocket. Therefore, only a couple themes per year
are added. If a proposed theme seems like a cool idea and the community likes it, there is a
good chance it will become a reality.

View File

@@ -1,15 +1,16 @@
import base64
import os import os
import socket
import uuid import uuid
import base64
import socket
from flask import Flask import jinja2.exceptions
from pony.flask import Pony
from flask import Flask, request, redirect, url_for, render_template, Response, session, abort, send_from_directory
from flask_caching import Cache from flask_caching import Cache
from flask_compress import Compress from flask_compress import Compress
from pony.flask import Pony from worlds.AutoWorld import AutoWorldRegister
from werkzeug.routing import BaseConverter
from Utils import title_sorted from .models import *
UPLOAD_FOLDER = os.path.relpath('uploads') UPLOAD_FOLDER = os.path.relpath('uploads')
LOGS_FOLDER = os.path.relpath('logs') LOGS_FOLDER = os.path.relpath('logs')
@@ -31,10 +32,8 @@ app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit
# if you want to deploy, make sure you have a non-guessable secret key # if you want to deploy, make sure you have a non-guessable secret key
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8") app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
# at what amount of worlds should scheduling be used, instead of rolling in the web-thread # at what amount of worlds should scheduling be used, instead of rolling in the webthread
app.config["JOB_THRESHOLD"] = 2 app.config["JOB_THRESHOLD"] = 2
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
app.config["JOB_TIME"] = 600
app.config['SESSION_PERMANENT'] = True app.config['SESSION_PERMANENT'] = True
# waitress uses one thread for I/O, these are for processing of views that then get sent # waitress uses one thread for I/O, these are for processing of views that then get sent
@@ -54,6 +53,8 @@ app.config["PATCH_TARGET"] = "archipelago.gg"
cache = Cache(app) cache = Cache(app)
Compress(app) Compress(app)
from werkzeug.routing import BaseConverter
class B64UUIDConverter(BaseConverter): class B64UUIDConverter(BaseConverter):
@@ -67,20 +68,170 @@ class B64UUIDConverter(BaseConverter):
# short UUID # short UUID
app.url_map.converters["suuid"] = B64UUIDConverter app.url_map.converters["suuid"] = B64UUIDConverter
app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii') app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
app.jinja_env.filters["title_sorted"] = title_sorted
def register(): def get_world_theme(game_name: str):
"""Import submodules, triggering their registering on flask routing. if game_name in AutoWorldRegister.world_types:
Note: initializes worlds subsystem.""" return AutoWorldRegister.world_types[game_name].web.theme
# has automatic patch integration return 'grass'
import worlds.AutoWorld
import worlds.Files
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
game_name in worlds.Files.AutoPatchRegister.patch_types
from WebHostLib.customserver import run_server_process
# to trigger app routing picking up on it
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc
app.register_blueprint(api.api_endpoints) @app.before_request
def register_session():
session.permanent = True # technically 31 days after the last visit
if not session.get("_id", None):
session["_id"] = uuid4() # uniquely identify each session without needing a login
@app.errorhandler(404)
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
def page_not_found(err):
return render_template('404.html'), 404
# Start Playing Page
@app.route('/start-playing')
def start_playing():
return render_template(f"startPlaying.html")
@app.route('/weighted-settings')
def weighted_settings():
return render_template(f"weighted-settings.html")
# Player settings pages
@app.route('/games/<string:game>/player-settings')
def player_settings(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, theme=get_world_theme(game))
# List of supported games
@app.route('/games')
def games():
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
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, theme=get_world_theme(game))
@app.route('/tutorial/')
def tutorial_landing():
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
return render_template("tutorialLanding.html")
@app.route('/faq/<string:lang>/')
def faq(lang):
return render_template("faq.html", lang=lang)
@app.route('/glossary/<string:lang>/')
def terms(lang):
return render_template("glossary.html", lang=lang)
@app.route('/seed/<suuid:seed>')
def view_seed(seed: UUID):
seed = Seed.get(id=seed)
if not seed:
abort(404)
return render_template("viewSeed.html", seed=seed, slot_count=count(seed.slots))
@app.route('/new_room/<suuid:seed>')
def new_room(seed: UUID):
seed = Seed.get(id=seed)
if not seed:
abort(404)
room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
commit()
return redirect(url_for("host_room", room=room.id))
def _read_log(path: str):
if os.path.exists(path):
with open(path, encoding="utf-8-sig") as log:
yield from log
else:
yield f"Logfile {path} does not exist. " \
f"Likely a crash during spinup of multiworld instance or it is still spinning up."
@app.route('/log/<suuid:room>')
def display_log(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
if room.owner == session["_id"]:
return Response(_read_log(os.path.join("logs", str(room.id) + ".txt")), mimetype="text/plain;charset=UTF-8")
return "Access Denied", 403
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
def host_room(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
if request.method == "POST":
if room.owner == session["_id"]:
cmd = request.form["cmd"]
if cmd:
Command(room=room, commandtext=cmd)
commit()
with db_session:
room.last_activity = datetime.utcnow() # will trigger a spinup, if it's not already running
return render_template("hostRoom.html", room=room)
@app.route('/favicon.ico')
def favicon():
return send_from_directory(os.path.join(app.root_path, 'static/static'),
'favicon.ico', mimetype='image/vnd.microsoft.icon')
@app.route('/discord')
def discord():
return redirect("https://discord.gg/archipelago")
@app.route('/datapackage')
@cache.cached()
def get_datapackge():
"""A pretty print version of /api/datapackage"""
from worlds import network_data_package
import json
return Response(json.dumps(network_data_package, indent=4), mimetype="text/plain")
@app.route('/index')
@app.route('/sitemap')
def get_sitemap():
available_games = []
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
available_games.append(game)
return render_template("siteMap.html", games=available_games)
from WebHostLib.customserver import run_server_process
from . import tracker, upload, landing, check, generate, downloads, api, stats # to trigger app routing picking up on it
app.register_blueprint(api.api_endpoints)

View File

@@ -1,11 +1,11 @@
"""API endpoints package.""" """API endpoints package."""
from typing import List, Tuple
from uuid import UUID from uuid import UUID
from typing import List, Tuple
from flask import Blueprint, abort from flask import Blueprint, abort
from .. import cache
from ..models import Room, Seed from ..models import Room, Seed
from .. import cache
api_endpoints = Blueprint('api', __name__, url_prefix="/api") api_endpoints = Blueprint('api', __name__, url_prefix="/api")
@@ -32,18 +32,17 @@ def room_info(room: UUID):
@api_endpoints.route('/datapackage') @api_endpoints.route('/datapackage')
@cache.cached() @cache.cached()
def get_datapackage(): def get_datapackge():
from worlds import network_data_package from worlds import network_data_package
return network_data_package return network_data_package
@api_endpoints.route('/datapackage_version') @api_endpoints.route('/datapackage_version')
@cache.cached() @cache.cached()
def get_datapackge_versions():
def get_datapackage_versions():
from worlds import network_data_package, AutoWorldRegister from worlds import network_data_package, AutoWorldRegister
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()} version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
version_package["version"] = network_data_package["version"]
return version_package return version_package

View File

@@ -1,15 +1,15 @@
import json import json
import pickle import pickle
from uuid import UUID from uuid import UUID
from flask import request, session, url_for, Markup from . import api_endpoints
from flask import request, session, url_for
from pony.orm import commit from pony.orm import commit
from WebHostLib import app from WebHostLib import app, Generation, STATE_QUEUED, Seed, STATE_ERROR
from WebHostLib.check import get_yaml_data, roll_options from WebHostLib.check import get_yaml_data, roll_options
from WebHostLib.generate import get_meta from WebHostLib.generate import get_meta
from WebHostLib.models import Generation, STATE_QUEUED, Seed, STATE_ERROR
from . import api_endpoints
@api_endpoints.route('/generate', methods=['POST']) @api_endpoints.route('/generate', methods=['POST'])
@@ -21,18 +21,13 @@ def generate_api():
if 'file' in request.files: if 'file' in request.files:
file = request.files['file'] file = request.files['file']
options = get_yaml_data(file) options = get_yaml_data(file)
if isinstance(options, Markup): if type(options) == str:
return {"text": options.striptags()}, 400
if isinstance(options, str):
return {"text": options}, 400 return {"text": options}, 400
if "race" in request.form: if "race" in request.form:
race = bool(0 if request.form["race"] in {"false"} else int(request.form["race"])) race = bool(0 if request.form["race"] in {"false"} else int(request.form["race"]))
meta_options_source = request.form meta_options_source = request.form
# json_data is optional, we can have it silently fall to None as it used to do. json_data = request.get_json()
# See https://flask.palletsprojects.com/en/2.2.x/api/#flask.Request.get_json -> Changelog -> 2.1
json_data = request.get_json(silent=True)
if json_data: if json_data:
meta_options_source = json_data meta_options_source = json_data
if 'weights' in json_data: if 'weights' in json_data:

View File

@@ -1,7 +1,6 @@
from flask import session, jsonify from flask import session, jsonify
from pony.orm import select
from WebHostLib.models import Room, Seed from WebHostLib.models import *
from . import api_endpoints, get_players from . import api_endpoints, get_players

View File

@@ -1,15 +1,15 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import json
import multiprocessing import multiprocessing
import os
import sys
import threading import threading
import time
import typing
from datetime import timedelta, datetime from datetime import timedelta, datetime
import sys
import typing
import time
import os
from pony.orm import db_session, select, commit from pony.orm import db_session, select, commit
from Utils import restricted_loads from Utils import restricted_loads
@@ -154,10 +154,8 @@ def autogen(config: dict):
while 1: while 1:
time.sleep(0.1) time.sleep(0.1)
with db_session: with db_session:
# for update locks the database row(s) during transaction, preventing writes from elsewhere
to_start = select( to_start = select(
generation for generation in Generation generation for generation in Generation if generation.state == STATE_QUEUED)
if generation.state == STATE_QUEUED).for_update()
for generation in to_start: for generation in to_start:
launch_generator(generator_pool, generation) launch_generator(generator_pool, generation)
except AlreadyRunningException: except AlreadyRunningException:
@@ -184,7 +182,7 @@ class MultiworldInstance():
logging.info(f"Spinning up {self.room_id}") logging.info(f"Spinning up {self.room_id}")
process = multiprocessing.Process(group=None, target=run_server_process, process = multiprocessing.Process(group=None, target=run_server_process,
args=(self.room_id, self.ponyconfig, get_static_server_data()), args=(self.room_id, self.ponyconfig),
name="MultiHost") name="MultiHost")
process.start() process.start()
# bind after start to prevent thread sync issues with guardian. # bind after start to prevent thread sync issues with guardian.
@@ -238,5 +236,5 @@ def run_guardian():
from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed
from .customserver import run_server_process, get_static_server_data from .customserver import run_server_process
from .generate import gen_game from .generate import gen_game

View File

@@ -1,7 +1,7 @@
import zipfile import zipfile
from typing import * from typing import *
from flask import request, flash, redirect, url_for, render_template, Markup from flask import request, flash, redirect, url_for, session, render_template
from WebHostLib import app from WebHostLib import app
@@ -12,7 +12,7 @@ def allowed_file(filename):
return filename.endswith(('.txt', ".yaml", ".zip")) return filename.endswith(('.txt', ".yaml", ".zip"))
from Generate import roll_settings, PlandoSettings from Generate import roll_settings
from Utils import parse_yamls from Utils import parse_yamls
@@ -25,7 +25,7 @@ def check():
else: else:
file = request.files['file'] file = request.files['file']
options = get_yaml_data(file) options = get_yaml_data(file)
if isinstance(options, str): if type(options) == str:
flash(options) flash(options)
else: else:
results, _ = roll_options(options) results, _ = roll_options(options)
@@ -38,7 +38,7 @@ def mysterycheck():
return redirect(url_for("check"), 301) return redirect(url_for("check"), 301)
def get_yaml_data(file) -> Union[Dict[str, str], str, Markup]: def get_yaml_data(file) -> Union[Dict[str, str], str]:
options = {} options = {}
# if user does not select file, browser also # if user does not select file, browser also
# submit an empty part without filename # submit an empty part without filename
@@ -50,10 +50,6 @@ def get_yaml_data(file) -> Union[Dict[str, str], str, Markup]:
with zipfile.ZipFile(file, 'r') as zfile: with zipfile.ZipFile(file, 'r') as zfile:
infolist = zfile.infolist() infolist = zfile.infolist()
if any(file.filename.endswith(".archipelago") for file in infolist):
return Markup("Error: Your .zip file contains an .archipelago file. "
'Did you mean to <a href="/uploads">host a game</a>?')
for file in infolist: for file in infolist:
if file.filename.endswith(banned_zip_contents): if file.filename.endswith(banned_zip_contents):
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. Your file was deleted." return "Uploaded data contained a rom file, which is likely to contain copyrighted material. Your file was deleted."
@@ -69,7 +65,7 @@ def get_yaml_data(file) -> Union[Dict[str, str], str, Markup]:
def roll_options(options: Dict[str, Union[dict, str]], def roll_options(options: Dict[str, Union[dict, str]],
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \ plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]: Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
plando_options = PlandoSettings.from_set(set(plando_options)) plando_options = set(plando_options)
results = {} results = {}
rolled_results = {} rolled_results = {}
for filename, text in options.items(): for filename, text in options.items():

View File

@@ -1,23 +1,20 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import collections
import datetime
import functools import functools
import logging import websockets
import pickle import asyncio
import random
import socket import socket
import threading import threading
import time import time
import random
import websockets import pickle
from pony.orm import db_session, commit, select import logging
import Utils import Utils
from .models import *
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless from Utils import get_public_ipv4, get_public_ipv6, restricted_loads
from .models import Room, Command, db
class CustomClientMessageProcessor(ClientMessageProcessor): class CustomClientMessageProcessor(ClientMessageProcessor):
@@ -42,7 +39,7 @@ class CustomClientMessageProcessor(ClientMessageProcessor):
import MultiServer import MultiServer
MultiServer.client_message_processor = CustomClientMessageProcessor MultiServer.client_message_processor = CustomClientMessageProcessor
del MultiServer del (MultiServer)
class DBCommandProcessor(ServerCommandProcessor): class DBCommandProcessor(ServerCommandProcessor):
@@ -51,24 +48,12 @@ class DBCommandProcessor(ServerCommandProcessor):
class WebHostContext(Context): class WebHostContext(Context):
room_id: int def __init__(self):
def __init__(self, static_server_data: dict):
# static server data is used during _load_game_data to load required data,
# without needing to import worlds system, which takes quite a bit of memory
self.static_server_data = static_server_data
super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", "enabled", 0, 2) super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", "enabled", 0, 2)
del self.static_server_data
self.main_loop = asyncio.get_running_loop() self.main_loop = asyncio.get_running_loop()
self.video = {} self.video = {}
self.tags = ["AP", "WebHost"] self.tags = ["AP", "WebHost"]
def _load_game_data(self):
for key, value in self.static_server_data.items():
setattr(self, key, value)
self.forced_auto_forfeits = collections.defaultdict(lambda: False, self.forced_auto_forfeits)
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
def listen_to_db_commands(self): def listen_to_db_commands(self):
cmdprocessor = DBCommandProcessor(self) cmdprocessor = DBCommandProcessor(self)
@@ -109,7 +94,7 @@ class WebHostContext(Context):
room.multisave = pickle.dumps(self.get_save()) room.multisave = pickle.dumps(self.get_save())
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity # saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
room.last_activity = datetime.datetime.utcnow() room.last_activity = datetime.utcnow()
return True return True
def get_save(self) -> dict: def get_save(self) -> dict:
@@ -122,32 +107,14 @@ def get_random_port():
return random.randint(49152, 65535) return random.randint(49152, 65535)
@cache_argsless def run_server_process(room_id, ponyconfig: dict):
def get_static_server_data() -> dict:
import worlds
data = {
"forced_auto_forfeits": {},
"non_hintable_names": {},
"gamespackage": worlds.network_data_package["games"],
"item_name_groups": {world_name: world.item_name_groups for world_name, world in
worlds.AutoWorldRegister.world_types.items()},
}
for world_name, world in worlds.AutoWorldRegister.world_types.items():
data["forced_auto_forfeits"][world_name] = world.forced_auto_forfeit
data["non_hintable_names"][world_name] = world.hint_blacklist
return data
def run_server_process(room_id, ponyconfig: dict, static_server_data: dict):
# establish DB connection for multidata and multisave # establish DB connection for multidata and multisave
db.bind(**ponyconfig) db.bind(**ponyconfig)
db.generate_mapping(check_tables=False) db.generate_mapping(check_tables=False)
async def main(): async def main():
Utils.init_logging(str(room_id), write_mode="a") Utils.init_logging(str(room_id), write_mode="a")
ctx = WebHostContext(static_server_data) ctx = WebHostContext()
ctx.load(room_id) ctx.load(room_id)
ctx.init_save() ctx.init_save()
@@ -184,12 +151,4 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict):
from .autolauncher import Locker from .autolauncher import Locker
with Locker(room_id): with Locker(room_id):
try: asyncio.run(main())
asyncio.run(main())
except:
with db_session:
room = Room.get(id=room_id)
room.last_port = -1
# ensure the Room does not spin up again on its own, minute of safety buffer
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
raise

View File

@@ -1,13 +1,12 @@
import json
import zipfile import zipfile
import json
from io import BytesIO from io import BytesIO
from flask import send_file, Response, render_template from flask import send_file, Response, render_template
from pony.orm import select from pony.orm import select
from worlds.Files import AutoPatchRegister from Patch import update_patch_data, preferred_endings, AutoPatchRegister
from . import app, cache from WebHostLib import app, Slot, Room, Seed, cache
from .models import Slot, Room, Seed
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>") @app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
@@ -33,16 +32,18 @@ def download_patch(room_id, patch_id):
new_zip.writestr("archipelago.json", json.dumps(manifest)) new_zip.writestr("archipelago.json", json.dumps(manifest))
else: else:
new_zip.writestr(file.filename, zf.read(file), file.compress_type, 9) new_zip.writestr(file.filename, zf.read(file), file.compress_type, 9)
if "patch_file_ending" in manifest:
patch_file_ending = manifest["patch_file_ending"]
else:
patch_file_ending = AutoPatchRegister.patch_types[patch.game].patch_file_ending
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}" \ fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}" \
f"{patch_file_ending}" f"{AutoPatchRegister.patch_types[patch.game].patch_file_ending}"
new_file.seek(0) new_file.seek(0)
return send_file(new_file, as_attachment=True, download_name=fname) return send_file(new_file, as_attachment=True, attachment_filename=fname)
else: else:
return "Old Patch file, no longer compatible." 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]}"
return send_file(patch_data, as_attachment=True, attachment_filename=fname)
@app.route("/dl_spoiler/<suuid:seed_id>") @app.route("/dl_spoiler/<suuid:seed_id>")
@@ -65,32 +66,21 @@ def download_slot_file(room_id, player_id: int):
from worlds.minecraft import mc_update_output from worlds.minecraft import mc_update_output
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc" fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
data = mc_update_output(slot_data.data, server=app.config['PATCH_TARGET'], port=room.last_port) data = mc_update_output(slot_data.data, server=app.config['PATCH_TARGET'], port=room.last_port)
return send_file(io.BytesIO(data), as_attachment=True, download_name=fname) return send_file(io.BytesIO(data), as_attachment=True, attachment_filename=fname)
elif slot_data.game == "Factorio": elif slot_data.game == "Factorio":
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf: with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
for name in zf.namelist(): for name in zf.namelist():
if name.endswith("info.json"): if name.endswith("info.json"):
fname = name.rsplit("/", 1)[0] + ".zip" fname = name.rsplit("/", 1)[0] + ".zip"
elif slot_data.game == "Ocarina of Time": elif slot_data.game == "Ocarina of Time":
stream = io.BytesIO(slot_data.data) fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
if zipfile.is_zipfile(stream):
with zipfile.ZipFile(stream) as zf:
for name in zf.namelist():
if name.endswith(".zpf"):
fname = name.rsplit(".", 1)[0] + ".apz5"
else: # pre-ootr-7.0 support
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": elif slot_data.game == "VVVVVV":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6" fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
elif slot_data.game == "Zillion":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apzl"
elif slot_data.game == "Super Mario 64": elif slot_data.game == "Super Mario 64":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex" fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
elif slot_data.game == "Dark Souls III":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json"
else: else:
return "Game download not supported." return "Game download not supported."
return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname) return send_file(io.BytesIO(slot_data.data), as_attachment=True, attachment_filename=fname)
@app.route("/templates") @app.route("/templates")

View File

@@ -1,24 +1,23 @@
import json
import os import os
import pickle
import random
import tempfile import tempfile
import random
import json
import zipfile import zipfile
import concurrent.futures
from collections import Counter from collections import Counter
from typing import Dict, Optional, Any from typing import Dict, Optional as TypeOptional
from Utils import __version__
from flask import request, flash, redirect, url_for, session, render_template from flask import request, flash, redirect, url_for, session, render_template
from pony.orm import commit, db_session
from BaseClasses import seeddigits, get_seed
from Generate import handle_name, PlandoSettings
from Main import main as ERmain
from Utils import __version__
from WebHostLib import app
from worlds.alttp.EntranceRandomizer import parse_arguments from worlds.alttp.EntranceRandomizer import parse_arguments
from Main import main as ERmain
from BaseClasses import seeddigits, get_seed
from Generate import handle_name
import pickle
from .models import *
from WebHostLib import app
from .check import get_yaml_data, roll_options from .check import get_yaml_data, roll_options
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
from .upload import upload_zip_to_db from .upload import upload_zip_to_db
@@ -31,15 +30,16 @@ def get_meta(options_source: dict) -> dict:
} }
plando_options -= {""} plando_options -= {""}
server_options = { meta = {
"hint_cost": int(options_source.get("hint_cost", 10)), "hint_cost": int(options_source.get("hint_cost", 10)),
"forfeit_mode": options_source.get("forfeit_mode", "goal"), "forfeit_mode": options_source.get("forfeit_mode", "goal"),
"remaining_mode": options_source.get("remaining_mode", "disabled"), "remaining_mode": options_source.get("remaining_mode", "disabled"),
"collect_mode": options_source.get("collect_mode", "disabled"), "collect_mode": options_source.get("collect_mode", "disabled"),
"item_cheat": bool(int(options_source.get("item_cheat", 1))), "item_cheat": bool(int(options_source.get("item_cheat", 1))),
"server_password": options_source.get("server_password", None), "server_password": options_source.get("server_password", None),
"plando_options": list(plando_options)
} }
return {"server_options": server_options, "plando_options": list(plando_options)} return meta
@app.route('/generate', methods=['GET', 'POST']) @app.route('/generate', methods=['GET', 'POST'])
@@ -52,7 +52,7 @@ def generate(race=False):
else: else:
file = request.files['file'] file = request.files['file']
options = get_yaml_data(file) options = get_yaml_data(file)
if isinstance(options, str): if type(options) == str:
flash(options) flash(options)
else: else:
meta = get_meta(request.form) meta = get_meta(request.form)
@@ -60,13 +60,13 @@ def generate(race=False):
results, gen_options = roll_options(options, meta["plando_options"]) results, gen_options = roll_options(options, meta["plando_options"])
if race: if race:
meta["server_options"]["item_cheat"] = False meta["item_cheat"] = False
meta["server_options"]["remaining_mode"] = "disabled" meta["remaining_mode"] = "disabled"
if any(type(result) == str for result in results.values()): if any(type(result) == str for result in results.values()):
return render_template("checkResult.html", results=results) return render_template("checkResult.html", results=results)
elif len(gen_options) > app.config["MAX_ROLL"]: elif len(gen_options) > app.config["MAX_ROLL"]:
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. " flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players for now. "
f"If you have a larger group, please generate it yourself and upload it.") f"If you have a larger group, please generate it yourself and upload it.")
elif len(gen_options) >= app.config["JOB_THRESHOLD"]: elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
gen = Generation( gen = Generation(
@@ -92,35 +92,35 @@ def generate(race=False):
return render_template("generate.html", race=race, version=__version__) return render_template("generate.html", race=race, version=__version__)
def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None): def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=None, sid=None):
if not meta: if not meta:
meta: Dict[str, Any] = {} meta: Dict[str, object] = {}
meta.setdefault("server_options", {}).setdefault("hint_cost", 10) meta.setdefault("hint_cost", 10)
race = meta.setdefault("race", False) race = meta.get("race", False)
del (meta["race"])
def task(): plando_options = meta.get("plando", {"bosses", "items", "connections", "texts"})
del (meta["plando_options"])
try:
target = tempfile.TemporaryDirectory() target = tempfile.TemporaryDirectory()
playercount = len(gen_options) playercount = len(gen_options)
seed = get_seed() seed = get_seed()
random.seed(seed)
if race: if race:
random.seed() # use time-based random source random.seed() # reset to time-based random source
else:
random.seed(seed)
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)) seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
erargs = parse_arguments(['--multi', str(playercount)]) erargs = parse_arguments(['--multi', str(playercount)])
erargs.seed = seed erargs.seed = seed
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwrittin in mystery
erargs.spoiler = 0 if race else 3 erargs.spoiler = 0 if race else 2
erargs.race = race erargs.race = race
erargs.outputname = seedname erargs.outputname = seedname
erargs.outputpath = target.name erargs.outputpath = target.name
erargs.teams = 1 erargs.teams = 1
erargs.plando_options = PlandoSettings.from_set(meta.setdefault("plando_options", erargs.plando_options = ", ".join(plando_options)
{"bosses", "items", "connections", "texts"}))
name_counter = Counter() name_counter = Counter()
for player, (playerfile, settings) in enumerate(gen_options.items(), 1): for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
@@ -136,26 +136,9 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
erargs.name[player] = handle_name(erargs.name[player], player, name_counter) erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
if len(set(erargs.name.values())) != len(erargs.name): if len(set(erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}") raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
ERmain(erargs, seed, baked_server_options=meta["server_options"]) ERmain(erargs, seed, baked_server_options=meta)
return upload_to_db(target.name, sid, owner, race) return upload_to_db(target.name, sid, owner, race)
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
thread = thread_pool.submit(task)
try:
return thread.result(app.config["JOB_TIME"])
except concurrent.futures.TimeoutError as e:
if sid:
with db_session:
gen = Generation.get(id=sid)
if gen is not None:
gen.state = STATE_ERROR
meta = json.loads(gen.meta)
meta["error"] = (
"Allowed time for Generation exceeded, please consider generating locally instead. " +
e.__class__.__name__ + ": " + str(e))
gen.meta = json.dumps(meta)
commit()
except BaseException as e: except BaseException as e:
if sid: if sid:
with db_session: with db_session:
@@ -165,6 +148,7 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
meta = json.loads(gen.meta) meta = json.loads(gen.meta)
meta["error"] = (e.__class__.__name__ + ": " + str(e)) meta["error"] = (e.__class__.__name__ + ": " + str(e))
gen.meta = json.dumps(meta) gen.meta = json.dumps(meta)
commit() commit()
raise raise

View File

@@ -1,11 +1,7 @@
from datetime import timedelta, datetime
from flask import render_template from flask import render_template
from pony.orm import count
from WebHostLib import app, cache from WebHostLib import app, cache
from .models import Room, Seed from .models import *
from datetime import timedelta
@app.route('/', methods=['GET', 'POST']) @app.route('/', methods=['GET', 'POST'])
@cache.cached(timeout=300) # cache has to appear under app route for caching to work @cache.cached(timeout=300) # cache has to appear under app route for caching to work

View File

@@ -32,7 +32,7 @@ def update_sprites_lttp():
spriteData = [] spriteData = []
for file in (file for file in os.listdir(input_dir) if not file.startswith(".")): for file in os.listdir(input_dir):
sprite = Sprite(os.path.join(input_dir, file)) sprite = Sprite(os.path.join(input_dir, file))
if not sprite.name: if not sprite.name:

View File

@@ -1,174 +0,0 @@
import datetime
import os
import jinja2.exceptions
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
from pony.orm import count, commit, db_session
from worlds.AutoWorld import AutoWorldRegister
from . import app, cache
from .models import Seed, Room, Command, UUID, uuid4
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
if not session.get("_id", None):
session["_id"] = uuid4() # uniquely identify each session without needing a login
@app.errorhandler(404)
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
def page_not_found(err):
return render_template('404.html'), 404
# Start Playing Page
@app.route('/start-playing')
def start_playing():
return render_template(f"startPlaying.html")
@app.route('/weighted-settings')
def weighted_settings():
return render_template(f"weighted-settings.html")
# Player settings pages
@app.route('/games/<string:game>/player-settings')
def player_settings(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, theme=get_world_theme(game))
# List of supported games
@app.route('/games')
def games():
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
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, theme=get_world_theme(game))
@app.route('/tutorial/')
def tutorial_landing():
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
return render_template("tutorialLanding.html")
@app.route('/faq/<string:lang>/')
def faq(lang):
return render_template("faq.html", lang=lang)
@app.route('/glossary/<string:lang>/')
def terms(lang):
return render_template("glossary.html", lang=lang)
@app.route('/seed/<suuid:seed>')
def view_seed(seed: UUID):
seed = Seed.get(id=seed)
if not seed:
abort(404)
return render_template("viewSeed.html", seed=seed, slot_count=count(seed.slots))
@app.route('/new_room/<suuid:seed>')
def new_room(seed: UUID):
seed = Seed.get(id=seed)
if not seed:
abort(404)
room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
commit()
return redirect(url_for("host_room", room=room.id))
def _read_log(path: str):
if os.path.exists(path):
with open(path, encoding="utf-8-sig") as log:
yield from log
else:
yield f"Logfile {path} does not exist. " \
f"Likely a crash during spinup of multiworld instance or it is still spinning up."
@app.route('/log/<suuid:room>')
def display_log(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
if room.owner == session["_id"]:
return Response(_read_log(os.path.join("logs", str(room.id) + ".txt")), mimetype="text/plain;charset=UTF-8")
return "Access Denied", 403
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
def host_room(room: UUID):
room: Room = Room.get(id=room)
if room is None:
return abort(404)
if request.method == "POST":
if room.owner == session["_id"]:
cmd = request.form["cmd"]
if cmd:
Command(room=room, commandtext=cmd)
commit()
now = datetime.datetime.utcnow()
# indicate that the page should reload to get the assigned port
should_refresh = not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3)
with db_session:
room.last_activity = now # will trigger a spinup, if it's not already running
return render_template("hostRoom.html", room=room, should_refresh=should_refresh)
@app.route('/favicon.ico')
def favicon():
return send_from_directory(os.path.join(app.root_path, 'static/static'),
'favicon.ico', mimetype='image/vnd.microsoft.icon')
@app.route('/discord')
def discord():
return redirect("https://discord.gg/8Z65BR2")
@app.route('/datapackage')
@cache.cached()
def get_datapackage():
"""A pretty print version of /api/datapackage"""
from worlds import network_data_package
import json
return Response(json.dumps(network_data_package, indent=4), mimetype="text/plain")
@app.route('/index')
@app.route('/sitemap')
def get_sitemap():
available_games = []
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
available_games.append(game)
return render_template("siteMap.html", games=available_games)

View File

@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from pony.orm import Database, PrimaryKey, Required, Set, Optional, buffer, LongStr from pony.orm import *
db = Database() db = Database()
@@ -27,9 +27,8 @@ class Room(db.Entity):
seed = Required('Seed', index=True) seed = Required('Seed', index=True)
multisave = Optional(buffer, lazy=True) multisave = Optional(buffer, lazy=True)
show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always
timeout = Required(int, default=lambda: 2 * 60 * 60) # seconds since last activity to shutdown timeout = Required(int, default=lambda: 6 * 60 * 60) # seconds since last activity to shutdown
tracker = Optional(UUID, index=True) tracker = Optional(UUID, index=True)
# Port special value -1 means the server errored out. Another attempt can be made with a page refresh
last_port = Optional(int, default=lambda: 0) last_port = Optional(int, default=lambda: 0)

View File

@@ -1,38 +1,41 @@
import json
import logging import logging
import os import os
from Utils import __version__
from jinja2 import Template
import yaml
import json
import typing import typing
import yaml
from jinja2 import Template
import Options
from Utils import __version__, local_path
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
import Options
target_folder = os.path.join("WebHostLib", "static", "generated")
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints", handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
"exclude_locations"} "exclude_locations"}
def create(): def create():
target_folder = local_path("WebHostLib", "static", "generated") os.makedirs(os.path.join(target_folder, 'configs'), exist_ok=True)
yaml_folder = os.path.join(target_folder, "configs")
os.makedirs(yaml_folder, exist_ok=True)
for file in os.listdir(yaml_folder):
full_path: str = os.path.join(yaml_folder, file)
if os.path.isfile(full_path):
os.unlink(full_path)
def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]): def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]):
data = {option.default: 50} data = {}
for sub_option in ["random", "random-low", "random-high"]: special = getattr(option, "special_range_cutoff", None)
if sub_option != option.default: if special is not None:
data[sub_option] = 0 data[special] = 0
data.update({
option.range_start: 0,
option.range_end: 0,
"random": 0, "random-low": 0, "random-high": 0,
option.default: 50
})
notes = {
special: "minimum value without special meaning",
option.range_start: "minimum value",
option.range_end: "maximum value"
}
notes = {}
for name, number in getattr(option, "special_range_names", {}).items(): for name, number in getattr(option, "special_range_names", {}).items():
notes[name] = f"equivalent to {number}"
if number in data: if number in data:
data[name] = data[number] data[name] = data[number]
del data[number] del data[number]
@@ -41,10 +44,10 @@ def create():
return data, notes return data, notes
def get_html_doc(option_type: type(Options.Option)) -> str: def default_converter(default_value):
if not option_type.__doc__: if isinstance(default_value, (set, frozenset)):
return "Please document me!" return list(default_value)
return "\n".join(line.strip() for line in option_type.__doc__.split("\n")).strip() return default_value
weighted_settings = { weighted_settings = {
"baseOptions": { "baseOptions": {
@@ -57,21 +60,14 @@ def create():
for game_name, world in AutoWorldRegister.world_types.items(): for game_name, world in AutoWorldRegister.world_types.items():
all_options: typing.Dict[str, Options.AssembleOptions] = { all_options = {**world.options, **Options.per_game_common_options}
**Options.per_game_common_options, res = Template(open(os.path.join("WebHostLib", "templates", "options.yaml")).read()).render(
**world.option_definitions
}
with open(local_path("WebHostLib", "templates", "options.yaml")) as f:
file_data = f.read()
res = Template(file_data).render(
options=all_options, options=all_options,
__version__=__version__, game=game_name, yaml_dump=yaml.dump, __version__=__version__, game=game_name, yaml_dump=yaml.dump,
dictify_range=dictify_range, dictify_range=dictify_range, default_converter=default_converter,
) )
del file_data with open(os.path.join(target_folder, 'configs', game_name + ".yaml"), "w") as f:
with open(os.path.join(target_folder, "configs", game_name + ".yaml"), "w", encoding="utf-8") as f:
f.write(res) f.write(res)
# Generate JSON files for player-settings pages # Generate JSON files for player-settings pages
@@ -92,7 +88,7 @@ def create():
game_options[option_name] = this_option = { game_options[option_name] = this_option = {
"type": "select", "type": "select",
"displayName": option.display_name if hasattr(option, "display_name") else option_name, "displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option), "description": option.__doc__ if option.__doc__ else "Please document me!",
"defaultValue": None, "defaultValue": None,
"options": [] "options": []
} }
@@ -106,21 +102,26 @@ def create():
if sub_option_id == option.default: if sub_option_id == option.default:
this_option["defaultValue"] = sub_option_name this_option["defaultValue"] = sub_option_name
this_option["options"].append({
"name": "Random",
"value": "random",
})
if option.default == "random": if option.default == "random":
this_option["defaultValue"] = "random" this_option["defaultValue"] = "random"
elif issubclass(option, Options.Range): elif hasattr(option, "range_start") and hasattr(option, "range_end"):
game_options[option_name] = { game_options[option_name] = {
"type": "range", "type": "range",
"displayName": option.display_name if hasattr(option, "display_name") else option_name, "displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option), "description": option.__doc__ if option.__doc__ else "Please document me!",
"defaultValue": option.default if hasattr( "defaultValue": option.default if hasattr(
option, "default") and option.default != "random" else option.range_start, option, "default") and option.default != "random" else option.range_start,
"min": option.range_start, "min": option.range_start,
"max": option.range_end, "max": option.range_end,
} }
if issubclass(option, Options.SpecialRange): if hasattr(option, "special_range_names"):
game_options[option_name]["type"] = 'special_range' game_options[option_name]["type"] = 'special_range'
game_options[option_name]["value_names"] = {} game_options[option_name]["value_names"] = {}
for key, val in option.special_range_names.items(): for key, val in option.special_range_names.items():
@@ -130,22 +131,22 @@ def create():
game_options[option_name] = { game_options[option_name] = {
"type": "items-list", "type": "items-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name, "displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option), "description": option.__doc__ if option.__doc__ else "Please document me!",
} }
elif getattr(option, "verify_location_name", False): elif getattr(option, "verify_location_name", False):
game_options[option_name] = { game_options[option_name] = {
"type": "locations-list", "type": "locations-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name, "displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option), "description": option.__doc__ if option.__doc__ else "Please document me!",
} }
elif issubclass(option, Options.OptionList) or issubclass(option, Options.OptionSet): elif hasattr(option, "valid_keys"):
if option.valid_keys: if option.valid_keys:
game_options[option_name] = { game_options[option_name] = {
"type": "custom-list", "type": "custom-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name, "displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option), "description": option.__doc__ if option.__doc__ else "Please document me!",
"options": list(option.valid_keys), "options": list(option.valid_keys),
} }

View File

@@ -1,7 +1,7 @@
flask>=2.2.2 flask>=2.1.2
pony>=0.7.16 pony>=0.7.16
waitress>=2.1.2 waitress>=2.1.1
Flask-Caching>=2.0.1 flask-caching>=1.11.1
Flask-Compress>=1.13 Flask-Compress>=1.12
Flask-Limiter>=2.8.1 Flask-Limiter>=2.4.6
bokeh>=3.0.2 bokeh>=2.4.3

View File

@@ -4,7 +4,6 @@ window.addEventListener('load', () => {
"ordering": true, "ordering": true,
"info": false, "info": false,
"dom": "t", "dom": "t",
"stateSave": true,
}); });
console.log(tables); console.log(tables);
}); });

View File

@@ -26,22 +26,24 @@ window.addEventListener('load', () => {
adjustHeaderWidth(); adjustHeaderWidth();
// Reset the id of all header divs to something nicer // Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) { const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase(); const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
header.setAttribute('id', headerId); for (let i=0; i < headers.length; i++){
header.addEventListener('click', () => { const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
window.location.hash = `#${headerId}`; headers[i].setAttribute('id', headerId);
header.scrollIntoView(); headers[i].addEventListener('click', () =>
}); window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
} }
// Manually scroll the user to the appropriate header if anchor navigation is used // Manually scroll the user to the appropriate header if anchor navigation is used
document.fonts.ready.finally(() => { if (scrollTargetIndex > -1) {
if (window.location.hash) { try{
const scrollTarget = document.getElementById(window.location.hash.substring(1)); const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
scrollTarget?.scrollIntoView(); document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
} catch(error) {
console.error(error);
} }
}); }
}).catch((error) => { }).catch((error) => {
console.error(error); console.error(error);
tutorialWrapper.innerHTML = tutorialWrapper.innerHTML =

View File

@@ -46,7 +46,7 @@ the website is not required to generate them.
## How do I get started? ## How do I get started?
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
our discord server at the [Archipelago Discord](https://discord.gg/8Z65BR2). There are always people ready to answer our discord server at the [Archipelago Discord](https://discord.gg/archipelago). There are always people ready to answer
any questions you might have. any questions you might have.
## What are some common terms I should know? ## What are some common terms I should know?

View File

@@ -26,22 +26,24 @@ window.addEventListener('load', () => {
adjustHeaderWidth(); adjustHeaderWidth();
// Reset the id of all header divs to something nicer // Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) { const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase(); const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
header.setAttribute('id', headerId); for (let i=0; i < headers.length; i++){
header.addEventListener('click', () => { const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
window.location.hash = `#${headerId}`; headers[i].setAttribute('id', headerId);
header.scrollIntoView(); headers[i].addEventListener('click', () =>
}); window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
} }
// Manually scroll the user to the appropriate header if anchor navigation is used // Manually scroll the user to the appropriate header if anchor navigation is used
document.fonts.ready.finally(() => { if (scrollTargetIndex > -1) {
if (window.location.hash) { try{
const scrollTarget = document.getElementById(window.location.hash.substring(1)); const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
scrollTarget?.scrollIntoView(); document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
} catch(error) {
console.error(error);
} }
}); }
}).catch((error) => { }).catch((error) => {
console.error(error); console.error(error);
gameInfo.innerHTML = gameInfo.innerHTML =

View File

@@ -26,22 +26,24 @@ window.addEventListener('load', () => {
adjustHeaderWidth(); adjustHeaderWidth();
// Reset the id of all header divs to something nicer // Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) { const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase(); const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
header.setAttribute('id', headerId); for (let i=0; i < headers.length; i++){
header.addEventListener('click', () => { const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
window.location.hash = `#${headerId}`; headers[i].setAttribute('id', headerId);
header.scrollIntoView(); headers[i].addEventListener('click', () =>
}); window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
} }
// Manually scroll the user to the appropriate header if anchor navigation is used // Manually scroll the user to the appropriate header if anchor navigation is used
document.fonts.ready.finally(() => { if (scrollTargetIndex > -1) {
if (window.location.hash) { try{
const scrollTarget = document.getElementById(window.location.hash.substring(1)); const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
scrollTarget?.scrollIntoView(); document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
} catch(error) {
console.error(error);
} }
}); }
}).catch((error) => { }).catch((error) => {
console.error(error); console.error(error);
tutorialWrapper.innerHTML = tutorialWrapper.innerHTML =

View File

@@ -102,15 +102,9 @@ const buildOptionsTable = (settings, romOpts = false) => {
// td Left // td Left
const tdl = document.createElement('td'); const tdl = document.createElement('td');
const label = document.createElement('label'); const label = document.createElement('label');
label.textContent = `${settings[setting].displayName}: `;
label.setAttribute('for', setting); label.setAttribute('for', setting);
label.setAttribute('data-tooltip', settings[setting].description);
const questionSpan = document.createElement('span'); label.innerText = `${settings[setting].displayName}:`;
questionSpan.classList.add('interactive');
questionSpan.setAttribute('data-tooltip', settings[setting].description);
questionSpan.innerText = '(?)';
label.appendChild(questionSpan);
tdl.appendChild(label); tdl.appendChild(label);
tr.appendChild(tdl); tr.appendChild(tdl);
@@ -118,8 +112,6 @@ const buildOptionsTable = (settings, romOpts = false) => {
const tdr = document.createElement('td'); const tdr = document.createElement('td');
let element = null; let element = null;
const randomButton = document.createElement('button');
switch(settings[setting].type){ switch(settings[setting].type){
case 'select': case 'select':
element = document.createElement('div'); element = document.createElement('div');
@@ -140,21 +132,8 @@ const buildOptionsTable = (settings, romOpts = false) => {
} }
select.appendChild(option); select.appendChild(option);
}); });
select.addEventListener('change', (event) => updateGameSetting(event.target)); select.addEventListener('change', (event) => updateGameSetting(event));
element.appendChild(select); element.appendChild(select);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, [select]));
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
select.disabled = true;
}
element.appendChild(randomButton);
break; break;
case 'range': case 'range':
@@ -169,29 +148,15 @@ const buildOptionsTable = (settings, romOpts = false) => {
range.value = currentSettings[gameName][setting]; range.value = currentSettings[gameName][setting];
range.addEventListener('change', (event) => { range.addEventListener('change', (event) => {
document.getElementById(`${setting}-value`).innerText = event.target.value; document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event.target); updateGameSetting(event);
}); });
element.appendChild(range); element.appendChild(range);
let rangeVal = document.createElement('span'); let rangeVal = document.createElement('span');
rangeVal.classList.add('range-value'); rangeVal.classList.add('range-value');
rangeVal.setAttribute('id', `${setting}-value`); rangeVal.setAttribute('id', `${setting}-value`);
rangeVal.innerText = currentSettings[gameName][setting] !== 'random' ? rangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
currentSettings[gameName][setting] : settings[setting].defaultValue;
element.appendChild(rangeVal); element.appendChild(rangeVal);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, [range]));
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
range.disabled = true;
}
element.appendChild(randomButton);
break; break;
case 'special_range': case 'special_range':
@@ -230,8 +195,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
let specialRangeVal = document.createElement('span'); let specialRangeVal = document.createElement('span');
specialRangeVal.classList.add('range-value'); specialRangeVal.classList.add('range-value');
specialRangeVal.setAttribute('id', `${setting}-value`); specialRangeVal.setAttribute('id', `${setting}-value`);
specialRangeVal.innerText = currentSettings[gameName][setting] !== 'random' ? specialRangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
currentSettings[gameName][setting] : settings[setting].defaultValue;
// Configure select event listener // Configure select event listener
specialRangeSelect.addEventListener('change', (event) => { specialRangeSelect.addEventListener('change', (event) => {
@@ -240,7 +204,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
// Update range slider // Update range slider
specialRange.value = event.target.value; specialRange.value = event.target.value;
document.getElementById(`${setting}-value`).innerText = event.target.value; document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event.target); updateGameSetting(event);
}); });
// Configure range event handler // Configure range event handler
@@ -250,29 +214,13 @@ const buildOptionsTable = (settings, romOpts = false) => {
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ? (Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
parseInt(event.target.value) : 'custom'; parseInt(event.target.value) : 'custom';
document.getElementById(`${setting}-value`).innerText = event.target.value; document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event.target); updateGameSetting(event);
}); });
element.appendChild(specialRangeSelect); element.appendChild(specialRangeSelect);
specialRangeWrapper.appendChild(specialRange); specialRangeWrapper.appendChild(specialRange);
specialRangeWrapper.appendChild(specialRangeVal); specialRangeWrapper.appendChild(specialRangeVal);
element.appendChild(specialRangeWrapper); element.appendChild(specialRangeWrapper);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(
event, [specialRange, specialRangeSelect])
);
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
specialRange.disabled = true;
specialRangeSelect.disabled = true;
}
specialRangeWrapper.appendChild(randomButton);
break; break;
default: default:
@@ -289,25 +237,6 @@ const buildOptionsTable = (settings, romOpts = false) => {
return table; return table;
}; };
const toggleRandomize = (event, inputElements) => {
const active = event.target.classList.contains('active');
const randomButton = event.target;
if (active) {
randomButton.classList.remove('active');
for (const element of inputElements) {
element.disabled = undefined;
updateGameSetting(element);
}
} else {
randomButton.classList.add('active');
for (const element of inputElements) {
element.disabled = true;
updateGameSetting(randomButton);
}
}
};
const updateBaseSetting = (event) => { const updateBaseSetting = (event) => {
const options = JSON.parse(localStorage.getItem(gameName)); const options = JSON.parse(localStorage.getItem(gameName));
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ? options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
@@ -315,17 +244,10 @@ const updateBaseSetting = (event) => {
localStorage.setItem(gameName, JSON.stringify(options)); localStorage.setItem(gameName, JSON.stringify(options));
}; };
const updateGameSetting = (settingElement) => { const updateGameSetting = (event) => {
const options = JSON.parse(localStorage.getItem(gameName)); const options = JSON.parse(localStorage.getItem(gameName));
options[gameName][event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
if (settingElement.classList.contains('randomize-button')) { event.target.value : parseInt(event.target.value, 10);
// If the event passed in is the randomize button, then we know what we must do.
options[gameName][settingElement.getAttribute('data-key')] = 'random';
} else {
options[gameName][settingElement.getAttribute('data-key')] = isNaN(settingElement.value) ?
settingElement.value : parseInt(settingElement.value, 10);
}
localStorage.setItem(gameName, JSON.stringify(options)); localStorage.setItem(gameName, JSON.stringify(options));
}; };

View File

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

View File

@@ -17,13 +17,6 @@ window.addEventListener('load', () => {
paging: false, paging: false,
info: false, info: false,
dom: "t", dom: "t",
stateSave: true,
stateSaveCallback: function(settings,data) {
localStorage.setItem(`DataTables_${settings.sInstance}_/tracker`, JSON.stringify(data));
},
stateLoadCallback: function(settings) {
return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`));
},
columnDefs: [ columnDefs: [
{ {
targets: 'hours', targets: 'hours',
@@ -75,18 +68,10 @@ window.addEventListener('load', () => {
console.info(tables.search()); console.info(tables.search());
tables.draw(); tables.draw();
}); });
const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
const target_second = document.getElementById('tracker-wrapper').getAttribute('data-second') + 3;
function getSleepTimeSeconds(){
// -40 % 60 is -40, which is absolutely wrong and should burn
var sleepSeconds = (((target_second - new Date().getSeconds()) % 60) + 60) % 60;
return sleepSeconds || 60;
}
const update = () => { const update = () => {
const target = $("<div></div>"); const target = $("<div></div>");
console.log("Updating Tracker..."); const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
target.load("/tracker/" + tracker, function (response, status) { target.load("/tracker/" + tracker, function (response, status) {
if (status === "success") { if (status === "success") {
target.find(".table").each(function (i, new_table) { target.find(".table").each(function (i, new_table) {
@@ -105,9 +90,9 @@ window.addEventListener('load', () => {
console.log(response); console.log(response);
} }
}) })
setTimeout(update, getSleepTimeSeconds()*1000);
} }
setTimeout(update, getSleepTimeSeconds()*1000);
setInterval(update, 30000);
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
adjustTableHeight(); adjustTableHeight();

View File

@@ -27,28 +27,25 @@ window.addEventListener('load', () => {
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results); tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
adjustHeaderWidth(); adjustHeaderWidth();
const title = document.querySelector('h1')
if (title) {
document.title = title.textContent;
}
// Reset the id of all header divs to something nicer // Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) { const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase(); const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
header.setAttribute('id', headerId); for (let i=0; i < headers.length; i++){
header.addEventListener('click', () => { const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
window.location.hash = `#${headerId}`; headers[i].setAttribute('id', headerId);
header.scrollIntoView(); headers[i].addEventListener('click', () =>
}); window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
} }
// Manually scroll the user to the appropriate header if anchor navigation is used // Manually scroll the user to the appropriate header if anchor navigation is used
document.fonts.ready.finally(() => { if (scrollTargetIndex > -1) {
if (window.location.hash) { try{
const scrollTarget = document.getElementById(window.location.hash.substring(1)); const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
scrollTarget?.scrollIntoView(); document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
} catch(error) {
console.error(error);
} }
}); }
}).catch((error) => { }).catch((error) => {
console.error(error); console.error(error);
tutorialWrapper.innerHTML = tutorialWrapper.innerHTML =

View File

@@ -23,7 +23,6 @@ window.addEventListener('load', () => {
games.forEach((game) => { games.forEach((game) => {
const gameTitle = document.createElement('h2'); const gameTitle = document.createElement('h2');
gameTitle.innerText = game.gameTitle; gameTitle.innerText = game.gameTitle;
gameTitle.id = `${encodeURIComponent(game.gameTitle)}`;
tutorialDiv.appendChild(gameTitle); tutorialDiv.appendChild(gameTitle);
game.tutorials.forEach((tutorial) => { game.tutorials.forEach((tutorial) => {
@@ -66,15 +65,6 @@ window.addEventListener('load', () => {
showError(); showError();
console.error(error); console.error(error);
} }
// Check if we are on an anchor when coming in, and scroll to it.
const hash = window.location.hash;
if (hash) {
const offset = 128; // To account for navbar banner at top of page.
window.scrollTo(0, 0);
const rect = document.getElementById(hash.slice(1)).getBoundingClientRect();
window.scrollTo(rect.left, rect.top - offset);
}
}; };
ajax.open('GET', `${window.location.origin}/static/generated/tutorials.json`, true); ajax.open('GET', `${window.location.origin}/static/generated/tutorials.json`, true);
ajax.send(); ajax.send();

View File

@@ -6,7 +6,6 @@ window.addEventListener('load', () => {
"order": [[ 3, "desc" ]], "order": [[ 3, "desc" ]],
"info": false, "info": false,
"dom": "t", "dom": "t",
"stateSave": true,
}); });
$("#seeds-table").DataTable({ $("#seeds-table").DataTable({
"paging": false, "paging": false,
@@ -14,6 +13,5 @@ window.addEventListener('load', () => {
"order": [[ 2, "desc" ]], "order": [[ 2, "desc" ]],
"info": false, "info": false,
"dom": "t", "dom": "t",
"stateSave": true,
}); });
}); });

View File

@@ -78,16 +78,13 @@ const createDefaultSettings = (settingData) => {
break; break;
case 'range': case 'range':
case 'special_range': case 'special_range':
newSettings[game][gameSetting][setting.min] = 0; for (let i = setting.min; i <= setting.max; ++i){
newSettings[game][gameSetting][setting.max] = 0; newSettings[game][gameSetting][i] =
(setting.hasOwnProperty('defaultValue') && setting.defaultValue === i) ? 25 : 0;
}
newSettings[game][gameSetting]['random'] = 0; newSettings[game][gameSetting]['random'] = 0;
newSettings[game][gameSetting]['random-low'] = 0; newSettings[game][gameSetting]['random-low'] = 0;
newSettings[game][gameSetting]['random-high'] = 0; newSettings[game][gameSetting]['random-high'] = 0;
if (setting.hasOwnProperty('defaultValue')) {
newSettings[game][gameSetting][setting.defaultValue] = 25;
} else {
newSettings[game][gameSetting][setting.min] = 25;
}
break; break;
case 'items-list': case 'items-list':
@@ -404,17 +401,11 @@ const buildWeightedSettingsDiv = (game, settings) => {
tr.appendChild(tdDelete); tr.appendChild(tdDelete);
rangeTbody.appendChild(tr); rangeTbody.appendChild(tr);
// Save new option to settings
range.dispatchEvent(new Event('change'));
}); });
Object.keys(currentSettings[game][settingName]).forEach((option) => { Object.keys(currentSettings[game][settingName]).forEach((option) => {
// These options are statically generated below, and should always appear even if they are deleted if (currentSettings[game][settingName][option] > 0) {
// from localStorage const tr = document.createElement('tr');
if (['random-low', 'random', 'random-high'].includes(option)) { return; }
const tr = document.createElement('tr');
const tdLeft = document.createElement('td'); const tdLeft = document.createElement('td');
tdLeft.classList.add('td-left'); tdLeft.classList.add('td-left');
tdLeft.innerText = option; tdLeft.innerText = option;
@@ -448,15 +439,14 @@ const buildWeightedSettingsDiv = (game, settings) => {
deleteButton.innerText = '❌'; deleteButton.innerText = '❌';
deleteButton.addEventListener('click', () => { deleteButton.addEventListener('click', () => {
range.value = 0; range.value = 0;
const changeEvent = new Event('change'); range.dispatchEvent(new Event('change'));
changeEvent.action = 'rangeDelete';
range.dispatchEvent(changeEvent);
rangeTbody.removeChild(tr); rangeTbody.removeChild(tr);
}); });
tdDelete.appendChild(deleteButton); tdDelete.appendChild(deleteButton);
tr.appendChild(tdDelete); tr.appendChild(tdDelete);
rangeTbody.appendChild(tr); rangeTbody.appendChild(tr);
}
}); });
} }
@@ -914,12 +904,8 @@ const updateGameSetting = (evt) => {
const setting = evt.target.getAttribute('data-setting'); const setting = evt.target.getAttribute('data-setting');
const option = evt.target.getAttribute('data-option'); const option = evt.target.getAttribute('data-option');
document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value; document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value;
console.log(event); options[game][setting][option] = isNaN(evt.target.value) ?
if (evt.action && evt.action === 'rangeDelete') { evt.target.value : parseInt(evt.target.value, 10);
delete options[game][setting][option];
} else {
options[game][setting][option] = parseInt(evt.target.value, 10);
}
localStorage.setItem('weighted-settings', JSON.stringify(options)); localStorage.setItem('weighted-settings', JSON.stringify(options));
}; };

View File

@@ -56,3 +56,7 @@
#file-input{ #file-input{
display: none; display: none;
} }
.interactive{
color: #ffef00;
}

View File

@@ -105,10 +105,3 @@ h5, h6{
margin-bottom: 20px; margin-bottom: 20px;
background-color: #ffff00; background-color: #ffff00;
} }
.user-message a{
color: #ff7700;
}
.interactive{
color: #ffef00;
}

View File

@@ -55,6 +55,4 @@
border: 1px solid #2a6c2f; border: 1px solid #2a6c2f;
border-radius: 6px; border-radius: 6px;
color: #000000; color: #000000;
overflow-y: auto;
max-height: 400px;
} }

View File

@@ -9,7 +9,7 @@
border-top-left-radius: 4px; border-top-left-radius: 4px;
border-top-right-radius: 4px; border-top-right-radius: 4px;
padding: 3px 3px 10px; padding: 3px 3px 10px;
width: 480px; width: 448px;
background-color: rgb(60, 114, 157); background-color: rgb(60, 114, 157);
} }
@@ -46,7 +46,7 @@
} }
#location-table{ #location-table{
width: 480px; width: 448px;
border-left: 2px solid #000000; border-left: 2px solid #000000;
border-right: 2px solid #000000; border-right: 2px solid #000000;
border-bottom: 2px solid #000000; border-bottom: 2px solid #000000;
@@ -108,7 +108,7 @@
} }
#location-table td:first-child { #location-table td:first-child {
width: 300px; width: 272px;
} }
.location-category td:first-child { .location-category td:first-child {

View File

@@ -116,10 +116,6 @@ html{
flex-grow: 1; flex-grow: 1;
} }
#player-settings table select:disabled{
background-color: lightgray;
}
#player-settings table .range-container{ #player-settings table .range-container{
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -142,27 +138,12 @@ html{
#player-settings table .special-range-wrapper{ #player-settings table .special-range-wrapper{
display: flex; display: flex;
flex-direction: row; flex-direction: row;
margin-top: 0.25rem;
} }
#player-settings table .special-range-wrapper input[type=range]{ #player-settings table .special-range-wrapper input[type=range]{
flex-grow: 1; flex-grow: 1;
} }
#player-settings table .randomize-button {
max-height: 24px;
line-height: 16px;
padding: 2px 8px;
margin: 0 0 0 0.25rem;
font-size: 12px;
border: 1px solid black;
border-radius: 3px;
}
#player-settings table .randomize-button.active {
background-color: #ffef00; /* Same as .interactive in globalStyles.css */
}
#player-settings table label{ #player-settings table label{
display: block; display: block;
min-width: 200px; min-width: 200px;

View File

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

View File

@@ -1,7 +1,5 @@
html{ html{
padding-top: 110px; padding-top: 110px;
scroll-padding-top: 100px;
scroll-behavior: smooth;
} }
#base-header{ #base-header{

View File

@@ -52,7 +52,6 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -52,7 +52,6 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -52,7 +52,6 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -52,7 +52,6 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -52,7 +52,6 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -53,7 +53,6 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -52,7 +52,6 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -50,7 +50,6 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -14,6 +14,7 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
/* Base styles for the element that has a tooltip */ /* Base styles for the element that has a tooltip */
[data-tooltip], .tooltip { [data-tooltip], .tooltip {
position: relative; position: relative;
cursor: pointer;
} }
/* Base styles for the entire tooltip */ /* Base styles for the entire tooltip */
@@ -54,15 +55,14 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
/** Content styles */ /** Content styles */
.tooltip:after, [data-tooltip]:after { .tooltip:after, [data-tooltip]:after {
width: 260px;
z-index: 10000; z-index: 10000;
padding: 8px; padding: 8px;
width: 160px;
border-radius: 4px; border-radius: 4px;
background-color: #000; background-color: #000;
background-color: hsla(0, 0%, 20%, 0.9); background-color: hsla(0, 0%, 20%, 0.9);
color: #fff; color: #fff;
content: attr(data-tooltip); content: attr(data-tooltip);
white-space: pre-wrap;
font-size: 14px; font-size: 14px;
line-height: 1.2; line-height: 1.2;
} }

View File

@@ -1,12 +1,10 @@
import typing
from collections import Counter, defaultdict from collections import Counter, defaultdict
from colorsys import hsv_to_rgb from itertools import cycle
from datetime import datetime, timedelta, date from datetime import datetime, timedelta, date
from math import tau from math import tau
from bokeh.colors import RGB
from bokeh.embed import components from bokeh.embed import components
from bokeh.models import HoverTool from bokeh.palettes import Dark2_8 as palette
from bokeh.plotting import figure, ColumnDataSource from bokeh.plotting import figure, ColumnDataSource
from bokeh.resources import INLINE from bokeh.resources import INLINE
from flask import render_template from flask import render_template
@@ -15,91 +13,42 @@ from pony.orm import select
from . import app, cache from . import app, cache
from .models import Room from .models import Room
PLOT_WIDTH = 600
def get_db_data():
def get_db_data(known_games: typing.Set[str]) -> typing.Tuple[typing.Counter[str],
typing.DefaultDict[datetime.date, typing.Dict[str, int]]]:
games_played = defaultdict(Counter) games_played = defaultdict(Counter)
total_games = Counter() total_games = Counter()
cutoff = date.today() - timedelta(days=30) cutoff = date.today()-timedelta(days=30000)
room: Room room: Room
for room in select(room for room in Room if room.creation_time >= cutoff): for room in select(room for room in Room if room.creation_time >= cutoff):
for slot in room.seed.slots: for slot in room.seed.slots:
if slot.game in known_games: total_games[slot.game] += 1
total_games[slot.game] += 1 games_played[room.creation_time.date()][slot.game] += 1
games_played[room.creation_time.date()][slot.game] += 1
return total_games, games_played return total_games, games_played
def get_color_palette(colors_needed: int) -> typing.List[RGB]:
colors = []
# colors_needed +1 to prevent first and last color being too close to each other
colors_needed += 1
for x in range(0, 361, 360 // colors_needed):
# a bit of noise on value to add some luminosity difference
colors.append(RGB(*(val * 255 for val in hsv_to_rgb(x / 360, 0.8, 0.8 + (x / 1800)))))
# splice colors for maximum hue contrast.
colors = colors[::2] + colors[1::2]
return colors
def create_game_played_figure(all_games_data: typing.Dict[datetime.date, typing.Dict[str, int]],
game: str, color: RGB) -> figure:
occurences = []
days = [day for day, game_data in all_games_data.items() if game_data[game]]
for day in days:
occurences.append(all_games_data[day][game])
data = {
"days": [datetime.combine(day, datetime.min.time()) for day in days],
"played": occurences
}
plot = figure(
title=f"{game} Played Per Day", x_axis_type='datetime', x_axis_label="Date",
y_axis_label="Games Played", sizing_mode="scale_both", width=PLOT_WIDTH, height=500,
toolbar_location=None, tools="",
# setting legend to False seems broken in bokeh currently?
# legend=False
)
hover = HoverTool(tooltips=[("Date:", "@days{%F}"), ("Played:", "@played")], formatters={"@days": "datetime"})
plot.add_tools(hover)
plot.vbar(x="days", top="played", legend_label=game, color=color, source=ColumnDataSource(data=data), width=1)
return plot
@app.route('/stats') @app.route('/stats')
@cache.memoize(timeout=60 * 60) # regen once per hour should be plenty @cache.memoize(timeout=60*60) # regen once per hour should be plenty
def stats(): def stats():
from worlds import network_data_package
known_games = set(network_data_package["games"])
plot = figure(title="Games Played Per Day", x_axis_type='datetime', x_axis_label="Date", plot = figure(title="Games Played Per Day", x_axis_type='datetime', x_axis_label="Date",
y_axis_label="Games Played", sizing_mode="scale_both", width=PLOT_WIDTH, height=500) y_axis_label="Games Played", sizing_mode="scale_both", width=500, height=500)
total_games, games_played = get_db_data(known_games) total_games, games_played = get_db_data()
days = sorted(games_played) days = sorted(games_played)
color_palette = get_color_palette(len(total_games)) cyc_palette = cycle(palette)
game_to_color: typing.Dict[str, RGB] = {game: color for game, color in zip(total_games, color_palette)}
for game in sorted(total_games): for game in sorted(total_games):
occurences = [] occurences = []
for day in days: for day in days:
occurences.append(games_played[day][game]) occurences.append(games_played[day][game])
plot.line([datetime.combine(day, datetime.min.time()) for day in days], plot.line([datetime.combine(day, datetime.min.time()) for day in days],
occurences, legend_label=game, line_width=2, color=game_to_color[game]) occurences, legend_label=game, line_width=2, color=next(cyc_palette))
total = sum(total_games.values()) total = sum(total_games.values())
pie = figure(title=f"Games Played in the Last 30 Days (Total: {total})", toolbar_location=None, pie = figure(plot_height=350, title=f"Games Played in the Last 30 Days (Total: {total})", toolbar_location=None,
tools="hover", tooltips=[("Game:", "@games"), ("Played:", "@count")], tools="hover", tooltips=[("Game:", "@games"), ("Played:", "@count")],
sizing_mode="scale_both", width=PLOT_WIDTH, height=500, x_range=(-0.5, 1.2)) sizing_mode="scale_both", width=500, height=500)
pie.axis.visible = False pie.axis.visible = False
pie.xgrid.visible = False
pie.ygrid.visible = False
data = { data = {
"games": [], "games": [],
@@ -116,16 +65,12 @@ def stats():
current_angle += angle current_angle += angle
data["end_angles"].append(current_angle) data["end_angles"].append(current_angle)
data["colors"] = [game_to_color[game] for game in data["games"]] data["colors"] = [element[1] for element in sorted((game, color) for game, color in
zip(data["games"], cycle(palette)))]
pie.wedge(x=0, y=0, radius=0.5, pie.wedge(x=0.5, y=0.5, radius=0.5,
start_angle="start_angles", end_angle="end_angles", fill_color="colors", start_angle="start_angles", end_angle="end_angles", fill_color="colors",
source=ColumnDataSource(data=data), legend_field="games") source=ColumnDataSource(data=data), legend_field="games")
per_game_charts = [create_game_played_figure(games_played, game, game_to_color[game]) for game in script, charts = components((plot, pie))
sorted(total_games, key=lambda game: total_games[game])
if total_games[game] > 1]
script, charts = components((plot, pie, *per_game_charts))
return render_template("stats.html", js_resources=INLINE.render_js(), css_resources=INLINE.render_css(), return render_template("stats.html", js_resources=INLINE.render_js(), css_resources=INLINE.render_css(),
chart_data=script, charts=charts) chart_data=script, charts=charts)

View File

@@ -1,6 +1,7 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% block head %} {% block head %}
{{ super() }}
<title>Mystery Check Result</title> <title>Mystery Check Result</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/check.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/check.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/check.js") }}"></script> <script type="application/ecmascript" src="{{ url_for('static', filename="assets/check.js") }}"></script>

View File

@@ -12,7 +12,7 @@
<div id="check-result" class="grass-island"> <div id="check-result" class="grass-island">
<h1>Verification Results</h1> <h1>Verification Results</h1>
<p>The results of your requested file check are below.</p> <p>The results of your requested file check are below.</p>
<table id="results-table" class="table autodatatable"> <table class="table autodatatable">
<thead> <thead>
<tr> <tr>
<th>File</th> <th>File</th>

View File

@@ -1,6 +1,7 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% block head %} {% block head %}
{{ super() }}
<title>Generate Game</title> <title>Generate Game</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/generate.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/generate.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/generate.js") }}"></script> <script type="application/ecmascript" src="{{ url_for('static', filename="assets/generate.js") }}"></script>
@@ -40,11 +41,12 @@
<tbody> <tbody>
<tr> <tr>
<td> <td>
<label for="forfeit_mode">Forfeit Permission: <label for="forfeit_mode">Forfeit Permission:</label>
<span class="interactive" data-tooltip="A forfeit releases all remaining items from the locations in your world."> <span
(?) class="interactive"
</span> data-tooltip="A forfeit releases all remaining items from the locations
</label> in your world.">(?)
</span>
</td> </td>
<td> <td>
<select name="forfeit_mode" id="forfeit_mode"> <select name="forfeit_mode" id="forfeit_mode">
@@ -61,11 +63,12 @@
<tr> <tr>
<td> <td>
<label for="collect_mode">Collect Permission: <label for="collect_mode">Collect Permission:</label>
<span class="interactive" data-tooltip="A collect releases all of your remaining items to you from across the multiworld."> <span
(?) class="interactive"
</span> data-tooltip="A collect releases all of your remaining items to you
</label> from across the multiworld.">(?)
</span>
</td> </td>
<td> <td>
<select name="collect_mode" id="collect_mode"> <select name="collect_mode" id="collect_mode">
@@ -82,11 +85,12 @@
<tr> <tr>
<td> <td>
<label for="remaining_mode">Remaining Permission: <label for="remaining_mode">Remaining Permission:</label>
<span class="interactive" data-tooltip="Remaining lists all items still in your world by name only."> <span
(?) class="interactive"
</span> data-tooltip="Remaining lists all items still in your world by name only."
</label> >(?)
</span>
</td> </td>
<td> <td>
<select name="remaining_mode" id="remaining_mode"> <select name="remaining_mode" id="remaining_mode">
@@ -102,11 +106,11 @@
</tr> </tr>
<tr> <tr>
<td> <td>
<label for="item_cheat">Item Cheat: <label for="item_cheat">Item Cheat:</label>
<span class="interactive" data-tooltip="Allows players to use the !getitem command."> <span
(?) class="interactive"
</span> data-tooltip="Allows players to use the !getitem command.">(?)
</label> </span>
</td> </td>
<td> <td>
<select name="item_cheat" id="item_cheat"> <select name="item_cheat" id="item_cheat">
@@ -127,11 +131,12 @@
<tbody> <tbody>
<tr> <tr>
<td> <td>
<label for="hint_cost"> Hint Cost: <label for="hint_cost"> Hint Cost:</label>
<span class="interactive" data-tooltip="After gathering this many checks, players can !hint <itemname> to get the location of that hint item."> <span
(?) class="interactive"
</span> data-tooltip="After gathering this many checks, players can !hint <itemname>
</label> to get the location of that hint item.">(?)
</span>
</td> </td>
<td> <td>
<select name="hint_cost" id="hint_cost"> <select name="hint_cost" id="hint_cost">
@@ -145,11 +150,11 @@
</tr> </tr>
<tr> <tr>
<td> <td>
<label for="server_password">Server Password: <label for="server_password">Server Password:</label>
<span class="interactive" data-tooltip="Allows for issuing of server console commands from any text client or in-game client using the !admin command."> <span
(?) class="interactive"
</span> data-tooltip="Allows for issuing of server console commands from any text client or in-game client using the !admin command.">(?)
</label> </span>
</td> </td>
<td> <td>
<input id="server_password" name="server_password"> <input id="server_password" name="server_password">
@@ -157,22 +162,23 @@
</tr> </tr>
<tr> <tr>
<td> <td>
Plando Options: <label for="plando_options">Plando Options:</label>
<span class="interactive" data-tooltip="Allows players to plan some of the randomization. See the 'Archipelago Plando Guide' in 'Setup Guides' for more information."> <span
(?) class="interactive"
data-tooltip="Allows players to plan some of the randomization. See the 'Archipelago Plando Guide' in 'Setup Guides' for more information.">(?)
</span> </span>
</td> </td>
<td> <td>
<input type="checkbox" id="plando_bosses" name="plando_bosses" value="bosses" checked> <input type="checkbox" name="plando_bosses" value="bosses" checked>
<label for="plando_bosses">Bosses</label><br> <label for="plando_bosses">Bosses</label><br>
<input type="checkbox" id="plando_items" name="plando_items" value="items" checked> <input type="checkbox" name="plando_items" value="items" checked>
<label for="plando_items">Items</label><br> <label for="plando_items">Items</label><br>
<input type="checkbox" id="plando_connections" name="plando_connections" value="connections" checked> <input type="checkbox" name="plando_connections" value="connections" checked>
<label for="plando_connections">Connections</label><br> <label for="plando_connections">Connections</label><br>
<input type="checkbox" id="plando_texts" name="plando_texts" value="texts" checked> <input type="checkbox" name="plando_texts" value="texts" checked>
<label for="plando_texts">Text</label> <label for="plando_texts">Text</label>
</td> </td>
</tr> </tr>

View File

@@ -9,13 +9,13 @@
{% block body %} {% block body %}
{% include 'header/dirtHeader.html' %} {% include 'header/dirtHeader.html' %}
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}/{{ team }}/{{ player }}" data-second="{{ saving_second }}"> <div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}/{{ team }}/{{ player }}">
<div id="tracker-header-bar"> <div id="tracker-header-bar">
<input placeholder="Search" id="search"/> <input placeholder="Search" id="search"/>
<span class="info">This tracker will automatically update itself periodically.</span> <span class="info">This tracker will automatically update itself periodically.</span>
</div> </div>
<div class="table-wrapper"> <div class="table-wrapper">
<table id="received-table" class="table non-unique-item-table"> <table class="table non-unique-item-table">
<thead> <thead>
<tr> <tr>
<th>Item</th> <th>Item</th>
@@ -37,7 +37,7 @@
</table> </table>
</div> </div>
<div class="table-wrapper"> <div class="table-wrapper">
<table id="locations-table" class="table non-unique-item-table"> <table class="table non-unique-item-table">
<thead> <thead>
<tr> <tr>
<th>Location</th> <th>Location</th>

View File

@@ -1,6 +1,7 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% block head %} {% block head %}
{{ super() }}
<title>Upload Multidata</title> <title>Upload Multidata</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostGame.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostGame.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/hostGame.js") }}"></script> <script type="application/ecmascript" src="{{ url_for('static', filename="assets/hostGame.js") }}"></script>

View File

@@ -2,7 +2,6 @@
{% import "macros.html" as macros %} {% import "macros.html" as macros %}
{% block head %} {% block head %}
<title>Multiworld {{ room.id|suuid }}</title> <title>Multiworld {{ room.id|suuid }}</title>
{% if should_refresh %}<meta http-equiv="refresh" content="2">{% endif %}
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostRoom.css") }}"/> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostRoom.css") }}"/>
{% endblock %} {% endblock %}
@@ -17,19 +16,15 @@
This room has a <a href="{{ url_for("getTracker", tracker=room.tracker) }}">Multiworld Tracker</a> enabled. This room has a <a href="{{ url_for("getTracker", tracker=room.tracker) }}">Multiworld Tracker</a> enabled.
<br /> <br />
{% endif %} {% endif %}
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity. This room will be closed after {{ room.timeout//60//60 }} hours of inactivity. Should you wish to continue
Should you wish to continue later, later,
anyone can simply refresh this page and the server will resume.<br> you can simply refresh this page and the server will be started again.<br>
{% if room.last_port == -1 %} {% if room.last_port %}
There was an error hosting this Room. Another attempt will be made on refreshing this page.
The most likely failure reason is that the multiworld is too old to be loaded now.
{% elif room.last_port %}
You can connect to this room by using <span class="interactive" You can connect to this room by using <span class="interactive"
data-tooltip="This means address/ip is {{ config['PATCH_TARGET'] }} and port is {{ room.last_port }}."> data-tooltip="This means address/ip is {{ config['PATCH_TARGET'] }} and port is {{ room.last_port }}.">
'/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}' '/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}'
</span> </span>
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br> in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>{% endif %}
{% endif %}
{{ macros.list_patches_room(room) }} {{ macros.list_patches_room(room) }}
{% if room.owner == session["_id"] %} {% if room.owner == session["_id"] %}
<form method=post> <form method=post>

View File

@@ -6,6 +6,8 @@
- -
<a href="https://github.com/ArchipelagoMW/Archipelago">Source Code</a> <a href="https://github.com/ArchipelagoMW/Archipelago">Source Code</a>
- -
<a href="https://github.com/ArchipelagoMW/Archipelago/wiki">Wiki</a>
-
<a href="https://github.com/ArchipelagoMW/Archipelago/graphs/contributors">Contributors</a> <a href="https://github.com/ArchipelagoMW/Archipelago/graphs/contributors">Contributors</a>
- -
<a href="https://github.com/ArchipelagoMW/Archipelago/issues">Bug Report</a> <a href="https://github.com/ArchipelagoMW/Archipelago/issues">Bug Report</a>

View File

@@ -40,17 +40,14 @@
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %} {% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download> <a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APSM64EX File...</a> Download APSM64EX File...</a>
{% elif patch.game | supports_apdeltapatch %} {% elif patch.game in ["A Link to the Past", "Secret of Evermore", "Super Metroid", "SMZ3"] %}
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download> <a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
Download Patch File...</a> Download Patch File...</a>
{% elif patch.game == "Dark Souls III" and patch.data %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download JSON File...</a>
{% else %} {% else %}
No file to download for this game. No file to download for this game.
{% endif %} {% endif %}
</td> </td>
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=0, tracked_player=patch.player_id) }}">Tracker</a></td> <td><a href="{{ url_for("getPlayerTracker", tracker=room.tracker, tracked_team=0, tracked_player=patch.player_id) }}">Tracker</a></td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@@ -43,19 +43,6 @@
<td><img src="{{ icons['Fishing Rod'] }}" class="{{ 'acquired' if 'Fishing Rod' in acquired_items }}" title="Fishing Rod" /></td> <td><img src="{{ icons['Fishing Rod'] }}" class="{{ 'acquired' if 'Fishing Rod' in acquired_items }}" title="Fishing Rod" /></td>
<td><img src="{{ icons['Campfire'] }}" class="{{ 'acquired' if 'Campfire' in acquired_items }}" title="Campfire" /></td> <td><img src="{{ icons['Campfire'] }}" class="{{ 'acquired' if 'Campfire' in acquired_items }}" title="Campfire" /></td>
<td><img src="{{ icons['Spyglass'] }}" class="{{ 'acquired' if 'Spyglass' in acquired_items }}" title="Spyglass" /></td> <td><img src="{{ icons['Spyglass'] }}" class="{{ 'acquired' if 'Spyglass' in acquired_items }}" title="Spyglass" /></td>
<td>
<div class="counted-item">
<img src="{{ icons['Dragon Egg Shard'] }}" class="{{ 'acquired' if 'Dragon Egg Shard' in acquired_items }}" title="Dragon Egg Shard" />
<div class="item-count">{{ shard_count }}</div>
</div>
</td>
</tr>
<tr>
<td><img src="{{ icons['Lead'] }}" class="{{ 'acquired' if 'Lead' in acquired_items }}" title="Lead" /></td>
<td><img src="{{ icons['Saddle'] }}" class="{{ 'acquired' if 'Saddle' in acquired_items }}" title="Saddle" /></td>
<td><img src="{{ icons['Channeling Book'] }}" class="{{ 'acquired' if 'Channeling Book' in acquired_items }}" title="Channeling Book" /></td>
<td><img src="{{ icons['Silk Touch Book'] }}" class="{{ 'acquired' if 'Silk Touch Book' in acquired_items }}" title="Silk Touch Book" /></td>
<td><img src="{{ icons['Piercing IV Book'] }}" class="{{ 'acquired' if 'Piercing IV Book' in acquired_items }}" title="Piercing IV Book" /></td>
</tr> </tr>
</table> </table>
<table id="location-table"> <table id="location-table">

View File

@@ -1,85 +1,56 @@
# Q. What is this file? # What is this file?
# A. This file contains options which allow you to configure your multiworld experience while allowing # This file contains options which allow you to configure your multiworld experience while allowing others
# others to play how they want as well. # to play how they want as well.
#
# Q. How do I use it?
# A. The options in this file are weighted. This means the higher number you assign to a value, the
# more chances you have for that option to be chosen. For example, an option like this:
#
# map_shuffle:
# on: 5
# off: 15
#
# Means you have 5 chances for map shuffle to occur, and 15 chances for map shuffle to be turned
# off.
#
# Q. I've never seen a file like this before. What characters am I allowed to use?
# A. This is a .yaml file. You are allowed to use most characters.
# To test if your yaml is valid or not, you can use this website:
# http://www.yamllint.com/
# You can also verify your Archipelago settings are valid at this site:
# https://archipelago.gg/check
# Your name in-game. Spaces will be replaced with underscores and there is a 16-character limit. # How do I use it?
# {player} will be replaced with the player's slot number. # The options in this file are weighted. This means the higher number you assign to a value, the more
# {PLAYER} will be replaced with the player's slot number, if that slot number is greater than 1. # chances you have for that option to be chosen. For example, an option like this:
# {number} will be replaced with the counter value of the name. #
# {NUMBER} will be replaced with the counter value of the name, if the counter value is greater than 1. # map_shuffle:
name: Player{number} # on: 5
# off: 15
#
# Means you have 5 chances for map shuffle to occur, and 15 chances for map shuffle to be turned off
# Used to describe your yaml. Useful if you have multiple files. # I've never seen a file like this before. What characters am I allowed to use?
description: Default {{ game }} Template # This is a .yaml file. You are allowed to use most characters.
# To test if your yaml is valid or not, you can use this website:
# http://www.yamllint.com/
game: {{ game }} description: Default {{ game }} Template # Used to describe your yaml. Useful if you have multiple files
# Your name in-game. Spaces will be replaced with underscores and there is a 16 character limit
name: YourName{number}
#{player} will be replaced with the player's slot number.
#{PLAYER} will be replaced with the player's slot number if that slot number is greater than 1.
#{number} will be replaced with the counter value of the name.
#{NUMBER} will be replaced with the counter value of the name if the counter value is greater than 1.
game:
{{ game }}: 1
requires: requires:
version: {{ __version__ }} # Version of Archipelago required for this yaml to work as expected. version: {{ __version__ }} # Version of Archipelago required for this yaml to work as expected.
# Shared Options supported by all games:
{%- macro range_option(option) %} {%- macro range_option(option) %}
# You can define additional values between the minimum and maximum values. # you can add additional values between minimum and maximum
# Minimum value is {{ option.range_start }}
# Maximum value is {{ option.range_end }}
{%- set data, notes = dictify_range(option) %} {%- set data, notes = dictify_range(option) %}
{%- for entry, default in data.items() %} {%- for entry, default in data.items() %}
{{ entry }}: {{ default }}{% if notes[entry] %} # {{ notes[entry] }}{% endif %} {{ entry }}: {{ default }}{% if notes[entry] %} # {{ notes[entry] }}{% endif %}
{%- endfor -%} {%- endfor -%}
{% endmacro %} {% endmacro %}
{{ game }}: {{ game }}:
{%- for option_key, option in options.items() %} {%- for option_key, option in options.items() %}
{{ option_key }}: {{ option_key }}:{% if option.__doc__ %} # {{ option.__doc__ | replace('\n', '\n#') | indent(4, first=False) }}{% endif %}
{%- if option.__doc__ %}
# {{ option.__doc__
| trim
| replace('\n\n', '\n \n')
| replace('\n ', '\n# ')
| indent(4, first=False)
}}
{%- endif -%}
{%- if option.__doc__ and option.range_start is defined %}
#
{%- endif -%}
{%- if option.range_start is defined and option.range_start is number %} {%- if option.range_start is defined and option.range_start is number %}
{{- range_option(option) -}} {{- range_option(option) -}}
{%- elif option.options -%} {%- elif option.options -%}
{%- for suboption_option_id, sub_option_name in option.name_lookup.items() %} {%- for suboption_option_id, sub_option_name in option.name_lookup.items() %}
{{ sub_option_name }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %} {{ sub_option_name }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %}
{%- endfor -%} {%- endfor -%}
{% if option.default == "random" %}
{%- if option.name_lookup[option.default] not in option.options %} random: 50
{{ option.default }}: 50
{%- endif -%} {%- endif -%}
{%- elif option.default is string %}
{{ option.default }}: 50
{%- elif option.default is iterable and option.default is not mapping %}
{{ option.default | list }}
{%- else %} {%- else %}
{{ yaml_dump(option.default) | trim | indent(4, first=false) }} {{ yaml_dump(default_converter(option.default)) | indent(4, first=False) }}
{%- endif -%} {%- endif -%}
{{ "\n" }}
{%- endfor %} {%- endfor %}
{% if not options %}{}{% endif %}

View File

@@ -1,233 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/sc2wolTracker.css') }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/sc2wolTracker.js') }}"></script>
<link rel="stylesheet" media="screen" href="https://fontlibrary.org//face/jura" type="text/css"/>
</head>
<body>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>
<td colspan="10" class="title">
Starting Resources
</td>
</tr>
<tr>
<td><img src="{{ icons['Starting Minerals'] }}" class="{{ 'acquired' if '+15 Starting Minerals' in acquired_items }}" title="Starting Minerals" /></td>
<td colspan="2"><div class="item-count">+{{ minerals_count }}</div></td>
<td><img src="{{ icons['Starting Vespene'] }}" class="{{ 'acquired' if '+15 Starting Vespene' in acquired_items }}" title="Starting Vespene" /></td>
<td colspan="2"><div class="item-count">+{{ vespene_count }}</div></td>
<!--
<td><img src="{{ icons['Starting Supply'] }}" class="{{ 'acquired' if '+2 Starting Supply' in acquired_items }}" title="Starting Supply" /></td>
<td colspan="2"><div class="item-count">+{{ supply_count }}</div></td>
-->
</tr>
<tr>
<td colspan="10" class="title">
Weapon & Armor Upgrades
</td>
</tr>
<tr>
<td><img src="{{ infantry_weapon_url }}" class="{{ 'acquired' if 'Progressive Infantry Weapon' in acquired_items }}" title="Progressive Infantry Weapons{% if infantry_weapon_level > 0 %} (Level {{ infantry_weapon_level }}){% endif %}" /></td>
<td><img src="{{ infantry_armor_url }}" class="{{ 'acquired' if 'Progressive Infantry Armor' in acquired_items }}" title="Progressive Infantry Armor{% if infantry_armor_level > 0 %} (Level {{ infantry_armor_level }}){% endif %}" /></td>
<td><img src="{{ vehicle_weapon_url }}" class="{{ 'acquired' if 'Progressive Vehicle Weapon' in acquired_items }}" title="Progressive Vehicle Weapons{% if vehicle_weapon_level > 0 %} (Level {{ vehicle_weapon_level }}){% endif %}" /></td>
<td><img src="{{ vehicle_armor_url }}" class="{{ 'acquired' if 'Progressive Vehicle Armor' in acquired_items }}" title="Progressive Vehicle Armor{% if vehicle_armor_level > 0 %} (Level {{ vehicle_armor_level }}){% endif %}" /></td>
<td><img src="{{ ship_weapon_url }}" class="{{ 'acquired' if 'Progressive Ship Weapon' in acquired_items }}" title="Progressive Ship Weapons{% if ship_weapon_level > 0 %} (Level {{ ship_weapon_level }}){% endif %}" /></td>
<td><img src="{{ ship_armor_url }}" class="{{ 'acquired' if 'Progressive Ship Armor' in acquired_items }}" title="Progressive Ship Armor{% if ship_armor_level > 0 %} (Level {{ ship_armor_level }}){% endif %}" /></td>
</tr>
<tr>
<td colspan="10" class="title">
Base
</td>
</tr>
<tr>
<td colspan="2"><img src="{{ icons['Bunker'] }}" class="{{ 'acquired' if 'Bunker' in acquired_items }}" title="Bunker" /></td>
<td colspan="2"><img src="{{ icons['Missile Turret'] }}" class="{{ 'acquired' if 'Missile Turret' in acquired_items }}" title="Missile Turret" /></td>
<td colspan="2"><img src="{{ icons['Sensor Tower'] }}" class="{{ 'acquired' if 'Sensor Tower' in acquired_items }}" title="Sensor Tower" /></td>
</tr>
<tr>
<td><img src="{{ icons['Projectile Accelerator (Bunker)'] }}" class="{{ 'acquired' if 'Projectile Accelerator (Bunker)' in acquired_items }}" title="Projectile Accelerator (Bunker)" /></td>
<td><img src="{{ icons['Neosteel Bunker (Bunker)'] }}" class="{{ 'acquired' if 'Neosteel Bunker (Bunker)' in acquired_items }}" title="Neosteel Bunker (Bunker)" /></td>
<td><img src="{{ icons['Titanium Housing (Missile Turret)'] }}" class="{{ 'acquired' if 'Titanium Housing (Missile Turret)' in acquired_items }}" title="Titanium Housing (Missile Turret)" /></td>
<td><img src="{{ icons['Hellstorm Batteries (Missile Turret)'] }}" class="{{ 'acquired' if 'Hellstorm Batteries (Missile Turret)' in acquired_items }}" title="Hellstorm Batteries (Missile Turret)" /></td>
<td colspan="2">&nbsp;</td>
<td><img src="{{ icons['Advanced Construction (SCV)'] }}" class="{{ 'acquired' if 'Advanced Construction (SCV)' in acquired_items }}" title="Advanced Construction (SCV)" /></td>
<td><img src="{{ icons['Dual-Fusion Welders (SCV)'] }}" class="{{ 'acquired' if 'Dual-Fusion Welders (SCV)' in acquired_items }}" title="Dual-Fusion Welders (SCV)" /></td>
<td><img src="{{ icons['Fire-Suppression System (Building)'] }}" class="{{ 'acquired' if 'Fire-Suppression System (Building)' in acquired_items }}" title="Fire-Suppression System (Building)" /></td>
<td><img src="{{ icons['Orbital Command (Building)'] }}" class="{{ 'acquired' if 'Orbital Command (Building)' in acquired_items }}" title="Orbital Command (Building)" /></td>
</tr>
<tr>
<td colspan="10" class="title">
Infantry
</td>
</tr>
<tr>
<td colspan="2"><img src="{{ icons['Marine'] }}" class="{{ 'acquired' if 'Marine' in acquired_items }}" title="Marine" /></td>
<td colspan="2"><img src="{{ icons['Medic'] }}" class="{{ 'acquired' if 'Medic' in acquired_items }}" title="Medic" /></td>
<td colspan="2"><img src="{{ icons['Firebat'] }}" class="{{ 'acquired' if 'Firebat' in acquired_items }}" title="Firebat" /></td>
<td colspan="2"><img src="{{ icons['Marauder'] }}" class="{{ 'acquired' if 'Marauder' in acquired_items }}" title="Marauder" /></td>
<td colspan="2"><img src="{{ icons['Reaper'] }}" class="{{ 'acquired' if 'Reaper' in acquired_items }}" title="Reaper" /></td>
</tr>
<tr>
<td><img src="{{ icons['Stimpack (Marine)'] }}" class="{{ 'acquired' if 'Stimpack (Marine)' in acquired_items }}" title="Stimpack (Marine)" /></td>
<td><img src="{{ icons['Combat Shield (Marine)'] }}" class="{{ 'acquired' if 'Combat Shield (Marine)' in acquired_items }}" title="Combat Shield (Marine)" /></td>
<td><img src="{{ icons['Advanced Medic Facilities (Medic)'] }}" class="{{ 'acquired' if 'Advanced Medic Facilities (Medic)' in acquired_items }}" title="Advanced Medic Facilities (Medic)" /></td>
<td><img src="{{ icons['Stabilizer Medpacks (Medic)'] }}" class="{{ 'acquired' if 'Stabilizer Medpacks (Medic)' in acquired_items }}" title="Stabilizer Medpacks (Medic)" /></td>
<td><img src="{{ icons['Incinerator Gauntlets (Firebat)'] }}" class="{{ 'acquired' if 'Incinerator Gauntlets (Firebat)' in acquired_items }}" title="Incinerator Gauntlets (Firebat)" /></td>
<td><img src="{{ icons['Juggernaut Plating (Firebat)'] }}" class="{{ 'acquired' if 'Juggernaut Plating (Firebat)' in acquired_items }}" title="Juggernaut Plating (Firebat)" /></td>
<td><img src="{{ icons['Concussive Shells (Marauder)'] }}" class="{{ 'acquired' if 'Concussive Shells (Marauder)' in acquired_items }}" title="Concussive Shells (Marauder)" /></td>
<td><img src="{{ icons['Kinetic Foam (Marauder)'] }}" class="{{ 'acquired' if 'Kinetic Foam (Marauder)' in acquired_items }}" title="Kinetic Foam (Marauder)" /></td>
<td><img src="{{ icons['U-238 Rounds (Reaper)'] }}" class="{{ 'acquired' if 'U-238 Rounds (Reaper)' in acquired_items }}" title="U-238 Rounds (Reaper)" /></td>
<td><img src="{{ icons['G-4 Clusterbomb (Reaper)'] }}" class="{{ 'acquired' if 'G-4 Clusterbomb (Reaper)' in acquired_items }}" title="G-4 Clusterbomb (Reaper)" /></td>
</tr>
<tr>
<td colspan="10" class="title">
Vehicles
</td>
</tr>
<tr>
<td colspan="2"><img src="{{ icons['Hellion'] }}" class="{{ 'acquired' if 'Hellion' in acquired_items }}" title="Hellion" /></td>
<td colspan="2"><img src="{{ icons['Vulture'] }}" class="{{ 'acquired' if 'Vulture' in acquired_items }}" title="Vulture" /></td>
<td colspan="2"><img src="{{ icons['Goliath'] }}" class="{{ 'acquired' if 'Goliath' in acquired_items }}" title="Goliath" /></td>
<td colspan="2"><img src="{{ icons['Diamondback'] }}" class="{{ 'acquired' if 'Diamondback' in acquired_items }}" title="Diamondback" /></td>
<td colspan="2"><img src="{{ icons['Siege Tank'] }}" class="{{ 'acquired' if 'Siege Tank' in acquired_items }}" title="Siege Tank" /></td>
</tr>
<tr>
<td><img src="{{ icons['Twin-Linked Flamethrower (Hellion)'] }}" class="{{ 'acquired' if 'Twin-Linked Flamethrower (Hellion)' in acquired_items }}" title="Twin-Linked Flamethrower (Hellion)" /></td>
<td><img src="{{ icons['Thermite Filaments (Hellion)'] }}" class="{{ 'acquired' if 'Thermite Filaments (Hellion)' in acquired_items }}" title="Thermite Filaments (Hellion)" /></td>
<td><img src="{{ icons['Cerberus Mine (Vulture)'] }}" class="{{ 'acquired' if 'Cerberus Mine (Vulture)' in acquired_items }}" title="Cerberus Mine (Vulture)" /></td>
<td><img src="{{ icons['Replenishable Magazine (Vulture)'] }}" class="{{ 'acquired' if 'Replenishable Magazine (Vulture)' in acquired_items }}" title="Replenishable Magazine (Vulture)" /></td>
<td><img src="{{ icons['Multi-Lock Weapons System (Goliath)'] }}" class="{{ 'acquired' if 'Multi-Lock Weapons System (Goliath)' in acquired_items }}" title="Multi-Lock Weapons System (Goliath)" /></td>
<td><img src="{{ icons['Ares-Class Targeting System (Goliath)'] }}" class="{{ 'acquired' if 'Ares-Class Targeting System (Goliath)' in acquired_items }}" title="Ares-Class Targeting System (Goliath)" /></td>
<td><img src="{{ icons['Tri-Lithium Power Cell (Diamondback)'] }}" class="{{ 'acquired' if 'Tri-Lithium Power Cell (Diamondback)' in acquired_items }}" title="Tri-Lithium Power Cell (Diamondback)" /></td>
<td><img src="{{ icons['Shaped Hull (Diamondback)'] }}" class="{{ 'acquired' if 'Shaped Hull (Diamondback)' in acquired_items }}" title="Shaped Hull (Diamondback)" /></td>
<td><img src="{{ icons['Maelstrom Rounds (Siege Tank)'] }}" class="{{ 'acquired' if 'Maelstrom Rounds (Siege Tank)' in acquired_items }}" title="Maelstrom Rounds (Siege Tank)" /></td>
<td><img src="{{ icons['Shaped Blast (Siege Tank)'] }}" class="{{ 'acquired' if 'Shaped Blast (Siege Tank)' in acquired_items }}" title="Shaped Blast (Siege Tank)" /></td>
</tr>
<tr>
<td colspan="10" class="title">
Starships
</td>
</tr>
<tr>
<td colspan="2"><img src="{{ icons['Medivac'] }}" class="{{ 'acquired' if 'Medivac' in acquired_items }}" title="Medivac" /></td>
<td colspan="2"><img src="{{ icons['Wraith'] }}" class="{{ 'acquired' if 'Wraith' in acquired_items }}" title="Wraith" /></td>
<td colspan="2"><img src="{{ icons['Viking'] }}" class="{{ 'acquired' if 'Viking' in acquired_items }}" title="Viking" /></td>
<td colspan="2"><img src="{{ icons['Banshee'] }}" class="{{ 'acquired' if 'Banshee' in acquired_items }}" title="Banshee" /></td>
<td colspan="2"><img src="{{ icons['Battlecruiser'] }}" class="{{ 'acquired' if 'Battlecruiser' in acquired_items }}" title="Battlecruiser" /></td>
</tr>
<tr>
<td><img src="{{ icons['Rapid Deployment Tube (Medivac)'] }}" class="{{ 'acquired' if 'Rapid Deployment Tube (Medivac)' in acquired_items }}" title="Rapid Deployment Tube (Medivac)" /></td>
<td><img src="{{ icons['Advanced Healing AI (Medivac)'] }}" class="{{ 'acquired' if 'Advanced Healing AI (Medivac)' in acquired_items }}" title="Advanced Healing AI (Medivac)" /></td>
<td><img src="{{ icons['Tomahawk Power Cells (Wraith)'] }}" class="{{ 'acquired' if 'Tomahawk Power Cells (Wraith)' in acquired_items }}" title="Tomahawk Power Cells (Wraith)" /></td>
<td><img src="{{ icons['Displacement Field (Wraith)'] }}" class="{{ 'acquired' if 'Displacement Field (Wraith)' in acquired_items }}" title="Displacement Field (Wraith)" /></td>
<td><img src="{{ icons['Ripwave Missiles (Viking)'] }}" class="{{ 'acquired' if 'Ripwave Missiles (Viking)' in acquired_items }}" title="Ripwave Missiles (Viking)" /></td>
<td><img src="{{ icons['Phobos-Class Weapons System (Viking)'] }}" class="{{ 'acquired' if 'Phobos-Class Weapons System (Viking)' in acquired_items }}" title="Phobos-Class Weapons System (Viking)" /></td>
<td><img src="{{ icons['Cross-Spectrum Dampeners (Banshee)'] }}" class="{{ 'acquired' if 'Cross-Spectrum Dampeners (Banshee)' in acquired_items }}" title="Cross-Spectrum Dampeners (Banshee)" /></td>
<td><img src="{{ icons['Shockwave Missile Battery (Banshee)'] }}" class="{{ 'acquired' if 'Shockwave Missile Battery (Banshee)' in acquired_items }}" title="Shockwave Missile Battery (Banshee)" /></td>
<td><img src="{{ icons['Missile Pods (Battlecruiser)'] }}" class="{{ 'acquired' if 'Missile Pods (Battlecruiser)' in acquired_items }}" title="Missile Pods (Battlecruiser)" /></td>
<td><img src="{{ icons['Defensive Matrix (Battlecruiser)'] }}" class="{{ 'acquired' if 'Defensive Matrix (Battlecruiser)' in acquired_items }}" title="Defensive Matrix (Battlecruiser)" /></td>
</tr>
<tr>
<td colspan="10" class="title">
Dominion
</td>
</tr>
<tr>
<td colspan="2"><img src="{{ icons['Ghost'] }}" class="{{ 'acquired' if 'Ghost' in acquired_items }}" title="Ghost" /></td>
<td colspan="2"><img src="{{ icons['Spectre'] }}" class="{{ 'acquired' if 'Spectre' in acquired_items }}" title="Spectre" /></td>
<td colspan="2"><img src="{{ icons['Thor'] }}" class="{{ 'acquired' if 'Thor' in acquired_items }}" title="Thor" /></td>
</tr>
<tr>
<td><img src="{{ icons['Ocular Implants (Ghost)'] }}" class="{{ 'acquired' if 'Ocular Implants (Ghost)' in acquired_items }}" title="Ocular Implants (Ghost)" /></td>
<td><img src="{{ icons['Crius Suit (Ghost)'] }}" class="{{ 'acquired' if 'Crius Suit (Ghost)' in acquired_items }}" title="Crius Suit (Ghost)" /></td>
<td><img src="{{ icons['Psionic Lash (Spectre)'] }}" class="{{ 'acquired' if 'Psionic Lash (Spectre)' in acquired_items }}" title="Psionic Lash (Spectre)" /></td>
<td><img src="{{ icons['Nyx-Class Cloaking Module (Spectre)'] }}" class="{{ 'acquired' if 'Nyx-Class Cloaking Module (Spectre)' in acquired_items }}" title="Nyx-Class Cloaking Module (Spectre)" /></td>
<td><img src="{{ icons['330mm Barrage Cannon (Thor)'] }}" class="{{ 'acquired' if '330mm Barrage Cannon (Thor)' in acquired_items }}" title="330mm Barrage Cannon (Thor)" /></td>
<td><img src="{{ icons['Immortality Protocol (Thor)'] }}" class="{{ 'acquired' if 'Immortality Protocol (Thor)' in acquired_items }}" title="Immortality Protocol (Thor)" /></td>
</tr>
<tr>
<td colspan="10" class="title">
Mercenaries
</td>
</tr>
<tr>
<td><img src="{{ icons['War Pigs'] }}" class="{{ 'acquired' if 'War Pigs' in acquired_items }}" title="War Pigs" /></td>
<td><img src="{{ icons['Devil Dogs'] }}" class="{{ 'acquired' if 'Devil Dogs' in acquired_items }}" title="Devil Dogs" /></td>
<td><img src="{{ icons['Hammer Securities'] }}" class="{{ 'acquired' if 'Hammer Securities' in acquired_items }}" title="Hammer Securities" /></td>
<td><img src="{{ icons['Spartan Company'] }}" class="{{ 'acquired' if 'Spartan Company' in acquired_items }}" title="Spartan Company" /></td>
<td><img src="{{ icons['Siege Breakers'] }}" class="{{ 'acquired' if 'Siege Breakers' in acquired_items }}" title="Siege Breakers" /></td>
<td><img src="{{ icons['Hel\'s Angel'] }}" class="{{ 'acquired' if 'Hel\'s Angel' in acquired_items }}" title="Hel's Angel" /></td>
<td><img src="{{ icons['Dusk Wings'] }}" class="{{ 'acquired' if 'Dusk Wings' in acquired_items }}" title="Dusk Wings" /></td>
<td><img src="{{ icons['Jackson\'s Revenge'] }}" class="{{ 'acquired' if 'Jackson\'s Revenge' in acquired_items }}" title="Jackson's Revenge" /></td>
</tr>
<tr>
<td colspan="10" class="title">
Lab Upgrades
</td>
</tr>
<tr>
<td><img src="{{ icons['Ultra-Capacitors'] }}" class="{{ 'acquired' if 'Ultra-Capacitors' in acquired_items }}" title="Ultra-Capacitors" /></td>
<td><img src="{{ icons['Vanadium Plating'] }}" class="{{ 'acquired' if 'Vanadium Plating' in acquired_items }}" title="Vanadium Plating" /></td>
<td><img src="{{ icons['Orbital Depots'] }}" class="{{ 'acquired' if 'Orbital Depots' in acquired_items }}" title="Orbital Depots" /></td>
<td><img src="{{ icons['Micro-Filtering'] }}" class="{{ 'acquired' if 'Micro-Filtering' in acquired_items }}" title="Micro-Filtering" /></td>
<td><img src="{{ icons['Automated Refinery'] }}" class="{{ 'acquired' if 'Automated Refinery' in acquired_items }}" title="Automated Refinery" /></td>
<td><img src="{{ icons['Command Center Reactor'] }}" class="{{ 'acquired' if 'Command Center Reactor' in acquired_items }}" title="Command Center Reactor" /></td>
<td><img src="{{ icons['Raven'] }}" class="{{ 'acquired' if 'Raven' in acquired_items }}" title="Raven" /></td>
<td><img src="{{ icons['Science Vessel'] }}" class="{{ 'acquired' if 'Science Vessel' in acquired_items }}" title="Science Vessel" /></td>
<td><img src="{{ icons['Tech Reactor'] }}" class="{{ 'acquired' if 'Tech Reactor' in acquired_items }}" title="Tech Reactor" /></td>
<td><img src="{{ icons['Orbital Strike'] }}" class="{{ 'acquired' if 'Orbital Strike' in acquired_items }}" title="Orbital Strike" /></td>
</tr>
<tr>
<td><img src="{{ icons['Shrike Turret'] }}" class="{{ 'acquired' if 'Shrike Turret' in acquired_items }}" title="Shrike Turret" /></td>
<td><img src="{{ icons['Fortified Bunker'] }}" class="{{ 'acquired' if 'Fortified Bunker' in acquired_items }}" title="Fortified Bunker" /></td>
<td><img src="{{ icons['Planetary Fortress'] }}" class="{{ 'acquired' if 'Planetary Fortress' in acquired_items }}" title="Planetary Fortress" /></td>
<td><img src="{{ icons['Perdition Turret'] }}" class="{{ 'acquired' if 'Perdition Turret' in acquired_items }}" title="Perdition Turret" /></td>
<td><img src="{{ icons['Predator'] }}" class="{{ 'acquired' if 'Predator' in acquired_items }}" title="Predator" /></td>
<td><img src="{{ icons['Hercules'] }}" class="{{ 'acquired' if 'Hercules' in acquired_items }}" title="Hercules" /></td>
<td><img src="{{ icons['Cellular Reactor'] }}" class="{{ 'acquired' if 'Cellular Reactor' in acquired_items }}" title="Cellular Reactor" /></td>
<td><img src="{{ icons['Regenerative Bio-Steel'] }}" class="{{ 'acquired' if 'Regenerative Bio-Steel' in acquired_items }}" title="Regenerative Bio-Steel" /></td>
<td><img src="{{ icons['Hive Mind Emulator'] }}" class="{{ 'acquired' if 'Hive Mind Emulator' in acquired_items }}" title="Hive Mind Emulator" /></td>
<td><img src="{{ icons['Psi Disrupter'] }}" class="{{ 'acquired' if 'Psi Disrupter' in acquired_items }}" title="Psi Disrupter" /></td>
</tr>
<tr>
<td colspan="10" class="title">
Protoss Units
</td>
</tr>
<tr>
<td><img src="{{ icons['Zealot'] }}" class="{{ 'acquired' if 'Zealot' in acquired_items }}" title="Zealot" /></td>
<td><img src="{{ icons['Stalker'] }}" class="{{ 'acquired' if 'Stalker' in acquired_items }}" title="Stalker" /></td>
<td><img src="{{ icons['High Templar'] }}" class="{{ 'acquired' if 'High Templar' in acquired_items }}" title="High Templar" /></td>
<td><img src="{{ icons['Dark Templar'] }}" class="{{ 'acquired' if 'Dark Templar' in acquired_items }}" title="Dark Templar" /></td>
<td><img src="{{ icons['Immortal'] }}" class="{{ 'acquired' if 'Immortal' in acquired_items }}" title="Immortal" /></td>
<td><img src="{{ icons['Colossus'] }}" class="{{ 'acquired' if 'Colossus' in acquired_items }}" title="Colossus" /></td>
<td><img src="{{ icons['Phoenix'] }}" class="{{ 'acquired' if 'Phoenix' in acquired_items }}" title="Phoenix" /></td>
<td><img src="{{ icons['Void Ray'] }}" class="{{ 'acquired' if 'Void Ray' in acquired_items }}" title="Void Ray" /></td>
<td><img src="{{ icons['Carrier'] }}" class="{{ 'acquired' if 'Carrier' in acquired_items }}" title="Carrier" /></td>
</tr>
</table>
<table id="location-table">
{% for area in checks_in_area %}
{% if checks_in_area[area] > 0 %}
<tr class="location-category" id="{{area}}-header">
<td>{{ area }} {{'▼' if area != 'Total'}}</td>
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
</tr>
<tbody class="locations hide" id="{{area}}">
{% for location in location_info[area] %}
<tr>
<td class="location-name">{{ location }}</td>
<td class="counter">{{ '✔' if location_info[area][location] else '' }}</td>
</tr>
{% endfor %}
</tbody>
{% endif %}
{% endfor %}
</table>
</div>
</body>
</html>

View File

@@ -1,6 +1,7 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% block head %} {% block head %}
{{ super() }}
<title>Start Playing</title> <title>Start Playing</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/startPlaying.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/startPlaying.css") }}" />
{% endblock %} {% endblock %}

View File

@@ -1,7 +1,7 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% block head %} {% block head %}
<title>Supported Games</title> <title>Player Settings</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/supportedGames.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/supportedGames.css") }}" />
{% endblock %} {% endblock %}
@@ -10,21 +10,15 @@
{% include 'header/oceanHeader.html' %} {% include 'header/oceanHeader.html' %}
<div id="games" class="markdown"> <div id="games" class="markdown">
<h1>Currently Supported Games</h1> <h1>Currently Supported Games</h1>
{% for game_name in worlds | title_sorted %} {% for game_name, world in worlds.items() | sort(attribute=0) %}
{% set world = worlds[game_name] %}
<h2>{{ game_name }}</h2> <h2>{{ game_name }}</h2>
<p> <p>
{{ world.__doc__ | default("No description provided.", true) }}<br /> {{ world.__doc__ | default("No description provided.", true) }}<br />
<a href="{{ url_for("game_info", game=game_name, lang="en") }}">Game Page</a> <a href="{{ url_for("game_info", game=game_name, lang="en") }}">Game Page</a>
{% if world.web.tutorials %}
<span class="link-spacer">|</span> <span class="link-spacer">|</span>
<a href="{{ url_for("tutorial_landing") }}#{{ game_name }}">Setup Guides</a>
{% endif %}
{% if world.web.settings_page is string %} {% if world.web.settings_page is string %}
<span class="link-spacer">|</span>
<a href="{{ world.web.settings_page }}">Settings Page</a> <a href="{{ world.web.settings_page }}">Settings Page</a>
{% elif world.web.settings_page %} {% elif world.web.settings_page %}
<span class="link-spacer">|</span>
<a href="{{ url_for("player_settings", game=game_name) }}">Settings Page</a> <a href="{{ url_for("player_settings", game=game_name) }}">Settings Page</a>
{% endif %} {% endif %}
{% if world.web.bug_report_page %} {% if world.web.bug_report_page %}

View File

@@ -41,7 +41,7 @@
<td></td> <td></td>
{% endif %} {% endif %}
<td><img src="{{ icons['Elevator Keycard'] }}" class="{{ 'acquired' if 'Elevator Keycard' in acquired_items }}" title="Elevator Keycard" /></td> <td><img src="{{ icons['Elevator Keycard'] }}" class="{{ 'acquired' if 'Elevator Keycard' in acquired_items }}" title="Elevator Keycard" /></td>
{% if 'EyeSpy' in options %} {% if 'FacebookMode' in options %}
<td><img src="{{ icons['Oculus Ring'] }}" class="{{ 'acquired' if 'Oculus Ring' in acquired_items }}" title="Oculus Ring" /></td> <td><img src="{{ icons['Oculus Ring'] }}" class="{{ 'acquired' if 'Oculus Ring' in acquired_items }}" title="Oculus Ring" /></td>
{% else %} {% else %}
<td></td> <td></td>

View File

@@ -25,7 +25,7 @@
<div id="tables-container"> <div id="tables-container">
{% for team, players in inventory.items() %} {% for team, players in inventory.items() %}
<div class="table-wrapper"> <div class="table-wrapper">
<table id="inventory-table" class="table unique-item-table"> <table class="table unique-item-table">
<thead> <thead>
<tr> <tr>
<th>#</th> <th>#</th>
@@ -44,7 +44,7 @@
<tbody> <tbody>
{%- for player, items in players.items() -%} {%- for player, items in players.items() -%}
<tr> <tr>
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker, <td><a href="{{ url_for("getPlayerTracker", tracker=room.tracker,
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td> tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
{%- if (team, loop.index) in video -%} {%- if (team, loop.index) in video -%}
{%- if video[(team, loop.index)][0] == "Twitch" -%} {%- if video[(team, loop.index)][0] == "Twitch" -%}
@@ -78,7 +78,7 @@
{% for team, players in checks_done.items() %} {% for team, players in checks_done.items() %}
<div class="table-wrapper"> <div class="table-wrapper">
<table id="checks-table" class="table non-unique-item-table"> <table class="table non-unique-item-table">
<thead> <thead>
<tr> <tr>
<th rowspan="2">#</th> <th rowspan="2">#</th>
@@ -121,7 +121,7 @@
<tbody> <tbody>
{%- for player, checks in players.items() -%} {%- for player, checks in players.items() -%}
<tr> <tr>
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker, <td><a href="{{ url_for("getPlayerTracker", tracker=room.tracker,
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td> tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
<td>{{ player_names[(team, loop.index)]|e }}</td> <td>{{ player_names[(team, loop.index)]|e }}</td>
{%- for area in ordered_areas -%} {%- for area in ordered_areas -%}
@@ -153,7 +153,7 @@
{% endfor %} {% endfor %}
{% for team, hints in hints.items() %} {% for team, hints in hints.items() %}
<div class="table-wrapper"> <div class="table-wrapper">
<table id="hints-table" class="table non-unique-item-table" data-order='[[5, "asc"], [0, "asc"]]'> <table class="table non-unique-item-table" data-order='[[5, "asc"], [0, "asc"]]'>
<thead> <thead>
<tr> <tr>
<th>Finder</th> <th>Finder</th>

View File

@@ -1,19 +1,18 @@
import collections import collections
import datetime
import typing import typing
from typing import Counter, Optional, Dict, Any, Tuple from typing import Counter, Optional, Dict, Any, Tuple
from uuid import UUID
from flask import render_template from flask import render_template
from werkzeug.exceptions import abort from werkzeug.exceptions import abort
import datetime
from uuid import UUID
from MultiServer import Context, get_saving_second from worlds.alttp import Items
from NetUtils import SlotType from WebHostLib import app, cache, Room
from Utils import restricted_loads from Utils import restricted_loads
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name
from worlds.alttp import Items from MultiServer import get_item_name_from_id, Context
from . import app, cache from NetUtils import SlotType
from .models import Room
alttp_icons = { alttp_icons = {
"Blue Shield": r"https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png", "Blue Shield": r"https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png",
@@ -280,25 +279,16 @@ def get_static_room_data(room: Room):
player_location_to_area = {playernumber: get_location_table(multidata["checks_in_area"][playernumber]) player_location_to_area = {playernumber: get_location_table(multidata["checks_in_area"][playernumber])
for playernumber in range(1, len(names[0]) + 1) for playernumber in range(1, len(names[0]) + 1)
if playernumber not in groups} if playernumber not in groups}
saving_second = get_saving_second(multidata["seed_name"])
result = locations, names, use_door_tracker, player_checks_in_area, player_location_to_area, \ result = locations, names, use_door_tracker, player_checks_in_area, player_location_to_area, \
multidata["precollected_items"], multidata["games"], multidata["slot_data"], groups, saving_second multidata["precollected_items"], multidata["games"], multidata["slot_data"], groups
_multidata_cache[room.seed.id] = result _multidata_cache[room.seed.id] = result
return result return result
@app.route('/tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>') @app.route('/tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>')
def get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, want_generic: bool = False): @cache.memoize(timeout=60) # multisave is currently created at most every minute
key = f"{tracker}_{tracked_team}_{tracked_player}_{want_generic}" def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want_generic: bool = False):
tracker_page = cache.get(key)
if tracker_page:
return tracker_page
timeout, tracker_page = _get_player_tracker(tracker, tracked_team, tracked_player, want_generic)
cache.set(key, tracker_page, timeout)
return tracker_page
def _get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, want_generic: bool):
# Team and player must be positive and greater than zero # Team and player must be positive and greater than zero
if tracked_team < 0 or tracked_player < 1: if tracked_team < 0 or tracked_player < 1:
abort(404) abort(404)
@@ -309,7 +299,7 @@ def _get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, w
# Collect seed information and pare it down to a single player # Collect seed information and pare it down to a single player
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \ locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \
precollected_items, games, slot_data, groups, saving_second = get_static_room_data(room) precollected_items, games, slot_data, groups = get_static_room_data(room)
player_name = names[tracked_team][tracked_player - 1] player_name = names[tracked_team][tracked_player - 1]
location_to_area = player_location_to_area[tracked_player] location_to_area = player_location_to_area[tracked_player]
inventory = collections.Counter() inventory = collections.Counter()
@@ -347,24 +337,21 @@ def _get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, w
checks_done["Total"] += 1 checks_done["Total"] += 1
specific_tracker = game_specific_trackers.get(games[tracked_player], None) specific_tracker = game_specific_trackers.get(games[tracked_player], None)
if specific_tracker and not want_generic: if specific_tracker and not want_generic:
tracker = specific_tracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name, return specific_tracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name,
seed_checks_in_area, checks_done, slot_data[tracked_player], saving_second) seed_checks_in_area, checks_done, slot_data[tracked_player])
else: else:
tracker = __renderGenericTracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name, return __renderGenericTracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name,
seed_checks_in_area, checks_done, saving_second) seed_checks_in_area, checks_done)
return (saving_second - datetime.datetime.now().second) % 60 or 60, tracker
@app.route('/generic_tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>') @app.route('/generic_tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>')
def get_generic_tracker(tracker: UUID, tracked_team: int, tracked_player: int): def get_generic_tracker(tracker: UUID, tracked_team: int, tracked_player: int):
return get_player_tracker(tracker, tracked_team, tracked_player, True) return getPlayerTracker(tracker, tracked_team, tracked_player, True)
def __renderAlttpTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]], def __renderAlttpTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
inventory: Counter, team: int, player: int, player_name: str, inventory: Counter, team: int, player: int, player_name: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict, seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
saving_second: int) -> str:
# Note the presence of the triforce item # Note the presence of the triforce item
game_state = multisave.get("client_game_state", {}).get((team, player), 0) game_state = multisave.get("client_game_state", {}).get((team, player), 0)
@@ -426,8 +413,7 @@ def __renderAlttpTracker(multisave: Dict[str, Any], room: Room, locations: Dict[
def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]], def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
inventory: Counter, team: int, player: int, playerName: str, inventory: Counter, team: int, player: int, playerName: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict, seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
saving_second: int) -> str:
icons = { icons = {
"Wooden Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d2/Wooden_Pickaxe_JE3_BE3.png", "Wooden Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d2/Wooden_Pickaxe_JE3_BE3.png",
@@ -456,23 +442,17 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D
"Campfire": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/91/Campfire_JE2_BE2.gif", "Campfire": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/91/Campfire_JE2_BE2.gif",
"Water Bottle": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/75/Water_Bottle_JE2_BE2.png", "Water Bottle": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/75/Water_Bottle_JE2_BE2.png",
"Spyglass": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c1/Spyglass_JE2_BE1.png", "Spyglass": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c1/Spyglass_JE2_BE1.png",
"Dragon Egg Shard": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/38/Dragon_Egg_JE4.png",
"Lead": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/1/1f/Lead_JE2_BE2.png",
"Saddle": "https://i.imgur.com/2QtDyR0.png",
"Channeling Book": "https://i.imgur.com/J3WsYZw.png",
"Silk Touch Book": "https://i.imgur.com/iqERxHQ.png",
"Piercing IV Book": "https://i.imgur.com/OzJptGz.png",
} }
minecraft_location_ids = { minecraft_location_ids = {
"Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070, "Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070,
42041, 42049, 42004, 42031, 42025, 42029, 42051, 42077], 42041, 42049, 42004, 42031, 42025, 42029, 42051, 42077],
"Nether": [42017, 42044, 42069, 42058, 42034, 42060, 42066, 42076, 42064, 42071, 42021, "Nether": [42017, 42044, 42069, 42058, 42034, 42060, 42066, 42076, 42064, 42071, 42021,
42062, 42008, 42061, 42033, 42011, 42006, 42019, 42000, 42040, 42001, 42015, 42104, 42014], 42062, 42008, 42061, 42033, 42011, 42006, 42019, 42000, 42040, 42001, 42015, 42014],
"The End": [42052, 42005, 42012, 42032, 42030, 42042, 42018, 42038, 42046], "The End": [42052, 42005, 42012, 42032, 42030, 42042, 42018, 42038, 42046],
"Adventure": [42047, 42050, 42096, 42097, 42098, 42059, 42055, 42072, 42003, 42109, 42035, 42016, 42020, "Adventure": [42047, 42050, 42096, 42097, 42098, 42059, 42055, 42072, 42003, 42035, 42016, 42020,
42048, 42054, 42068, 42043, 42106, 42074, 42075, 42024, 42026, 42037, 42045, 42056, 42105, 42099, 42103, 42110, 42100], 42048, 42054, 42068, 42043, 42074, 42075, 42024, 42026, 42037, 42045, 42056, 42099, 42100],
"Husbandry": [42065, 42067, 42078, 42022, 42113, 42107, 42007, 42079, 42013, 42028, 42036, 42108, 42111, 42112, "Husbandry": [42065, 42067, 42078, 42022, 42007, 42079, 42013, 42028, 42036,
42057, 42063, 42053, 42102, 42101, 42092, 42093, 42094, 42095], 42057, 42063, 42053, 42102, 42101, 42092, 42093, 42094, 42095],
"Archipelago": [42080, 42081, 42082, 42083, 42084, 42085, 42086, 42087, 42088, 42089, 42090, 42091], "Archipelago": [42080, 42081, 42082, 42083, 42084, 42085, 42086, 42087, 42088, 42089, 42090, 42091],
} }
@@ -501,8 +481,7 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D
# Multi-items # Multi-items
multi_items = { multi_items = {
"3 Ender Pearls": 45029, "3 Ender Pearls": 45029,
"8 Netherite Scrap": 45015, "8 Netherite Scrap": 45015
"Dragon Egg Shard": 45043
} }
for item_name, item_id in multi_items.items(): for item_name, item_id in multi_items.items():
base_name = item_name.split()[-1].lower() base_name = item_name.split()[-1].lower()
@@ -529,15 +508,14 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D
inventory=inventory, icons=icons, inventory=inventory, icons=icons,
acquired_items={lookup_any_item_id_to_name[id] for id in inventory if acquired_items={lookup_any_item_id_to_name[id] for id in inventory if
id in lookup_any_item_id_to_name}, id in lookup_any_item_id_to_name},
player=player, team=team, room=room, player_name=playerName, saving_second = saving_second, player=player, team=team, room=room, player_name=playerName,
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info, checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
**display_data) **display_data)
def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]], def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
inventory: Counter, team: int, player: int, playerName: str, inventory: Counter, team: int, player: int, playerName: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict, seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
saving_second: int) -> str:
icons = { icons = {
"Fairy Ocarina": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/97/OoT_Fairy_Ocarina_Icon.png", "Fairy Ocarina": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/97/OoT_Fairy_Ocarina_Icon.png",
@@ -650,47 +628,43 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
# Gather dungeon locations # Gather dungeon locations
area_id_ranges = { area_id_ranges = {
"Overworld": ((67000, 67258), (67264, 67280), (67747, 68024), (68054, 68062)), "Overworld": (67000, 67280),
"Deku Tree": ((67281, 67303), (68063, 68077)), "Deku Tree": (67281, 67303),
"Dodongo's Cavern": ((67304, 67334), (68078, 68160)), "Dodongo's Cavern": (67304, 67334),
"Jabu Jabu's Belly": ((67335, 67359), (68161, 68188)), "Jabu Jabu's Belly": (67335, 67359),
"Bottom of the Well": ((67360, 67384), (68189, 68230)), "Bottom of the Well": (67360, 67384),
"Forest Temple": ((67385, 67420), (68231, 68281)), "Forest Temple": (67385, 67420),
"Fire Temple": ((67421, 67457), (68282, 68350)), "Fire Temple": (67421, 67457),
"Water Temple": ((67458, 67484), (68351, 68483)), "Water Temple": (67458, 67484),
"Shadow Temple": ((67485, 67532), (68484, 68565)), "Shadow Temple": (67485, 67532),
"Spirit Temple": ((67533, 67582), (68566, 68625)), "Spirit Temple": (67533, 67582),
"Ice Cavern": ((67583, 67596), (68626, 68649)), "Ice Cavern": (67583, 67596),
"Gerudo Training Ground": ((67597, 67635), (68650, 68656)), "Gerudo Training Ground": (67597, 67635),
"Thieves' Hideout": ((67259, 67263), (68025, 68053)), "Thieves' Hideout": (67259, 67263),
"Ganon's Castle": ((67636, 67673), (68657, 68705)), "Ganon's Castle": (67636, 67673),
} }
def lookup_and_trim(id, area): def lookup_and_trim(id, area):
full_name = lookup_any_location_id_to_name[id] full_name = lookup_any_location_id_to_name[id]
if 'Ganons Tower' in full_name: if id == 67673:
return full_name return full_name[13:] # Ganons Tower Boss Key Chest
if area not in ["Overworld", "Thieves' Hideout"]: if area not in ["Overworld", "Thieves' Hideout"]:
# trim dungeon name. leaves an extra space that doesn't display, or trims fully for DC/Jabu/GC # trim dungeon name. leaves an extra space that doesn't display, or trims fully for DC/Jabu/GC
return full_name[len(area):] return full_name[len(area):]
return full_name return full_name
checked_locations = multisave.get("location_checks", {}).get((team, player), set()).intersection(set(locations[player])) checked_locations = multisave.get("location_checks", {}).get((team, player), set()).intersection(set(locations[player]))
location_info = {} location_info = {area: {lookup_and_trim(id, area): id in checked_locations for id in range(min_id, max_id+1) if id in locations[player]}
checks_done = {} for area, (min_id, max_id) in area_id_ranges.items()}
checks_in_area = {} checks_done = {area: len(list(filter(lambda x: x, location_info[area].values()))) for area in area_id_ranges}
for area, ranges in area_id_ranges.items(): checks_in_area = {area: len([id for id in range(min_id, max_id+1) if id in locations[player]])
location_info[area] = {} for area, (min_id, max_id) in area_id_ranges.items()}
checks_done[area] = 0
checks_in_area[area] = 0 # Remove Thieves' Hideout checks from Overworld, since it's in the middle of the range
for r in ranges: checks_in_area["Overworld"] -= checks_in_area["Thieves' Hideout"]
min_id, max_id = r checks_done["Overworld"] -= checks_done["Thieves' Hideout"]
for id in range(min_id, max_id+1): for loc in location_info["Thieves' Hideout"]:
if id in locations[player]: del location_info["Overworld"][loc]
checked = id in checked_locations
location_info[area][lookup_and_trim(id, area)] = checked
checks_in_area[area] += 1
checks_done[area] += checked
checks_done['Total'] = sum(checks_done.values()) checks_done['Total'] = sum(checks_done.values())
checks_in_area['Total'] = sum(checks_in_area.values()) checks_in_area['Total'] = sum(checks_in_area.values())
@@ -701,28 +675,25 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
if "GS" in lookup_and_trim(id, ''): if "GS" in lookup_and_trim(id, ''):
display_data["token_count"] += 1 display_data["token_count"] += 1
oot_y = ''
oot_x = ''
# Gather small and boss key info # Gather small and boss key info
small_key_counts = { small_key_counts = {
"Forest Temple": oot_y if inventory[66203] else inventory[66175], "Forest Temple": inventory[66175],
"Fire Temple": oot_y if inventory[66204] else inventory[66176], "Fire Temple": inventory[66176],
"Water Temple": oot_y if inventory[66205] else inventory[66177], "Water Temple": inventory[66177],
"Spirit Temple": oot_y if inventory[66206] else inventory[66178], "Spirit Temple": inventory[66178],
"Shadow Temple": oot_y if inventory[66207] else inventory[66179], "Shadow Temple": inventory[66179],
"Bottom of the Well": oot_y if inventory[66208] else inventory[66180], "Bottom of the Well": inventory[66180],
"Gerudo Training Ground": oot_y if inventory[66209] else inventory[66181], "Gerudo Training Ground": inventory[66181],
"Thieves' Hideout": oot_y if inventory[66210] else inventory[66182], "Thieves' Hideout": inventory[66182],
"Ganon's Castle": oot_y if inventory[66211] else inventory[66183], "Ganon's Castle": inventory[66183],
} }
boss_key_counts = { boss_key_counts = {
"Forest Temple": oot_y if inventory[66149] else oot_x, "Forest Temple": '' if inventory[66149] else '',
"Fire Temple": oot_y if inventory[66150] else oot_x, "Fire Temple": '' if inventory[66150] else '',
"Water Temple": oot_y if inventory[66151] else oot_x, "Water Temple": '' if inventory[66151] else '',
"Spirit Temple": oot_y if inventory[66152] else oot_x, "Spirit Temple": '' if inventory[66152] else '',
"Shadow Temple": oot_y if inventory[66153] else oot_x, "Shadow Temple": '' if inventory[66153] else '',
"Ganon's Castle": oot_y if inventory[66154] else oot_x, "Ganon's Castle": '' if inventory[66154] else '',
} }
# Victory condition # Victory condition
@@ -739,8 +710,7 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]], def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
inventory: Counter, team: int, player: int, playerName: str, inventory: Counter, team: int, player: int, playerName: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict[str, Any]) -> str:
slot_data: Dict[str, Any], saving_second: int) -> str:
icons = { icons = {
"Timespinner Wheel": "https://timespinnerwiki.com/mediawiki/images/7/76/Timespinner_Wheel.png", "Timespinner Wheel": "https://timespinnerwiki.com/mediawiki/images/7/76/Timespinner_Wheel.png",
@@ -846,31 +816,30 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations:
def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]], def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
inventory: Counter, team: int, player: int, playerName: str, inventory: Counter, team: int, player: int, playerName: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict, seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
saving_second: int) -> str:
icons = { icons = {
"Energy Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/ETank.png", "Energy Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/ETank.png",
"Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Missile.png", "Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Missile.png",
"Super Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Super.png", "Super Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Super.png",
"Power Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/PowerBomb.png", "Power Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/PowerBomb.png",
"Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Bomb.png", "Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Bomb.png",
"Charge Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Charge.png", "Charge Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Charge.png",
"Ice Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Ice.png", "Ice Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Ice.png",
"Hi-Jump Boots": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/HiJump.png", "Hi-Jump Boots": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/HiJump.png",
"Speed Booster": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpeedBooster.png", "Speed Booster": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/SpeedBooster.png",
"Wave Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Wave.png", "Wave Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Wave.png",
"Spazer": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Spazer.png", "Spazer": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Spazer.png",
"Spring Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpringBall.png", "Spring Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/SpringBall.png",
"Varia Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Varia.png", "Varia Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Varia.png",
"Plasma Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Plasma.png", "Plasma Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Plasma.png",
"Grappling Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Grapple.png", "Grappling Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Grapple.png",
"Morph Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Morph.png", "Morph Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Morph.png",
"Reserve Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Reserve.png", "Reserve Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Reserve.png",
"Gravity Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Gravity.png", "Gravity Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Gravity.png",
"X-Ray Scope": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/XRayScope.png", "X-Ray Scope": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/XRayScope.png",
"Space Jump": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpaceJump.png", "Space Jump": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/SpaceJump.png",
"Screw Attack": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/ScrewAttack.png", "Screw Attack": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/ScrewAttack.png",
"Nothing": "", "Nothing": "",
"No Energy": "", "No Energy": "",
"Kraid": "", "Kraid": "",
@@ -945,285 +914,37 @@ def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info, checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
**display_data) **display_data)
def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
inventory: Counter, team: int, player: int, playerName: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int],
slot_data: Dict, saving_second: int) -> str:
SC2WOL_LOC_ID_OFFSET = 1000
SC2WOL_ITEM_ID_OFFSET = 1000
icons = {
"Starting Minerals": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/icons/icon-mineral-protoss.png",
"Starting Vespene": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/icons/icon-gas-terran.png",
"Starting Supply": "https://static.wikia.nocookie.net/starcraft/images/d/d3/TerranSupply_SC2_Icon1.gif",
"Infantry Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel1.png",
"Infantry Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel2.png",
"Infantry Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel3.png",
"Infantry Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel1.png",
"Infantry Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel2.png",
"Infantry Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel3.png",
"Vehicle Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel1.png",
"Vehicle Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel2.png",
"Vehicle Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel3.png",
"Vehicle Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel1.png",
"Vehicle Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel2.png",
"Vehicle Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel3.png",
"Ship Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel1.png",
"Ship Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel2.png",
"Ship Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel3.png",
"Ship Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel1.png",
"Ship Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel2.png",
"Ship Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel3.png",
"Bunker": "https://static.wikia.nocookie.net/starcraft/images/c/c5/Bunker_SC2_Icon1.jpg",
"Missile Turret": "https://static.wikia.nocookie.net/starcraft/images/5/5f/MissileTurret_SC2_Icon1.jpg",
"Sensor Tower": "https://static.wikia.nocookie.net/starcraft/images/d/d2/SensorTower_SC2_Icon1.jpg",
"Projectile Accelerator (Bunker)": "https://0rganics.org/archipelago/sc2wol/ProjectileAccelerator.png",
"Neosteel Bunker (Bunker)": "https://0rganics.org/archipelago/sc2wol/NeosteelBunker.png",
"Titanium Housing (Missile Turret)": "https://0rganics.org/archipelago/sc2wol/TitaniumHousing.png",
"Hellstorm Batteries (Missile Turret)": "https://0rganics.org/archipelago/sc2wol/HellstormBatteries.png",
"Advanced Construction (SCV)": "https://0rganics.org/archipelago/sc2wol/AdvancedConstruction.png",
"Dual-Fusion Welders (SCV)": "https://0rganics.org/archipelago/sc2wol/Dual-FusionWelders.png",
"Fire-Suppression System (Building)": "https://0rganics.org/archipelago/sc2wol/Fire-SuppressionSystem.png",
"Orbital Command (Building)": "https://0rganics.org/archipelago/sc2wol/OrbitalCommandCampaign.png",
"Marine": "https://static.wikia.nocookie.net/starcraft/images/4/47/Marine_SC2_Icon1.jpg",
"Medic": "https://static.wikia.nocookie.net/starcraft/images/7/74/Medic_SC2_Rend1.jpg",
"Firebat": "https://static.wikia.nocookie.net/starcraft/images/3/3c/Firebat_SC2_Rend1.jpg",
"Marauder": "https://static.wikia.nocookie.net/starcraft/images/b/ba/Marauder_SC2_Icon1.jpg",
"Reaper": "https://static.wikia.nocookie.net/starcraft/images/7/7d/Reaper_SC2_Icon1.jpg",
"Stimpack (Marine)": "https://0rganics.org/archipelago/sc2wol/StimpacksCampaign.png",
"Combat Shield (Marine)": "https://0rganics.org/archipelago/sc2wol/CombatShieldCampaign.png",
"Advanced Medic Facilities (Medic)": "https://0rganics.org/archipelago/sc2wol/AdvancedMedicFacilities.png",
"Stabilizer Medpacks (Medic)": "https://0rganics.org/archipelago/sc2wol/StabilizerMedpacks.png",
"Incinerator Gauntlets (Firebat)": "https://0rganics.org/archipelago/sc2wol/IncineratorGauntlets.png",
"Juggernaut Plating (Firebat)": "https://0rganics.org/archipelago/sc2wol/JuggernautPlating.png",
"Concussive Shells (Marauder)": "https://0rganics.org/archipelago/sc2wol/ConcussiveShellsCampaign.png",
"Kinetic Foam (Marauder)": "https://0rganics.org/archipelago/sc2wol/KineticFoam.png",
"U-238 Rounds (Reaper)": "https://0rganics.org/archipelago/sc2wol/U-238Rounds.png",
"G-4 Clusterbomb (Reaper)": "https://0rganics.org/archipelago/sc2wol/G-4Clusterbomb.png",
"Hellion": "https://static.wikia.nocookie.net/starcraft/images/5/56/Hellion_SC2_Icon1.jpg",
"Vulture": "https://static.wikia.nocookie.net/starcraft/images/d/da/Vulture_WoL.jpg",
"Goliath": "https://static.wikia.nocookie.net/starcraft/images/e/eb/Goliath_WoL.jpg",
"Diamondback": "https://static.wikia.nocookie.net/starcraft/images/a/a6/Diamondback_WoL.jpg",
"Siege Tank": "https://static.wikia.nocookie.net/starcraft/images/5/57/SiegeTank_SC2_Icon1.jpg",
"Twin-Linked Flamethrower (Hellion)": "https://0rganics.org/archipelago/sc2wol/Twin-LinkedFlamethrower.png",
"Thermite Filaments (Hellion)": "https://0rganics.org/archipelago/sc2wol/ThermiteFilaments.png",
"Cerberus Mine (Vulture)": "https://0rganics.org/archipelago/sc2wol/CerberusMine.png",
"Replenishable Magazine (Vulture)": "https://0rganics.org/archipelago/sc2wol/ReplenishableMagazine.png",
"Multi-Lock Weapons System (Goliath)": "https://0rganics.org/archipelago/sc2wol/Multi-LockWeaponsSystem.png",
"Ares-Class Targeting System (Goliath)": "https://0rganics.org/archipelago/sc2wol/Ares-ClassTargetingSystem.png",
"Tri-Lithium Power Cell (Diamondback)": "https://0rganics.org/archipelago/sc2wol/Tri-LithiumPowerCell.png",
"Shaped Hull (Diamondback)": "https://0rganics.org/archipelago/sc2wol/ShapedHull.png",
"Maelstrom Rounds (Siege Tank)": "https://0rganics.org/archipelago/sc2wol/MaelstromRounds.png",
"Shaped Blast (Siege Tank)": "https://0rganics.org/archipelago/sc2wol/ShapedBlast.png",
"Medivac": "https://static.wikia.nocookie.net/starcraft/images/d/db/Medivac_SC2_Icon1.jpg",
"Wraith": "https://static.wikia.nocookie.net/starcraft/images/7/75/Wraith_WoL.jpg",
"Viking": "https://static.wikia.nocookie.net/starcraft/images/2/2a/Viking_SC2_Icon1.jpg",
"Banshee": "https://static.wikia.nocookie.net/starcraft/images/3/32/Banshee_SC2_Icon1.jpg",
"Battlecruiser": "https://static.wikia.nocookie.net/starcraft/images/f/f5/Battlecruiser_SC2_Icon1.jpg",
"Rapid Deployment Tube (Medivac)": "https://0rganics.org/archipelago/sc2wol/RapidDeploymentTube.png",
"Advanced Healing AI (Medivac)": "https://0rganics.org/archipelago/sc2wol/AdvancedHealingAI.png",
"Tomahawk Power Cells (Wraith)": "https://0rganics.org/archipelago/sc2wol/TomahawkPowerCells.png",
"Displacement Field (Wraith)": "https://0rganics.org/archipelago/sc2wol/DisplacementField.png",
"Ripwave Missiles (Viking)": "https://0rganics.org/archipelago/sc2wol/RipwaveMissiles.png",
"Phobos-Class Weapons System (Viking)": "https://0rganics.org/archipelago/sc2wol/Phobos-ClassWeaponsSystem.png",
"Cross-Spectrum Dampeners (Banshee)": "https://0rganics.org/archipelago/sc2wol/Cross-SpectrumDampeners.png",
"Shockwave Missile Battery (Banshee)": "https://0rganics.org/archipelago/sc2wol/ShockwaveMissileBattery.png",
"Missile Pods (Battlecruiser)": "https://0rganics.org/archipelago/sc2wol/MissilePods.png",
"Defensive Matrix (Battlecruiser)": "https://0rganics.org/archipelago/sc2wol/DefensiveMatrix.png",
"Ghost": "https://static.wikia.nocookie.net/starcraft/images/6/6e/Ghost_SC2_Icon1.jpg",
"Spectre": "https://static.wikia.nocookie.net/starcraft/images/0/0d/Spectre_WoL.jpg",
"Thor": "https://static.wikia.nocookie.net/starcraft/images/e/ef/Thor_SC2_Icon1.jpg",
"Ocular Implants (Ghost)": "https://0rganics.org/archipelago/sc2wol/OcularImplants.png",
"Crius Suit (Ghost)": "https://0rganics.org/archipelago/sc2wol/CriusSuit.png",
"Psionic Lash (Spectre)": "https://0rganics.org/archipelago/sc2wol/PsionicLash.png",
"Nyx-Class Cloaking Module (Spectre)": "https://0rganics.org/archipelago/sc2wol/Nyx-ClassCloakingModule.png",
"330mm Barrage Cannon (Thor)": "https://0rganics.org/archipelago/sc2wol/330mmBarrageCannon.png",
"Immortality Protocol (Thor)": "https://0rganics.org/archipelago/sc2wol/ImmortalityProtocol.png",
"War Pigs": "https://static.wikia.nocookie.net/starcraft/images/e/ed/WarPigs_SC2_Icon1.jpg",
"Devil Dogs": "https://static.wikia.nocookie.net/starcraft/images/3/33/DevilDogs_SC2_Icon1.jpg",
"Hammer Securities": "https://static.wikia.nocookie.net/starcraft/images/3/3b/HammerSecurity_SC2_Icon1.jpg",
"Spartan Company": "https://static.wikia.nocookie.net/starcraft/images/b/be/SpartanCompany_SC2_Icon1.jpg",
"Siege Breakers": "https://static.wikia.nocookie.net/starcraft/images/3/31/SiegeBreakers_SC2_Icon1.jpg",
"Hel's Angel": "https://static.wikia.nocookie.net/starcraft/images/6/63/HelsAngels_SC2_Icon1.jpg",
"Dusk Wings": "https://static.wikia.nocookie.net/starcraft/images/5/52/DuskWings_SC2_Icon1.jpg",
"Jackson's Revenge": "https://static.wikia.nocookie.net/starcraft/images/9/95/JacksonsRevenge_SC2_Icon1.jpg",
"Ultra-Capacitors": "https://static.wikia.nocookie.net/starcraft/images/2/23/SC2_Lab_Ultra_Capacitors_Icon.png",
"Vanadium Plating": "https://static.wikia.nocookie.net/starcraft/images/6/67/SC2_Lab_VanPlating_Icon.png",
"Orbital Depots": "https://static.wikia.nocookie.net/starcraft/images/0/01/SC2_Lab_Orbital_Depot_Icon.png",
"Micro-Filtering": "https://static.wikia.nocookie.net/starcraft/images/2/20/SC2_Lab_MicroFilter_Icon.png",
"Automated Refinery": "https://static.wikia.nocookie.net/starcraft/images/7/71/SC2_Lab_Auto_Refinery_Icon.png",
"Command Center Reactor": "https://static.wikia.nocookie.net/starcraft/images/e/ef/SC2_Lab_CC_Reactor_Icon.png",
"Raven": "https://static.wikia.nocookie.net/starcraft/images/1/19/SC2_Lab_Raven_Icon.png",
"Science Vessel": "https://static.wikia.nocookie.net/starcraft/images/c/c3/SC2_Lab_SciVes_Icon.png",
"Tech Reactor": "https://static.wikia.nocookie.net/starcraft/images/c/c5/SC2_Lab_Tech_Reactor_Icon.png",
"Orbital Strike": "https://static.wikia.nocookie.net/starcraft/images/d/df/SC2_Lab_Orb_Strike_Icon.png",
"Shrike Turret": "https://static.wikia.nocookie.net/starcraft/images/4/44/SC2_Lab_Shrike_Turret_Icon.png",
"Fortified Bunker": "https://static.wikia.nocookie.net/starcraft/images/4/4f/SC2_Lab_FortBunker_Icon.png",
"Planetary Fortress": "https://static.wikia.nocookie.net/starcraft/images/0/0b/SC2_Lab_PlanetFortress_Icon.png",
"Perdition Turret": "https://static.wikia.nocookie.net/starcraft/images/a/af/SC2_Lab_PerdTurret_Icon.png",
"Predator": "https://static.wikia.nocookie.net/starcraft/images/8/83/SC2_Lab_Predator_Icon.png",
"Hercules": "https://static.wikia.nocookie.net/starcraft/images/4/40/SC2_Lab_Hercules_Icon.png",
"Cellular Reactor": "https://static.wikia.nocookie.net/starcraft/images/d/d8/SC2_Lab_CellReactor_Icon.png",
"Regenerative Bio-Steel": "https://static.wikia.nocookie.net/starcraft/images/d/d3/SC2_Lab_BioSteel_Icon.png",
"Hive Mind Emulator": "https://static.wikia.nocookie.net/starcraft/images/b/bc/SC2_Lab_Hive_Emulator_Icon.png",
"Psi Disrupter": "https://static.wikia.nocookie.net/starcraft/images/c/cf/SC2_Lab_Psi_Disruptor_Icon.png",
"Zealot": "https://static.wikia.nocookie.net/starcraft/images/6/6e/Icon_Protoss_Zealot.jpg",
"Stalker": "https://static.wikia.nocookie.net/starcraft/images/0/0d/Icon_Protoss_Stalker.jpg",
"High Templar": "https://static.wikia.nocookie.net/starcraft/images/a/a0/Icon_Protoss_High_Templar.jpg",
"Dark Templar": "https://static.wikia.nocookie.net/starcraft/images/9/90/Icon_Protoss_Dark_Templar.jpg",
"Immortal": "https://static.wikia.nocookie.net/starcraft/images/c/c1/Icon_Protoss_Immortal.jpg",
"Colossus": "https://static.wikia.nocookie.net/starcraft/images/4/40/Icon_Protoss_Colossus.jpg",
"Phoenix": "https://static.wikia.nocookie.net/starcraft/images/b/b1/Icon_Protoss_Phoenix.jpg",
"Void Ray": "https://static.wikia.nocookie.net/starcraft/images/1/1d/VoidRay_SC2_Rend1.jpg",
"Carrier": "https://static.wikia.nocookie.net/starcraft/images/2/2c/Icon_Protoss_Carrier.jpg",
"Nothing": "",
}
sc2wol_location_ids = {
"Liberation Day": [SC2WOL_LOC_ID_OFFSET + 100, SC2WOL_LOC_ID_OFFSET + 101, SC2WOL_LOC_ID_OFFSET + 102, SC2WOL_LOC_ID_OFFSET + 103, SC2WOL_LOC_ID_OFFSET + 104, SC2WOL_LOC_ID_OFFSET + 105, SC2WOL_LOC_ID_OFFSET + 106],
"The Outlaws": [SC2WOL_LOC_ID_OFFSET + 200, SC2WOL_LOC_ID_OFFSET + 201],
"Zero Hour": [SC2WOL_LOC_ID_OFFSET + 300, SC2WOL_LOC_ID_OFFSET + 301, SC2WOL_LOC_ID_OFFSET + 302, SC2WOL_LOC_ID_OFFSET + 303],
"Evacuation": [SC2WOL_LOC_ID_OFFSET + 400, SC2WOL_LOC_ID_OFFSET + 401, SC2WOL_LOC_ID_OFFSET + 402, SC2WOL_LOC_ID_OFFSET + 403],
"Outbreak": [SC2WOL_LOC_ID_OFFSET + 500, SC2WOL_LOC_ID_OFFSET + 501, SC2WOL_LOC_ID_OFFSET + 502],
"Safe Haven": [SC2WOL_LOC_ID_OFFSET + 600, SC2WOL_LOC_ID_OFFSET + 601, SC2WOL_LOC_ID_OFFSET + 602, SC2WOL_LOC_ID_OFFSET + 603],
"Haven's Fall": [SC2WOL_LOC_ID_OFFSET + 700, SC2WOL_LOC_ID_OFFSET + 701, SC2WOL_LOC_ID_OFFSET + 702, SC2WOL_LOC_ID_OFFSET + 703],
"Smash and Grab": [SC2WOL_LOC_ID_OFFSET + 800, SC2WOL_LOC_ID_OFFSET + 801, SC2WOL_LOC_ID_OFFSET + 802, SC2WOL_LOC_ID_OFFSET + 803, SC2WOL_LOC_ID_OFFSET + 804],
"The Dig": [SC2WOL_LOC_ID_OFFSET + 900, SC2WOL_LOC_ID_OFFSET + 901, SC2WOL_LOC_ID_OFFSET + 902, SC2WOL_LOC_ID_OFFSET + 903],
"The Moebius Factor": [SC2WOL_LOC_ID_OFFSET + 1000, SC2WOL_LOC_ID_OFFSET + 1003, SC2WOL_LOC_ID_OFFSET + 1004, SC2WOL_LOC_ID_OFFSET + 1005, SC2WOL_LOC_ID_OFFSET + 1006, SC2WOL_LOC_ID_OFFSET + 1007, SC2WOL_LOC_ID_OFFSET + 1008],
"Supernova": [SC2WOL_LOC_ID_OFFSET + 1100, SC2WOL_LOC_ID_OFFSET + 1101, SC2WOL_LOC_ID_OFFSET + 1102, SC2WOL_LOC_ID_OFFSET + 1103, SC2WOL_LOC_ID_OFFSET + 1104],
"Maw of the Void": [SC2WOL_LOC_ID_OFFSET + 1200, SC2WOL_LOC_ID_OFFSET + 1201, SC2WOL_LOC_ID_OFFSET + 1202, SC2WOL_LOC_ID_OFFSET + 1203, SC2WOL_LOC_ID_OFFSET + 1204, SC2WOL_LOC_ID_OFFSET + 1205],
"Devil's Playground": [SC2WOL_LOC_ID_OFFSET + 1300, SC2WOL_LOC_ID_OFFSET + 1301, SC2WOL_LOC_ID_OFFSET + 1302],
"Welcome to the Jungle": [SC2WOL_LOC_ID_OFFSET + 1400, SC2WOL_LOC_ID_OFFSET + 1401, SC2WOL_LOC_ID_OFFSET + 1402, SC2WOL_LOC_ID_OFFSET + 1403],
"Breakout": [SC2WOL_LOC_ID_OFFSET + 1500, SC2WOL_LOC_ID_OFFSET + 1501, SC2WOL_LOC_ID_OFFSET + 1502],
"Ghost of a Chance": [SC2WOL_LOC_ID_OFFSET + 1600, SC2WOL_LOC_ID_OFFSET + 1601, SC2WOL_LOC_ID_OFFSET + 1602, SC2WOL_LOC_ID_OFFSET + 1603, SC2WOL_LOC_ID_OFFSET + 1604, SC2WOL_LOC_ID_OFFSET + 1605],
"The Great Train Robbery": [SC2WOL_LOC_ID_OFFSET + 1700, SC2WOL_LOC_ID_OFFSET + 1701, SC2WOL_LOC_ID_OFFSET + 1702, SC2WOL_LOC_ID_OFFSET + 1703],
"Cutthroat": [SC2WOL_LOC_ID_OFFSET + 1800, SC2WOL_LOC_ID_OFFSET + 1801, SC2WOL_LOC_ID_OFFSET + 1802, SC2WOL_LOC_ID_OFFSET + 1803, SC2WOL_LOC_ID_OFFSET + 1804],
"Engine of Destruction": [SC2WOL_LOC_ID_OFFSET + 1900, SC2WOL_LOC_ID_OFFSET + 1901, SC2WOL_LOC_ID_OFFSET + 1902, SC2WOL_LOC_ID_OFFSET + 1903, SC2WOL_LOC_ID_OFFSET + 1904, SC2WOL_LOC_ID_OFFSET + 1905],
"Media Blitz": [SC2WOL_LOC_ID_OFFSET + 2000, SC2WOL_LOC_ID_OFFSET + 2001, SC2WOL_LOC_ID_OFFSET + 2002, SC2WOL_LOC_ID_OFFSET + 2003, SC2WOL_LOC_ID_OFFSET + 2004],
"Piercing the Shroud": [SC2WOL_LOC_ID_OFFSET + 2100, SC2WOL_LOC_ID_OFFSET + 2101, SC2WOL_LOC_ID_OFFSET + 2102, SC2WOL_LOC_ID_OFFSET + 2103, SC2WOL_LOC_ID_OFFSET + 2104, SC2WOL_LOC_ID_OFFSET + 2105],
"Whispers of Doom": [SC2WOL_LOC_ID_OFFSET + 2200, SC2WOL_LOC_ID_OFFSET + 2201, SC2WOL_LOC_ID_OFFSET + 2202, SC2WOL_LOC_ID_OFFSET + 2203],
"A Sinister Turn": [SC2WOL_LOC_ID_OFFSET + 2300, SC2WOL_LOC_ID_OFFSET + 2301, SC2WOL_LOC_ID_OFFSET + 2302, SC2WOL_LOC_ID_OFFSET + 2303],
"Echoes of the Future": [SC2WOL_LOC_ID_OFFSET + 2400, SC2WOL_LOC_ID_OFFSET + 2401, SC2WOL_LOC_ID_OFFSET + 2402],
"In Utter Darkness": [SC2WOL_LOC_ID_OFFSET + 2500, SC2WOL_LOC_ID_OFFSET + 2501, SC2WOL_LOC_ID_OFFSET + 2502],
"Gates of Hell": [SC2WOL_LOC_ID_OFFSET + 2600, SC2WOL_LOC_ID_OFFSET + 2601],
"Belly of the Beast": [SC2WOL_LOC_ID_OFFSET + 2700, SC2WOL_LOC_ID_OFFSET + 2701, SC2WOL_LOC_ID_OFFSET + 2702, SC2WOL_LOC_ID_OFFSET + 2703],
"Shatter the Sky": [SC2WOL_LOC_ID_OFFSET + 2800, SC2WOL_LOC_ID_OFFSET + 2801, SC2WOL_LOC_ID_OFFSET + 2802, SC2WOL_LOC_ID_OFFSET + 2803, SC2WOL_LOC_ID_OFFSET + 2804, SC2WOL_LOC_ID_OFFSET + 2805],
}
display_data = {}
# Determine display for progressive items
progressive_items = {
"Progressive Infantry Weapon": 100 + SC2WOL_ITEM_ID_OFFSET,
"Progressive Infantry Armor": 102 + SC2WOL_ITEM_ID_OFFSET,
"Progressive Vehicle Weapon": 103 + SC2WOL_ITEM_ID_OFFSET,
"Progressive Vehicle Armor": 104 + SC2WOL_ITEM_ID_OFFSET,
"Progressive Ship Weapon": 105 + SC2WOL_ITEM_ID_OFFSET,
"Progressive Ship Armor": 106 + SC2WOL_ITEM_ID_OFFSET
}
progressive_names = {
"Progressive Infantry Weapon": ["Infantry Weapons Level 1", "Infantry Weapons Level 1", "Infantry Weapons Level 2", "Infantry Weapons Level 3"],
"Progressive Infantry Armor": ["Infantry Armor Level 1", "Infantry Armor Level 1", "Infantry Armor Level 2", "Infantry Armor Level 3"],
"Progressive Vehicle Weapon": ["Vehicle Weapons Level 1", "Vehicle Weapons Level 1", "Vehicle Weapons Level 2", "Vehicle Weapons Level 3"],
"Progressive Vehicle Armor": ["Vehicle Armor Level 1", "Vehicle Armor Level 1", "Vehicle Armor Level 2", "Vehicle Armor Level 3"],
"Progressive Ship Weapon": ["Ship Weapons Level 1", "Ship Weapons Level 1", "Ship Weapons Level 2", "Ship Weapons Level 3"],
"Progressive Ship Armor": ["Ship Armor Level 1", "Ship Armor Level 1", "Ship Armor Level 2", "Ship Armor Level 3"]
}
for item_name, item_id in progressive_items.items():
level = min(inventory[item_id], len(progressive_names[item_name]) - 1)
display_name = progressive_names[item_name][level]
base_name = item_name.split(maxsplit=1)[1].lower().replace(' ', '_')
display_data[base_name + "_level"] = level
display_data[base_name + "_url"] = icons[display_name]
# Multi-items
multi_items = {
"+15 Starting Minerals": 800 + SC2WOL_ITEM_ID_OFFSET,
"+15 Starting Vespene": 801 + SC2WOL_ITEM_ID_OFFSET,
"+2 Starting Supply": 802 + SC2WOL_ITEM_ID_OFFSET
}
for item_name, item_id in multi_items.items():
base_name = item_name.split()[-1].lower()
count = inventory[item_id]
if base_name == "supply":
count = count * 2
display_data[base_name + "_count"] = count
else:
count = count * 15
display_data[base_name + "_count"] = count
# Victory condition
game_state = multisave.get("client_game_state", {}).get((team, player), 0)
display_data['game_finished'] = game_state == 30
# Turn location IDs into mission objective counts
checked_locations = multisave.get("location_checks", {}).get((team, player), set())
lookup_name = lambda id: lookup_any_location_id_to_name[id]
location_info = {mission_name: {lookup_name(id): (id in checked_locations) for id in mission_locations if id in set(locations[player])} for mission_name, mission_locations in sc2wol_location_ids.items()}
checks_done = {mission_name: len([id for id in mission_locations if id in checked_locations and id in set(locations[player])]) for mission_name, mission_locations in sc2wol_location_ids.items()}
checks_done['Total'] = len(checked_locations)
checks_in_area = {mission_name: len([id for id in mission_locations if id in set(locations[player])]) for mission_name, mission_locations in sc2wol_location_ids.items()}
checks_in_area['Total'] = sum(checks_in_area.values())
return render_template("sc2wolTracker.html",
inventory=inventory, icons=icons,
acquired_items={lookup_any_item_id_to_name[id] for id in inventory if
id in lookup_any_item_id_to_name},
player=player, team=team, room=room, player_name=playerName,
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
**display_data)
def __renderGenericTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]], def __renderGenericTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
inventory: Counter, team: int, player: int, playerName: str, inventory: Counter, team: int, player: int, playerName: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int]) -> str:
saving_second: int) -> str:
checked_locations = multisave.get("location_checks", {}).get((team, player), set()) checked_locations = multisave.get("location_checks", {}).get((team, player), set())
player_received_items = {} player_received_items = {}
if multisave.get('version', 0) > 0: if multisave.get('version', 0) > 0:
# add numbering to all items but starter_inventory
ordered_items = multisave.get('received_items', {}).get((team, player, True), []) ordered_items = multisave.get('received_items', {}).get((team, player, True), [])
else: else:
ordered_items = multisave.get('received_items', {}).get((team, player), []) ordered_items = multisave.get('received_items', {}).get((team, player), [])
# add numbering to all items but starter_inventory
for order_index, networkItem in enumerate(ordered_items, start=1): for order_index, networkItem in enumerate(ordered_items, start=1):
player_received_items[networkItem.item] = order_index player_received_items[networkItem.item] = order_index
return render_template("genericTracker.html", return render_template("genericTracker.html",
inventory=inventory, inventory=inventory,
player=player, team=team, room=room, player_name=playerName, player=player, team=team, room=room, player_name=playerName,
checked_locations=checked_locations, checked_locations=checked_locations,
not_checked_locations=set(locations[player]) - checked_locations, not_checked_locations=set(locations[player]) - checked_locations,
received_items=player_received_items, received_items=player_received_items)
saving_second=saving_second)
@app.route('/tracker/<suuid:tracker>') @app.route('/tracker/<suuid:tracker>')
@cache.memoize(timeout=1) # multisave is currently created at most every minute @cache.memoize(timeout=60) # multisave is currently created at most every minute
def getTracker(tracker: UUID): def getTracker(tracker: UUID):
room: Room = Room.get(tracker=tracker) room: Room = Room.get(tracker=tracker)
if not room: if not room:
abort(404) abort(404)
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \ locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \
precollected_items, games, slot_data, groups, saving_second = get_static_room_data(room) precollected_items, games, slot_data, groups = get_static_room_data(room)
inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in range(1, len(team) + 1) if playernumber not in groups} inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in range(1, len(team) + 1) if playernumber not in groups}
for teamnumber, team in enumerate(names)} for teamnumber, team in enumerate(names)}
@@ -1266,10 +987,10 @@ def getTracker(tracker: UUID):
if game_state == 30: if game_state == 30:
inventory[team][player][106] = 1 # Triforce inventory[team][player][106] = 1 # Triforce
player_big_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1)} player_big_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1) if playernumber not in groups}
player_small_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1)} player_small_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1) if playernumber not in groups}
for loc_data in locations.values(): for loc_data in locations.values():
for values in loc_data.values(): for values in loc_data.values():
item_id, item_player, flags = values item_id, item_player, flags = values
if item_id in ids_big_key: if item_id in ids_big_key:
@@ -1300,7 +1021,7 @@ def getTracker(tracker: UUID):
for (team, player), data in multisave.get("video", []): for (team, player), data in multisave.get("video", []):
video[(team, player)] = data video[(team, player)] = data
return render_template("tracker.html", inventory=inventory, get_item_name_from_id=lookup_any_item_id_to_name, return render_template("tracker.html", inventory=inventory, get_item_name_from_id=get_item_name_from_id,
lookup_id_to_name=Items.lookup_id_to_name, player_names=player_names, lookup_id_to_name=Items.lookup_id_to_name, player_names=player_names,
tracking_names=tracking_names, tracking_ids=tracking_ids, room=room, icons=alttp_icons, tracking_names=tracking_names, tracking_ids=tracking_ids, room=room, icons=alttp_icons,
multi_items=multi_items, checks_done=checks_done, ordered_areas=ordered_areas, multi_items=multi_items, checks_done=checks_done, ordered_areas=ordered_areas,
@@ -1315,6 +1036,5 @@ game_specific_trackers: typing.Dict[str, typing.Callable] = {
"Ocarina of Time": __renderOoTTracker, "Ocarina of Time": __renderOoTTracker,
"Timespinner": __renderTimespinnerTracker, "Timespinner": __renderTimespinnerTracker,
"A Link to the Past": __renderAlttpTracker, "A Link to the Past": __renderAlttpTracker,
"Super Metroid": __renderSuperMetroidTracker, "Super Metroid": __renderSuperMetroidTracker
"Starcraft 2 Wings of Liberty": __renderSC2WoLTracker }
}

View File

@@ -1,55 +1,88 @@
import base64
import json
import typing import typing
import uuid
import zipfile import zipfile
import lzma
import json
import base64
import MultiServer
import uuid
from io import BytesIO from io import BytesIO
from flask import request, flash, redirect, url_for, session, render_template, Markup from flask import request, flash, redirect, url_for, session, render_template
from pony.orm import flush, select from pony.orm import flush, select
import MultiServer from WebHostLib import app, Seed, Room, Slot
from Utils import parse_yaml, VersionException, __version__
from Patch import preferred_endings, AutoPatchRegister
from NetUtils import NetworkSlot, SlotType from NetUtils import NetworkSlot, SlotType
from Utils import VersionException, __version__
from worlds.Files import AutoPatchRegister
from . import app
from .models import Seed, Room, Slot
banned_zip_contents = (".sfc", ".z64", ".n64", ".sms", ".gb") banned_zip_contents = (".sfc",)
def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, sid=None): def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, sid=None):
if not owner: if not owner:
owner = session["_id"] owner = session["_id"]
infolist = zfile.infolist() infolist = zfile.infolist()
if all(file.filename.endswith((".yaml", ".yml")) or file.is_dir() for file in infolist): slots = set()
flash(Markup("Error: Your .zip file only contains .yaml files. "
'Did you mean to <a href="/generate">generate a game</a>?'))
return
slots: typing.Set[Slot] = set()
spoiler = "" spoiler = ""
files = {}
multidata = None multidata = None
# Load files.
for file in infolist: for file in infolist:
handler = AutoPatchRegister.get_handler(file.filename) handler = AutoPatchRegister.get_handler(file.filename)
if file.filename.endswith(banned_zip_contents): if file.filename.endswith(banned_zip_contents):
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \ return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \
"Your file was deleted." "Your file was deleted."
# AP Container
elif handler: elif handler:
data = zfile.open(file, "r").read() raw = zfile.open(file, "r").read()
patch = handler(BytesIO(data)) patch = handler(BytesIO(raw))
patch.read() patch.read()
files[patch.player] = data slots.add(Slot(data=raw,
player_name=patch.player_name,
player_id=patch.player,
game=patch.game))
elif file.filename.endswith(tuple(preferred_endings.values())):
data = zfile.open(file, "r").read()
yaml_data = parse_yaml(lzma.decompress(data).decode("utf-8-sig"))
if yaml_data["version"] < 2:
return "Old format cannot be uploaded (outdated .apbp)"
metadata = yaml_data["meta"]
slots.add(Slot(data=data,
player_name=metadata["player_name"],
player_id=metadata["player_id"],
game=yaml_data["game"]))
elif file.filename.endswith(".apmc"):
data = zfile.open(file, "r").read()
metadata = json.loads(base64.b64decode(data).decode("utf-8"))
slots.add(Slot(data=data,
player_name=metadata["player_name"],
player_id=metadata["player_id"],
game="Minecraft"))
elif file.filename.endswith(".apv6"):
_, seed_name, slot_id, slot_name = file.filename.split('.')[0].split('_', 3)
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
player_id=int(slot_id[1:]), game="VVVVVV"))
elif file.filename.endswith(".apsm64ex"):
_, seed_name, slot_id, slot_name = file.filename.split('.')[0].split('_', 3)
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
player_id=int(slot_id[1:]), game="Super Mario 64"))
elif file.filename.endswith(".zip"):
# Factorio mods need a specific name or they do not function
_, seed_name, slot_id, slot_name = file.filename.rsplit("_", 1)[0].split("-", 3)
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
player_id=int(slot_id[1:]), game="Factorio"))
elif file.filename.endswith(".apz5"):
# .apz5 must be named specifically since they don't contain any metadata
_, seed_name, slot_id, slot_name = file.filename.split('.')[0].split('_', 3)
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
player_id=int(slot_id[1:]), game="Ocarina of Time"))
# Spoiler
elif file.filename.endswith(".txt"): elif file.filename.endswith(".txt"):
spoiler = zfile.open(file, "r").read().decode("utf-8-sig") spoiler = zfile.open(file, "r").read().decode("utf-8-sig")
# Multi-data
elif file.filename.endswith(".archipelago"): elif file.filename.endswith(".archipelago"):
try: try:
multidata = zfile.open(file).read() multidata = zfile.open(file).read()
@@ -57,36 +90,17 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
flash("Could not load multidata. File may be corrupted or incompatible.") flash("Could not load multidata. File may be corrupted or incompatible.")
multidata = None multidata = None
# Minecraft
elif file.filename.endswith(".apmc"):
data = zfile.open(file, "r").read()
metadata = json.loads(base64.b64decode(data).decode("utf-8"))
files[metadata["player_id"]] = data
# Factorio
elif file.filename.endswith(".zip"):
_, _, slot_id, *_ = file.filename.split('_')[0].split('-', 3)
data = zfile.open(file, "r").read()
files[int(slot_id[1:])] = data
# All other files using the standard MultiWorld.get_out_file_name_base method
else:
_, _, slot_id, *_ = file.filename.split('.')[0].split('_', 3)
data = zfile.open(file, "r").read()
files[int(slot_id[1:])] = data
# Load multi data.
if multidata: if multidata:
decompressed_multidata = MultiServer.Context.decompress(multidata) decompressed_multidata = MultiServer.Context.decompress(multidata)
if "slot_info" in decompressed_multidata: if "slot_info" in decompressed_multidata:
for slot, slot_info in decompressed_multidata["slot_info"].items(): player_names = {slot.player_name for slot in slots}
# Ignore Player Groups (e.g. item links) leftover_names: typing.Dict[int, NetworkSlot] = {
if slot_info.type == SlotType.group: slot_id: slot_info for slot_id, slot_info in decompressed_multidata["slot_info"].items()
continue if slot_info.name not in player_names and slot_info.type != SlotType.group}
slots.add(Slot(data=files.get(slot, None), newslots = [(Slot(data=None, player_name=slot_info.name, player_id=slot, game=slot_info.game))
player_name=slot_info.name, for slot, slot_info in leftover_names.items()]
player_id=slot, for slot in newslots:
game=slot_info.game)) slots.add(slot)
flush() # commit slots flush() # commit slots

View File

@@ -1,499 +0,0 @@
import asyncio
import base64
import platform
from typing import Any, ClassVar, Coroutine, Dict, List, Optional, Protocol, Tuple, Type, cast
# CommonClient import first to trigger ModuleUpdater
from CommonClient import CommonContext, server_loop, gui_enabled, \
ClientCommandProcessor, logger, get_base_parser
from NetUtils import ClientStatus
import Utils
from Utils import async_start
import colorama # type: ignore
from zilliandomizer.zri.memory import Memory
from zilliandomizer.zri import events
from zilliandomizer.utils.loc_name_maps import id_to_loc
from zilliandomizer.options import Chars
from zilliandomizer.patch import RescueInfo
from worlds.zillion.id_maps import make_id_to_others
from worlds.zillion.config import base_id, zillion_map
class ZillionCommandProcessor(ClientCommandProcessor):
ctx: "ZillionContext"
def _cmd_sms(self) -> None:
""" Tell the client that Zillion is running in RetroArch. """
logger.info("ready to look for game")
self.ctx.look_for_retroarch.set()
def _cmd_map(self) -> None:
""" Toggle view of the map tracker. """
self.ctx.ui_toggle_map()
class ToggleCallback(Protocol):
def __call__(self) -> None: ...
class SetRoomCallback(Protocol):
def __call__(self, rooms: List[List[int]]) -> None: ...
class ZillionContext(CommonContext):
game = "Zillion"
command_processor: Type[ClientCommandProcessor] = ZillionCommandProcessor
items_handling = 1 # receive items from other players
from_game: "asyncio.Queue[events.EventFromGame]"
to_game: "asyncio.Queue[events.EventToGame]"
ap_local_count: int
""" local checks watched by server """
next_item: int
""" index in `items_received` """
ap_id_to_name: Dict[int, str]
ap_id_to_zz_id: Dict[int, int]
start_char: Chars = "JJ"
rescues: Dict[int, RescueInfo] = {}
loc_mem_to_id: Dict[int, int] = {}
got_room_info: asyncio.Event
""" flag for connected to server """
got_slot_data: asyncio.Event
""" serves as a flag for whether I am logged in to the server """
look_for_retroarch: asyncio.Event
"""
There is a bug in Python in Windows
https://github.com/python/cpython/issues/91227
that makes it so if I look for RetroArch before it's ready,
it breaks the asyncio udp transport system.
As a workaround, we don't look for RetroArch until this event is set.
"""
ui_toggle_map: ToggleCallback
ui_set_rooms: SetRoomCallback
""" parameter is y 16 x 8 numbers to show in each room """
def __init__(self,
server_address: str,
password: str) -> None:
super().__init__(server_address, password)
self.from_game = asyncio.Queue()
self.to_game = asyncio.Queue()
self.got_room_info = asyncio.Event()
self.got_slot_data = asyncio.Event()
self.ui_toggle_map = lambda: None
self.ui_set_rooms = lambda rooms: None
self.look_for_retroarch = asyncio.Event()
if platform.system() != "Windows":
# asyncio udp bug is only on Windows
self.look_for_retroarch.set()
self.reset_game_state()
def reset_game_state(self) -> None:
for _ in range(self.from_game.qsize()):
self.from_game.get_nowait()
for _ in range(self.to_game.qsize()):
self.to_game.get_nowait()
self.got_slot_data.clear()
self.ap_local_count = 0
self.next_item = 0
self.ap_id_to_name = {}
self.ap_id_to_zz_id = {}
self.rescues = {}
self.loc_mem_to_id = {}
self.locations_checked.clear()
self.missing_locations.clear()
self.checked_locations.clear()
self.finished_game = False
self.items_received.clear()
# override
def on_deathlink(self, data: Dict[str, Any]) -> None:
self.to_game.put_nowait(events.DeathEventToGame())
return super().on_deathlink(data)
# override
async def server_auth(self, password_requested: bool = False) -> None:
if password_requested and not self.password:
await super().server_auth(password_requested)
if not self.auth:
logger.info('waiting for connection to game...')
return
logger.info("logging in to server...")
await self.send_connect()
# override
def run_gui(self) -> None:
from kvui import GameManager
from kivy.core.text import Label as CoreLabel
from kivy.graphics import Ellipse, Color, Rectangle
from kivy.uix.layout import Layout
from kivy.uix.widget import Widget
class ZillionManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Zillion Client"
class MapPanel(Widget):
MAP_WIDTH: ClassVar[int] = 281
_number_textures: List[Any] = []
rooms: List[List[int]] = []
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.rooms = [[0 for _ in range(8)] for _ in range(16)]
self._make_numbers()
self.update_map()
self.bind(pos=self.update_map)
# self.bind(size=self.update_bg)
def _make_numbers(self) -> None:
self._number_textures = []
for n in range(10):
label = CoreLabel(text=str(n), font_size=22, color=(0.1, 0.9, 0, 1))
label.refresh()
self._number_textures.append(label.texture)
def update_map(self, *args: Any) -> None:
self.canvas.clear()
with self.canvas:
Color(1, 1, 1, 1)
Rectangle(source=zillion_map,
pos=self.pos,
size=(ZillionManager.MapPanel.MAP_WIDTH,
int(ZillionManager.MapPanel.MAP_WIDTH * 1.456))) # aspect ratio of that image
for y in range(16):
for x in range(8):
num = self.rooms[15 - y][x]
if num > 0:
Color(0, 0, 0, 0.4)
pos = [self.pos[0] + 17 + x * 32, self.pos[1] + 14 + y * 24]
Ellipse(size=[22, 22], pos=pos)
Color(1, 1, 1, 1)
pos = [self.pos[0] + 22 + x * 32, self.pos[1] + 12 + y * 24]
num_texture = self._number_textures[num]
Rectangle(texture=num_texture, size=num_texture.size, pos=pos)
def build(self) -> Layout:
container = super().build()
self.map_widget = ZillionManager.MapPanel(size_hint_x=None, width=0)
self.main_area_container.add_widget(self.map_widget)
return container
def toggle_map_width(self) -> None:
if self.map_widget.width == 0:
self.map_widget.width = ZillionManager.MapPanel.MAP_WIDTH
else:
self.map_widget.width = 0
self.container.do_layout()
def set_rooms(self, rooms: List[List[int]]) -> None:
self.map_widget.rooms = rooms
self.map_widget.update_map()
self.ui = ZillionManager(self)
self.ui_toggle_map = lambda: self.ui.toggle_map_width()
self.ui_set_rooms = lambda rooms: self.ui.set_rooms(rooms)
run_co: Coroutine[Any, Any, None] = self.ui.async_run()
self.ui_task = asyncio.create_task(run_co, name="UI")
def on_package(self, cmd: str, args: Dict[str, Any]) -> None:
self.room_item_numbers_to_ui()
if cmd == "Connected":
logger.info("logged in to Archipelago server")
if "slot_data" not in args:
logger.warn("`Connected` packet missing `slot_data`")
return
slot_data = args["slot_data"]
if "start_char" not in slot_data:
logger.warn("invalid Zillion `Connected` packet, `slot_data` missing `start_char`")
return
self.start_char = slot_data['start_char']
if self.start_char not in {"Apple", "Champ", "JJ"}:
logger.warn("invalid Zillion `Connected` packet, "
f"`slot_data` `start_char` has invalid value: {self.start_char}")
if "rescues" not in slot_data:
logger.warn("invalid Zillion `Connected` packet, `slot_data` missing `rescues`")
return
rescues = slot_data["rescues"]
self.rescues = {}
for rescue_id, json_info in rescues.items():
assert rescue_id in ("0", "1"), f"invalid rescue_id in Zillion slot_data: {rescue_id}"
# TODO: just take start_char out of the RescueInfo so there's no opportunity for a mismatch?
assert json_info["start_char"] == self.start_char, \
f'mismatch in Zillion slot data: {json_info["start_char"]} {self.start_char}'
ri = RescueInfo(json_info["start_char"],
json_info["room_code"],
json_info["mask"])
self.rescues[0 if rescue_id == "0" else 1] = ri
if "loc_mem_to_id" not in slot_data:
logger.warn("invalid Zillion `Connected` packet, `slot_data` missing `loc_mem_to_id`")
return
loc_mem_to_id = slot_data["loc_mem_to_id"]
self.loc_mem_to_id = {}
for mem_str, id_str in loc_mem_to_id.items():
mem = int(mem_str)
id_ = int(id_str)
room_i = mem // 256
assert 0 <= room_i < 74
assert id_ in id_to_loc
self.loc_mem_to_id[mem] = id_
if len(self.loc_mem_to_id) != 394:
logger.warn("invalid Zillion `Connected` packet, "
f"`slot_data` missing locations in `loc_mem_to_id` - len {len(self.loc_mem_to_id)}")
self.got_slot_data.set()
payload = {
"cmd": "Get",
"keys": [f"zillion-{self.auth}-doors"]
}
async_start(self.send_msgs([payload]))
elif cmd == "Retrieved":
if "keys" not in args:
logger.warning(f"invalid Retrieved packet to ZillionClient: {args}")
return
keys = cast(Dict[str, Optional[str]], args["keys"])
doors_b64 = keys[f"zillion-{self.auth}-doors"]
if doors_b64:
logger.info("received door data from server")
doors = base64.b64decode(doors_b64)
self.to_game.put_nowait(events.DoorEventToGame(doors))
elif cmd == "RoomInfo":
self.seed_name = args["seed_name"]
self.got_room_info.set()
def room_item_numbers_to_ui(self) -> None:
rooms = [[0 for _ in range(8)] for _ in range(16)]
for loc_id in self.missing_locations:
loc_id_small = loc_id - base_id
loc_name = id_to_loc[loc_id_small]
y = ord(loc_name[0]) - 65
x = ord(loc_name[2]) - 49
if y == 9 and x == 5:
# don't show main computer in numbers
continue
assert (0 <= y < 16) and (0 <= x < 8), f"invalid index from location name {loc_name}"
rooms[y][x] += 1
# TODO: also add locations with locals lost from loading save state or reset
self.ui_set_rooms(rooms)
def process_from_game_queue(self) -> None:
if self.from_game.qsize():
event_from_game = self.from_game.get_nowait()
if isinstance(event_from_game, events.AcquireLocationEventFromGame):
server_id = event_from_game.id + base_id
loc_name = id_to_loc[event_from_game.id]
self.locations_checked.add(server_id)
if server_id in self.missing_locations:
self.ap_local_count += 1
n_locations = len(self.missing_locations) + len(self.checked_locations) - 1 # -1 to ignore win
logger.info(f'New Check: {loc_name} ({self.ap_local_count}/{n_locations})')
async_start(self.send_msgs([
{"cmd": 'LocationChecks', "locations": [server_id]}
]))
else:
# This will happen a lot in Zillion,
# because all the key words are local and unwatched by the server.
logger.debug(f"DEBUG: {loc_name} not in missing")
elif isinstance(event_from_game, events.DeathEventFromGame):
async_start(self.send_death())
elif isinstance(event_from_game, events.WinEventFromGame):
if not self.finished_game:
async_start(self.send_msgs([
{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}
]))
self.finished_game = True
elif isinstance(event_from_game, events.DoorEventFromGame):
if self.auth:
doors_b64 = base64.b64encode(event_from_game.doors).decode()
payload = {
"cmd": "Set",
"key": f"zillion-{self.auth}-doors",
"operations": [{"operation": "replace", "value": doors_b64}]
}
async_start(self.send_msgs([payload]))
else:
logger.warning(f"WARNING: unhandled event from game {event_from_game}")
def process_items_received(self) -> None:
if len(self.items_received) > self.next_item:
zz_item_ids = [self.ap_id_to_zz_id[item.item] for item in self.items_received]
for index in range(self.next_item, len(self.items_received)):
ap_id = self.items_received[index].item
from_name = self.player_names[self.items_received[index].player]
# TODO: colors in this text, like sni client?
logger.info(f'received {self.ap_id_to_name[ap_id]} from {from_name}')
self.to_game.put_nowait(
events.ItemEventToGame(zz_item_ids)
)
self.next_item = len(self.items_received)
def name_seed_from_ram(data: bytes) -> Tuple[str, str]:
""" returns player name, and end of seed string """
if len(data) == 0:
# no connection to game
return "", "xxx"
null_index = data.find(b'\x00')
if null_index == -1:
logger.warning(f"invalid game id in rom {repr(data)}")
null_index = len(data)
name = data[:null_index].decode()
null_index_2 = data.find(b'\x00', null_index + 1)
if null_index_2 == -1:
null_index_2 = len(data)
seed_name = data[null_index + 1:null_index_2].decode()
return name, seed_name
async def zillion_sync_task(ctx: ZillionContext) -> None:
logger.info("started zillion sync task")
# to work around the Python bug where we can't check for RetroArch
if not ctx.look_for_retroarch.is_set():
logger.info("Start Zillion in RetroArch, then use the /sms command to connect to it.")
await asyncio.wait((
asyncio.create_task(ctx.look_for_retroarch.wait()),
asyncio.create_task(ctx.exit_event.wait())
), return_when=asyncio.FIRST_COMPLETED)
last_log = ""
def log_no_spam(msg: str) -> None:
nonlocal last_log
if msg != last_log:
last_log = msg
logger.info(msg)
# to only show this message once per client run
help_message_shown = False
with Memory(ctx.from_game, ctx.to_game) as memory:
while not ctx.exit_event.is_set():
ram = await memory.read()
game_id = memory.get_rom_to_ram_data(ram)
name, seed_end = name_seed_from_ram(game_id)
if len(name):
if name == ctx.auth:
# this is the name we know
if ctx.server and ctx.server.socket: # type: ignore
if ctx.got_room_info.is_set():
if ctx.seed_name and ctx.seed_name.endswith(seed_end):
# correct seed
if memory.have_generation_info():
log_no_spam("everything connected")
await memory.process_ram(ram)
ctx.process_from_game_queue()
ctx.process_items_received()
else: # no generation info
if ctx.got_slot_data.is_set():
memory.set_generation_info(ctx.rescues, ctx.loc_mem_to_id)
ctx.ap_id_to_name, ctx.ap_id_to_zz_id, _ap_id_to_zz_item = \
make_id_to_others(ctx.start_char)
ctx.next_item = 0
ctx.ap_local_count = len(ctx.checked_locations)
else: # no slot data yet
async_start(ctx.send_connect())
log_no_spam("logging in to server...")
await asyncio.wait((
ctx.got_slot_data.wait(),
ctx.exit_event.wait(),
asyncio.sleep(6)
), return_when=asyncio.FIRST_COMPLETED) # to not spam connect packets
else: # not correct seed name
log_no_spam("incorrect seed - did you mix up roms?")
else: # no room info
# If we get here, it looks like `RoomInfo` packet got lost
log_no_spam("waiting for room info from server...")
else: # server not connected
log_no_spam("waiting for server connection...")
else: # new game
log_no_spam("connected to new game")
await ctx.disconnect()
ctx.reset_server_state()
ctx.seed_name = None
ctx.got_room_info.clear()
ctx.reset_game_state()
memory.reset_game_state()
ctx.auth = name
async_start(ctx.connect())
await asyncio.wait((
ctx.got_room_info.wait(),
ctx.exit_event.wait(),
asyncio.sleep(6)
), return_when=asyncio.FIRST_COMPLETED)
else: # no name found in game
if not help_message_shown:
logger.info('In RetroArch, make sure "Settings > Network > Network Commands" is on.')
help_message_shown = True
log_no_spam("looking for connection to game...")
await asyncio.sleep(0.3)
await asyncio.sleep(0.09375)
logger.info("zillion sync task ending")
async def main() -> None:
parser = get_base_parser()
parser.add_argument('diff_file', default="", type=str, nargs="?",
help='Path to a .apzl Archipelago Binary Patch file')
# SNI parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical'])
args = parser.parse_args()
print(args)
if args.diff_file:
import Patch
logger.info("patch file was supplied - creating sms rom...")
meta, rom_file = Patch.create_rom_file(args.diff_file)
if "server" in meta:
args.connect = meta["server"]
logger.info(f"wrote rom file to {rom_file}")
ctx = ZillionContext(args.connect, args.password)
if ctx.server_task is None:
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
sync_task = asyncio.create_task(zillion_sync_task(ctx))
await ctx.exit_event.wait()
ctx.server_address = None
logger.debug("waiting for sync task to end")
await sync_task
logger.debug("sync task ended")
await ctx.shutdown()
if __name__ == "__main__":
Utils.init_logging("ZillionClient", exception_logger="Client")
colorama.init()
asyncio.run(main())
colorama.deinit()

BIN
data/basepatch.apbp Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -15,8 +15,6 @@
<UILog>: <UILog>:
viewclass: 'SelectableLabel' viewclass: 'SelectableLabel'
scroll_y: 0 scroll_y: 0
scroll_type: ["content", "bars"]
bar_width: dp(12)
effect_cls: "ScrollEffect" effect_cls: "ScrollEffect"
SelectableRecycleBoxLayout: SelectableRecycleBoxLayout:
default_size: None, dp(20) default_size: None, dp(20)

View File

@@ -97,11 +97,6 @@ local extensionConsumableLookup = {
[443] = 0x3F [443] = 0x3F
} }
local noOverworldItemsLookup = {
[499] = 0x2B,
[500] = 0x12,
}
local itemMessages = {} local itemMessages = {}
local consumableStacks = nil local consumableStacks = nil
local prevstate = "" local prevstate = ""
@@ -346,7 +341,7 @@ function processBlock(block)
-- This is a key item -- This is a key item
memoryLocation = memoryLocation - 0x0E0 memoryLocation = memoryLocation - 0x0E0
wU8(memoryLocation, 0x01) wU8(memoryLocation, 0x01)
elseif v >= 0x1E0 and v <= 0x1F2 then elseif v >= 0x1E0 then
-- This is a movement item -- This is a movement item
-- Minus Offset (0x100) - movement offset (0xE0) -- Minus Offset (0x100) - movement offset (0xE0)
memoryLocation = memoryLocation - 0x1E0 memoryLocation = memoryLocation - 0x1E0
@@ -356,10 +351,7 @@ function processBlock(block)
else else
wU8(memoryLocation, 0x01) wU8(memoryLocation, 0x01)
end end
elseif v >= 0x1F3 and v <= 0x1F4 then
-- NoOverworld special items
memoryLocation = noOverworldItemsLookup[v]
wU8(memoryLocation, 0x01)
elseif v >= 0x16C and v <= 0x1AF then elseif v >= 0x16C and v <= 0x1AF then
-- This is a gold item -- This is a gold item
amountToAdd = goldLookup[v] amountToAdd = goldLookup[v]

View File

@@ -2,8 +2,8 @@ local socket = require("socket")
local json = require('json') local json = require('json')
local math = require('math') local math = require('math')
local last_modified_date = '2022-11-27' -- Should be the last modified date local last_modified_date = '2022-05-25' -- Should be the last modified date
local script_version = 3 local script_version = 1
-------------------------------------------------- --------------------------------------------------
-- Heavily modified form of RiptideSage's tracker -- Heavily modified form of RiptideSage's tracker
@@ -25,9 +25,6 @@ local inf_table_offset = save_context_offset + 0xEF8 -- 0x11B4C8
local temp_context = nil local temp_context = nil
local collectibles_overrides = nil
local collectible_offsets = nil
-- Offsets for scenes can be found here -- Offsets for scenes can be found here
-- https://wiki.cloudmodding.com/oot/Scene_Table/NTSC_1.0 -- https://wiki.cloudmodding.com/oot/Scene_Table/NTSC_1.0
-- Each scene is 0x1c bits long, chests at 0x0, switches at 0x4, collectibles at 0xc -- Each scene is 0x1c bits long, chests at 0x0, switches at 0x4, collectibles at 0xc
@@ -43,16 +40,12 @@ end
-- [1] is the scene id -- [1] is the scene id
-- [2] is the location type, which varies as input to the function -- [2] is the location type, which varies as input to the function
-- [3] is the location id within the scene, and represents the bit which was checked -- [3] is the location id within the scene, and represents the bit which was checked
-- REORDERED IN 7.0 TO scene id - location type - 0x00 - location id
-- Note that temp_context is 0-indexed and expected_values is 1-indexed, because consistency. -- Note that temp_context is 0-indexed and expected_values is 1-indexed, because consistency.
local check_temp_context = function(expected_values) local check_temp_context = function(expected_values)
-- if temp_context[0] ~= 0x00 then return false end if temp_context[0] ~= 0x00 then return false end
-- for i=1,3 do for i=1,3 do
-- if temp_context[i] ~= expected_values[i] then return false end if temp_context[i] ~= expected_values[i] then return false end
-- end end
if temp_context[0] ~= expected_values[1] then return false end
if temp_context[1] ~= expected_values[2] then return false end
if temp_context[3] ~= expected_values[3] then return false end
return true return true
end end
@@ -74,7 +67,7 @@ local on_the_ground_check = function(scene_offset, bit_to_check)
end end
local boss_item_check = function(scene_offset) local boss_item_check = function(scene_offset)
return on_the_ground_check(scene_offset, 0x1F) return chest_check(scene_offset, 0x1F)
or check_temp_context({scene_offset, 0x00, 0x4F}) or check_temp_context({scene_offset, 0x00, 0x4F})
end end
@@ -84,13 +77,12 @@ local scrub_sanity_check = function(scene_offset, bit_to_check)
return scene_check(scene_offset, bit_to_check, 0x10) return scene_check(scene_offset, bit_to_check, 0x10)
end end
-- Why is there an extra offset of 3 for temp context checks? Who knows.
local cow_check = function(scene_offset, bit_to_check) local cow_check = function(scene_offset, bit_to_check)
return scene_check(scene_offset, bit_to_check, 0xC) return scene_check(scene_offset, bit_to_check, 0xC)
or check_temp_context({scene_offset, 0x00, bit_to_check - 0x03}) or check_temp_context({scene_offset, 0x00, bit_to_check})
end end
-- DMT and DMC fairies are weird, their temp context check is special-coded for them -- Haven't been able to get DMT and DMC fairy to send instantly
local great_fairy_magic_check = function(scene_offset, bit_to_check) local great_fairy_magic_check = function(scene_offset, bit_to_check)
return scene_check(scene_offset, bit_to_check, 0x4) return scene_check(scene_offset, bit_to_check, 0x4)
or check_temp_context({scene_offset, 0x05, bit_to_check}) or check_temp_context({scene_offset, 0x05, bit_to_check})
@@ -108,18 +100,6 @@ local bean_sale_check = function(scene_offset, bit_to_check)
or check_temp_context({scene_offset, 0x00, 0x16}) or check_temp_context({scene_offset, 0x00, 0x16})
end end
-- Medigoron reports 0x00620028 to 0x40002C
local medigoron_check = function(scene_offset, bit_to_check)
return scene_check(scene_offset, bit_to_check, 0xC)
or check_temp_context({scene_offset, 0x00, 0x28})
end
-- Bombchu salesman reports 0x005E0003 to 0x40002C
local salesman_check = function(scene_offset, bit_to_check)
return scene_check(scene_offset, bit_to_check, 0xC)
or check_temp_context({scene_offset, 0x00, 0x03})
end
--Helper method to resolve skulltula lookup location --Helper method to resolve skulltula lookup location
local function skulltula_scene_to_array_index(i) local function skulltula_scene_to_array_index(i)
return (i + 3) - 2 * (i % 4) return (i + 3) - 2 * (i % 4)
@@ -233,8 +213,6 @@ local read_kokiri_forest_checks = function()
checks["KF Shop Item 6"] = shop_check(0x6, 0x1) checks["KF Shop Item 6"] = shop_check(0x6, 0x1)
checks["KF Shop Item 7"] = shop_check(0x6, 0x2) checks["KF Shop Item 7"] = shop_check(0x6, 0x2)
checks["KF Shop Item 8"] = shop_check(0x6, 0x3) checks["KF Shop Item 8"] = shop_check(0x6, 0x3)
checks["KF Shop Blue Rupee"] = on_the_ground_check(0x2D, 0x1)
return checks return checks
end end
@@ -463,7 +441,7 @@ local read_kakariko_village_checks = function()
checks["Kak Impas House Cow"] = cow_check(0x37, 0x18) checks["Kak Impas House Cow"] = cow_check(0x37, 0x18)
checks["Kak GS Tree"] = skulltula_check(0x10, 0x5) checks["Kak GS Tree"] = skulltula_check(0x10, 0x5)
checks["Kak GS Near Gate Guard"] = skulltula_check(0x10, 0x1) checks["Kak GS Guards House"] = skulltula_check(0x10, 0x1)
checks["Kak GS Watchtower"] = skulltula_check(0x10, 0x2) checks["Kak GS Watchtower"] = skulltula_check(0x10, 0x2)
checks["Kak GS Skulltula House"] = skulltula_check(0x10, 0x4) checks["Kak GS Skulltula House"] = skulltula_check(0x10, 0x4)
checks["Kak GS House Under Construction"] = skulltula_check(0x10, 0x3) checks["Kak GS House Under Construction"] = skulltula_check(0x10, 0x3)
@@ -489,7 +467,7 @@ local read_graveyard_checks = function()
checks["Graveyard Royal Familys Tomb Chest"] = chest_check(0x41, 0x00) checks["Graveyard Royal Familys Tomb Chest"] = chest_check(0x41, 0x00)
checks["Graveyard Freestanding PoH"] = on_the_ground_check(0x53, 0x4) checks["Graveyard Freestanding PoH"] = on_the_ground_check(0x53, 0x4)
checks["Graveyard Dampe Gravedigging Tour"] = on_the_ground_check(0x53, 0x8) checks["Graveyard Dampe Gravedigging Tour"] = on_the_ground_check(0x53, 0x8)
checks["Graveyard Dampe Race Hookshot Chest"] = chest_check(0x48, 0x00) checks["Graveyard Hookshot Chest"] = chest_check(0x48, 0x00)
checks["Graveyard Dampe Race Freestanding PoH"] = on_the_ground_check(0x48, 0x7) checks["Graveyard Dampe Race Freestanding PoH"] = on_the_ground_check(0x48, 0x7)
checks["Graveyard GS Bean Patch"] = skulltula_check(0x10, 0x0) checks["Graveyard GS Bean Patch"] = skulltula_check(0x10, 0x0)
@@ -554,7 +532,7 @@ local read_shadow_temple_checks = function(mq_table_address)
checks["Shadow Temple Boss Key Chest"] = chest_check(0x07, 0x0B) checks["Shadow Temple Boss Key Chest"] = chest_check(0x07, 0x0B)
checks["Shadow Temple Invisible Floormaster Chest"] = chest_check(0x07, 0x0D) checks["Shadow Temple Invisible Floormaster Chest"] = chest_check(0x07, 0x0D)
checks["Shadow Temple GS Invisible Blades Room"] = skulltula_check(0x07, 0x3) checks["Shadow Temple GS Like Like Room"] = skulltula_check(0x07, 0x3)
checks["Shadow Temple GS Falling Spikes Room"] = skulltula_check(0x07, 0x1) checks["Shadow Temple GS Falling Spikes Room"] = skulltula_check(0x07, 0x1)
checks["Shadow Temple GS Single Giant Pot"] = skulltula_check(0x07, 0x0) checks["Shadow Temple GS Single Giant Pot"] = skulltula_check(0x07, 0x0)
checks["Shadow Temple GS Near Ship"] = skulltula_check(0x07, 0x4) checks["Shadow Temple GS Near Ship"] = skulltula_check(0x07, 0x4)
@@ -597,7 +575,7 @@ local read_death_mountain_trail_checks = function()
checks["DMT Freestanding PoH"] = on_the_ground_check(0x60, 0x1E) checks["DMT Freestanding PoH"] = on_the_ground_check(0x60, 0x1E)
checks["DMT Chest"] = chest_check(0x60, 0x01) checks["DMT Chest"] = chest_check(0x60, 0x01)
checks["DMT Storms Grotto Chest"] = chest_check(0x3E, 0x17) checks["DMT Storms Grotto Chest"] = chest_check(0x3E, 0x17)
checks["DMT Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x18) or check_temp_context({0xFF, 0x05, 0x13}) checks["DMT Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x18)
checks["DMT Biggoron"] = big_goron_sword_check() checks["DMT Biggoron"] = big_goron_sword_check()
checks["DMT Cow Grotto Cow"] = cow_check(0x3E, 0x18) checks["DMT Cow Grotto Cow"] = cow_check(0x3E, 0x18)
@@ -614,7 +592,7 @@ local read_goron_city_checks = function()
checks["GC Pot Freestanding PoH"] = on_the_ground_check(0x62, 0x1F) checks["GC Pot Freestanding PoH"] = on_the_ground_check(0x62, 0x1F)
checks["GC Rolling Goron as Child"] = info_table_check(0x22, 0x6) checks["GC Rolling Goron as Child"] = info_table_check(0x22, 0x6)
checks["GC Rolling Goron as Adult"] = info_table_check(0x20, 0x1) checks["GC Rolling Goron as Adult"] = info_table_check(0x20, 0x1)
checks["GC Medigoron"] = medigoron_check(0x62, 0x1) checks["GC Medigoron"] = on_the_ground_check(0x62, 0x1)
checks["GC Maze Left Chest"] = chest_check(0x62, 0x00) checks["GC Maze Left Chest"] = chest_check(0x62, 0x00)
checks["GC Maze Right Chest"] = chest_check(0x62, 0x01) checks["GC Maze Right Chest"] = chest_check(0x62, 0x01)
checks["GC Maze Center Chest"] = chest_check(0x62, 0x02) checks["GC Maze Center Chest"] = chest_check(0x62, 0x02)
@@ -636,7 +614,7 @@ local read_death_mountain_crater_checks = function()
checks["DMC Volcano Freestanding PoH"] = on_the_ground_check(0x61, 0x08) checks["DMC Volcano Freestanding PoH"] = on_the_ground_check(0x61, 0x08)
checks["DMC Wall Freestanding PoH"] = on_the_ground_check(0x61, 0x02) checks["DMC Wall Freestanding PoH"] = on_the_ground_check(0x61, 0x02)
checks["DMC Upper Grotto Chest"] = chest_check(0x3E, 0x1A) checks["DMC Upper Grotto Chest"] = chest_check(0x3E, 0x1A)
checks["DMC Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x10) or check_temp_context({0xFF, 0x05, 0x14}) checks["DMC Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x10)
checks["DMC Deku Scrub"] = scrub_sanity_check(0x61, 0x6) checks["DMC Deku Scrub"] = scrub_sanity_check(0x61, 0x6)
checks["DMC Deku Scrub Grotto Left"] = scrub_sanity_check(0x23, 0x1) checks["DMC Deku Scrub Grotto Left"] = scrub_sanity_check(0x23, 0x1)
@@ -732,9 +710,9 @@ local read_fire_temple_checks = function(mq_table_address)
checks["Fire Temple MQ GS Big Lava Room Open Door"] = skulltula_check(0x4, 0x0) checks["Fire Temple MQ GS Big Lava Room Open Door"] = skulltula_check(0x4, 0x0)
checks["Fire Temple MQ GS Skull On Fire"] = skulltula_check(0x4, 0x2) checks["Fire Temple MQ GS Skull On Fire"] = skulltula_check(0x4, 0x2)
checks["Fire Temple MQ GS Flame Maze Center"] = skulltula_check(0x4, 0x3) checks["Fire Temple MQ GS Fire Wall Maze Center"] = skulltula_check(0x4, 0x3)
checks["Fire Temple MQ GS Flame Maze Side Room"] = skulltula_check(0x4, 0x4) checks["Fire Temple MQ GS Fire Wall Maze Side Room"] = skulltula_check(0x4, 0x4)
checks["Fire Temple MQ GS Above Flame Maze"] = skulltula_check(0x4, 0x1) checks["Fire Temple MQ GS Above Fire Wall Maze"] = skulltula_check(0x4, 0x1)
end end
checks["Fire Temple Volvagia Heart"] = boss_item_check(0x15) checks["Fire Temple Volvagia Heart"] = boss_item_check(0x15)
@@ -752,12 +730,6 @@ local read_zoras_river_checks = function()
checks["ZR Deku Scrub Grotto Front"] = scrub_sanity_check(0x15, 0x9) checks["ZR Deku Scrub Grotto Front"] = scrub_sanity_check(0x15, 0x9)
checks["ZR Deku Scrub Grotto Rear"] = scrub_sanity_check(0x15, 0x8) checks["ZR Deku Scrub Grotto Rear"] = scrub_sanity_check(0x15, 0x8)
checks["ZR Frogs Zeldas Lullaby"] = event_check(0xD, 0x1)
checks["ZR Frogs Eponas Song"] = event_check(0xD, 0x2)
checks["ZR Frogs Suns Song"] = event_check(0xD, 0x3)
checks["ZR Frogs Sarias Song"] = event_check(0xD, 0x4)
checks["ZR Frogs Song of Time"] = event_check(0xD, 0x5)
checks["ZR GS Tree"] = skulltula_check(0x11, 0x1) checks["ZR GS Tree"] = skulltula_check(0x11, 0x1)
--NOTE: There is no GS in the soft soil. It's the only one that doesn't have one. --NOTE: There is no GS in the soft soil. It's the only one that doesn't have one.
checks["ZR GS Ladder"] = skulltula_check(0x11, 0x0) checks["ZR GS Ladder"] = skulltula_check(0x11, 0x0)
@@ -927,10 +899,10 @@ end
local read_gerudo_fortress_checks = function() local read_gerudo_fortress_checks = function()
local checks = {} local checks = {}
checks["Hideout 1 Torch Jail Gerudo Key"] = on_the_ground_check(0xC, 0xC) checks["Hideout Jail Guard (1 Torch)"] = on_the_ground_check(0xC, 0xC)
checks["Hideout 2 Torches Jail Gerudo Key"] = on_the_ground_check(0xC, 0xF) checks["Hideout Jail Guard (2 Torches)"] = on_the_ground_check(0xC, 0xF)
checks["Hideout 3 Torches Jail Gerudo Key"] = on_the_ground_check(0xC, 0xA) checks["Hideout Jail Guard (3 Torches)"] = on_the_ground_check(0xC, 0xA)
checks["Hideout 4 Torches Jail Gerudo Key"] = on_the_ground_check(0xC, 0xE) checks["Hideout Jail Guard (4 Torches)"] = on_the_ground_check(0xC, 0xE)
checks["Hideout Gerudo Membership Card"] = membership_card_check(0xC, 0x2) checks["Hideout Gerudo Membership Card"] = membership_card_check(0xC, 0x2)
checks["GF Chest"] = chest_check(0x5D, 0x0) checks["GF Chest"] = chest_check(0x5D, 0x0)
checks["GF HBA 1000 Points"] = info_table_check(0x33, 0x0) checks["GF HBA 1000 Points"] = info_table_check(0x33, 0x0)
@@ -989,7 +961,7 @@ end
local read_haunted_wasteland_checks = function() local read_haunted_wasteland_checks = function()
local checks = {} local checks = {}
checks["Wasteland Bombchu Salesman"] = salesman_check(0x5E, 0x01) checks["Wasteland Bombchu Salesman"] = on_the_ground_check(0x5E, 0x01)
checks["Wasteland Chest"] = chest_check(0x5E, 0x00) checks["Wasteland Chest"] = chest_check(0x5E, 0x00)
checks["Wasteland GS"] = skulltula_check(0x15, 0x1) checks["Wasteland GS"] = skulltula_check(0x15, 0x1)
return checks return checks
@@ -1185,22 +1157,9 @@ local check_all_locations = function(mq_table_address)
for k,v in pairs(read_ganons_castle_checks(mq_table_address)) do location_checks[k] = v end for k,v in pairs(read_ganons_castle_checks(mq_table_address)) do location_checks[k] = v end
for k,v in pairs(read_outside_ganons_castle_checks()) do location_checks[k] = v end for k,v in pairs(read_outside_ganons_castle_checks()) do location_checks[k] = v end
for k,v in pairs(read_song_checks()) do location_checks[k] = v end for k,v in pairs(read_song_checks()) do location_checks[k] = v end
-- write 0 to temp context values
mainmemory.write_u32_be(0x40002C, 0)
mainmemory.write_u32_be(0x400030, 0)
return location_checks return location_checks
end end
local check_collectibles = function()
local retval = {}
if collectible_offsets ~= nil then
for id, data in pairs(collectible_offsets) do
local mem = mainmemory.readbyte(collectible_overrides + data[1] + bit.rshift(data[2], 3))
retval[id] = bit.check(mem, data[2] % 8)
end
end
return retval
end
-- convenience functions -- convenience functions
@@ -1585,10 +1544,9 @@ local outgoing_player_addr = coop_context + 18
local player_names_address = coop_context + 20 local player_names_address = coop_context + 20
local player_name_length = 8 -- 8 bytes local player_name_length = 8 -- 8 bytes
local rom_name_location = player_names_address + 0x800 + 0x5 -- 0x800 player names, 0x5 CFG_FILE_SELECT_HASH local rom_name_location = player_names_address + 0x800
-- TODO: load dynamically from slot data local master_quest_table_address = rando_context + (mainmemory.read_u32_be(rando_context + 0x0CE0) - 0x03480000)
local master_quest_table_address = rando_context + (mainmemory.read_u32_be(rando_context + 0x0E9F) - 0x03480000)
local save_context_addr = 0x11A5D0 local save_context_addr = 0x11A5D0
local internal_count_addr = save_context_addr + 0x90 local internal_count_addr = save_context_addr + 0x90
@@ -1597,7 +1555,7 @@ local item_queue = {}
local first_connect = true local first_connect = true
local game_complete = false local game_complete = false
NUM_BIG_POES_REQUIRED = mainmemory.read_u8(rando_context + 0x0EAD) NUM_BIG_POES_REQUIRED = mainmemory.read_u8(rando_context + 0x0CEE)
local bytes_to_string = function(bytes) local bytes_to_string = function(bytes)
local string = '' local string = ''
@@ -1747,7 +1705,7 @@ function is_game_complete()
end end
function deathlink_enabled() function deathlink_enabled()
local death_link_flag = mainmemory.readbyte(coop_context + 0xB) local death_link_flag = mainmemory.read_u16_be(coop_context + 0xA)
return death_link_flag > 0 return death_link_flag > 0
end end
@@ -1765,11 +1723,6 @@ function get_death_state()
end end
function kill_link() function kill_link()
-- market entrance: 27/28/29
-- outside ToT: 35/36/37.
-- if killed on these scenes the game crashes, so we wait until not on this screen.
local scene = global_context:rawget('cur_scene'):rawget()
if scene == 27 or scene == 28 or scene == 29 or scene == 35 or scene == 36 or scene == 37 then return end
mainmemory.write_u16_be(0x11A600, 0) mainmemory.write_u16_be(0x11A600, 0)
end end
@@ -1803,13 +1756,6 @@ function process_block(block)
mainmemory.write_u16_be(incoming_item_addr, item_queue[received_items_count+1]) mainmemory.write_u16_be(incoming_item_addr, item_queue[received_items_count+1])
end end
end end
-- Record collectible data if necessary
if collectible_overrides == nil and block['collectibleOverrides'] ~= 0 then
collectible_overrides = mainmemory.read_u32_be(rando_context + block['collectibleOverrides']) - 0x80000000
end
if collectible_offsets ~= block['collectibleOffsets'] then
collectible_offsets = block['collectibleOffsets']
end
return return
end end
@@ -1841,7 +1787,6 @@ function receive()
retTable["deathlinkActive"] = deathlink_enabled() retTable["deathlinkActive"] = deathlink_enabled()
if InSafeState() then if InSafeState() then
retTable["locations"] = check_all_locations(master_quest_table_address) retTable["locations"] = check_all_locations(master_quest_table_address)
retTable["collectibles"] = check_collectibles()
retTable["isDead"] = get_death_state() retTable["isDead"] = get_death_state()
retTable["gameComplete"] = is_game_complete() retTable["gameComplete"] = is_game_complete()
end end
@@ -1879,15 +1824,13 @@ function main()
elseif (curstate == STATE_UNINITIALIZED) then elseif (curstate == STATE_UNINITIALIZED) then
if (frame % 60 == 0) then if (frame % 60 == 0) then
server:settimeout(2) server:settimeout(2)
print("Attempting to connect")
local client, timeout = server:accept() local client, timeout = server:accept()
if timeout == nil then if timeout == nil then
print('Initial Connection Made') print('Initial Connection Made')
curstate = STATE_INITIAL_CONNECTION_MADE curstate = STATE_INITIAL_CONNECTION_MADE
ootSocket = client ootSocket = client
ootSocket:settimeout(0) ootSocket:settimeout(0)
else
print('Connection failed, ensure OoTClient is running and rerun oot_connector.lua')
return
end end
end end
end end

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