Compare commits

..

9 Commits

Author SHA1 Message Date
CaitSith2
63fb888191 Now possible to run generated modded seeds even without the json files. 2022-09-25 07:38:05 -07:00
CaitSith2
38eef5ac00 Remaining changes
* Process burnt results.
* Add fluidbox info to machine definition
* Recipe base cost now uses cheapest product source.
* free sample exclusions no longer hard-coded
* science pack pool exclusions no longer hard-coded
* burnt result is forced to have cost of machine used to obtain the results factored into the cost of crafting.
* name of machine is no longer assumed to be name of item.  machine definition now list item sources specifically.
* science pack difficulty is now the minimum of average estimated difficulty of unlocked recipes and 8.
* Actually exclude archipelago-extractor from mod list.
2022-09-25 04:09:14 -07:00
CaitSith2
3e627f80fd Items and Fluids now have their own list of product_sources 2022-09-25 03:35:07 -07:00
CaitSith2
0d6aeea9fd Cut back on the recursion loop false positives. 2022-09-25 03:31:35 -07:00
CaitSith2
6cd1e8a295 Resolve mining energy TODO 2022-09-25 03:28:41 -07:00
CaitSith2
1cbe5ae669 Fluids now a FactorioElement. 2022-09-25 03:27:55 -07:00
CaitSith2
c5b5ad495c Merge branch 'main' into factorio_data_extraction_refactor 2022-09-24 02:50:16 -07:00
CaitSith2
5b3f4460b8 remove debug print statements. 2022-09-21 03:07:19 -07:00
CaitSith2
de8eff39b3 Refactoring the data extraction for factorio
Extracted more information with a modified version of Archipelago extractor.   Matching PR for the extractor on its respective github.
2022-09-21 02:42:59 -07:00
728 changed files with 31226 additions and 107090 deletions

View File

@@ -2,20 +2,10 @@
name: Build
on:
push:
paths:
- '.github/workflows/build.yaml'
- 'setup.py'
- 'requirements.txt'
pull_request:
paths:
- '.github/workflows/build.yaml'
- 'setup.py'
- 'requirements.txt'
workflow_dispatch:
on: workflow_dispatch
env:
SNI_VERSION: v0.0.84
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
@@ -25,20 +15,22 @@ jobs:
build-win-py38: # RCs will still be built and signed by hand
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v2
- name: Install python
uses: actions/setup-python@v4
uses: actions/setup-python@v3
with:
python-version: '3.8'
- name: Download run-time dependencies
run: |
Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/${Env:SNI_VERSION}/sni-${Env:SNI_VERSION}-windows-amd64.zip -OutFile sni.zip
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
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
- name: Build
run: |
python -m pip install --upgrade pip setuptools
pip install -r requirements.txt
python setup.py build_exe --yes
python setup.py build --yes
$NAME="$(ls build)".Split('.',2)[1]
$ZIP_NAME="Archipelago_$NAME.7z"
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
@@ -47,7 +39,7 @@ jobs:
Rename-Item exe.$NAME Archipelago
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
- name: Store 7z
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v2
with:
name: ${{ env.ZIP_NAME }}
path: dist/${{ env.ZIP_NAME }}
@@ -57,14 +49,14 @@ jobs:
runs-on: ubuntu-18.04
steps:
# - copy code below to release.yml -
- uses: actions/checkout@v3
- uses: actions/checkout@v2
- name: Install base dependencies
run: |
sudo apt update
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
- name: Get a recent python
uses: actions/setup-python@v4
uses: actions/setup-python@v3
with:
python-version: '3.9'
- name: Install build-time dependencies
@@ -77,17 +69,20 @@ jobs:
chmod a+rx appimagetool
- name: Download run-time dependencies
run: |
wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-manylinux2014-amd64.tar.xz
tar xf sni-*.tar.xz
rm sni-*.tar.xz
mv sni-* SNI
wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
- name: Build
run: |
# pygobject is an optional dependency for kivy that's not in requirements
# charset-normalizer was somehow incomplete in the github runner
"${{ env.PYTHON }}" -m pip install --upgrade pip virtualenv PyGObject setuptools
"${{ env.PYTHON }}" -m venv venv
source venv/bin/activate
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject setuptools charset-normalizer
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 dist output:\n `ls dist`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
@@ -97,13 +92,13 @@ jobs:
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - copy code above to release.yml -
- name: Store AppImage
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v2
with:
name: ${{ env.APPIMAGE_NAME }}
path: dist/${{ env.APPIMAGE_NAME }}
retention-days: 7
- name: Store .tar.gz
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v2
with:
name: ${{ env.TAR_NAME }}
path: dist/${{ env.TAR_NAME }}

View File

@@ -35,7 +35,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL

View File

@@ -11,9 +11,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v2
- name: Set up Python 3.9
uses: actions/setup-python@v4
uses: actions/setup-python@v1
with:
python-version: 3.9
- name: Install dependencies

View File

@@ -8,6 +8,7 @@ on:
- '*.*.*'
env:
SNI_VERSION: v0.0.84
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
@@ -35,14 +36,14 @@ jobs:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
# - code below copied from build.yml -
- uses: actions/checkout@v3
- uses: actions/checkout@v2
- name: Install base dependencies
run: |
sudo apt update
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
- name: Get a recent python
uses: actions/setup-python@v4
uses: actions/setup-python@v3
with:
python-version: '3.9'
- name: Install build-time dependencies
@@ -55,15 +56,18 @@ jobs:
chmod a+rx appimagetool
- name: Download run-time dependencies
run: |
wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-manylinux2014-amd64.tar.xz
tar xf sni-*.tar.xz
rm sni-*.tar.xz
mv sni-* SNI
wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
- name: Build
run: |
# pygobject is an optional dependency for kivy that's not in requirements
# charset-normalizer was somehow incomplete in the github runner
"${{ env.PYTHON }}" -m pip install --upgrade pip virtualenv PyGObject setuptools
"${{ env.PYTHON }}" -m venv venv
source venv/bin/activate
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject setuptools charset-normalizer
pip install -r requirements.txt
python setup.py build --yes bdist_appimage --yes
echo -e "setup.py build output:\n `ls build`"

View File

@@ -3,19 +3,7 @@
name: unittests
on:
push:
paths-ignore:
- 'docs/**'
- 'setup.py'
- '*.iss'
- '.gitignore'
pull_request:
paths-ignore:
- 'docs/**'
- 'setup.py'
- '*.iss'
- '.gitignore'
on: [push, pull_request]
jobs:
build:
@@ -35,13 +23,11 @@ jobs:
os: windows-latest
- python: {version: '3.10'} # current
os: windows-latest
- python: {version: '3.10'} # current
os: macos-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python.version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python.version }}
- name: Install dependencies
@@ -51,4 +37,4 @@ jobs:
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
- name: Unittests
run: |
pytest
pytest test

14
.gitignore vendored
View File

@@ -4,21 +4,15 @@
*_Spoiler.txt
*.bmbp
*.apbp
*.apl2ac
*.apm3
*.apmc
*.apz5
*.aptloz
*.pyc
*.pyd
*.sfc
*.z64
*.n64
*.nes
*.sms
*.gb
*.gbc
*.gba
*.wixobj
*.lck
*.db3
@@ -50,8 +44,7 @@ Output Logs/
/freeze_requirements.txt
/Archipelago.zip
/setup.ini
/installdelete.iss
/data/user.kv
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -132,14 +125,12 @@ ipython_config.py
# Environments
.env
.venv*
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
.code-workspace
shell.nix
# Spyder project settings
.spyderproject
@@ -169,7 +160,6 @@ cython_debug/
jdk*/
minecraft*/
minecraft_versions.json
!worlds/minecraft/
# pyenv
.python-version

File diff suppressed because it is too large Load Diff

View File

@@ -20,13 +20,10 @@ if __name__ == "__main__":
from MultiServer import CommandProcessor
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser
from Utils import Version, stream_input, async_start
from Utils import Version, stream_input
from worlds import network_data_package, AutoWorldRegister
import os
if typing.TYPE_CHECKING:
import kvui
logger = logging.getLogger("Client")
# without terminal, we have to use gui mode
@@ -47,23 +44,21 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_connect(self, address: str = "") -> bool:
"""Connect to a MultiWorld Server"""
if address:
self.ctx.server_address = None
self.ctx.username = None
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")
self.ctx.server_address = None
self.ctx.username = None
asyncio.create_task(self.ctx.connect(address if address else None), name="connecting")
return True
def _cmd_disconnect(self) -> bool:
"""Disconnect from a MultiWorld Server"""
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
def _cmd_received(self) -> bool:
"""List all received items"""
self.output(f'{len(self.ctx.items_received)} received items:')
logger.info(f'{len(self.ctx.items_received)} received items:')
for index, item in enumerate(self.ctx.items_received, 1):
self.output(f"{self.ctx.item_names[item.item]} from {self.ctx.player_names[item.player]}")
return True
@@ -96,18 +91,12 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_items(self):
"""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}")
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
self.output(item_name)
def _cmd_locations(self):
"""List all location names for the currently running game."""
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}")
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
self.output(location_name)
@@ -121,12 +110,12 @@ class ClientCommandProcessor(CommandProcessor):
else:
state = ClientStatus.CLIENT_CONNECTED
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 default(self, raw: str):
raw = self.ctx.on_user_say(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:
@@ -134,7 +123,6 @@ class CommonContext:
tags: typing.Set[str] = {"AP"}
game: typing.Optional[str] = None
items_handling: typing.Optional[int] = None
want_slot_data: bool = True # should slot_data be retrieved via Connect
# datapackage
# Contents in flux until connection to server is made, to download correct data for this multiworld.
@@ -144,36 +132,28 @@ class CommonContext:
# defaults
starting_reconnect_delay: int = 5
current_reconnect_delay: int = starting_reconnect_delay
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor
command_processor: type(CommandProcessor) = ClientCommandProcessor
ui = None
ui_task: typing.Optional["asyncio.Task[None]"] = None
input_task: typing.Optional["asyncio.Task[None]"] = None
keep_alive_task: typing.Optional["asyncio.Task[None]"] = None
server_task: typing.Optional["asyncio.Task[None]"] = None
autoreconnect_task: typing.Optional["asyncio.Task[None]"] = None
disconnected_intentionally: bool = False
ui_task: typing.Optional[asyncio.Task] = None
input_task: typing.Optional[asyncio.Task] = None
keep_alive_task: typing.Optional[asyncio.Task] = None
server_task: typing.Optional[asyncio.Task] = None
server: typing.Optional[Endpoint] = None
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
# remaining type info
slot_info: typing.Dict[int, NetworkSlot]
server_address: typing.Optional[str]
server_address: str
password: typing.Optional[str]
hint_cost: typing.Optional[int]
player_names: typing.Dict[int, str]
finished_game: bool
ready: bool
auth: typing.Optional[str]
seed_name: typing.Optional[str]
# locations
locations_checked: typing.Set[int] # local state
locations_scouted: typing.Set[int]
items_received: typing.List[NetworkItem]
missing_locations: typing.Set[int] # server state
checked_locations: typing.Set[int] # server state
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
@@ -181,11 +161,9 @@ class CommonContext:
# internals
# current message box through kvui
_messagebox: typing.Optional["kvui.MessageBox"] = None
# message box reporting a loss of connection
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
_messagebox = None
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None:
def __init__(self, server_address, password):
# server state
self.server_address = server_address
self.username = None
@@ -193,7 +171,7 @@ class CommonContext:
self.hint_cost = None
self.slot_info = {}
self.permissions = {
"release": "disabled",
"forfeit": "disabled",
"collect": "disabled",
"remaining": "disabled",
}
@@ -228,12 +206,6 @@ class CommonContext:
# execution
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)
@@ -245,9 +217,9 @@ class CommonContext:
return len(self.checked_locations | self.missing_locations)
async def connection_closed(self):
self.reset_server_state()
if self.server and self.server.socket is not None:
await self.server.socket.close()
self.reset_server_state()
def reset_server_state(self):
self.auth = None
@@ -260,23 +232,18 @@ class CommonContext:
self.server_task = None
self.hint_cost = None
self.permissions = {
"release": "disabled",
"forfeit": "disabled",
"collect": "disabled",
"remaining": "disabled",
}
async def disconnect(self, allow_autoreconnect: bool = False):
if not allow_autoreconnect:
self.disconnected_intentionally = True
if self.cancel_autoreconnect():
logger.info("Cancelled auto-reconnect.")
async def disconnect(self):
if self.server and not self.server.socket.closed:
await self.server.socket.close()
if self.server_task is not None:
await self.server_task
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
""" `msgs` JSON serializable """
async def send_msgs(self, msgs):
if not self.server or not self.server.socket.open or self.server.socket.closed:
return
await self.server.socket.send(encode(msgs))
@@ -304,36 +271,25 @@ class CommonContext:
logger.info('Enter slot name:')
self.auth = await self.console_input()
async def send_connect(self, **kwargs: typing.Any) -> None:
""" send `Connect` packet to log in to server """
async def send_connect(self, **kwargs):
payload = {
'cmd': 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
'tags': self.tags, 'items_handling': self.items_handling,
'uuid': Utils.get_unique_identifier(), 'game': self.game, "slot_data": self.want_slot_data,
'uuid': Utils.get_unique_identifier(), 'game': self.game
}
if kwargs:
payload.update(kwargs)
await self.send_msgs([payload])
async def console_input(self) -> str:
if self.ui:
self.ui.focus_textinput()
async def console_input(self):
self.input_requests += 1
return await self.input_queue.get()
async def connect(self, address: typing.Optional[str] = None) -> None:
""" disconnect any previous connection, and open new connection to the server """
async def connect(self, address=None):
await self.disconnect()
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:
if slot == self.slot:
return True
@@ -341,17 +297,6 @@ class CommonContext:
return self.slot in self.slot_info[slot].group_members
return False
def is_echoed_chat(self, print_json_packet: dict) -> bool:
return print_json_packet.get("type", "") == "Chat" \
and print_json_packet.get("team", None) == self.team \
and print_json_packet.get("slot", None) == self.slot
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):
logger.info(args["text"])
@@ -383,7 +328,6 @@ class CommonContext:
async def shutdown(self):
self.server_address = ""
self.username = None
self.cancel_autoreconnect()
if self.server and not self.server.socket.closed:
await self.server.socket.close()
if self.server_task:
@@ -446,7 +390,7 @@ class CommonContext:
# 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."""
self.last_death_link = max(data["time"], self.last_death_link)
text = data.get("cause", "")
@@ -477,10 +421,10 @@ class CommonContext:
if old_tags != self.tags and self.server and not self.server.socket.closed:
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"""
if not self.ui:
return None
return
title = title or "Error"
from kvui import MessageBox
if self._messagebox:
@@ -497,13 +441,6 @@ class CommonContext:
# display error
self._messagebox = MessageBox(title, text, error=True)
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):
"""Import kivy UI system and start running it as self.ui_task."""
@@ -540,7 +477,7 @@ async def keep_alive(ctx: CommonContext, seconds_between_checks=100):
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:
logger.error('Already connected')
return
@@ -553,11 +490,6 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
logger.info('Please connect to an Archipelago server.')
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 \
else address.replace("archipelago://", "ws://")
@@ -568,9 +500,6 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
ctx.password = server_url.password
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}')
try:
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
@@ -580,33 +509,31 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
logger.info('Connected')
ctx.server_address = address
ctx.current_reconnect_delay = ctx.starting_reconnect_delay
ctx.disconnected_intentionally = False
async for data in ctx.server.socket:
for msg in decode(data):
await process_server_cmd(ctx, msg)
logger.warning(f"Disconnected from multiworld server{reconnect_hint()}")
except websockets.InvalidMessage:
# probably encrypted
if address.startswith("ws://"):
await server_loop(ctx, "ws" + address[1:])
else:
ctx.handle_connection_loss(f"Lost connection to the multiworld server due to InvalidMessage"
f"{reconnect_hint()}")
except ConnectionRefusedError:
ctx.handle_connection_loss("Connection refused by the server. "
"May not be running Archipelago on that address or port.")
except websockets.InvalidURI:
ctx.handle_connection_loss("Failed to connect to the multiworld server (invalid URI)")
except OSError:
ctx.handle_connection_loss("Failed to connect to the multiworld server")
except Exception:
ctx.handle_connection_loss(f"Lost connection to the multiworld server{reconnect_hint()}")
logger.warning('Disconnected from multiworld server, type /connect to reconnect')
except ConnectionRefusedError as e:
msg = 'Connection refused by the server. May not be running Archipelago on that address or port.'
logger.exception(msg, extra={'compact_gui': True})
ctx.gui_error(msg, e)
except websockets.InvalidURI as e:
msg = 'Failed to connect to the multiworld server (invalid URI)'
logger.exception(msg, extra={'compact_gui': True})
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:
await ctx.connection_closed()
if ctx.server_address and ctx.username and not ctx.disconnected_intentionally:
logger.info(f"... automatically reconnecting in {ctx.current_reconnect_delay} seconds")
assert ctx.autoreconnect_task is None
ctx.autoreconnect_task = asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
if ctx.server_address:
logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s")
asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
ctx.current_reconnect_delay *= 2
@@ -717,9 +644,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
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':
start_index = args["index"]
@@ -798,7 +722,7 @@ async def console_loop(ctx: CommonContext):
logger.exception(e)
def get_base_parser(description: typing.Optional[str] = None):
def get_base_parser(description=None):
import argparse
parser = argparse.ArgumentParser(description=description)
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
@@ -812,10 +736,9 @@ if __name__ == '__main__':
# Text Mode to use !hint and such with games that have no text entry
class TextContext(CommonContext):
tags = {"AP", "TextOnly"}
tags = {"AP", "IgnoreGame", "TextOnly"}
game = "" # empty matches any game since 0.3.2
items_handling = 0b111 # receive all items for /received
want_slot_data = False # Can't use game specific slot_data
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -826,15 +749,12 @@ if __name__ == '__main__':
def on_package(self, cmd: str, args: dict):
if cmd == "Connected":
self.game = self.slot_info[self.slot].game
async def disconnect(self, allow_autoreconnect: bool = False):
self.game = ""
await super().disconnect(allow_autoreconnect)
async def main(args):
ctx = TextContext(args.connect, args.password)
ctx.auth = args.name
ctx.server_address = args.connect
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
if gui_enabled:

View File

@@ -7,7 +7,6 @@ from typing import List
import Utils
from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
get_base_parser
@@ -70,7 +69,7 @@ class FF1Context(CommonContext):
def on_package(self, cmd: str, args: dict):
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':
msg = args['text']
if ': !' not in msg:
@@ -181,7 +180,7 @@ async def nes_sync_task(ctx: FF1Context):
# print(data_decoded)
if ctx.game is not None and 'locations' in data_decoded:
# Not just a keep alive ping, parse
async_start(parse_locations(data_decoded['locations'], ctx, False))
asyncio.create_task(parse_locations(data_decoded['locations'], ctx, False))
if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '':

View File

@@ -4,12 +4,9 @@ import logging
import json
import string
import copy
import re
import subprocess
import sys
import time
import random
import typing
import ModuleUpdate
ModuleUpdate.update()
@@ -20,18 +17,12 @@ import asyncio
from queue import Queue
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__":
Utils.init_logging("FactorioClient", exception_logger="Client")
check_stdin()
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser
from MultiServer import mark_raw
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
from Utils import async_start
from worlds.factorio import Factorio
@@ -39,10 +30,6 @@ from worlds.factorio import Factorio
class FactorioCommandProcessor(ClientCommandProcessor):
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
def _cmd_factorio(self, text: str) -> bool:
"""Send the following command to the bound Factorio Server."""
@@ -59,13 +46,6 @@ class FactorioCommandProcessor(ClientCommandProcessor):
"""Manually trigger a resync."""
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):
command_processor = FactorioCommandProcessor
@@ -85,9 +65,7 @@ class FactorioContext(CommonContext):
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
self.energy_link_increment = 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
self.custom_data_package = 0
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -104,16 +82,12 @@ class FactorioContext(CommonContext):
def on_print(self, args: dict):
super(FactorioContext, self).on_print(args)
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):
if self.rcon_client:
if (not self.filter_item_sends or not self.is_uninteresting_item_send(args)) \
and not self.is_echoed_chat(args):
text = self.factorio_json_text_parser(copy.deepcopy(args["data"]))
if not text.startswith(self.player_names[self.slot] + ":"): # TODO: Remove string heuristic in the future.
self.print_to_game(text)
text = self.factorio_json_text_parser(copy.deepcopy(args["data"]))
self.print_to_game(text)
super(FactorioContext, self).on_print_json(args)
@property
@@ -124,15 +98,6 @@ class FactorioContext(CommonContext):
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
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):
if self.rcon_client:
self.rcon_client.send_command(f"/ap-deathlink {data['source']}")
@@ -145,7 +110,7 @@ class FactorioContext(CommonContext):
self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for
item_name in args["checked_locations"]})
if cmd == "Connected" and self.energy_link_increment:
async_start(self.send_msgs([{
asyncio.create_task(self.send_msgs([{
"cmd": "SetNotify", "keys": ["EnergyLink"]
}]))
elif cmd == "SetReply":
@@ -159,45 +124,6 @@ class FactorioContext(CommonContext):
f"{Utils.format_SI_prefix(args['value'])}J remaining.")
self.rcon_client.send_command(f"/ap-energylink {gained}")
def on_user_say(self, text: str) -> typing.Optional[str]:
# 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):
from kvui import GameManager
@@ -215,6 +141,7 @@ class FactorioContext(CommonContext):
async def game_watcher(ctx: FactorioContext):
bridge_logger = logging.getLogger("FactorioWatcher")
from worlds.factorio.Technologies import lookup_id_to_name
next_bridge = time.perf_counter() + 1
try:
while not ctx.exit_event.is_set():
@@ -236,7 +163,6 @@ async def game_watcher(ctx: FactorioContext):
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
victory = data["victory"]
await ctx.update_death_link(data["death_link"])
ctx.multiplayer = data.get("multiplayer", False)
if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
@@ -245,14 +171,14 @@ async def game_watcher(ctx: FactorioContext):
if ctx.locations_checked != research_data:
bridge_logger.debug(
f"New researches done: "
f"{[ctx.location_names[rid] for rid in research_data - ctx.locations_checked]}")
f"{[lookup_id_to_name.get(rid, f'Unknown Research (ID: {rid})') for rid in research_data - ctx.locations_checked]}")
ctx.locations_checked = research_data
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
death_link_tick = data.get("death_link_tick", 0)
if death_link_tick != ctx.death_link_tick:
ctx.death_link_tick = death_link_tick
if "DeathLink" in ctx.tags:
async_start(ctx.send_death())
asyncio.create_task(ctx.send_death())
if ctx.energy_link_increment:
in_world_bridges = data["energy_bridges"]
if in_world_bridges:
@@ -260,7 +186,7 @@ async def game_watcher(ctx: FactorioContext):
if in_world_energy < (ctx.energy_link_increment * in_world_bridges):
# attempt to refill
ctx.last_deplete = time.time()
async_start(ctx.send_msgs([{
asyncio.create_task(ctx.send_msgs([{
"cmd": "Set", "key": "EnergyLink", "operations":
[{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges},
{"operation": "max", "value": 0}],
@@ -270,7 +196,7 @@ async def game_watcher(ctx: FactorioContext):
elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \
ctx.energy_link_increment*in_world_bridges:
value = ctx.energy_link_increment * in_world_bridges
async_start(ctx.send_msgs([{
asyncio.create_task(ctx.send_msgs([{
"cmd": "Set", "key": "EnergyLink", "operations":
[{"operation": "add", "value": value}]
}]))
@@ -286,8 +212,6 @@ async def game_watcher(ctx: FactorioContext):
def stream_factorio_output(pipe, queue, process):
pipe.reconfigure(errors="replace")
def queuer():
while process.poll() is None:
text = pipe.readline().strip()
@@ -320,7 +244,7 @@ async def factorio_server_watcher(ctx: FactorioContext):
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
try:
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.")
ctx.exit_event.set()
@@ -333,32 +257,23 @@ async def factorio_server_watcher(ctx: FactorioContext):
if not ctx.server:
logger.info("Established bridge to Factorio Server. "
"Ready to connect to Archipelago via /connect")
check_stdin()
if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg:
ctx.awaiting_bridge = True
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:
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:
commands = {}
while ctx.send_index < len(ctx.items_received):
transfer_item: NetworkItem = ctx.items_received[ctx.send_index]
item_id = transfer_item.item
player_name = ctx.player_names[transfer_item.player]
if item_id not in Factorio.item_id_to_name:
if ctx.custom_data_package:
item_name = Factorio.item_id_to_name.get(item_id, f"Unknown Item (ID: {item_id})")
factorio_server_logger.info(f"Sending {item_name} to Nauvis from {player_name}.{(' (Item name might not match the seed.)' if Factorio.data_version else '')}")
commands[ctx.send_index] = f'/ap-get-technology {item_id}\t{ctx.send_index}\t{player_name}'
elif item_id not in Factorio.item_id_to_name:
factorio_server_logger.error(f"Cannot send unknown item ID: {item_id}")
else:
item_name = Factorio.item_id_to_name[item_id]
@@ -372,34 +287,12 @@ async def factorio_server_watcher(ctx: FactorioContext):
except Exception as e:
logging.exception(e)
logging.error("Aborted Factorio Server Bridge")
ctx.rcon_client = None
ctx.exit_event.set()
finally:
if factorio_process.poll() is not None:
if ctx.rcon_client:
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()
factorio_process.terminate()
factorio_process.wait(5)
async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient):
@@ -409,6 +302,7 @@ async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient):
# 0.2.0 addition, not present earlier
death_link = bool(info.get("death_link", False))
ctx.energy_link_increment = info.get("energy_link", 0)
ctx.custom_data_package = info.get("custom_data_package", 0)
logger.debug(f"Energy Link Increment: {ctx.energy_link_increment}")
if ctx.energy_link_increment and ctx.ui:
ctx.ui.enable_energy_link()
@@ -473,8 +367,6 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
async def main(args):
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")
if gui_enabled:
@@ -527,12 +419,6 @@ if __name__ == '__main__':
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)):
raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.")

335
Fill.py
View File

@@ -4,10 +4,9 @@ import collections
import itertools
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.generic.Rules import add_item_rule
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],
itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
allow_partial: bool = False) -> None:
itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False) -> None:
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
@@ -72,68 +69,62 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
else:
# we filled all reachable spots.
if swap:
# 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
swap_count = swapped_items[placed_item.player,
placed_item.name]
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)
# 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
swap_count = swapped_items[placed_item.player,
placed_item.name]
if swap_count > 1:
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)
continue
world.push_item(spot_to_fill, item_to_place, False)
spot_to_fill.locked = lock
placements.append(spot_to_fill)
spot_to_fill.event = 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
if world.can_beat_game():
logging.warning(
@@ -218,130 +209,12 @@ def fast_fill(world: MultiWorld,
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:
fill_locations = sorted(world.get_unfilled_locations())
world.random.shuffle(fill_locations)
# get items to distribute
itempool = sorted(world.itempool)
world.random.shuffle(itempool)
fill_locations, itempool = distribute_early_items(world, fill_locations, itempool)
progitempool: typing.List[Item] = []
usefulitempool: typing.List[Item] = []
filleritempool: typing.List[Item] = []
@@ -366,33 +239,15 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
defaultlocations = locations[LocationProgressType.DEFAULT]
excludedlocations = locations[LocationProgressType.EXCLUDED]
# can't lock due to accessibility corrections touching things, so we remember which ones got placed and lock later
lock_later = []
def mark_for_locking(location: Location):
nonlocal lock_later
lock_later.append(location)
fill_restrictive(world, world.state, prioritylocations, progitempool, lock=True)
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
if progitempool:
# "progression fill"
fill_restrictive(world, world.state, defaultlocations, progitempool)
if progitempool:
raise FillError(
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 location.item:
location.locked = True
del mark_for_locking, lock_later
inaccessible_location_rules(world, world.state, defaultlocations)
remaining_fill(world, excludedlocations, filleritempool)
if excludedlocations:
@@ -693,17 +548,6 @@ def distribute_planned(world: MultiWorld) -> None:
else:
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
from worlds.alttp.Regions import key_drop_data
world_name_lookup = world.world_name_lookup
@@ -719,39 +563,7 @@ def distribute_planned(world: MultiWorld) -> None:
if 'from_pool' not in block:
block['from_pool'] = True
if 'world' not in block:
target_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
block['world'] = False
items: block_value = []
if "items" in block:
items = block["items"]
@@ -788,17 +600,6 @@ def distribute_planned(world: MultiWorld) -> None:
for key, value in locations.items():
location_list += [key] * value
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
if not block['count']:
@@ -834,13 +635,41 @@ def distribute_planned(world: MultiWorld) -> None:
for placement in plando_blocks:
player = placement['player']
try:
worlds = placement['world']
target_world = placement['world']
locations = placement['locations']
items = placement['items']
maxcount = placement['count']['target']
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(world.get_unfilled_locations_for_players(locations, sorted(worlds)))
candidates = list(location for location in world.get_unfilled_locations_for_players(locations,
worlds))
world.random.shuffle(candidates)
world.random.shuffle(items)
count = 0

View File

@@ -2,13 +2,14 @@ from __future__ import annotations
import argparse
import logging
import os
import random
import string
import urllib.parse
import urllib.request
import urllib.parse
from typing import Set, Dict, Tuple, Callable, Any, Union
import os
from collections import Counter, ChainMap
from typing import Dict, Tuple, Callable, Any, Union
import string
import enum
import ModuleUpdate
@@ -17,17 +18,52 @@ ModuleUpdate.update()
import Utils
from worlds.alttp import Options as LttPOptions
from worlds.generic import PlandoConnection
from Utils import parse_yamls, version_tuple, __version__, tuplize_version, get_options, user_path
from Utils import parse_yamls, version_tuple, __version__, tuplize_version, get_options, local_path, user_path
from worlds.alttp.EntranceRandomizer import parse_arguments
from Main import main as ERmain
from BaseClasses import seeddigits, get_seed, PlandoOptions
from BaseClasses import seeddigits, get_seed
import Options
from worlds.alttp.Text import TextTable
from worlds.AutoWorld import AutoWorldRegister
import copy
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():
@@ -61,7 +97,7 @@ def mystery_argparse():
args.weights_file_path = os.path.join(args.player_files_path, args.weights_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.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
args.plando: PlandoSettings = PlandoSettings.from_option_string(args.plando)
return args, options
@@ -107,7 +143,7 @@ def main(args=None, callback=ERmain):
player_files = {}
for file in os.scandir(args.player_files_path):
fname = file.name
if file.is_file() and not fname.startswith(".") and \
if file.is_file() and not file.name.startswith(".") and \
os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
path = os.path.join(args.player_files_path, fname)
try:
@@ -118,12 +154,11 @@ def main(args=None, callback=ERmain):
# sort dict for consistent results across platforms:
weights_cache = {key: value for key, value in sorted(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:
print(f"P{player_id} Weights: {filename} >> "
f"{get_choice('description', yaml, 'No description specified')}")
player_files[player_id] = filename
player_id += 1
for yaml in yaml_data:
print(f"P{player_id} Weights: {filename} >> "
f"{get_choice('description', yaml, 'No description specified')}")
player_files[player_id] = filename
player_id += 1
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: "
@@ -134,7 +169,6 @@ def main(args=None, callback=ERmain):
f"A mix is also permitted.")
erargs = parse_arguments(['--multi', str(args.multi)])
erargs.seed = seed
erargs.plando_options = args.plando
erargs.glitch_triforce = options["generator"]["glitch_triforce_room"]
erargs.spoiler = args.spoiler
erargs.race = args.race
@@ -191,15 +225,15 @@ def main(args=None, callback=ERmain):
elif not erargs.name[player]: # if name was not specified, generate it from filename
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
player += 1
except Exception as e:
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
else:
raise RuntimeError(f'No weights specified for player {player}')
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
if len(set(erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
if args.yaml_output:
import yaml
@@ -282,11 +316,11 @@ class SafeDict(dict):
def handle_name(name: str, player: int, name_counter: Counter):
name_counter[name.lower()] += 1
number = name_counter[name.lower()]
name_counter[name] += 1
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,
NUMBER=(number if number > 1 else ''),
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=name_counter[name],
NUMBER=(name_counter[name] if name_counter[
name] > 1 else ''),
player=player,
PLAYER=(player if player > 1 else '')))
new_name = new_name.strip()[:16]
@@ -343,7 +377,7 @@ def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
if option_key in options:
if options[option_key].supports_weighting:
return get_choice(option_key, category_dict)
return category_dict[option_key]
return options[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",
@@ -408,7 +442,7 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
return weights
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions):
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoSettings):
if option_key in game_weights:
try:
if not option.supports_weighting:
@@ -424,7 +458,7 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
setattr(ret, option_key, option.from_any(option.default)) # call the from_any here to support default "random"
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings.bosses):
if "linked_options" in weights:
weights = roll_linked_options(weights)
@@ -437,7 +471,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
if tuplize_version(version) > version_tuple:
raise Exception(f"Settings reports required version of generator is at least {version}, "
f"however generator is of version {__version__}")
required_plando_options = PlandoOptions.from_option_string(requirements.get("plando", ""))
required_plando_options = PlandoSettings.from_option_string(requirements.get("plando", ""))
if required_plando_options not in plando_options:
if required_plando_options:
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
@@ -468,15 +502,14 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
handle_option(ret, game_weights, option_key, option, plando_options)
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
if option_key not in world_type.option_definitions and \
(option_key not in Options.common_options or option_key in game_weights):
if not (option_key in Options.common_options and option_key not in game_weights):
handle_option(ret, game_weights, option_key, option, plando_options)
if PlandoOptions.items in plando_options:
if PlandoSettings.items in plando_options:
ret.plando_items = game_weights.get("plando_items", [])
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
# bad hardcoded behavior to make this work for now
ret.plando_connections = []
if PlandoOptions.connections in plando_options:
if PlandoSettings.connections in plando_options:
options = game_weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)):
@@ -591,7 +624,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'}")
ret.plando_texts = {}
if PlandoOptions.texts in plando_options:
if PlandoSettings.texts in plando_options:
tt = TextTable()
tt.removeUnwantedText()
options = weights.get("plando_texts", [])
@@ -603,7 +636,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
ret.plando_connections = []
if PlandoOptions.connections in plando_options:
if PlandoSettings.connections in plando_options:
options = weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice_legacy("percentage", placement, 100)):

View File

@@ -132,8 +132,7 @@ components: Iterable[Component] = (
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
# SNI
Component('SNI Client', 'SNIClient',
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3',
'.apsmw', '.apl2ac')),
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3')),
Component('LttP Adjuster', 'LttPAdjuster'),
# Factorio
Component('Factorio Client', 'FactorioClient'),
@@ -146,19 +145,10 @@ components: Iterable[Component] = (
Component('OoT Adjuster', 'OoTAdjuster'),
# FF1
Component('FF1 Client', 'FF1Client'),
# Pokémon
Component('Pokemon Client', 'PokemonClient', file_identifier=SuffixIdentifier('.apred', '.apblue')),
# TLoZ
Component('Zelda 1 Client', 'Zelda1Client'),
# ChecksFinder
Component('ChecksFinder Client', 'ChecksFinderClient'),
# Starcraft 2
Component('Starcraft 2 Client', 'Starcraft2Client'),
# Wargroove
Component('Wargroove Client', 'WargrooveClient'),
# Zillion
Component('Zillion Client', 'ZillionClient',
file_identifier=SuffixIdentifier('.apzl')),
# Functions
Component('Open host.yaml', func=open_host_yaml),
Component('Open Patch', func=open_patch),

View File

@@ -26,16 +26,14 @@ ModuleUpdate.update()
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, \
get_adjuster_settings, tkinter_center_window, init_logging
GAME_ALTTP = "A Link to the Past"
from Patch import GAME_ALTTP
class AdjusterWorld(object):
def __init__(self, sprite_pool):
import random
self.sprite_pool = {1: sprite_pool}
self.per_slot_randoms = {1: random}
self.slot_seeds = {1: random}
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
@@ -141,7 +139,7 @@ def adjust(args):
vanillaRom = args.baserom
if not os.path.exists(vanillaRom) and not os.path.isabs(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
meta, args.rom = Patch.create_rom_file(args.rom)
@@ -197,7 +195,7 @@ def adjustGUI():
romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
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)
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
@@ -727,7 +725,7 @@ def get_rom_options_frame(parent=None):
vars.auto_apply = StringVar(value=adjuster_settings.auto_apply)
autoApplyFrame = Frame(romOptionsFrame)
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)
askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask')
askRadio.pack(side=LEFT, padx=5, pady=5)

291
Main.py
View File

@@ -1,4 +1,5 @@
import collections
from itertools import zip_longest, chain
import logging
import os
import time
@@ -7,16 +8,15 @@ import concurrent.futures
import pickle
import tempfile
import zipfile
from typing import Dict, List, Tuple, Optional, Set
from typing import Dict, Tuple, Optional, Set
from BaseClasses import Item, MultiWorld, CollectionState, Region, LocationProgressType, Location
import worlds
from worlds.alttp.SubClasses import LTTPRegionType
from BaseClasses import MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location
from worlds.alttp.Items import item_name_groups
from worlds.alttp.Regions import is_main_entrance
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
from worlds.alttp.Shops import FillDisabledShopSlots
from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
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
ordered_areas = (
@@ -38,8 +38,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world = MultiWorld(args.multi)
logger = logging.getLogger()
world.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
world.plando_options = args.plando_options
world.set_seed(seed, args.race, str(args.outputname if args.outputname else world.seed))
world.shuffle = args.shuffle.copy()
world.logic = args.logic.copy()
@@ -53,6 +52,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.enemy_damage = args.enemy_damage.copy()
world.beemizer_total_chance = args.beemizer_total_chance.copy()
world.beemizer_trap_chance = args.beemizer_trap_chance.copy()
world.timer = args.timer.copy()
world.countdown_start_time = args.countdown_start_time.copy()
world.red_clock_time = args.red_clock_time.copy()
world.blue_clock_time = args.blue_clock_time.copy()
@@ -78,32 +78,17 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.state = CollectionState(world)
logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
logger.info("Found World Types:")
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
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
numlength = 8
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
if not cls.hidden and len(cls.item_names) > 0:
logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} "
f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - "
f"{max(cls.item_id_to_name):{item_digits}}) | "
f"{len(cls.location_names):{location_count}} "
f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - "
f"{max(cls.location_id_to_name):{location_digits}})")
del item_digits, location_digits, item_count, location_count
if not cls.hidden:
logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} "
f"Items (IDs: {min(cls.item_id_to_name):{numlength}} - "
f"{max(cls.item_id_to_name):{numlength}}) | "
f"{len(cls.location_names):3} "
f"Locations (IDs: {min(cls.location_id_to_name):{numlength}} - "
f"{max(cls.location_id_to_name):{numlength}})")
AutoWorld.call_stage(world, "assert_generate")
@@ -116,25 +101,30 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for _ in range(count):
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.')
AutoWorld.call_all(world, "create_regions")
logger.info('Creating Items.')
AutoWorld.call_all(world, "create_items")
# All worlds should have finished creating all regions, locations, and entrances.
# Recache to ensure that they are all visible for locality rules.
world._recache()
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:
locality_rules(world)
for player in world.player_ids:
locality_rules(world, player)
group_locality_rules(world)
else:
world.non_local_items[1].value = set()
world.local_items[1].value = set()
@@ -151,10 +141,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
for group_id, group in world.groups.items():
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
]:
classifications: Dict[str, int] = collections.defaultdict(int)
def find_common_pool(players: Set[int], shared_pool: Set[str]):
classifications = collections.defaultdict(int)
counters = {player: {name: 0 for name in shared_pool} for player in players}
for item in world.itempool:
if item.player in counters and item.name in shared_pool:
@@ -164,7 +152,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player in players.copy():
if all([counters[player][item] == 0 for item in shared_pool]):
players.remove(player)
del (counters[player])
del(counters[player])
if not players:
return None, None
@@ -176,14 +164,14 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
counters[player][item] = count
else:
for player in players:
del (counters[player][item])
del(counters[player][item])
return counters, classifications
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
if not common_item_count:
continue
new_itempool: List[Item] = []
new_itempool = []
for item_name, item_count in next(iter(common_item_count.values())).items():
for _ in range(item_count):
new_item = group["world"].create_item(item_name)
@@ -191,7 +179,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
new_item.classification |= classifications[item_name]
new_itempool.append(new_item)
region = Region("Menu", group_id, world, "ItemLink")
region = Region("Menu", RegionType.Generic, "ItemLink", group_id, world)
world.regions.append(region)
locations = region.locations = []
for item in world.itempool:
@@ -214,15 +202,11 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
while itemcount > len(world.itempool):
items_to_add = []
for player in group["players"]:
if group["link_replacement"]:
item_player = group_id
else:
item_player = 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]))
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.itempool.extend(items_to_add[:itemcount - len(world.itempool)])
@@ -251,10 +235,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
balance_multiworld_progression(world)
logger.info(f'Beginning output...')
# we're about to output using multithreading, so we're removing the global random state to prevent accidental use
world.random.passthrough = False
outfilebase = 'AP_' + world.seed_name
output = tempfile.TemporaryDirectory()
@@ -290,16 +270,37 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
'Inverted Ganons Tower': 'Ganons Tower'} \
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
checks_in_area[location.player][dungeonname].append(location.address)
elif location.parent_region.type == LTTPRegionType.LightWorld:
elif location.parent_region.type == RegionType.LightWorld:
checks_in_area[location.player]["Light World"].append(location.address)
elif location.parent_region.type == LTTPRegionType.DarkWorld:
elif location.parent_region.type == RegionType.DarkWorld:
checks_in_area[location.player]["Dark World"].append(location.address)
elif main_entrance.parent_region.type == LTTPRegionType.LightWorld:
elif main_entrance.parent_region.type == RegionType.LightWorld:
checks_in_area[location.player]["Light World"].append(location.address)
elif main_entrance.parent_region.type == LTTPRegionType.DarkWorld:
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
oldmancaves = []
takeanyregions = ["Old Man Sword Cave", "Take-Any #1", "Take-Any #2", "Take-Any #3", "Take-Any #4"]
for index, take_any in enumerate(takeanyregions):
for region in [world.get_region(take_any, player) for player in
world.get_game_players("A Link to the Past") if world.retro_caves[player]]:
item = world.create_item(
region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'],
region.player)
player = region.player
location_id = SHOP_ID_START + total_shop_slots + index
main_entrance = region.get_connecting_entrance(is_main_entrance)
if main_entrance.parent_region.type == RegionType.LightWorld:
checks_in_area[player]["Light World"].append(location_id)
else:
checks_in_area[player]["Dark World"].append(location_id)
checks_in_area[player]["Total"] += 1
er_hint_data[player][location_id] = main_entrance.name
oldmancaves.append(((location_id, player), (item.code, player)))
FillDisabledShopSlots(world)
def write_multidata():
@@ -355,19 +356,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", [])]):
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
datapackage[game_world.game]["location_name_groups"] = game_world.location_name_groups
multidata = {
"slot_data": slot_data,
"slot_info": slot_info,
"names": names, # TODO: remove after 0.3.9
"names": names, # TODO: remove around 0.2.5 in favor of slot_info
"games": games, # TODO: remove around 0.2.5 in favor of slot_info
"connect_names": {name: (0, player) for player, name in world.player_name.items()},
"remote_items": {player for player in world.player_ids if
world.worlds[player].remote_items},
"remote_start_inventory": {player for player in world.player_ids if
world.worlds[player].remote_start_inventory},
"locations": locations_data,
"checks_in_area": checks_in_area,
"server_options": baked_server_options,
@@ -377,8 +375,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
"version": tuple(version_tuple),
"tags": ["AP"],
"minimum_versions": minimum_versions,
"seed_name": world.seed_name,
"datapackage": datapackage,
"seed_name": world.seed_name
}
AutoWorld.call_all(world, "modify_multidata", multidata)
@@ -404,7 +401,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
if args.spoiler > 1:
logger.info('Calculating playthrough.')
world.spoiler.create_playthrough(create_paths=args.spoiler > 2)
create_playthrough(world)
if args.spoiler:
world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
@@ -418,3 +415,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)
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

@@ -2,7 +2,6 @@ import os
import sys
import subprocess
import pkg_resources
import warnings
local_dir = os.path.dirname(__file__)
requirements_files = {os.path.join(local_dir, 'requirements.txt')}
@@ -14,12 +13,10 @@ update_ran = getattr(sys, "frozen", False) # don't run update if environment is
if not update_ran:
for entry in os.scandir(os.path.join(local_dir, "worlds")):
# skip .* (hidden / disabled) folders
if not entry.name.startswith("."):
if entry.is_dir():
req_file = os.path.join(entry.path, "requirements.txt")
if os.path.exists(req_file):
requirements_files.add(req_file)
if entry.is_dir():
req_file = os.path.join(entry.path, "requirements.txt")
if os.path.exists(req_file):
requirements_files.add(req_file)
def update_command():
@@ -40,50 +37,21 @@ def update(yes=False, force=False):
path = os.path.join(os.path.dirname(__file__), req_file)
with open(path) as requirementsfile:
for line in requirementsfile:
if not line or line[0] == "#":
continue # ignore comments
if line.startswith(("https://", "git+https://")):
# extract name and version for url
rest = line.split('/')[-1]
line = ""
if "#egg=" in rest:
# from egg info
rest, egg = rest.split("#egg=", 1)
egg = egg.split(";", 1)[0].rstrip()
if any(compare in egg for compare in ("==", ">=", ">", "<", "<=", "!=")):
warnings.warn(f"Specifying version as #egg={egg} will become unavailable in pip 25.0. "
"Use name @ url#version instead.", DeprecationWarning)
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}'
elif "@" in line and "#" in line:
# PEP 508 does not allow us to specify a version, so we use custom syntax
# name @ url#version ; marker
name, rest = line.split("@", 1)
version = rest.split("#", 1)[1].split(";", 1)[0].rstrip()
line = f"{name.rstrip()}=={version}"
if ";" in rest: # keep marker
line += rest[rest.find(";"):]
if line.startswith('https://'):
# extract name and version from url
wheel = line.split('/')[-1]
name, version, _ = wheel.split('-', 2)
line = f'{name}=={version}'
requirements = pkg_resources.parse_requirements(line)
for requirement in map(str, requirements):
for requirement in requirements:
requirement = str(requirement)
try:
pkg_resources.require(requirement)
except pkg_resources.ResolutionError:
if not yes:
import traceback
traceback.print_exc()
try:
input(f"\nRequirement {requirement} is not satisfied, press enter to install it")
except KeyboardInterrupt:
print("\nAborting")
sys.exit(1)
input(f'Requirement {requirement} is not satisfied, press enter to install it')
update_command()
return

File diff suppressed because it is too large Load Diff

View File

@@ -43,7 +43,7 @@ class Permission(enum.IntFlag):
disabled = 0b000 # 0, completely disables access
enabled = 0b001 # 1, allows manual use
goal = 0b010 # 2, allows manual use after goal completion
auto = 0b110 # 6, forces use after goal completion, only works for release
auto = 0b110 # 6, forces use after goal completion, only works for forfeit
auto_enabled = 0b111 # 7, forces use after goal completion, allows manual use any time
@staticmethod
@@ -86,7 +86,7 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
data = obj._asdict()
data["class"] = obj.__class__.__name__
return data
if isinstance(obj, (tuple, list, set, frozenset)):
if isinstance(obj, (tuple, list, set)):
return tuple(_scan_for_TypedTuples(o) for o in obj)
if isinstance(obj, dict):
return {key: _scan_for_TypedTuples(value) for key, value in obj.items()}
@@ -100,7 +100,7 @@ _encode = JSONEncoder(
).encode
def encode(obj: typing.Any) -> str:
def encode(obj):
return _encode(_scan_for_TypedTuples(obj))
@@ -109,7 +109,7 @@ def get_any_version(data: dict) -> Version:
return Version(int(data["major"]), int(data["minor"]), int(data["build"]))
allowlist = {
whitelist = {
"NetworkPlayer": NetworkPlayer,
"NetworkItem": NetworkItem,
"NetworkSlot": NetworkSlot
@@ -125,7 +125,7 @@ def _object_hook(o: typing.Any) -> typing.Any:
hook = custom_hooks.get(o.get("class", None), None)
if hook:
return hook(o)
cls = allowlist.get(o.get("class", None), None)
cls = whitelist.get(o.get("class", None), None)
if cls:
for key in tuple(o):
if key not in cls._fields:

View File

@@ -3,7 +3,6 @@ import argparse
import logging
import random
import os
import zipfile
from itertools import chain
from BaseClasses import MultiWorld
@@ -197,7 +196,7 @@ def set_icon(window):
def adjust(args):
# Create a fake world and OOTWorld to use as a base
world = MultiWorld(1)
world.per_slot_randoms = {1: random}
world.slot_seeds = {1: random}
ootworld = OOTWorld(world, 1)
# Set options in the fake OOTWorld
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
@@ -218,18 +217,13 @@ def adjust(args):
# Load up the ROM
rom = Rom(file=args.rom, force_use=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
rom = Rom(file=args.vanilla_rom, force_use=True)
apz5_file = args.rom
base_name = os.path.splitext(apz5_file)[0]
# Patch file
apply_patch_file(rom, apz5_file,
sub_file=(os.path.basename(base_name) + '.zpf'
if zipfile.is_zipfile(apz5_file)
else None))
apply_patch_file(rom, args.rom)
else:
raise Exception("Invalid file extension; requires .n64, .z64, .apz5, .zpf")
raise Exception("Invalid file extension; requires .n64, .z64, .apz5")
# Call patch_cosmetics
try:
patch_cosmetics(ootworld, rom)

View File

@@ -3,14 +3,11 @@ import json
import os
import multiprocessing
import subprocess
import zipfile
from asyncio import StreamReader, StreamWriter
# CommonClient import first to trigger ModuleUpdater
from CommonClient import CommonContext, server_loop, gui_enabled, \
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, \
ClientCommandProcessor, logger, get_base_parser
import Utils
from Utils import async_start
from worlds import network_data_package
from worlds.oot.Rom import Rom, compress_rom_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"]
script_version: int = 3
script_version: int = 2
def get_item_value(ap_id):
return ap_id - 66000
@@ -71,7 +68,7 @@ class OoTCommandProcessor(ClientCommandProcessor):
if isinstance(self.ctx, OoTContext):
self.ctx.deathlink_client_override = True
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):
@@ -86,9 +83,6 @@ class OoTContext(CommonContext):
self.n64_status = CONNECTION_INITIAL_STATUS
self.awaiting_rom = False
self.location_table = {}
self.collectible_table = {}
self.collectible_override_flags_address = 0
self.collectible_offsets = {}
self.deathlink_enabled = False
self.deathlink_pending = False
self.deathlink_sent_this_death = False
@@ -121,13 +115,6 @@ class OoTContext(CommonContext):
self.ui = OoTManager(self)
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):
if ctx.deathlink_enabled and ctx.deathlink_pending:
@@ -136,32 +123,15 @@ def get_payload(ctx: OoTContext):
else:
trigger_death = False
payload = json.dumps({
return json.dumps({
"items": [get_item_value(item.item) for item in ctx.items_received],
"playerNames": [name for (i, name) in ctx.player_names.items() if i != 0],
"triggerDeath": trigger_death,
"collectibleOverrides": ctx.collectible_override_flags_address,
"collectibleOffsets": ctx.collectible_offsets
"triggerDeath": trigger_death
})
return payload
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
if payload['deathlinkActive'] and not ctx.deathlink_enabled and not ctx.deathlink_client_override:
await ctx.update_death_link(True)
@@ -176,17 +146,11 @@ async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
ctx.finished_game = True
# Locations handling
locations = payload['locations']
collectibles = payload['collectibles']
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]
if ctx.location_table != payload['locations']:
ctx.location_table = payload['locations']
await ctx.send_msgs([{
"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
@@ -212,13 +176,20 @@ async def n64_sync_task(ctx: OoTContext):
try:
await asyncio.wait_for(writer.drain(), timeout=1.5)
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_decoded = json.loads(data.decode())
reported_version = data_decoded.get('scriptVersion', 0)
if reported_version >= script_version:
if ctx.game is not None and 'locations' in data_decoded:
# 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:
ctx.auth = data_decoded['playerName']
if ctx.awaiting_rom:
@@ -284,21 +255,17 @@ async def run_game(romfile):
async def patch_and_run_game(apz5_file):
apz5_file = os.path.abspath(apz5_file)
base_name = os.path.splitext(apz5_file)[0]
decomp_path = base_name + '-decomp.z64'
comp_path = base_name + '.z64'
# Load vanilla ROM, patch file, compress ROM
rom = Rom(Utils.local_path(Utils.get_options()["oot_options"]["rom_file"]))
apply_patch_file(rom, apz5_file,
sub_file=(os.path.basename(base_name) + '.zpf'
if zipfile.is_zipfile(apz5_file)
else None))
apply_patch_file(rom, apz5_file)
rom.write_to_file(decomp_path)
os.chdir(data_path("Compress"))
compress_rom_file(decomp_path, comp_path)
os.remove(decomp_path)
async_start(run_game(comp_path))
asyncio.create_task(run_game(comp_path))
if __name__ == '__main__':
@@ -314,7 +281,7 @@ if __name__ == '__main__':
if args.apz5_file:
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.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")

View File

@@ -1,7 +1,5 @@
from __future__ import annotations
import abc
import logging
from copy import deepcopy
import math
import numbers
import typing
@@ -10,10 +8,6 @@ import random
from schema import Schema, And, Or, Optional
from Utils import get_fuzzy_results
if typing.TYPE_CHECKING:
from BaseClasses import PlandoOptions
from worlds.AutoWorld import World
class AssembleOptions(abc.ABCMeta):
def __new__(mcs, name, bases, attrs):
@@ -100,11 +94,11 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
supports_weighting = True
# filled by AssembleOptions:
name_lookup: typing.Dict[T, str]
name_lookup: typing.Dict[int, str]
options: typing.Dict[str, int]
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.current_option_name})"
return f"{self.__class__.__name__}({self.get_current_option_name()})"
def __hash__(self) -> int:
return hash(self.value)
@@ -114,14 +108,7 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
return self.name_lookup[self.value]
def get_current_option_name(self) -> str:
"""Deprecated. use current_option_name instead. TODO remove around 0.4"""
logging.warning(DeprecationWarning(f"get_current_option_name for {self.__class__.__name__} is deprecated."
f" use current_option_name instead. Worlds should use {self}.current_key"))
return self.current_option_name
@property
def current_option_name(self) -> str:
"""For display purposes. Worlds should be using current_key."""
"""For display purposes."""
return self.get_option_name(self.value)
@classmethod
@@ -138,19 +125,21 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
return bool(self.value)
@classmethod
@abc.abstractmethod
def from_any(cls, data: typing.Any) -> Option[T]:
...
raise NotImplementedError
if typing.TYPE_CHECKING:
def verify(self, world: typing.Type[World], player_name: str, plando_options: PlandoOptions) -> None:
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[str]):
class FreeText(Option):
"""Text option that allows users to enter strings.
Needs to be validated by the world or option definition."""
@@ -171,12 +160,11 @@ class FreeText(Option[str]):
return cls.from_text(str(data))
@classmethod
def get_option_name(cls, value: str) -> str:
def get_option_name(cls, value: T) -> str:
return value
class NumericOption(Option[int], numbers.Integral, abc.ABC):
default = 0
class NumericOption(Option[int], numbers.Integral):
# 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
# (even though isinstance(5, numbers.Integral) == True)
@@ -433,18 +421,19 @@ class Choice(NumericOption):
class TextChoice(Choice):
"""Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string"""
value: typing.Union[str, int]
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
super(TextChoice, self).__init__()
@property
def current_key(self) -> str:
if isinstance(self.value, str):
return self.value
return super().current_key
else:
return self.name_lookup[self.value]
@classmethod
def from_text(cls, text: str) -> TextChoice:
@@ -459,7 +448,7 @@ class TextChoice(Choice):
def get_option_name(cls, value: T) -> str:
if isinstance(value, str):
return value
return super().get_option_name(value)
return cls.name_lookup[value]
def __eq__(self, other: typing.Any):
if isinstance(other, self.__class__):
@@ -477,123 +466,6 @@ class TextChoice(Choice):
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: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
if isinstance(self.value, int):
return
from BaseClasses import PlandoOptions
if not(PlandoOptions.bosses & plando_options):
# 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):
range_start = 0
range_end = 1
@@ -724,7 +596,7 @@ class VerifyKeys:
value: typing.Any
@classmethod
def verify_keys(cls, data: typing.List[str]):
def verify_keys(cls, data):
if cls.valid_keys:
data = set(data)
dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
@@ -733,17 +605,12 @@ class VerifyKeys:
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
f"Allowed keys: {cls.valid_keys}.")
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
def verify(self, world, player_name: str, plando_options) -> None:
if self.convert_name_groups and self.verify_item_name:
new_value = type(self.value)() # empty container of whatever value is
for item_name in self.value:
new_value |= world.item_name_groups.get(item_name, {item_name})
self.value = new_value
elif self.convert_name_groups and self.verify_location_name:
new_value = type(self.value)()
for loc_name in self.value:
new_value |= world.location_name_groups.get(loc_name, {loc_name})
self.value = new_value
if self.verify_item_name:
for item_name in self.value:
if item_name not in world.item_names:
@@ -761,11 +628,11 @@ class VerifyKeys:
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
default: typing.Dict[str, typing.Any] = {}
default = {}
supports_weighting = False
def __init__(self, value: typing.Dict[str, typing.Any]):
self.value = deepcopy(value)
self.value = value
@classmethod
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
@@ -792,11 +659,11 @@ class ItemDict(OptionDict):
class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
default: typing.List[typing.Any] = []
default = []
supports_weighting = False
def __init__(self, value: typing.List[typing.Any]):
self.value = deepcopy(value)
self.value = value or []
super(OptionList, self).__init__()
@classmethod
@@ -818,11 +685,11 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
class OptionSet(Option[typing.Set[str]], VerifyKeys):
default: typing.Union[typing.Set[str], typing.FrozenSet[str]] = frozenset()
default = frozenset()
supports_weighting = False
def __init__(self, value: typing.Iterable[str]):
self.value = set(deepcopy(value))
def __init__(self, value: typing.Union[typing.Set[str, typing.Any], typing.List[str, typing.Any]]):
self.value = set(value)
super(OptionSet, self).__init__()
@classmethod
@@ -843,9 +710,7 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
return item in self.value
class ItemSet(OptionSet):
verify_item_name = True
convert_name_groups = True
local_objective = Toggle # local triforce pieces, local dungeon prizes etc.
class Accessibility(Choice):
@@ -863,7 +728,7 @@ class Accessibility(Choice):
class ProgressionBalancing(SpecialRange):
"""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
range_start = 0
range_end = 99
@@ -881,6 +746,11 @@ common_options = {
}
class ItemSet(OptionSet):
verify_item_name = True
convert_name_groups = True
class LocalItems(ItemSet):
"""Forces these items to be in their native world."""
display_name = "Local Items"
@@ -902,23 +772,22 @@ class StartHints(ItemSet):
display_name = "Start Hints"
class LocationSet(OptionSet):
class StartLocationHints(OptionSet):
"""Start with these locations and their item prefilled into the !hint command"""
display_name = "Start Location Hints"
verify_location_name = True
class StartLocationHints(LocationSet):
"""Start with these locations and their item prefilled into the !hint command"""
display_name = "Start Location Hints"
class ExcludeLocations(LocationSet):
class ExcludeLocations(OptionSet):
"""Prevent these locations from having an important item"""
display_name = "Excluded Locations"
verify_location_name = True
class PriorityLocations(LocationSet):
class PriorityLocations(OptionSet):
"""Prevent these locations from having an unimportant item"""
display_name = "Priority Locations"
verify_location_name = True
class DeathLink(Toggle):
@@ -936,8 +805,7 @@ class ItemLinks(OptionList):
Optional("exclude"): [And(str, len)],
"replacement_item": Or(And(str, len), None),
Optional("local_items"): [And(str, len)],
Optional("non_local_items"): [And(str, len)],
Optional("link_replacement"): Or(None, bool),
Optional("non_local_items"): [And(str, len)]
}
])
@@ -959,8 +827,7 @@ class ItemLinks(OptionList):
pool |= {item_name}
return pool
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
link: dict
def verify(self, world, player_name: str, plando_options) -> None:
super(ItemLinks, self).verify(world, player_name, plando_options)
existing_links = set()
for link in self.value:
@@ -985,9 +852,7 @@ class ItemLinks(OptionList):
intersection = local_items.intersection(non_local_items)
if intersection:
raise Exception(f"item_link {link['name']} has {intersection} "
f"items in both its local_items and non_local_items pool.")
link.setdefault("link_replacement", None)
raise Exception(f"item_link {link['name']} has {intersection} items in both its local_items and non_local_items pool.")
per_game_common_options = {

428
Patch.py
View File

@@ -1,23 +1,266 @@
from __future__ import annotations
import shutil
import json
import bsdiff4
import yaml
import os
import lzma
import threading
import concurrent.futures
import zipfile
import sys
from typing import Tuple, Optional, TypedDict
from typing import Tuple, Optional, Dict, Any, Union, BinaryIO
if __name__ == "__main__":
import ModuleUpdate
ModuleUpdate.update()
import ModuleUpdate
ModuleUpdate.update()
from worlds.Files import AutoPatchRegister, APDeltaPatch
import Utils
current_patch_version = 5
class RomMeta(TypedDict):
server: str
class AutoPatchRegister(type):
patch_types: Dict[str, APDeltaPatch] = {}
file_endings: Dict[str, APDeltaPatch] = {}
def __new__(cls, name: str, bases, dct: Dict[str, Any]):
# construct class
new_class = super().__new__(cls, name, bases, dct)
if "game" in dct:
AutoPatchRegister.patch_types[dct["game"]] = new_class
if not dct["patch_file_ending"]:
raise Exception(f"Need an expected file ending for {name}")
AutoPatchRegister.file_endings[dct["patch_file_ending"]] = new_class
return new_class
@staticmethod
def get_handler(file: str) -> Optional[type(APDeltaPatch)]:
for file_ending, handler in AutoPatchRegister.file_endings.items():
if file.endswith(file_ending):
return handler
class APContainer:
"""A zipfile containing at least archipelago.json"""
version: int = current_patch_version
compression_level: int = 9
compression_method: int = zipfile.ZIP_DEFLATED
game: Optional[str] = None
# instance attributes:
path: Optional[str]
player: Optional[int]
player_name: str
server: str
def __init__(self, path: Optional[str] = None, player: Optional[int] = None,
player_name: str = "", server: str = ""):
self.path = path
self.player = player
self.player_name = player_name
self.server = server
def write(self, file: Optional[Union[str, BinaryIO]] = None):
if not self.path and not file:
raise FileNotFoundError(f"Cannot write {self.__class__.__name__} due to no path provided.")
with zipfile.ZipFile(file if file else self.path, "w", self.compression_method, True, self.compression_level) \
as zf:
if file:
self.path = zf.filename
self.write_contents(zf)
def write_contents(self, opened_zipfile: zipfile.ZipFile):
manifest = self.get_manifest()
try:
manifest = json.dumps(manifest)
except Exception as e:
raise Exception(f"Manifest {manifest} did not convert to json.") from e
else:
opened_zipfile.writestr("archipelago.json", manifest)
def read(self, file: Optional[Union[str, BinaryIO]] = None):
"""Read data into patch object. file can be file-like, such as an outer zip file's stream."""
if not self.path and not file:
raise FileNotFoundError(f"Cannot read {self.__class__.__name__} due to no path provided.")
with zipfile.ZipFile(file if file else self.path, "r") as zf:
if file:
self.path = zf.filename
self.read_contents(zf)
def read_contents(self, opened_zipfile: zipfile.ZipFile):
with opened_zipfile.open("archipelago.json", "r") as f:
manifest = json.load(f)
if manifest["compatible_version"] > self.version:
raise Exception(f"File (version: {manifest['compatible_version']}) too new "
f"for this handler (version: {self.version})")
self.player = manifest["player"]
self.server = manifest["server"]
self.player_name = manifest["player_name"]
def get_manifest(self) -> dict:
return {
"server": self.server, # allow immediate connection to server in multiworld. Empty string otherwise
"player": self.player,
"player_name": self.player_name,
"game": self.game,
# minimum version of patch system expected for patching to be successful
"compatible_version": 4,
"version": current_patch_version,
}
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
manifest["patch_file_ending"] = self.patch_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"
GAME_DKC3 = "Donkey Kong Country 3"
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore", "SMZ3", "Donkey Kong Country 3"}
preferred_endings = {
GAME_ALTTP: "apbp",
GAME_SM: "apm3",
GAME_SOE: "apsoe",
GAME_SMZ3: "apsmz",
GAME_DKC3: "apdkc3"
}
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
elif game == GAME_DKC3:
from worlds.dkc3.Rom import USHASH as HASH
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 ".apdkc3" if game == GAME_DKC3
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
elif game == GAME_DKC3:
from worlds.dkc3.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)
if auto_handler:
handler: APDeltaPatch = auto_handler(patch_file)
@@ -26,10 +269,171 @@ def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]:
return {"server": handler.server,
"player": handler.player,
"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__":
for file in sys.argv[1:]:
meta_data, result_file = create_rom_file(file)
print(f"Patch with meta-data {meta_data} was written to {result_file}")
host = Utils.get_public_ipv4()
options = Utils.get_options()['server_options']
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(".apdkc3"):
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") or \
zfinfo.filename.endswith(".apdkc3"):
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,351 +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": {}, "DexSanityFlag": {}}
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 = 3
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
self.sent_release = False
self.sent_collect = False
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,
"options": ((ctx.permissions['release'] in ('goal', 'enabled')) * 2) + (ctx.permissions['collect'] in ('goal', 'enabled'))
}
)
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:0x140 + 0x20 + 0x0E + 0x01]}
if len(data) > 0x140 + 0x20 + 0x0E + 0x01:
flags["DexSanityFlag"] = data[0x140 + 0x20 + 0x0E + 0x01:]
else:
flags["DexSanityFlag"] = [0] * 19
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 'options' in data_decoded:
msgs = []
if data_decoded['options'] & 4 and not ctx.sent_release:
ctx.sent_release = True
msgs.append({"cmd": "Say", "text": "!release"})
if data_decoded['options'] & 8 and not ctx.sent_collect:
ctx.sent_collect = True
msgs.append({"cmd": "Say", "text": "!collect"})
if msgs:
await ctx.send_msgs(msgs)
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

@@ -28,17 +28,6 @@ Currently, the following games are supported:
* 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
* Blasphemous
* Wargroove
* Stardew Valley
* The Legend of Zelda
* The Messenger
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

File diff suppressed because it is too large Load Diff

View File

@@ -10,35 +10,34 @@ import re
import sys
import typing
import queue
import zipfile
import io
from pathlib import Path
# CommonClient import first to trigger ModuleUpdater
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
from Utils import init_logging, is_windows
if __name__ == "__main__":
init_logging("SC2Client", exception_logger="Client")
logger = logging.getLogger("Client")
sc2_logger = logging.getLogger("Starcraft2")
import nest_asyncio
import sc2
from sc2.bot_ai import BotAI
from sc2.data import Race
from sc2.main import run_game
from sc2.player import Bot
import NetUtils
from MultiServer import mark_raw
from Utils import init_logging, is_windows
from worlds.sc2wol import SC2WoLWorld
from worlds.sc2wol.Items import lookup_id_to_name, item_table, ItemData, type_flaggroups
from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET
from worlds.sc2wol.MissionTables import lookup_id_to_mission
from worlds.sc2wol.Regions import MissionInfo
if __name__ == "__main__":
init_logging("SC2Client", exception_logger="Client")
logger = logging.getLogger("Client")
sc2_logger = logging.getLogger("Starcraft2")
import colorama
from NetUtils import ClientStatus, NetworkItem, RawJSONtoTextParser
from MultiServer import mark_raw
from NetUtils import ClientStatus, RawJSONtoTextParser
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
nest_asyncio.apply()
max_bonus: int = 8
@@ -52,9 +51,9 @@ class StarcraftClientProcessor(ClientCommandProcessor):
"""Overrides the current difficulty set for the seed. Takes the argument casual, normal, hard, or brutal"""
options = difficulty.split()
num_options = len(options)
difficulty_choice = options[0].lower()
if num_options > 0:
difficulty_choice = options[0].lower()
if difficulty_choice == "casual":
self.ctx.difficulty_override = 0
elif difficulty_choice == "normal":
@@ -71,11 +70,7 @@ class StarcraftClientProcessor(ClientCommandProcessor):
return True
else:
if self.ctx.difficulty == -1:
self.output("Please connect to a seed before checking difficulty.")
else:
self.output("Current difficulty: " + ["Casual", "Normal", "Hard", "Brutal"][self.ctx.difficulty])
self.output("To change the difficulty, add the name of the difficulty after the command.")
self.output("Difficulty needs to be specified in the command.")
return False
def _cmd_disable_mission_check(self) -> bool:
@@ -120,40 +115,12 @@ class StarcraftClientProcessor(ClientCommandProcessor):
"""Manually set the SC2 install directory (if the automatic detection fails)."""
if path:
os.environ["SC2PATH"] = path
is_mod_installed_correctly()
check_mod_install()
return True
else:
sc2_logger.warning("When using set_path, you must type the path to your SC2 install directory.")
return False
def _cmd_download_data(self) -> bool:
"""Download the most recent release of the necessary files for playing SC2 with
Archipelago. Will overwrite existing files."""
if "SC2PATH" not in os.environ:
check_game_install_path()
if os.path.exists(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt"):
with open(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt", "r") as f:
current_ver = f.read()
else:
current_ver = None
tempzip, version = download_latest_release_zip('TheCondor07', 'Starcraft2ArchipelagoData',
current_version=current_ver, force_download=True)
if tempzip != '':
try:
zipfile.ZipFile(tempzip).extractall(path=os.environ["SC2PATH"])
sc2_logger.info(f"Download complete. Version {version} installed.")
with open(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt", "w") as f:
f.write(version)
finally:
os.remove(tempzip)
else:
sc2_logger.warning("Download aborted/failed. Read the log for more information.")
return False
return True
class SC2Context(CommonContext):
command_processor = StarcraftClientProcessor
@@ -161,9 +128,7 @@ class SC2Context(CommonContext):
items_handling = 0b111
difficulty = -1
all_in_choice = 0
mission_order = 0
mission_req_table: typing.Dict[str, MissionInfo] = {}
final_mission: int = 29
announcements = queue.Queue()
sc2_run_task: typing.Optional[asyncio.Task] = None
missions_unlocked: bool = False # allow launching missions ignoring requirements
@@ -188,29 +153,16 @@ class SC2Context(CommonContext):
self.difficulty = args["slot_data"]["game_difficulty"]
self.all_in_choice = args["slot_data"]["all_in_map"]
slot_req_table = args["slot_data"]["mission_req"]
# Maintaining backwards compatibility with older slot data
self.mission_req_table = {
mission: MissionInfo(
**{field: value for field, value in mission_info.items() if field in MissionInfo._fields}
)
for mission, mission_info in slot_req_table.items()
mission: MissionInfo(**slot_req_table[mission]) for mission in slot_req_table
}
self.mission_order = args["slot_data"].get("mission_order", 0)
self.final_mission = args["slot_data"].get("final_mission", 29)
self.build_location_to_mission_mapping()
# Looks for the required maps and mods for SC2. Runs check_game_install_path.
maps_present = is_mod_installed_correctly()
if os.path.exists(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt"):
with open(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt", "r") as f:
current_ver = f.read()
if is_mod_update_available("TheCondor07", "Starcraft2ArchipelagoData", current_ver):
sc2_logger.info("NOTICE: Update for required files found. Run /download_data to install.")
elif maps_present:
sc2_logger.warning("NOTICE: Your map files may be outdated (version number not found). "
"Run /download_data to update them.")
# Look for and set SC2PATH.
# check_game_install_path() returns True if and only if it finds + sets SC2PATH.
if "SC2PATH" not in os.environ and check_game_install_path():
check_mod_install()
def on_print_json(self, args: dict):
# goes to this world
@@ -322,6 +274,7 @@ class SC2Context(CommonContext):
self.refresh_from_launching = True
self.mission_panel.clear_widgets()
if self.ctx.mission_req_table:
self.last_checked_locations = self.ctx.checked_locations.copy()
self.first_check = False
@@ -339,20 +292,17 @@ class SC2Context(CommonContext):
for category in categories:
category_panel = MissionCategory()
if category.startswith('_'):
category_display_name = ''
else:
category_display_name = category
category_panel.add_widget(
Label(text=category_display_name, size_hint_y=None, height=50, outline_width=1))
Label(text=category, size_hint_y=None, height=50, outline_width=1))
for mission in categories[category]:
text: str = mission
tooltip: str = ""
mission_id: int = self.ctx.mission_req_table[mission].id
# Map has uncollected locations
if mission in unfinished_missions:
text = f"[color=6495ED]{text}[/color]"
elif mission in available_missions:
text = f"[color=FFFFFF]{text}[/color]"
# Map requirements not met
@@ -371,16 +321,6 @@ class SC2Context(CommonContext):
remaining_location_names: typing.List[str] = [
self.ctx.location_names[loc] for loc in self.ctx.locations_for_mission(mission)
if loc in self.ctx.missing_locations]
if mission_id == self.ctx.final_mission:
if mission in available_missions:
text = f"[color=FFBC95]{mission}[/color]"
else:
text = f"[color=D0C0BE]{mission}[/color]"
if tooltip:
tooltip += "\n"
tooltip += "Final Mission"
if remaining_location_names:
if tooltip:
tooltip += "\n"
@@ -390,7 +330,7 @@ class SC2Context(CommonContext):
mission_button = MissionButton(text=text, size_hint_y=None, height=50)
mission_button.tooltip_text = tooltip
mission_button.bind(on_press=self.mission_callback)
self.mission_id_to_button[mission_id] = mission_button
self.mission_id_to_button[self.ctx.mission_req_table[mission].id] = mission_button
category_panel.add_widget(mission_button)
category_panel.add_widget(Label(text=""))
@@ -417,9 +357,8 @@ class SC2Context(CommonContext):
self.ui = SC2Manager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
import pkgutil
data = pkgutil.get_data(SC2WoLWorld.__module__, "Starcraft2.kv").decode()
Builder.load_string(data)
Builder.load_file(Utils.local_path(os.path.dirname(SC2WoLWorld.__file__), "Starcraft2.kv"))
async def shutdown(self):
await super(SC2Context, self).shutdown()
@@ -499,13 +438,10 @@ wol_default_categories = [
"Rebellion", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Prophecy", "Prophecy", "Prophecy", "Prophecy",
"Char", "Char", "Char", "Char"
]
wol_default_category_names = [
"Mar Sara", "Colonist", "Artifact", "Covert", "Rebellion", "Prophecy", "Char"
]
def calculate_items(items: typing.List[NetworkItem]) -> typing.List[int]:
network_item: NetworkItem
def calculate_items(items: typing.List[NetUtils.NetworkItem]) -> typing.List[int]:
network_item: NetUtils.NetworkItem
accumulators: typing.List[int] = [0 for _ in type_flaggroups]
for network_item in items:
@@ -619,7 +555,7 @@ class ArchipelagoBot(sc2.bot_ai.BotAI):
if self.can_read_game:
if game_state & (1 << 1) and not self.mission_completed:
if self.mission_id != self.ctx.final_mission:
if self.mission_id != 29:
print("Mission Completed")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
@@ -649,13 +585,6 @@ def request_unfinished_missions(ctx: SC2Context):
_, unfinished_missions = calc_unfinished_missions(ctx, unlocks=unlocks)
# Removing All-In from location pool
final_mission = lookup_id_to_mission[ctx.final_mission]
if final_mission in unfinished_missions.keys():
message = f"Final Mission Available: {final_mission}[{ctx.final_mission}]\n" + message
if unfinished_missions[final_mission] == -1:
unfinished_missions.pop(final_mission)
message += ", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}[{ctx.mission_req_table[mission].id}] " +
mark_up_objectives(
f"[{len(unfinished_missions[mission])}/"
@@ -782,14 +711,13 @@ def calc_available_missions(ctx: SC2Context, unlocks=None):
return available_missions
def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete: int):
def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete):
"""Returns a bool signifying if the mission has all requirements complete and can be done
Arguments:
ctx -- instance of SC2Context
locations_to_check -- the mission string name to check
missions_complete -- an int of how many missions have been completed
mission_path -- a list of missions that have already been checked
"""
if len(ctx.mission_req_table[mission_name].required_world) >= 1:
# A check for when the requirements are being or'd
@@ -807,18 +735,7 @@ def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete
else:
req_success = False
# Grid-specific logic (to avoid long path checks and infinite recursion)
if ctx.mission_order in (3, 4):
if req_success:
return True
else:
if req_mission is ctx.mission_req_table[mission_name].required_world[-1]:
return False
else:
continue
# Recursively check required mission to see if it's requirements are met, in case !collect has been done
# Skipping recursive check on Grid settings to speed up checks and avoid infinite recursion
if not mission_reqs_completed(ctx, list(ctx.mission_req_table)[req_mission - 1], missions_complete):
if not ctx.mission_req_table[mission_name].or_requirements:
return False
@@ -903,53 +820,18 @@ def check_game_install_path() -> bool:
return False
def is_mod_installed_correctly() -> bool:
"""Searches for all required files."""
if "SC2PATH" not in os.environ:
check_game_install_path()
mapdir = os.environ['SC2PATH'] / Path('Maps/ArchipelagoCampaign')
modfile = os.environ["SC2PATH"] / Path("Mods/Archipelago.SC2Mod")
wol_required_maps = [
"ap_thanson01.SC2Map", "ap_thanson02.SC2Map", "ap_thanson03a.SC2Map", "ap_thanson03b.SC2Map",
"ap_thorner01.SC2Map", "ap_thorner02.SC2Map", "ap_thorner03.SC2Map", "ap_thorner04.SC2Map", "ap_thorner05s.SC2Map",
"ap_traynor01.SC2Map", "ap_traynor02.SC2Map", "ap_traynor03.SC2Map",
"ap_ttosh01.SC2Map", "ap_ttosh02.SC2Map", "ap_ttosh03a.SC2Map", "ap_ttosh03b.SC2Map",
"ap_ttychus01.SC2Map", "ap_ttychus02.SC2Map", "ap_ttychus03.SC2Map", "ap_ttychus04.SC2Map", "ap_ttychus05.SC2Map",
"ap_tvalerian01.SC2Map", "ap_tvalerian02a.SC2Map", "ap_tvalerian02b.SC2Map", "ap_tvalerian03.SC2Map",
"ap_tzeratul01.SC2Map", "ap_tzeratul02.SC2Map", "ap_tzeratul03.SC2Map", "ap_tzeratul04.SC2Map"
]
needs_files = False
# Check for maps.
missing_maps = []
for mapfile in wol_required_maps:
if not os.path.isfile(mapdir / mapfile):
missing_maps.append(mapfile)
if len(missing_maps) >= 19:
sc2_logger.warning(f"All map files missing from {mapdir}.")
needs_files = True
elif len(missing_maps) > 0:
for map in missing_maps:
sc2_logger.debug(f"Missing {map} from {mapdir}.")
sc2_logger.warning(f"Missing {len(missing_maps)} map files.")
needs_files = True
else: # Must be no maps missing
sc2_logger.info(f"All maps found in {mapdir}.")
# Check for mods.
if os.path.isfile(modfile):
sc2_logger.info(f"Archipelago mod found at {modfile}.")
else:
sc2_logger.warning(f"Archipelago mod could not be found at {modfile}.")
needs_files = True
# Final verdict.
if needs_files:
sc2_logger.warning(f"Required files are missing. Run /download_data to acquire them.")
return False
else:
return True
def check_mod_install() -> bool:
# Pull up the SC2PATH if set. If not, encourage the user to manually run /set_path.
try:
# Check inside the Mods folder for Archipelago.SC2Mod. If found, tell user. If not, tell user.
if os.path.isfile(modfile := (os.environ["SC2PATH"] / Path("Mods") / Path("Archipelago.SC2Mod"))):
sc2_logger.info(f"Archipelago mod found at {modfile}.")
return True
else:
sc2_logger.warning(f"Archipelago mod could not be found at {modfile}. Please install the mod file there.")
except KeyError:
sc2_logger.warning(f"SC2PATH isn't set. Please run /set_path with the path to your SC2 install.")
return False
class DllDirectory:
@@ -988,64 +870,6 @@ class DllDirectory:
return False
def download_latest_release_zip(owner: str, repo: str, current_version: str = None, force_download=False) -> (str, str):
"""Downloads the latest release of a GitHub repo to the current directory as a .zip file."""
import requests
headers = {"Accept": 'application/vnd.github.v3+json'}
url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
r1 = requests.get(url, headers=headers)
if r1.status_code == 200:
latest_version = r1.json()["tag_name"]
sc2_logger.info(f"Latest version: {latest_version}.")
else:
sc2_logger.warning(f"Status code: {r1.status_code}")
sc2_logger.warning(f"Failed to reach GitHub. Could not find download link.")
sc2_logger.warning(f"text: {r1.text}")
return "", current_version
if (force_download is False) and (current_version == latest_version):
sc2_logger.info("Latest version already installed.")
return "", current_version
sc2_logger.info(f"Attempting to download version {latest_version} of {repo}.")
download_url = r1.json()["assets"][0]["browser_download_url"]
r2 = requests.get(download_url, headers=headers)
if r2.status_code == 200 and zipfile.is_zipfile(io.BytesIO(r2.content)):
with open(f"{repo}.zip", "wb") as fh:
fh.write(r2.content)
sc2_logger.info(f"Successfully downloaded {repo}.zip.")
return f"{repo}.zip", latest_version
else:
sc2_logger.warning(f"Status code: {r2.status_code}")
sc2_logger.warning("Download failed.")
sc2_logger.warning(f"text: {r2.text}")
return "", current_version
def is_mod_update_available(owner: str, repo: str, current_version: str) -> bool:
import requests
headers = {"Accept": 'application/vnd.github.v3+json'}
url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
r1 = requests.get(url, headers=headers)
if r1.status_code == 200:
latest_version = r1.json()["tag_name"]
if current_version != latest_version:
return True
else:
return False
else:
sc2_logger.warning(f"Failed to reach GitHub while checking for updates.")
sc2_logger.warning(f"Status code: {r1.status_code}")
sc2_logger.warning(f"text: {r1.text}")
return False
if __name__ == '__main__':
colorama.init()
asyncio.run(main())

134
Utils.py
View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import asyncio
import typing
import builtins
import os
@@ -12,8 +11,6 @@ import io
import collections
import importlib
import logging
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
from yaml import load, load_all, dump, SafeLoader
try:
@@ -38,7 +35,7 @@ class Version(typing.NamedTuple):
build: int
__version__ = "0.3.9"
__version__ = "0.3.5"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -99,7 +96,7 @@ def local_path(*path: str) -> str:
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
else:
import __main__
if hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
if hasattr(__main__, "__file__"):
# we are running in a normal Python environment
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
else:
@@ -142,7 +139,7 @@ def user_path(*path: str) -> str:
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'):
return os.path.join(output_path.cached_path, *path)
output_path.cached_path = user_path(get_options()["general_options"]["output_path"])
@@ -195,11 +192,11 @@ def get_public_ipv4() -> str:
ip = socket.gethostbyname(socket.gethostname())
ctx = get_cert_none_ssl_context()
try:
ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx, timeout=10).read().decode("utf8").strip()
ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx).read().decode("utf8").strip()
except Exception as e:
# noinspection PyBroadException
try:
ip = urllib.request.urlopen("https://v4.ident.me", context=ctx, timeout=10).read().decode("utf8").strip()
ip = urllib.request.urlopen("https://v4.ident.me", context=ctx).read().decode("utf8").strip()
except Exception:
logging.exception(e)
pass # we could be offline, in a local game, so no point in erroring out
@@ -213,18 +210,15 @@ def get_public_ipv6() -> str:
ip = socket.gethostbyname(socket.gethostname())
ctx = get_cert_none_ssl_context()
try:
ip = urllib.request.urlopen("https://v6.ident.me", context=ctx, timeout=10).read().decode("utf8").strip()
ip = urllib.request.urlopen("https://v6.ident.me", context=ctx).read().decode("utf8").strip()
except Exception as e:
logging.exception(e)
pass # we could be offline, in a local game, or ipv6 may not be available
return ip
OptionsType = typing.Dict[str, typing.Dict[str, typing.Any]]
@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.
options = {
"general_options": {
@@ -232,21 +226,20 @@ def get_default_options() -> OptionsType:
},
"factorio_options": {
"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": {
"rom_file": "Super Metroid (JU).sfc",
"sni": "SNI",
"rom_start": True,
},
"soe_options": {
"rom_file": "Secret of Evermore (USA).sfc",
},
"lttp_options": {
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
"sni": "SNI",
"rom_start": True,
},
"server_options": {
"host": None,
@@ -260,7 +253,7 @@ def get_default_options() -> OptionsType:
"disable_item_cheat": False,
"location_check_points": 1,
"hint_cost": 10,
"release_mode": "goal",
"forfeit_mode": "goal",
"collect_mode": "disabled",
"remaining_mode": "goal",
"auto_shutdown": 0,
@@ -268,12 +261,13 @@ def get_default_options() -> OptionsType:
"log_network": 0
},
"generator": {
"teams": 1,
"enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core"),
"player_files_path": "Players",
"players": 0,
"weights_file_path": "weights.yaml",
"meta_file_path": "meta.yaml",
"spoiler": 3,
"spoiler": 2,
"glitch_triforce_room": 1,
"race": 0,
"plando_options": "bosses",
@@ -285,44 +279,18 @@ def get_default_options() -> OptionsType:
},
"oot_options": {
"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",
},
"tloz_options": {
"rom_file": "Legend of Zelda, The (U) (PRG0) [!].nes",
"sni": "SNI",
"rom_start": True,
"display_msgs": True,
},
"wargroove_options": {
"root_directory": "C:/Program Files (x86)/Steam/steamapps/common/Wargroove"
}
}
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():
new_keys = keys.copy()
new_keys.append(key)
@@ -342,9 +310,9 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> OptionsT
@cache_argsless
def get_options() -> OptionsType:
def get_options() -> dict:
filenames = ("options.yaml", "host.yaml")
locations: typing.List[str] = []
locations = []
if os.path.join(os.getcwd()) != local_path():
locations += filenames # use files from cwd only if it's not the local_path
locations += [user_path(filename) for filename in filenames]
@@ -385,7 +353,7 @@ def persistent_load() -> typing.Dict[str, dict]:
return storage
def get_adjuster_settings(game_name: str) -> typing.Dict[str, typing.Any]:
def get_adjuster_settings(game_name: str):
adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {})
return adjuster_settings
@@ -424,8 +392,7 @@ class RestrictedUnpickler(pickle.Unpickler):
# Options and Plando are unpickled by WebHost -> Generate
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
return getattr(self.generic_properties_module, name)
# pep 8 specifies that modules should have "all-lowercase names" (options, not Options)
if module.lower().endswith("options"):
if module.endswith("Options"):
if module == "Options":
mod = self.options_module
else:
@@ -465,7 +432,6 @@ loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': log
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
exception_logger: typing.Optional[str] = None):
import datetime
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
log_folder = user_path("logs")
os.makedirs(log_folder, exist_ok=True)
@@ -474,8 +440,6 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
root_logger.removeHandler(handler)
handler.close()
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(
os.path.join(log_folder, f"{name}.txt"),
write_mode,
@@ -503,25 +467,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
sys.excepthook = handle_exception
def _cleanup():
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.debug(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}"
)
logging.info(f"Archipelago ({__version__}) logging initialized.")
def stream_input(stream, queue):
@@ -670,42 +616,10 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
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: Union[str, Dict[str, Any]]) -> str:
if (not isinstance(element, str)):
element = element["title"]
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,445 +0,0 @@
from __future__ import annotations
import atexit
import os
import sys
import asyncio
import random
import shutil
from typing import Tuple, List, Iterable, Dict
from worlds.wargroove import WargrooveWorld
from worlds.wargroove.Items import item_table, faction_table, CommanderData, ItemData
import ModuleUpdate
ModuleUpdate.update()
import Utils
import json
import logging
if __name__ == "__main__":
Utils.init_logging("WargrooveClient", exception_logger="Client")
from NetUtils import NetworkItem, ClientStatus
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
CommonContext, server_loop
wg_logger = logging.getLogger("WG")
class WargrooveClientCommandProcessor(ClientCommandProcessor):
def _cmd_resync(self):
"""Manually trigger a resync."""
self.output(f"Syncing items.")
self.ctx.syncing = True
def _cmd_commander(self, *commander_name: Iterable[str]):
"""Set the current commander to the given commander."""
if commander_name:
self.ctx.set_commander(' '.join(commander_name))
else:
if self.ctx.can_choose_commander:
commanders = self.ctx.get_commanders()
wg_logger.info('Unlocked commanders: ' +
', '.join((commander.name for commander, unlocked in commanders if unlocked)))
wg_logger.info('Locked commanders: ' +
', '.join((commander.name for commander, unlocked in commanders if not unlocked)))
else:
wg_logger.error('Cannot set commanders in this game mode.')
class WargrooveContext(CommonContext):
command_processor: int = WargrooveClientCommandProcessor
game = "Wargroove"
items_handling = 0b111 # full remote
current_commander: CommanderData = faction_table["Starter"][0]
can_choose_commander: bool = False
commander_defense_boost_multiplier: int = 0
income_boost_multiplier: int = 0
starting_groove_multiplier: float
faction_item_ids = {
'Starter': 0,
'Cherrystone': 52025,
'Felheim': 52026,
'Floran': 52027,
'Heavensong': 52028,
'Requiem': 52029,
'Outlaw': 52030
}
buff_item_ids = {
'Income Boost': 52023,
'Commander Defense Boost': 52024,
}
def __init__(self, server_address, password):
super(WargrooveContext, self).__init__(server_address, password)
self.send_index: int = 0
self.syncing = False
self.awaiting_bridge = False
# self.game_communication_path: files go in this path to pass data between us and the actual game
if "appdata" in os.environ:
options = Utils.get_options()
root_directory = os.path.join(options["wargroove_options"]["root_directory"])
data_directory = os.path.join("lib", "worlds", "wargroove", "data")
dev_data_directory = os.path.join("worlds", "wargroove", "data")
appdata_wargroove = os.path.expandvars(os.path.join("%APPDATA%", "Chucklefish", "Wargroove"))
if not os.path.isfile(os.path.join(root_directory, "win64_bin", "wargroove64.exe")):
print_error_and_close("WargrooveClient couldn't find wargroove64.exe. "
"Unable to infer required game_communication_path")
self.game_communication_path = os.path.join(root_directory, "AP")
if not os.path.exists(self.game_communication_path):
os.makedirs(self.game_communication_path)
self.remove_communication_files()
atexit.register(self.remove_communication_files)
if not os.path.isdir(appdata_wargroove):
print_error_and_close("WargrooveClient couldn't find Wargoove in appdata!"
"Boot Wargroove and then close it to attempt to fix this error")
if not os.path.isdir(data_directory):
data_directory = dev_data_directory
if not os.path.isdir(data_directory):
print_error_and_close("WargrooveClient couldn't find Wargoove mod and save files in install!")
shutil.copytree(data_directory, appdata_wargroove, dirs_exist_ok=True)
else:
print_error_and_close("WargrooveClient couldn't detect system type. "
"Unable to infer required game_communication_path")
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(WargrooveContext, self).server_auth(password_requested)
await self.get_username()
await self.send_connect()
async def connection_closed(self):
await super(WargrooveContext, self).connection_closed()
self.remove_communication_files()
@property
def endpoints(self):
if self.server:
return [self.server]
else:
return []
async def shutdown(self):
await super(WargrooveContext, self).shutdown()
self.remove_communication_files()
def remove_communication_files(self):
for root, dirs, files in os.walk(self.game_communication_path):
for file in files:
os.remove(root + "/" + file)
def on_package(self, cmd: str, args: dict):
if cmd in {"Connected"}:
filename = f"AP_settings.json"
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
slot_data = args["slot_data"]
json.dump(args["slot_data"], f)
self.can_choose_commander = slot_data["can_choose_commander"]
print('can choose commander:', self.can_choose_commander)
self.starting_groove_multiplier = slot_data["starting_groove_multiplier"]
self.income_boost_multiplier = slot_data["income_boost"]
self.commander_defense_boost_multiplier = slot_data["commander_defense_boost"]
f.close()
for ss in self.checked_locations:
filename = f"send{ss}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
f.close()
self.update_commander_data()
self.ui.update_tracker()
random.seed(self.seed_name + str(self.slot))
# Our indexes start at 1 and we have 24 levels
for i in range(1, 25):
filename = f"seed{i}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
f.write(str(random.randint(0, 4294967295)))
f.close()
if cmd in {"RoomInfo"}:
self.seed_name = args["seed_name"]
if cmd in {"ReceivedItems"}:
received_ids = [item.item for item in self.items_received]
for network_item in self.items_received:
filename = f"AP_{str(network_item.item)}.item"
path = os.path.join(self.game_communication_path, filename)
# Newly-obtained items
if not os.path.isfile(path):
open(path, 'w').close()
# Announcing commander unlocks
item_name = self.item_names[network_item.item]
if item_name in faction_table.keys():
for commander in faction_table[item_name]:
logger.info(f"{commander.name} has been unlocked!")
with open(path, 'w') as f:
item_count = received_ids.count(network_item.item)
if self.buff_item_ids["Income Boost"] == network_item.item:
f.write(f"{item_count * self.income_boost_multiplier}")
elif self.buff_item_ids["Commander Defense Boost"] == network_item.item:
f.write(f"{item_count * self.commander_defense_boost_multiplier}")
else:
f.write(f"{item_count}")
f.close()
print_filename = f"AP_{str(network_item.item)}.item.print"
print_path = os.path.join(self.game_communication_path, print_filename)
if not os.path.isfile(print_path):
open(print_path, 'w').close()
with open(print_path, 'w') as f:
f.write("Received " +
self.item_names[network_item.item] +
" from " +
self.player_names[network_item.player])
f.close()
self.update_commander_data()
self.ui.update_tracker()
if cmd in {"RoomUpdate"}:
if "checked_locations" in args:
for ss in self.checked_locations:
filename = f"send{ss}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
f.close()
def run_gui(self):
"""Import kivy UI system and start running it as self.ui_task."""
from kvui import GameManager, HoverBehavior, ServerToolTip
from kivy.uix.tabbedpanel import TabbedPanelItem
from kivy.lang import Builder
from kivy.uix.button import Button
from kivy.uix.togglebutton import ToggleButton
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.gridlayout import GridLayout
from kivy.uix.image import AsyncImage, Image
from kivy.uix.stacklayout import StackLayout
from kivy.uix.label import Label
from kivy.properties import ColorProperty
from kivy.uix.image import Image
import pkgutil
class TrackerLayout(BoxLayout):
pass
class CommanderSelect(BoxLayout):
pass
class CommanderButton(ToggleButton):
pass
class FactionBox(BoxLayout):
pass
class CommanderGroup(BoxLayout):
pass
class ItemTracker(BoxLayout):
pass
class ItemLabel(Label):
pass
class WargrooveManager(GameManager):
logging_pairs = [
("Client", "Archipelago"),
("WG", "WG Console"),
]
base_title = "Archipelago Wargroove Client"
ctx: WargrooveContext
unit_tracker: ItemTracker
trigger_tracker: BoxLayout
boost_tracker: BoxLayout
commander_buttons: Dict[int, List[CommanderButton]]
tracker_items = {
"Swordsman": ItemData(None, "Unit", False),
"Dog": ItemData(None, "Unit", False),
**item_table
}
def build(self):
container = super().build()
panel = TabbedPanelItem(text="Wargroove")
panel.content = self.build_tracker()
self.tabs.add_widget(panel)
return container
def build_tracker(self) -> TrackerLayout:
try:
tracker = TrackerLayout(orientation="horizontal")
commander_select = CommanderSelect(orientation="vertical")
self.commander_buttons = {}
for faction, commanders in faction_table.items():
faction_box = FactionBox(size_hint=(None, None), width=100 * len(commanders), height=70)
commander_group = CommanderGroup()
commander_buttons = []
for commander in commanders:
commander_button = CommanderButton(text=commander.name, group="commanders")
if faction == "Starter":
commander_button.disabled = False
commander_button.bind(on_press=lambda instance: self.ctx.set_commander(instance.text))
commander_buttons.append(commander_button)
commander_group.add_widget(commander_button)
self.commander_buttons[faction] = commander_buttons
faction_box.add_widget(Label(text=faction, size_hint_x=None, pos_hint={'left': 1}, size_hint_y=None, height=10))
faction_box.add_widget(commander_group)
commander_select.add_widget(faction_box)
item_tracker = ItemTracker(padding=[0,20])
self.unit_tracker = BoxLayout(orientation="vertical")
other_tracker = BoxLayout(orientation="vertical")
self.trigger_tracker = BoxLayout(orientation="vertical")
self.boost_tracker = BoxLayout(orientation="vertical")
other_tracker.add_widget(self.trigger_tracker)
other_tracker.add_widget(self.boost_tracker)
item_tracker.add_widget(self.unit_tracker)
item_tracker.add_widget(other_tracker)
tracker.add_widget(commander_select)
tracker.add_widget(item_tracker)
self.update_tracker()
return tracker
except Exception as e:
print(e)
def update_tracker(self):
received_ids = [item.item for item in self.ctx.items_received]
for faction, item_id in self.ctx.faction_item_ids.items():
for commander_button in self.commander_buttons[faction]:
commander_button.disabled = not (faction == "Starter" or item_id in received_ids)
self.unit_tracker.clear_widgets()
self.trigger_tracker.clear_widgets()
for name, item in self.tracker_items.items():
if item.type in ("Unit", "Trigger"):
status_color = (1, 1, 1, 1) if item.code is None or item.code in received_ids else (0.6, 0.2, 0.2, 1)
label = ItemLabel(text=name, color=status_color)
if item.type == "Unit":
self.unit_tracker.add_widget(label)
else:
self.trigger_tracker.add_widget(label)
self.boost_tracker.clear_widgets()
extra_income = received_ids.count(52023) * self.ctx.income_boost_multiplier
extra_defense = received_ids.count(52024) * self.ctx.commander_defense_boost_multiplier
income_boost = ItemLabel(text="Extra Income: " + str(extra_income))
defense_boost = ItemLabel(text="Comm Defense: " + str(100 + extra_defense))
self.boost_tracker.add_widget(income_boost)
self.boost_tracker.add_widget(defense_boost)
self.ui = WargrooveManager(self)
data = pkgutil.get_data(WargrooveWorld.__module__, "Wargroove.kv").decode()
Builder.load_string(data)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def update_commander_data(self):
if self.can_choose_commander:
faction_items = 0
faction_item_names = [faction + ' Commanders' for faction in faction_table.keys()]
for network_item in self.items_received:
if self.item_names[network_item.item] in faction_item_names:
faction_items += 1
starting_groove = (faction_items - 1) * self.starting_groove_multiplier
# Must be an integer larger than 0
starting_groove = int(max(starting_groove, 0))
data = {
"commander": self.current_commander.internal_name,
"starting_groove": starting_groove
}
else:
data = {
"commander": "seed",
"starting_groove": 0
}
filename = 'commander.json'
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
json.dump(data, f)
if self.ui:
self.ui.update_tracker()
def set_commander(self, commander_name: str) -> bool:
"""Sets the current commander to the given one, if possible"""
if not self.can_choose_commander:
wg_logger.error("Cannot set commanders in this game mode.")
return
match_name = commander_name.lower()
for commander, unlocked in self.get_commanders():
if commander.name.lower() == match_name or commander.alt_name and commander.alt_name.lower() == match_name:
if unlocked:
self.current_commander = commander
self.syncing = True
wg_logger.info(f"Commander set to {commander.name}.")
self.update_commander_data()
return True
else:
wg_logger.error(f"Commander {commander.name} has not been unlocked.")
return False
else:
wg_logger.error(f"{commander_name} is not a recognized Wargroove commander.")
def get_commanders(self) -> List[Tuple[CommanderData, bool]]:
"""Gets a list of commanders with their unlocked status"""
commanders = []
received_ids = [item.item for item in self.items_received]
for faction in faction_table.keys():
unlocked = faction == 'Starter' or self.faction_item_ids[faction] in received_ids
commanders += [(commander, unlocked) for commander in faction_table[faction]]
return commanders
async def game_watcher(ctx: WargrooveContext):
from worlds.wargroove.Locations import location_table
while not ctx.exit_event.is_set():
if ctx.syncing == True:
sync_msg = [{'cmd': 'Sync'}]
if ctx.locations_checked:
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
await ctx.send_msgs(sync_msg)
ctx.syncing = False
sending = []
victory = False
for root, dirs, files in os.walk(ctx.game_communication_path):
for file in files:
if file.find("send") > -1:
st = file.split("send", -1)[1]
sending = sending+[(int(st))]
if file.find("victory") > -1:
victory = True
ctx.locations_checked = sending
message = [{"cmd": 'LocationChecks', "locations": sending}]
await ctx.send_msgs(message)
if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
await asyncio.sleep(0.1)
def print_error_and_close(msg):
logger.error("Error: " + msg)
Utils.messagebox("Error", msg, error=True)
sys.exit(1)
if __name__ == '__main__':
async def main(args):
ctx = WargrooveContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
progression_watcher = asyncio.create_task(
game_watcher(ctx), name="WargrooveProgressionWatcher")
await ctx.exit_event.wait()
ctx.server_address = None
await progression_watcher
await ctx.shutdown()
import colorama
parser = get_base_parser(description="Wargroove Client, for text interfacing.")
args, rest = parser.parse_known_args()
colorama.init()
asyncio.run(main(args))
colorama.deinit()

View File

@@ -1,4 +1,5 @@
import os
import sys
import multiprocessing
import logging
import typing
@@ -29,15 +30,10 @@ if not os.path.exists(configpath): # fall back to config.yaml in home
def get_app():
register()
app = raw_app
if os.path.exists(configpath) and not app.config["TESTING"]:
if os.path.exists(configpath):
import yaml
app.config.from_file(configpath, yaml.safe_load)
logging.info(f"Updated config from {configpath}")
if not app.config["HOST_ADDRESS"]:
logging.info("Getting public IP, as HOST_ADDRESS is empty.")
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}")
db.bind(**app.config["PONY"])
db.generate_mapping(create_tables=True)
return app

View File

@@ -1,15 +1,16 @@
import base64
import os
import socket
import uuid
import base64
import socket
from pony.flask import Pony
from flask import Flask
from flask_caching import Cache
from flask_compress import Compress
from pony.flask import Pony
from werkzeug.routing import BaseConverter
from Utils import title_sorted
from .models import *
UPLOAD_FOLDER = os.path.relpath('uploads')
LOGS_FOLDER = os.path.relpath('logs')
@@ -24,8 +25,6 @@ app.jinja_env.filters['all'] = all
app.config["SELFHOST"] = True # application process is in charge of running the websites
app.config["GENERATORS"] = 8 # maximum concurrent world gens
app.config["SELFLAUNCH"] = True # application process is in charge of launching Rooms.
app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections
app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
app.config["SELFGEN"] = True # application process is in charge of scheduling Generations.
app.config["DEBUG"] = False
app.config["PORT"] = 80
@@ -33,10 +32,8 @@ app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
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
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
# 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
# waitress uses one thread for I/O, these are for processing of views that then get sent
@@ -51,7 +48,7 @@ app.config["PONY"] = {
app.config["MAX_ROLL"] = 20
app.config["CACHE_TYPE"] = "flask_caching.backends.SimpleCache"
app.config["JSON_AS_ASCII"] = False
app.config["HOST_ADDRESS"] = ""
app.config["PATCH_TARGET"] = "archipelago.gg"
cache = Cache(app)
Compress(app)
@@ -76,10 +73,8 @@ def register():
"""Import submodules, triggering their registering on flask routing.
Note: initializes worlds subsystem."""
# has automatic patch integration
import worlds.AutoWorld
import worlds.Files
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
game_name in worlds.Files.AutoPatchRegister.patch_types
import Patch
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: game_name in Patch.AutoPatchRegister.patch_types
from WebHostLib.customserver import run_server_process
# to trigger app routing picking up on it

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
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

View File

@@ -1,15 +1,15 @@
from __future__ import annotations
import json
import logging
import json
import multiprocessing
import os
import sys
import threading
import time
import typing
from datetime import timedelta, datetime
import sys
import typing
import time
import os
from pony.orm import db_session, select, commit
from Utils import restricted_loads
@@ -177,9 +177,6 @@ class MultiworldInstance():
with guardian_lock:
multiworlds[self.room_id] = self
self.ponyconfig = config["PONY"]
self.cert = config["SELFLAUNCHCERT"]
self.key = config["SELFLAUNCHKEY"]
self.host = config["HOST_ADDRESS"]
def start(self):
if self.process and self.process.is_alive():
@@ -187,8 +184,7 @@ class MultiworldInstance():
logging.info(f"Spinning up {self.room_id}")
process = multiprocessing.Process(group=None, target=run_server_process,
args=(self.room_id, self.ponyconfig, get_static_server_data(),
self.cert, self.key, self.host),
args=(self.room_id, self.ponyconfig, get_static_server_data()),
name="MultiHost")
process.start()
# bind after start to prevent thread sync issues with guardian.

View File

@@ -1,7 +1,7 @@
import zipfile
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
@@ -12,7 +12,7 @@ def allowed_file(filename):
return filename.endswith(('.txt', ".yaml", ".zip"))
from Generate import roll_settings, PlandoOptions
from Generate import roll_settings, PlandoSettings
from Utils import parse_yamls
@@ -25,7 +25,7 @@ def check():
else:
file = request.files['file']
options = get_yaml_data(file)
if isinstance(options, str):
if type(options) == str:
flash(options)
else:
results, _ = roll_options(options)
@@ -38,7 +38,7 @@ def mysterycheck():
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 = {}
# if user does not select file, browser also
# 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:
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:
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."
@@ -69,7 +65,7 @@ def get_yaml_data(file) -> Union[Dict[str, str], str, Markup]:
def roll_options(options: Dict[str, Union[dict, str]],
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
plando_options = PlandoOptions.from_set(set(plando_options))
plando_options = PlandoSettings.from_set(set(plando_options))
results = {}
rolled_results = {}
for filename, text in options.items():

View File

@@ -10,16 +10,13 @@ import random
import socket
import threading
import time
import typing
import websockets
from pony.orm import commit, db_session, select
import Utils
from .models import db_session, Room, select, commit, Command, db
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless
from .models import Room, Command, db
class CustomClientMessageProcessor(ClientMessageProcessor):
@@ -68,6 +65,7 @@ class WebHostContext(Context):
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):
@@ -127,23 +125,21 @@ def get_random_port():
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()},
"location_name_groups": {world_name: world.location_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,
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
host: str):
def run_server_process(room_id, ponyconfig: dict, static_server_data: dict):
# establish DB connection for multidata and multisave
db.bind(**ponyconfig)
db.generate_mapping(check_tables=False)
@@ -153,33 +149,32 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
ctx = WebHostContext(static_server_data)
ctx.load(room_id)
ctx.init_save()
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
try:
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ping_timeout=None,
ping_interval=None, ssl=ssl_context)
ping_interval=None)
await ctx.server
except Exception: # likely port in use - in windows this is OSError, but I didn't check the others
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ping_timeout=None,
ping_interval=None, ssl=ssl_context)
ping_interval=None)
await ctx.server
port = 0
for wssocket in ctx.server.ws_server.sockets:
socketname = wssocket.getsockname()
if wssocket.family == socket.AF_INET6:
logging.info(f'Hosting game at [{get_public_ipv6()}]:{socketname[1]}')
# Prefer IPv4, as most users seem to not have working ipv6 support
if not port:
port = socketname[1]
elif wssocket.family == socket.AF_INET:
logging.info(f'Hosting game at {get_public_ipv4()}:{socketname[1]}')
port = socketname[1]
if port:
logging.info(f'Hosting game at {host}:{port}')
with db_session:
room = Room.get(id=ctx.room_id)
room.last_port = port
else:
logging.exception("Could not determine port. Likely hosting failure.")
with db_session:
ctx.auto_shutdown = Room.get(id=room_id).timeout
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
@@ -188,12 +183,4 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
from .autolauncher import Locker
with Locker(room_id):
try:
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
asyncio.run(main())

View File

@@ -1,13 +1,12 @@
import json
import zipfile
import json
from io import BytesIO
from flask import send_file, Response, render_template
from pony.orm import select
from worlds.Files import AutoPatchRegister
from . import app, cache
from .models import Slot, Room, Seed
from Patch import update_patch_data, preferred_endings, AutoPatchRegister
from WebHostLib import app, Slot, Room, Seed, cache
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
@@ -26,7 +25,7 @@ def download_patch(room_id, patch_id):
with zipfile.ZipFile(filelike, "a") as zf:
with zf.open("archipelago.json", "r") as f:
manifest = json.load(f)
manifest["server"] = f"{app.config['HOST_ADDRESS']}:{last_port}" if last_port else None
manifest["server"] = f"{app.config['PATCH_TARGET']}:{last_port}" if last_port else None
with zipfile.ZipFile(new_file, "w") as new_zip:
for file in zf.infolist():
if file.filename == "archipelago.json":
@@ -42,7 +41,12 @@ def download_patch(room_id, patch_id):
new_file.seek(0)
return send_file(new_file, as_attachment=True, download_name=fname)
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, download_name=fname)
@app.route("/dl_spoiler/<suuid:seed_id>")
@@ -64,7 +68,7 @@ def download_slot_file(room_id, player_id: int):
if slot_data.game == "Minecraft":
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"
data = mc_update_output(slot_data.data, server=app.config['HOST_ADDRESS'], 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)
elif slot_data.game == "Factorio":
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
@@ -72,18 +76,9 @@ def download_slot_file(room_id, player_id: int):
if name.endswith("info.json"):
fname = name.rsplit("/", 1)[0] + ".zip"
elif slot_data.game == "Ocarina of Time":
stream = io.BytesIO(slot_data.data)
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"
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
elif slot_data.game == "VVVVVV":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
elif slot_data.game == "Zillion":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apzl"
elif slot_data.game == "Super Mario 64":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
elif slot_data.game == "Dark Souls III":

View File

@@ -1,24 +1,23 @@
import json
import os
import pickle
import random
import tempfile
import random
import json
import zipfile
import concurrent.futures
from collections import Counter
from typing import Dict, Optional, Any
from Utils import __version__
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, PlandoOptions
from Main import main as ERmain
from Utils import __version__
from WebHostLib import app
from worlds.alttp.EntranceRandomizer import parse_arguments
from Main import main as ERmain
from BaseClasses import seeddigits, get_seed
from Generate import handle_name, PlandoSettings
import pickle
from .models import Generation, STATE_ERROR, STATE_QUEUED, commit, db_session, Seed, UUID
from WebHostLib import app
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
@@ -33,7 +32,7 @@ def get_meta(options_source: dict) -> dict:
server_options = {
"hint_cost": int(options_source.get("hint_cost", 10)),
"release_mode": options_source.get("release_mode", "goal"),
"forfeit_mode": options_source.get("forfeit_mode", "goal"),
"remaining_mode": options_source.get("remaining_mode", "disabled"),
"collect_mode": options_source.get("collect_mode", "disabled"),
"item_cheat": bool(int(options_source.get("item_cheat", 1))),
@@ -52,7 +51,7 @@ def generate(race=False):
else:
file = request.files['file']
options = get_yaml_data(file)
if isinstance(options, str):
if type(options) == str:
flash(options)
else:
meta = get_meta(request.form)
@@ -92,14 +91,14 @@ def generate(race=False):
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: Optional[Dict[str, Any]] = None, owner=None, sid=None):
if not meta:
meta: Dict[str, Any] = {}
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
race = meta.setdefault("race", False)
def task():
try:
target = tempfile.TemporaryDirectory()
playercount = len(gen_options)
seed = get_seed()
@@ -114,12 +113,12 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
erargs = parse_arguments(['--multi', str(playercount)])
erargs.seed = seed
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
erargs.spoiler = 0 if race else 3
erargs.spoiler = 0 if race else 2
erargs.race = race
erargs.outputname = seedname
erargs.outputpath = target.name
erargs.teams = 1
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
erargs.plando_options = PlandoSettings.from_set(meta.setdefault("plando_options",
{"bosses", "items", "connections", "texts"}))
name_counter = Counter()
@@ -139,23 +138,6 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
ERmain(erargs, seed, baked_server_options=meta["server_options"])
return upload_to_db(target.name, sid, owner, race)
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
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:
if sid:
with db_session:

View File

@@ -1,11 +1,7 @@
from datetime import timedelta, datetime
from flask import render_template
from pony.orm import count
from WebHostLib import app, cache
from .models import Room, Seed
from .models import *
from datetime import timedelta
@app.route('/', methods=['GET', 'POST'])
@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 = []
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))
if not sprite.name:

View File

@@ -1,14 +1,12 @@
import datetime
import os
from typing import List, Dict, Union
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 .models import count, Seed, commit, Room, db_session, Command, UUID, uuid4
from worlds.AutoWorld import AutoWorldRegister
from . import app, cache
from .models import Seed, Room, Command, UUID, uuid4
def get_world_theme(game_name: str):
@@ -70,6 +68,10 @@ def tutorial(game, file, lang):
@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")
@@ -149,7 +151,7 @@ def favicon():
@app.route('/discord')
def discord():
return redirect("https://discord.gg/8Z65BR2")
return redirect("https://discord.gg/archipelago")
@app.route('/datapackage')
@@ -164,9 +166,8 @@ def get_datapackage():
@app.route('/index')
@app.route('/sitemap')
def get_sitemap():
available_games: List[Dict[str, Union[str, bool]]] = []
available_games = []
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
has_settings: bool = isinstance(world.web.settings_page, bool) and world.web.settings_page
available_games.append({ 'title': game, 'has_settings': has_settings })
available_games.append(game)
return render_template("siteMap.html", games=available_games)

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from uuid import UUID, uuid4
from pony.orm import Database, PrimaryKey, Required, Set, Optional, buffer, LongStr
from pony.orm import *
db = Database()
@@ -29,7 +29,6 @@ class Room(db.Entity):
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
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)

View File

@@ -1,14 +1,13 @@
import json
import logging
import os
from Utils import __version__, local_path
from jinja2 import Template
import yaml
import json
import typing
import yaml
from jinja2 import Template
import Options
from Utils import __version__, local_path
from worlds.AutoWorld import AutoWorldRegister
import Options
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
"exclude_locations"}
@@ -16,23 +15,26 @@ handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hin
def create():
target_folder = local_path("WebHostLib", "static", "generated")
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)
os.makedirs(os.path.join(target_folder, "configs"), exist_ok=True)
def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]):
data = {option.default: 50}
for sub_option in ["random", "random-low", "random-high"]:
if sub_option != option.default:
data[sub_option] = 0
data = {}
special = getattr(option, "special_range_cutoff", None)
if special is not None:
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():
notes[name] = f"equivalent to {number}"
if number in data:
data[name] = data[number]
del data[number]
@@ -41,6 +43,11 @@ def create():
return data, notes
def default_converter(default_value):
if isinstance(default_value, (set, frozenset)):
return list(default_value)
return default_value
def get_html_doc(option_type: type(Options.Option)) -> str:
if not option_type.__doc__:
return "Please document me!"
@@ -57,21 +64,18 @@ def create():
for game_name, world in AutoWorldRegister.world_types.items():
all_options: typing.Dict[str, Options.AssembleOptions] = {
**Options.per_game_common_options,
**world.option_definitions
}
all_options = {**Options.per_game_common_options, **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,
__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", encoding="utf-8") as f:
with open(os.path.join(target_folder, 'configs', game_name + ".yaml"), "w") as f:
f.write(res)
# Generate JSON files for player-settings pages
@@ -106,6 +110,11 @@ def create():
if sub_option_id == option.default:
this_option["defaultValue"] = sub_option_name
this_option["options"].append({
"name": "Random",
"value": "random",
})
if option.default == "random":
this_option["defaultValue"] = "random"

View File

@@ -1,7 +1,7 @@
flask>=2.2.3
flask>=2.2.2
pony>=0.7.16
waitress>=2.1.2
Flask-Caching>=2.0.2
Flask-Compress>=1.13
Flask-Limiter>=3.3.0
bokeh>=3.1.0
Flask-Caching>=2.0.1
Flask-Compress>=1.12
Flask-Limiter>=2.6.2
bokeh>=2.4.3

View File

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

View File

@@ -1,18 +0,0 @@
window.addEventListener('load', () => {
const menuButton = document.getElementById('base-header-mobile-menu-button');
const mobileMenu = document.getElementById('base-header-mobile-menu');
menuButton.addEventListener('click', (evt) => {
evt.preventDefault();
if (!mobileMenu.style.display || mobileMenu.style.display === 'none') {
return mobileMenu.style.display = 'flex';
}
mobileMenu.style.display = 'none';
});
window.addEventListener('resize', () => {
mobileMenu.style.display = 'none';
});
});

View File

@@ -1,49 +0,0 @@
window.addEventListener('load', () => {
// Reload tracker every 60 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();
}, 60000)
// 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

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

View File

@@ -20,7 +20,7 @@ comfortable exploiting certain glitches in the game.
## What is a multi-world?
While a randomizer shuffles a game, a multi-world randomizer shuffles that game for multiple players. For example, in a
two player multi-world, players A and B each get their own randomized version of a game, called a world. In each player's
two player multi-world, players A and B each get their own randomized version of a game, called seeds. In each player's
game, they may find items which belong to the other player. If player A finds an item which belongs to player B, the
item will be sent to player B's world over the internet.
@@ -29,7 +29,7 @@ their game.
## What happens if a person has to leave early?
If a player must leave early, they can use Archipelago's release system. When a player releases their game, all the
If a player must leave early, they can use Archipelago's forfeit system. When a player forfeits their game, all the
items in that game which belong to other players are sent out automatically, so other players can continue to play.
## What does multi-game mean?
@@ -46,7 +46,7 @@ the website is not required to generate them.
## 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
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.
## What are some common terms I should know?

View File

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

View File

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

View File

@@ -1,6 +0,0 @@
window.addEventListener('load', () => {
$(".table-wrapper").scrollsync({
y_sync: true,
x_sync: true
});
});

View File

@@ -118,8 +118,6 @@ const buildOptionsTable = (settings, romOpts = false) => {
const tdr = document.createElement('td');
let element = null;
const randomButton = document.createElement('button');
switch(settings[setting].type){
case 'select':
element = document.createElement('div');
@@ -140,21 +138,8 @@ const buildOptionsTable = (settings, romOpts = false) => {
}
select.appendChild(option);
});
select.addEventListener('change', (event) => updateGameSetting(event.target));
select.addEventListener('change', (event) => updateGameSetting(event));
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;
case 'range':
@@ -169,29 +154,15 @@ const buildOptionsTable = (settings, romOpts = false) => {
range.value = currentSettings[gameName][setting];
range.addEventListener('change', (event) => {
document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event.target);
updateGameSetting(event);
});
element.appendChild(range);
let rangeVal = document.createElement('span');
rangeVal.classList.add('range-value');
rangeVal.setAttribute('id', `${setting}-value`);
rangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
currentSettings[gameName][setting] : settings[setting].defaultValue;
rangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
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;
case 'special_range':
@@ -205,11 +176,6 @@ const buildOptionsTable = (settings, romOpts = false) => {
let presetOption = document.createElement('option');
presetOption.innerText = presetName;
presetOption.value = settings[setting].value_names[presetName];
const words = presetOption.innerText.split("_");
for (let i = 0; i < words.length; i++) {
words[i] = words[i][0].toUpperCase() + words[i].substring(1);
}
presetOption.innerText = words.join(" ");
specialRangeSelect.appendChild(presetOption);
});
let customOption = document.createElement('option');
@@ -235,8 +201,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
let specialRangeVal = document.createElement('span');
specialRangeVal.classList.add('range-value');
specialRangeVal.setAttribute('id', `${setting}-value`);
specialRangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
currentSettings[gameName][setting] : settings[setting].defaultValue;
specialRangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
// Configure select event listener
specialRangeSelect.addEventListener('change', (event) => {
@@ -245,7 +210,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
// Update range slider
specialRange.value = event.target.value;
document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event.target);
updateGameSetting(event);
});
// Configure range event handler
@@ -255,29 +220,13 @@ const buildOptionsTable = (settings, romOpts = false) => {
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
parseInt(event.target.value) : 'custom';
document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event.target);
updateGameSetting(event);
});
element.appendChild(specialRangeSelect);
specialRangeWrapper.appendChild(specialRange);
specialRangeWrapper.appendChild(specialRangeVal);
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;
default:
@@ -294,25 +243,6 @@ const buildOptionsTable = (settings, romOpts = false) => {
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 options = JSON.parse(localStorage.getItem(gameName));
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
@@ -320,17 +250,10 @@ const updateBaseSetting = (event) => {
localStorage.setItem(gameName, JSON.stringify(options));
};
const updateGameSetting = (settingElement) => {
const updateGameSetting = (event) => {
const options = JSON.parse(localStorage.getItem(gameName));
if (settingElement.classList.contains('randomize-button')) {
// 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);
}
options[gameName][event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
event.target.value : parseInt(event.target.value, 10);
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

@@ -1,7 +1,5 @@
const adjustTableHeight = () => {
const tablesContainer = document.getElementById('tables-container');
if (!tablesContainer)
return;
const upperDistance = tablesContainer.getBoundingClientRect().top;
const containerHeight = window.innerHeight - upperDistance;
@@ -19,14 +17,6 @@ window.addEventListener('load', () => {
paging: false,
info: false,
dom: "t",
stateSave: true,
stateSaveCallback: function(settings, data) {
delete data.search;
localStorage.setItem(`DataTables_${settings.sInstance}_/tracker`, JSON.stringify(data));
},
stateLoadCallback: function(settings) {
return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`));
},
columnDefs: [
{
targets: 'hours',
@@ -73,44 +63,16 @@ window.addEventListener('load', () => {
// the tbody and render two separate tables.
});
const searchBox = document.getElementById("search");
searchBox.value = tables.search();
searchBox.focus();
searchBox.select();
const doSearch = () => {
tables.search(searchBox.value);
document.getElementById('search').addEventListener('keyup', (event) => {
tables.search(event.target.value);
console.info(tables.search());
tables.draw();
};
searchBox.addEventListener("keyup", doSearch);
window.addEventListener("keydown", (event) => {
if (!event.ctrlKey && !event.altKey && event.key.length === 1 && document.activeElement !== searchBox) {
searchBox.focus();
searchBox.select();
}
if (!event.ctrlKey && !event.altKey && !event.shiftKey && event.key === "Escape") {
if (searchBox.value !== "") {
searchBox.value = "";
doSearch();
}
searchBox.blur();
if (!document.getElementById("tables-container"))
window.scroll(0, 0);
event.preventDefault();
}
});
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 target = $("<div></div>");
console.log("Updating Tracker...");
target.load(location.href, function (response, status) {
const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
target.load("/tracker/" + tracker, function (response, status) {
if (status === "success") {
target.find(".table").each(function (i, new_table) {
const new_trs = $(new_table).find("tbody>tr");
@@ -128,14 +90,19 @@ window.addEventListener('load', () => {
console.log(response);
}
})
setTimeout(update, getSleepTimeSeconds()*1000);
}
setTimeout(update, getSleepTimeSeconds()*1000);
setInterval(update, 30000);
window.addEventListener('resize', () => {
adjustTableHeight();
tables.draw();
});
$(".table-wrapper").scrollsync({
y_sync: true,
x_sync: true
});
adjustTableHeight();
});

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -1,30 +0,0 @@
#player-tracker-wrapper{
margin: 0;
}
#inventory-table{
padding: 8px 10px 2px 6px;
background-color: #42b149;
border-radius: 4px;
border: 2px solid black;
}
#inventory-table tr.column-headers td {
font-size: 1rem;
padding: 0 5rem 0 0;
}
#inventory-table td{
padding: 0 0.5rem 0.5rem;
font-family: LexendDeca-Light, monospace;
font-size: 2.5rem;
color: #ffffff;
}
#inventory-table td img{
vertical-align: middle;
}
.hide {
display: none;
}

View File

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

View File

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

View File

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

View File

@@ -116,10 +116,6 @@ html{
flex-grow: 1;
}
#player-settings table select:disabled{
background-color: lightgray;
}
#player-settings table .range-container{
display: flex;
flex-direction: row;
@@ -142,27 +138,12 @@ html{
#player-settings table .special-range-wrapper{
display: flex;
flex-direction: row;
margin-top: 0.25rem;
}
#player-settings table .special-range-wrapper input[type=range]{
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{
display: block;
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{
padding-top: 110px;
scroll-padding-top: 100px;
scroll-behavior: smooth;
}
#base-header{
@@ -42,7 +40,7 @@ html{
margin-top: 4px;
}
#base-header a, #base-header-mobile-menu a{
#base-header a{
color: #2f6b83;
text-decoration: none;
cursor: pointer;
@@ -51,64 +49,3 @@ html{
font-family: LondrinaSolid-Light, sans-serif;
text-transform: uppercase;
}
#base-header-right-mobile{
display: none;
margin-top: 2rem;
margin-right: 1rem;
}
#base-header-mobile-menu{
display: none;
flex-direction: column;
background-color: #ffffff;
text-align: center;
overflow-y: auto;
z-index: 10000;
width: 100vw;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
position: absolute;
top: 7rem;
right: 0;
padding-top: 1rem;
}
#base-header-mobile-menu a{
padding: 4rem 2rem;
font-size: 5rem;
line-height: 5rem;
color: #699ca8;
border-top: 1px solid #d3d3d3;
}
#base-header-right-mobile img{
height: 3rem;
}
@media all and (max-width: 1580px){
html{
padding-top: 260px;
scroll-padding-top: 230px;
}
#base-header{
height: 200px;
background-size: auto 200px;
}
#base-header #site-title img{
height: calc(38px * 2);
margin-top: 30px;
margin-left: 20px;
}
#base-header-right{
display: none;
}
#base-header-right-mobile{
display: unset;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,54 +9,19 @@
border-top-left-radius: 4px;
border-top-right-radius: 4px;
padding: 3px 3px 10px;
width: 374px;
width: 384px;
background-color: #8d60a7;
}
display: grid;
grid-template-rows: repeat(5, 48px);
}
#inventory-table img{
display: block;
}
#inventory-table div.table-row{
display: grid;
grid-template-columns: repeat(5, 1fr);
}
#inventory-table div.C1{
grid-column: 1;
place-content: center;
place-items: center;
display: flex;
}
#inventory-table div.C2{
grid-column: 2;
place-content: center;
place-items: center;
display: flex;
}
#inventory-table div.C3{
grid-column: 3;
place-content: center;
place-items: center;
display: flex;
}
#inventory-table div.C4{
grid-column: 4;
place-content: center;
place-items: center;
display: flex;
}
#inventory-table div.C5{
grid-column: 5;
place-content: center;
place-items: center;
display: flex;
#inventory-table td{
width: 40px;
height: 40px;
text-align: center;
vertical-align: middle;
}
#inventory-table img{
height: 100%;
max-width: 40px;
max-height: 40px;
filter: grayscale(100%) contrast(75%) brightness(30%);
@@ -66,70 +31,11 @@
filter: none;
}
#inventory-table img.acquired.purple{ /*00FFFF*/
filter: hue-rotate(270deg) saturate(6) brightness(0.8);
}
#inventory-table img.acquired.cyan{ /*FF00FF*/
filter: hue-rotate(138deg) saturate(10) brightness(0.8);
}
#inventory-table img.acquired.green{ /*32CD32*/
filter: hue-rotate(84deg) saturate(10) brightness(0.7);
}
#inventory-table div.image-stack{
display: grid;
position: relative;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
}
#inventory-table div.image-stack div.stack-back{
grid-column: 1;
grid-row: 1;
}
#inventory-table div.image-stack div.stack-front{
grid-column: 1;
grid-row: 1;
display: grid;
grid-template-columns: 20px 20px;
grid-template-rows: 20px 20px;
}
#inventory-table div.image-stack div.stack-top-left{
grid-column: 1;
grid-row: 1;
z-index: 1;
}
#inventory-table div.image-stack div.stack-top-right{
grid-column: 2;
grid-row: 1;
z-index: 1;
}
#inventory-table div.image-stack div.stack-bottum-left{
grid-column: 1;
grid-row: 2;
z-index: 1;
}
#inventory-table div.image-stack div.stack-bottum-right{
grid-column: 2;
grid-row: 2;
z-index: 1;
}
#inventory-table div.image-stack div.stack-front img{
width: 20px;
height: 20px;
}
#inventory-table div.counted-item{
#inventory-table div.counted-item {
position: relative;
}
#inventory-table div.item-count{
#inventory-table div.item-count {
position: absolute;
color: white;
font-family: "Minecraftia", monospace;
@@ -163,16 +69,16 @@
line-height: 20px;
}
#location-table td.counter{
#location-table td.counter {
text-align: right;
font-size: 14px;
}
#location-table td.toggle-arrow{
#location-table td.toggle-arrow {
text-align: right;
}
#location-table tr#Total-header{
#location-table tr#Total-header {
font-weight: bold;
}
@@ -182,14 +88,14 @@
max-height: 30px;
}
#location-table tbody.locations{
#location-table tbody.locations {
font-size: 12px;
}
#location-table td.location-name{
#location-table td.location-name {
padding-left: 16px;
}
.hide{
.hide {
display: none;
}

View File

@@ -119,33 +119,6 @@ img.alttp-sprite {
background-color: #d3c97d;
}
#tracker-navigation {
display: inline-flex;
background-color: #b0a77d;
margin: 0.5rem;
border-radius: 4px;
}
.tracker-navigation-button {
display: block;
margin: 4px;
padding-left: 12px;
padding-right: 12px;
border-radius: 4px;
text-align: center;
font-size: 14px;
color: #000;
font-weight: lighter;
}
.tracker-navigation-button:hover {
background-color: #e2eabb !important;
}
.tracker-navigation-button.selected {
background-color: rgb(220, 226, 189);
}
@media all and (max-width: 1700px) {
table.dataTable thead th.upper-row{
position: -webkit-sticky;

View File

@@ -1,14 +1,14 @@
import typing
from collections import Counter, defaultdict
from colorsys import hsv_to_rgb
from datetime import datetime, timedelta, date
from math import tau
import typing
from bokeh.colors import RGB
from bokeh.embed import components
from bokeh.models import HoverTool
from bokeh.plotting import figure, ColumnDataSource
from bokeh.resources import INLINE
from bokeh.colors import RGB
from flask import render_template
from pony.orm import select
@@ -18,11 +18,10 @@ from .models import Room
PLOT_WIDTH = 600
def get_db_data(known_games: typing.Set[str]) -> typing.Tuple[typing.Counter[str],
typing.DefaultDict[datetime.date, typing.Dict[str, int]]]:
def get_db_data(known_games: str) -> typing.Tuple[typing.Dict[str, int], typing.Dict[datetime.date, typing.Dict[str, int]]]:
games_played = defaultdict(Counter)
total_games = Counter()
cutoff = date.today() - timedelta(days=30)
cutoff = date.today()-timedelta(days=30)
room: Room
for room in select(room for room in Room if room.creation_time >= cutoff):
for slot in room.seed.slots:
@@ -94,7 +93,7 @@ def stats():
occurences, legend_label=game, line_width=2, color=game_to_color[game])
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")],
sizing_mode="scale_both", width=PLOT_WIDTH, height=500, x_range=(-0.5, 1.2))
pie.axis.visible = False
@@ -122,8 +121,7 @@ def stats():
start_angle="start_angles", end_angle="end_angles", fill_color="colors",
source=ColumnDataSource(data=data), legend_field="games")
per_game_charts = [create_game_played_figure(games_played, game, game_to_color[game]) for game in
sorted(total_games, key=lambda game: total_games[game])
per_game_charts = [create_game_played_figure(games_played, game, game_to_color[game]) for game in total_games
if total_games[game] > 1]
script, charts = components((plot, pie, *per_game_charts))

View File

@@ -1,6 +1,7 @@
{% extends 'pageWrapper.html' %}
{% block head %}
{{ super() }}
<title>Mystery Check Result</title>
<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>

View File

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

View File

@@ -1,35 +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/checksfinderTracker.css') }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/checksfinderTracker.js') }}"></script>
</head>
<body>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr class="column-headers">
<td colspan="2">Checks Available:</td>
<td colspan="2">Map Bombs:</td>
</tr>
<tr>
<td><img alt="Checks Available" src="{{ icons['Checks Available'] }}" /></td>
<td>{{ checks_available }}</td>
<td><img alt="Bombs Remaining" src="{{ icons['Map Bombs'] }}" /></td>
<td>{{ bombs_display }}/20</td>
</tr>
<tr class="column-headers">
<td colspan="2">Map Width:</td>
<td colspan="2">Map Height:</td>
</tr>
<tr>
<td><img alt="Map Width" src="{{ icons['Map Width'] }}" /></td>
<td>{{ width_display }}/10</td>
<td><img alt="Map Height" src="{{ icons['Map Height'] }}" /></td>
<td>{{ height_display }}/10</td>
</tr>
</table>
</div>
</body>
</html>

View File

@@ -1,6 +1,7 @@
{% extends 'pageWrapper.html' %}
{% block head %}
{{ super() }}
<title>Generate Game</title>
<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>
@@ -40,20 +41,20 @@
<tbody>
<tr>
<td>
<label for="release_mode">Release Permission:
<span class="interactive" data-tooltip="Permissions on when players are able to release all remaining items from their world.">
<label for="forfeit_mode">Forfeit Permission:
<span class="interactive" data-tooltip="A forfeit releases all remaining items from the locations in your world.">
(?)
</span>
</label>
</td>
<td>
<select name="release_mode" id="release_mode">
<select name="forfeit_mode" id="forfeit_mode">
<option value="auto">Automatic on goal completion</option>
<option value="goal">Allow !release after goal completion</option>
<option value="goal">Allow !forfeit after goal completion</option>
<option value="auto-enabled">
Automatic on goal completion and manual !release
Automatic on goal completion and manual !forfeit
</option>
<option value="enabled">Manual !release</option>
<option value="enabled">Manual !forfeit</option>
<option value="disabled">Disabled</option>
</select>
</td>
@@ -62,7 +63,7 @@
<tr>
<td>
<label for="collect_mode">Collect Permission:
<span class="interactive" data-tooltip="Permissions on when players are able to collect all their remaining items from across the multiworld.">
<span class="interactive" data-tooltip="A collect releases all of your remaining items to you from across the multiworld.">
(?)
</span>
</label>

View File

@@ -4,18 +4,18 @@
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackerCommon.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tracker.js") }}"></script>
{% endblock %}
{% block body %}
{% 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">
<input placeholder="Search" id="search"/>
<span class="info">This tracker will automatically update itself periodically.</span>
</div>
<div class="table-wrapper">
<table id="received-table" class="table non-unique-item-table">
<table class="table non-unique-item-table">
<thead>
<tr>
<th>Item</th>
@@ -37,7 +37,7 @@
</table>
</div>
<div class="table-wrapper">
<table id="locations-table" class="table non-unique-item-table">
<table class="table non-unique-item-table">
<thead>
<tr>
<th>Location</th>

View File

@@ -1,6 +1,5 @@
{% block head %}
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/themes/base.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/baseHeader.js") }}"></script>
{% endblock %}
{% block header %}
@@ -17,17 +16,5 @@
<a href="/faq/en">f.a.q.</a>
<a href="https://discord.gg/8Z65BR2" target="_blank">discord</a>
</div>
<div id="base-header-right-mobile">
<a id="base-header-mobile-menu-button" href="#">
<img src="/static/static/button-images/hamburger-menu-icon.png" alt="Menu" />
</a>
</div>
</header>
<div id="base-header-mobile-menu">
<a href="/games">supported games</a>
<a href="/tutorial">setup guides</a>
<a href="/start-playing">start playing</a>
<a href="/faq/en">f.a.q.</a>
<a href="https://discord.gg/8Z65BR2" target="_blank">discord</a>
</div>
{% endblock %}

View File

@@ -1,6 +1,7 @@
{% extends 'pageWrapper.html' %}
{% block head %}
{{ super() }}
<title>Upload Multidata</title>
<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>

View File

@@ -14,22 +14,18 @@
<br />
{% endif %}
{% if room.tracker %}
This room has a <a href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}">Multiworld Tracker</a> enabled.
This room has a <a href="{{ url_for("getTracker", tracker=room.tracker) }}">Multiworld Tracker</a> enabled.
<br />
{% endif %}
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
Should you wish to continue later,
anyone can simply refresh this page and the server will resume.<br>
{% if room.last_port == -1 %}
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 %}
{% if room.last_port %}
You can connect to this room by using <span class="interactive"
data-tooltip="This means address/ip is {{ config['HOST_ADDRESS'] }} and port is {{ room.last_port }}.">
'/connect {{ config['HOST_ADDRESS'] }}:{{ 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 }}'
</span>
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>
{% endif %}
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>{% endif %}
{{ macros.list_patches_room(room) }}
{% if room.owner == session["_id"] %}
<form method=post>

View File

@@ -22,7 +22,7 @@
{% for patch in room.seed.slots|list|sort(attribute="player_id") %}
<tr>
<td>{{ patch.player_id }}</td>
<td data-tooltip="Connect via TextClient"><a href="archipelago://{{ patch.player_name | e}}:@{{ config['HOST_ADDRESS'] }}:{{ room.last_port }}">{{ patch.player_name }}</a></td>
<td data-tooltip="Connect via TextClient"><a href="archipelago://{{ patch.player_name | e}}:@{{ config['PATCH_TARGET'] }}:{{ room.last_port }}">{{ patch.player_name }}<a/></td>
<td>{{ patch.game }}</td>
<td>
{% if patch.game == "Minecraft" %}
@@ -43,14 +43,14 @@
{% elif patch.game | supports_apdeltapatch %}
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
Download Patch File...</a>
{% elif patch.game == "Dark Souls III" and patch.data %}
{% elif patch.game == "Dark Souls III" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download JSON File...</a>
{% else %}
No file to download for this game.
{% endif %}
</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>
{% endfor %}
</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['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>
<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>
</table>
<table id="location-table">

View File

@@ -1,44 +0,0 @@
{% extends "multiTracker.html" %}
{% block custom_table_headers %}
<th class="center-column">
<img src="https://wiki.factorio.com/images/thumb/Logistic_science_pack.png/32px-Logistic_science_pack.png"
alt="Logistic Science Pack">
</th>
<th class="center-column">
<img src="https://wiki.factorio.com/images/thumb/Military_science_pack.png/32px-Military_science_pack.png"
alt="Military Science Pack">
</th>
<th class="center-column">
<img src="https://wiki.factorio.com/images/thumb/Chemical_science_pack.png/32px-Chemical_science_pack.png"
alt="Chemical Science Pack">
</th>
<th class="center-column">
<img src="https://wiki.factorio.com/images/thumb/Production_science_pack.png/32px-Production_science_pack.png"
alt="Production Science Pack">
</th>
<th class="center-column">
<img src="https://wiki.factorio.com/images/thumb/Utility_science_pack.png/32px-Utility_science_pack.png"
alt="Utility Science Pack">
</th>
<th class="center-column">
<img src="https://wiki.factorio.com/images/thumb/Space_science_pack.png/32px-Space_science_pack.png"
alt="Space Science Pack">
</th>
{% endblock %}
{% block custom_table_row scoped %}
{% if games[player] == "Factorio" %}
<td class="center-column">{% if inventory[team][player][131161] or inventory[team][player][131281] %}✔{% endif %}</td>
<td class="center-column">{% if inventory[team][player][131172] or inventory[team][player][131281] > 1%}✔{% endif %}</td>
<td class="center-column">{% if inventory[team][player][131195] or inventory[team][player][131281] > 2%}✔{% endif %}</td>
<td class="center-column">{% if inventory[team][player][131240] or inventory[team][player][131281] > 3%}✔{% endif %}</td>
<td class="center-column">{% if inventory[team][player][131240] or inventory[team][player][131281] > 4%}✔{% endif %}</td>
<td class="center-column">{% if inventory[team][player][131220] or inventory[team][player][131281] > 5%}✔{% endif %}</td>
{% else %}
<td class="center-column"></td>
<td class="center-column"></td>
<td class="center-column"></td>
<td class="center-column"></td>
<td class="center-column"></td>
<td class="center-column"></td>
{% endif %}
{% endblock%}

View File

@@ -1,95 +0,0 @@
{% extends 'tablepage.html' %}
{% block head %}
{{ super() }}
<title>Multiworld Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackerCommon.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/dirtHeader.html' %}
{% include 'multiTrackerNavigation.html' %}
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<div id="tracker-header-bar">
<input placeholder="Search" id="search"/>
<span{% if not video %} hidden{% endif %} id="multi-stream-link">
<a target="_blank" href="https://multistream.me/
{%- for platform, link in video.values()|unique(False, 1)-%}
{%- if platform == "Twitch" -%}t{%- else -%}yt{%- endif -%}:{{- link -}}/
{%- endfor -%}">
Multistream
</a>
</span>
<span class="info">Clicking on a slot's number will bring up a slot-specific auto-tracker. This tracker will automatically update itself periodically.</span>
</div>
<div id="tables-container">
{% for team, players in checks_done.items() %}
<div class="table-wrapper">
<table id="checks-table" class="table non-unique-item-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Game</th>
{% block custom_table_headers %}
{# implement this block in game-specific multi trackers #}
{% endblock %}
<th class="center-column">Checks</th>
<th class="center-column">&percnt;</th>
<th class="center-column hours">Last<br>Activity</th>
</tr>
</thead>
<tbody>
{%- for player, checks in players.items() -%}
<tr>
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker,
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
<td>{{ player_names[(team, loop.index)]|e }}</td>
<td>{{ games[player] }}</td>
{% block custom_table_row scoped %}
{# implement this block in game-specific multi trackers #}
{% endblock %}
<td class="center-column">{{ checks["Total"] }}/{{ checks_in_area[player]["Total"] }}</td>
<td class="center-column">{{ percent_total_checks_done[team][player] }}</td>
{%- if activity_timers[(team, player)] -%}
<td class="center-column">{{ activity_timers[(team, player)].total_seconds() }}</td>
{%- else -%}
<td class="center-column">None</td>
{%- endif -%}
</tr>
{%- endfor -%}
</tbody>
</table>
</div>
{% endfor %}
{% for team, hints in hints.items() %}
<div class="table-wrapper">
<table id="hints-table" class="table non-unique-item-table" data-order='[[5, "asc"], [0, "asc"]]'>
<thead>
<tr>
<th>Finder</th>
<th>Receiver</th>
<th>Item</th>
<th>Location</th>
<th>Entrance</th>
<th>Found</th>
</tr>
</thead>
<tbody>
{%- for hint in hints -%}
<tr>
<td>{{ long_player_names[team, hint.finding_player] }}</td>
<td>{{ long_player_names[team, hint.receiving_player] }}</td>
<td>{{ hint.item|item_name }}</td>
<td>{{ hint.location|location_name }}</td>
<td>{% if hint.entrance %}{{ hint.entrance }}{% else %}Vanilla{% endif %}</td>
<td>{% if hint.found %}✔{% endif %}</td>
</tr>
{%- endfor -%}
</tbody>
</table>
</div>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@@ -1,9 +0,0 @@
{%- if enabled_multiworld_trackers|length > 1 -%}
<div id="tracker-navigation">
{% for enabled_tracker in enabled_multiworld_trackers %}
{% set tracker_url = url_for(enabled_tracker.endpoint, tracker=room.tracker) %}
<a class="tracker-navigation-button{%- if enabled_tracker.current -%} selected{% endif %}"
href="{{ tracker_url }}">{{ enabled_tracker.name }}</a>
{% endfor %}
</div>
{%- endif -%}

View File

@@ -1,85 +1,56 @@
# Q. What is this file?
# A. This file contains options which allow you to configure your multiworld experience while allowing
# others 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
# What is this file?
# This file contains options which allow you to configure your multiworld experience while allowing others
# to play how they want as well.
# Your name in-game. Spaces will be replaced with underscores and there is a 16-character limit.
# {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.
name: Player{number}
# How do I use it?
# 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
# Used to describe your yaml. Useful if you have multiple files.
description: Default {{ game }} Template
# I've never seen a file like this before. What characters am I allowed to use?
# 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:
version: {{ __version__ }} # Version of Archipelago required for this yaml to work as expected.
# Shared Options supported by all games:
{%- macro range_option(option) %}
# You can define additional values between the minimum and maximum values.
# Minimum value is {{ option.range_start }}
# Maximum value is {{ option.range_end }}
# you can add additional values between minimum and maximum
{%- set data, notes = dictify_range(option) %}
{%- for entry, default in data.items() %}
{{ entry }}: {{ default }}{% if notes[entry] %} # {{ notes[entry] }}{% endif %}
{%- endfor -%}
{% endmacro %}
{{ game }}:
{%- for option_key, option in options.items() %}
{{ option_key }}:
{%- 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 -%}
{{ option_key }}:{% if option.__doc__ %} # {{ option.__doc__ | replace('\n', '\n#') | indent(4, first=False) }}{% endif %}
{%- if option.range_start is defined and option.range_start is number %}
{{- range_option(option) -}}
{%- elif option.options -%}
{%- 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 %}
{%- endfor -%}
{%- if option.name_lookup[option.default] not in option.options %}
{{ option.default }}: 50
{% if option.default == "random" %}
random: 50
{%- endif -%}
{%- elif option.default is string %}
{{ option.default }}: 50
{%- elif option.default is iterable and option.default is not mapping %}
{{ option.default | list }}
{%- else %}
{{ yaml_dump(option.default) | trim | indent(4, first=false) }}
{%- endif -%}
{{ "\n" }}
{{ yaml_dump(default_converter(option.default)) | indent(4, first=False) }}
{%- endif -%}
{%- 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

@@ -29,30 +29,17 @@
<li><a href="/glossary/en">Glossary</a></li>
</ul>
<h2>Tutorials</h2>
<ul>
<li><a href="/tutorial/Archipelago/setup/en">Multiworld Setup Tutorial</a></li>
<li><a href="/tutorial/Archipelago/using_website/en">Website User Guide</a></li>
<li><a href="/tutorial/Archipelago/mac/en">Setup Guide for Mac</a></li>
<li><a href="/tutorial/Archipelago/commands/en">Server and Client Commands</a></li>
<li><a href="/tutorial/Archipelago/advanced_settings/en">Advanced YAML Guide</a></li>
<li><a href="/tutorial/Archipelago/triggers/en">Triggers Guide</a></li>
<li><a href="/tutorial/Archipelago/plando/en">Plando Guide</a></li>
</ul>
<h2>Game Info Pages</h2>
<ul>
{% for game in games | title_sorted %}
<li><a href="{{ url_for('game_info', game=game['title'], lang='en') }}">{{ game['title'] }}</a></li>
{% for game in games %}
<li><a href="{{ url_for('game_info', game=game, lang='en') }}">{{ game }}</a></li>
{% endfor %}
</ul>
<h2>Game Settings Pages</h2>
<ul>
{% for game in games | title_sorted %}
{% if game['has_settings'] %}
<li><a href="{{ url_for('player_settings', game=game['title']) }}">{{ game['title'] }}</a></li>
{% endif %}
{% for game in games %}
<li><a href="{{ url_for('player_settings', game=game) }}">{{ game }}</a></li>
{% endfor %}
</ul>
</div>

View File

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

View File

@@ -8,94 +8,79 @@
<body>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<div id="inventory-table">
<div class="table-row">
<div class="C1"><img src="{{ icons['Timespinner Wheel'] }}" class="{{ 'acquired' if 'Timespinner Wheel' in acquired_items }}" title="Timespinner Wheel" /></div>
<div class="C2"><img src="{{ icons['Timespinner Spindle'] }}" class="{{ 'acquired' if 'Timespinner Spindle' in acquired_items }}" title="Timespinner Spindle" /></div>
<div class="C3"><img src="{{ icons['Timespinner Gear 1'] }}" class="{{ 'acquired' if 'Timespinner Gear 1' in acquired_items }}" title="Timespinner Gear 1" /></div>
<div class="C4"><img src="{{ icons['Timespinner Gear 2'] }}" class="{{ 'acquired' if 'Timespinner Gear 2' in acquired_items }}" title="Timespinner Gear 2" /></div>
<div class="C5"><img src="{{ icons['Timespinner Gear 3'] }}" class="{{ 'acquired' if 'Timespinner Gear 3' in acquired_items }}" title="Timespinner Gear 3" /></div>
</div>
<div class="table-row">
<div class="C1"><img src="{{ icons['Talaria Attachment'] }}" class="{{ 'acquired' if 'Talaria Attachment' in acquired_items or 'QuickSeed' in options }}" title="Talaria Attachment" /></div>
<div class="C2"><img src="{{ icons['Succubus Hairpin'] }}" class="{{ 'acquired' if 'Succubus Hairpin' in acquired_items }}" title="Succubus Hairpin" /></div>
<div class="C3"><img src="{{ icons['Lightwall'] }}" class="{{ 'acquired' if 'Lightwall' in acquired_items }}" title="Lightwall" /></div>
<div class="C4"><img src="{{ icons['Celestial Sash'] }}" class="{{ 'acquired' if 'Celestial Sash' in acquired_items }}" title="Celestial Sash" /></div>
<div class="C5">
<div class="image-stack">
<div class="stack-back">
<img src="{{ icons['Twin Pyramid Key'] }}" class="{{ 'acquired' if 'Twin Pyramid Key' in acquired_items or 'UnchainedKeys' in options }}" title="Twin Pyramid Key" />
</div>
<div class="stack-front">
{% if 'UnchainedKeys' in options %}
{% if 'EnterSandman' in options %}
<div class="stack-top-right">
<img src="{{ icons['Twin Pyramid Key'] }}" class="green {{ 'acquired' if 'Mysterious Warp Beacon' in acquired_items }}" title="Mysterious Warp Beacon" />
</div>
{% endif %}
<div class="stack-bottum-left">
<img src="{{ icons['Twin Pyramid Key'] }}" class="cyan {{ 'acquired' if 'Timeworn Warp Beacon' in acquired_items }}" title="Timeworn Warp Beacon" />
</div>
<div class="stack-bottum-right">
<img src="{{ icons['Twin Pyramid Key'] }}" class="purple {{ 'acquired' if 'Modern Warp Beacon' in acquired_items }}" title="Modern Warp Beacon" />
</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="table-row">
<div class="C1"><img src="{{ icons['Security Keycard A'] }}" class="{{ 'acquired' if 'Security Keycard A' in acquired_items }}" title="Security Keycard A" /></div>
<div class="C2"><img src="{{ icons['Security Keycard B'] }}" class="{{ 'acquired' if 'Security Keycard B' in acquired_items }}" title="Security Keycard B" /></div>
<div class="C3"><img src="{{ icons['Security Keycard C'] }}" class="{{ 'acquired' if 'Security Keycard C' in acquired_items }}" title="Security Keycard C" /></div>
<div class="C4"><img src="{{ icons['Security Keycard D'] }}" class="{{ 'acquired' if 'Security Keycard D' in acquired_items }}" title="Security Keycard D" /></div>
{% if 'DownloadableItems' in options %}
<div class="C5"><img src="{{ icons['Library Keycard V'] }}" class="{{ 'acquired' if 'Library Keycard V' in acquired_items }}" title="Library Keycard V" /></div>
{% endif %}
</div>
<div class="table-row">
{% if 'DownloadableItems' in options %}
<div class="C1"><img src="{{ icons['Tablet'] }}" class="{{ 'acquired' if 'Tablet' in acquired_items }}" title="Tablet" /></div>
{% endif %}
<div class="C2"><img src="{{ icons['Elevator Keycard'] }}" class="{{ 'acquired' if 'Elevator Keycard' in acquired_items }}" title="Elevator Keycard" /></div>
{% if 'EyeSpy' in options %}
<div class="C3"><img src="{{ icons['Oculus Ring'] }}" class="{{ 'acquired' if 'Oculus Ring' in acquired_items }}" title="Oculus Ring" /></div>
{% endif %}
<div class="C4"><img src="{{ icons['Water Mask'] }}" class="{{ 'acquired' if 'Water Mask' in acquired_items }}" title="Water Mask" /></div>
<div class="C5"><img src="{{ icons['Gas Mask'] }}" class="{{ 'acquired' if 'Gas Mask' in acquired_items }}" title="Gas Mask" /></div>
</div>
<div class="table-row">
{% if 'GyreArchives' in options %}
<div class="C1"><img src="{{ icons['Kobo'] }}" class="{{ 'acquired' if 'Kobo' in acquired_items }}" title="Kobo" /></div>
<div class="C2"><img src="{{ icons['Merchant Crow'] }}" class="{{ 'acquired' if 'Merchant Crow' in acquired_items }}" title="Merchant Crow" /></div>
{% endif %}
<div class="C3">
{% if 'Djinn Inferno' in acquired_items %}
<img src="{{ icons['Djinn Inferno'] }}" class="acquired" title="Djinn Inferno" />
{% elif 'Pyro Ring' in acquired_items %}
<img src="{{ icons['Pyro Ring'] }}" class="acquired" title="Pyro Ring" />
{% elif 'Fire Orb' in acquired_items %}
<img src="{{ icons['Fire Orb'] }}" class="acquired" title="Fire Orb" />
{% elif 'Infernal Flames' in acquired_items %}
<img src="{{ icons['Infernal Flames'] }}" class="acquired" title="Infernal Flames" />
<table id="inventory-table">
<tr>
<td><img src="{{ icons['Timespinner Wheel'] }}" class="{{ 'acquired' if 'Timespinner Wheel' in acquired_items }}" title="Timespinner Wheel" /></td>
<td><img src="{{ icons['Timespinner Spindle'] }}" class="{{ 'acquired' if 'Timespinner Spindle' in acquired_items }}" title="Timespinner Spindle" /></td>
<td><img src="{{ icons['Timespinner Gear 1'] }}" class="{{ 'acquired' if 'Timespinner Gear 1' in acquired_items }}" title="Timespinner Gear 1" /></td>
<td><img src="{{ icons['Timespinner Gear 2'] }}" class="{{ 'acquired' if 'Timespinner Gear 2' in acquired_items }}" title="Timespinner Gear 2" /></td>
<td><img src="{{ icons['Timespinner Gear 3'] }}" class="{{ 'acquired' if 'Timespinner Gear 3' in acquired_items }}" title="Timespinner Gear 3" /></td>
</tr>
<tr>
<td><img src="{{ icons['Talaria Attachment'] }}" class="{{ 'acquired' if 'Talaria Attachment' in acquired_items or 'QuickSeed' in options }}" title="Talaria Attachment" /></td>
<td><img src="{{ icons['Succubus Hairpin'] }}" class="{{ 'acquired' if 'Succubus Hairpin' in acquired_items }}" title="Succubus Hairpin" /></td>
<td><img src="{{ icons['Lightwall'] }}" class="{{ 'acquired' if 'Lightwall' in acquired_items }}" title="Lightwall" /></td>
<td><img src="{{ icons['Celestial Sash'] }}" class="{{ 'acquired' if 'Celestial Sash' in acquired_items }}" title="Celestial Sash" /></td>
<td><img src="{{ icons['Twin Pyramid Key'] }}" class="{{ 'acquired' if 'Twin Pyramid Key' in acquired_items }}" title="Twin Pyramid Key" /></td>
</tr>
<tr>
<td><img src="{{ icons['Security Keycard A'] }}" class="{{ 'acquired' if 'Security Keycard A' in acquired_items }}" title="Security Keycard A" /></td>
<td><img src="{{ icons['Security Keycard B'] }}" class="{{ 'acquired' if 'Security Keycard B' in acquired_items }}" title="Security Keycard B" /></td>
<td><img src="{{ icons['Security Keycard C'] }}" class="{{ 'acquired' if 'Security Keycard C' in acquired_items }}" title="Security Keycard C" /></td>
<td><img src="{{ icons['Security Keycard D'] }}" class="{{ 'acquired' if 'Security Keycard D' in acquired_items }}" title="Security Keycard D" /></td>
{% if 'DownloadableItems' in options %}
<td><img src="{{ icons['Library Keycard V'] }}" class="{{ 'acquired' if 'Library Keycard V' in acquired_items }}" title="Library Keycard V" /></td>
{% else %}
<img src="{{ icons['Djinn Inferno'] }}" title="Djinn Inferno" />
{% endif %}
</div>
<div class="C4">
{% if 'Royal Ring' in acquired_items %}
<img src="{{ icons['Royal Ring'] }}" class="acquired" title="Royal Ring" />
{% elif 'Plasma Geyser' in acquired_items %}
<img src="{{ icons['Plasma Geyser'] }}" class="acquired" title="Plasma Geyser" />
{% elif 'Plasma Orb' in acquired_items %}
<img src="{{ icons['Plasma Orb'] }}" class="acquired" title="Plasma Orb" />
<td></td>
{% endif %}
</tr>
<tr>
{% if 'DownloadableItems' in options %}
<td><img src="{{ icons['Tablet'] }}" class="{{ 'acquired' if 'Tablet' in acquired_items }}" title="Tablet" /></td>
{% else %}
<img src="{{ icons['Royal Ring'] }}" title="Royal Ring" />
<td></td>
{% endif %}
<td><img src="{{ icons['Elevator Keycard'] }}" class="{{ 'acquired' if 'Elevator Keycard' in acquired_items }}" title="Elevator Keycard" /></td>
{% if 'FacebookMode' in options %}
<td><img src="{{ icons['Oculus Ring'] }}" class="{{ 'acquired' if 'Oculus Ring' in acquired_items }}" title="Oculus Ring" /></td>
{% else %}
<td></td>
{% endif %}
<td><img src="{{ icons['Water Mask'] }}" class="{{ 'acquired' if 'Water Mask' in acquired_items }}" title="Water Mask" /></td>
<td><img src="{{ icons['Gas Mask'] }}" class="{{ 'acquired' if 'Gas Mask' in acquired_items }}" title="Gas Mask" /></td>
</tr>
<tr>
{% if 'GyreArchives' in options %}
<td><img src="{{ icons['Kobo'] }}" class="{{ 'acquired' if 'Kobo' in acquired_items }}" title="Kobo" /></td>
<td><img src="{{ icons['Merchant Crow'] }}" class="{{ 'acquired' if 'Merchant Crow' in acquired_items }}" title="Merchant Crow" /></td>
{% else %}
<td></td>
<td></td>
{% endif %}
</div>
</div>
</div>
{% if 'Djinn Inferno' in acquired_items %}
<td><img src="{{ icons['Djinn Inferno'] }}" class="acquired" title="Djinn Inferno" /></td>
{% elif 'Pyro Ring' in acquired_items %}
<td><img src="{{ icons['Pyro Ring'] }}" class="acquired" title="Pyro Ring" /></td>
{% elif 'Fire Orb' in acquired_items %}
<td><img src="{{ icons['Fire Orb'] }}" class="acquired" title="Fire Orb" /></td>
{% elif 'Infernal Flames' in acquired_items %}
<td><img src="{{ icons['Infernal Flames'] }}" class="acquired" title="Infernal Flames" /></td>
{% else %}
<td><img src="{{ icons['Djinn Inferno'] }}" title="Djinn Inferno" /></td>
{% endif %}
{% if 'Royal Ring' in acquired_items %}
<td><img src="{{ icons['Royal Ring'] }}" class="acquired" title="Royal Ring" /></td>
{% elif 'Plasma Geyser' in acquired_items %}
<td><img src="{{ icons['Plasma Geyser'] }}" class="acquired" title="Plasma Geyser" /></td>
{% elif 'Plasma Orb' in acquired_items %}
<td><img src="{{ icons['Plasma Orb'] }}" class="acquired" title="Plasma Orb" /></td>
{% else %}
<td><img src="{{ icons['Royal Ring'] }}" title="Royal Ring" /></td>
{% endif %}
</tr>
</table>
<table id="location-table">
{% for area in checks_done %}
<tr class="location-category" id="{{area}}-header">

View File

@@ -1,16 +1,14 @@
{% extends 'tablepage.html' %}
{% block head %}
{{ super() }}
<title>ALttP Multiworld Tracker</title>
<title>Multiworld Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/lttpMultiTracker.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackerCommon.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tracker.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/dirtHeader.html' %}
{% include 'multiTrackerNavigation.html' %}
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<div id="tracker-header-bar">
<input placeholder="Search" id="search"/>
@@ -27,7 +25,7 @@
<div id="tables-container">
{% for team, players in inventory.items() %}
<div class="table-wrapper">
<table id="inventory-table" class="table unique-item-table">
<table class="table unique-item-table">
<thead>
<tr>
<th>#</th>
@@ -46,7 +44,7 @@
<tbody>
{%- for player, items in players.items() -%}
<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>
{%- if (team, loop.index) in video -%}
{%- if video[(team, loop.index)][0] == "Twitch" -%}
@@ -80,7 +78,7 @@
{% for team, players in checks_done.items() %}
<div class="table-wrapper">
<table id="checks-table" class="table non-unique-item-table">
<table class="table non-unique-item-table">
<thead>
<tr>
<th rowspan="2">#</th>
@@ -100,7 +98,6 @@
<th colspan="{{ colspan }}" class="center-column">{{ area }}</th>
{%- endif -%}
{%- endfor -%}
<th rowspan="2" class="center-column">&percnt;</th>
<th rowspan="2" class="center-column hours">Last<br>Activity</th>
</tr>
<tr>
@@ -124,7 +121,7 @@
<tbody>
{%- for player, checks in players.items() -%}
<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>
<td>{{ player_names[(team, loop.index)]|e }}</td>
{%- for area in ordered_areas -%}
@@ -143,7 +140,6 @@
<td class="center-column">{% if inventory[team][player][big_key_ids[area]] %}✔️{% endif %}</td>
{%- endif -%}
{%- endfor -%}
<td class="center-column">{{ percent_total_checks_done[team][player] }}</td>
{%- if activity_timers[(team, player)] -%}
<td class="center-column">{{ activity_timers[(team, player)].total_seconds() }}</td>
{%- else -%}
@@ -157,7 +153,7 @@
{% endfor %}
{% for team, hints in hints.items() %}
<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>
<tr>
<th>Finder</th>

File diff suppressed because it is too large Load Diff

View File

@@ -1,55 +1,93 @@
import base64
import json
import typing
import uuid
import zipfile
import lzma
import json
import base64
import MultiServer
import uuid
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
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 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):
if not owner:
owner = session["_id"]
infolist = zfile.infolist()
if all(file.filename.endswith((".yaml", ".yml")) or file.is_dir() for file in infolist):
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()
slots = set()
spoiler = ""
files = {}
multidata = None
# Load files.
for file in infolist:
handler = AutoPatchRegister.get_handler(file.filename)
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."
# AP Container
elif handler:
data = zfile.open(file, "r").read()
patch = handler(BytesIO(data))
raw = zfile.open(file, "r").read()
patch = handler(BytesIO(raw))
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"))
elif file.filename.endswith(".json"):
_, 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="Dark Souls III"))
# Spoiler
elif file.filename.endswith(".txt"):
spoiler = zfile.open(file, "r").read().decode("utf-8-sig")
# Multi-data
elif file.filename.endswith(".archipelago"):
try:
multidata = zfile.open(file).read()
@@ -57,36 +95,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.")
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:
decompressed_multidata = MultiServer.Context.decompress(multidata)
if "slot_info" in decompressed_multidata:
for slot, slot_info in decompressed_multidata["slot_info"].items():
# Ignore Player Groups (e.g. item links)
if slot_info.type == SlotType.group:
continue
slots.add(Slot(data=files.get(slot, None),
player_name=slot_info.name,
player_id=slot,
game=slot_info.game))
player_names = {slot.player_name for slot in slots}
leftover_names: typing.Dict[int, NetworkSlot] = {
slot_id: slot_info for slot_id, slot_info in decompressed_multidata["slot_info"].items()
if slot_info.name not in player_names and slot_info.type != SlotType.group}
newslots = [(Slot(data=None, player_name=slot_info.name, player_id=slot, game=slot_info.game))
for slot, slot_info in leftover_names.items()]
for slot in newslots:
slots.add(slot)
flush() # commit slots

View File

@@ -1,393 +0,0 @@
# Based (read: copied almost wholesale and edited) off the FF1 Client.
import asyncio
import copy
import json
import logging
import os
import subprocess
import time
import typing
from asyncio import StreamReader, StreamWriter
from typing import List
import Utils
from Utils import async_start
from worlds import lookup_any_location_id_to_name
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, ClientCommandProcessor, logger, \
get_base_parser
from worlds.tloz.Items import item_game_ids
from worlds.tloz.Locations import location_ids
from worlds.tloz import Items, Locations, Rom
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart Zelda_connector.lua"
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure Zelda_connector.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart Zelda_connector.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
DISPLAY_MSGS = True
item_ids = item_game_ids
location_ids = location_ids
items_by_id = {id: item for item, id in item_ids.items()}
locations_by_id = {id: location for location, id in location_ids.items()}
class ZeldaCommandProcessor(ClientCommandProcessor):
def _cmd_nes(self):
"""Check NES Connection State"""
if isinstance(self.ctx, ZeldaContext):
logger.info(f"NES Status: {self.ctx.nes_status}")
def _cmd_toggle_msgs(self):
"""Toggle displaying messages in bizhawk"""
global DISPLAY_MSGS
DISPLAY_MSGS = not DISPLAY_MSGS
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
class ZeldaContext(CommonContext):
command_processor = ZeldaCommandProcessor
items_handling = 0b101 # get sent remote and starting items
# Infinite Hyrule compatibility
overworld_item = 0x5F
armos_item = 0x24
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.bonus_items = []
self.nes_streams: (StreamReader, StreamWriter) = None
self.nes_sync_task = None
self.messages = {}
self.locations_array = None
self.nes_status = CONNECTION_INITIAL_STATUS
self.game = 'The Legend of Zelda'
self.awaiting_rom = False
self.shop_slots_left = 0
self.shop_slots_middle = 0
self.shop_slots_right = 0
self.shop_slots = [self.shop_slots_left, self.shop_slots_middle, self.shop_slots_right]
self.slot_data = dict()
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(ZeldaContext, self).server_auth(password_requested)
if not self.auth:
self.awaiting_rom = True
logger.info('Awaiting connection to NES to get Player information')
return
await self.send_connect()
def _set_message(self, msg: str, msg_id: int):
if DISPLAY_MSGS:
self.messages[(time.time(), msg_id)] = msg
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
self.slot_data = args.get("slot_data", {})
asyncio.create_task(parse_locations(self.locations_array, self, True))
elif cmd == 'Print':
msg = args['text']
if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID)
def on_print_json(self, args: dict):
if self.ui:
self.ui.print_json(copy.deepcopy(args["data"]))
else:
text = self.jsontotextparser(copy.deepcopy(args["data"]))
logger.info(text)
relevant = args.get("type", None) in {"Hint", "ItemSend"}
if relevant:
item = args["item"]
# goes to this world
if self.slot_concerns_self(args["receiving"]):
relevant = True
# found in this world
elif self.slot_concerns_self(item.player):
relevant = True
# not related
else:
relevant = False
if relevant:
item = args["item"]
msg = self.raw_text_parser(copy.deepcopy(args["data"]))
self._set_message(msg, item.item)
def run_gui(self):
from kvui import GameManager
class ZeldaManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Zelda 1 Client"
self.ui = ZeldaManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def get_payload(ctx: ZeldaContext):
current_time = time.time()
bonus_items = [item for item in ctx.bonus_items]
return json.dumps(
{
"items": [item.item for item in ctx.items_received],
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
if key[0] > current_time - 10},
"shops": {
"left": ctx.shop_slots_left,
"middle": ctx.shop_slots_middle,
"right": ctx.shop_slots_right
},
"bonusItems": bonus_items
}
)
def reconcile_shops(ctx: ZeldaContext):
checked_location_names = [lookup_any_location_id_to_name[location] for location in ctx.checked_locations]
shops = [location for location in checked_location_names if "Shop" in location]
left_slots = [shop for shop in shops if "Left" in shop]
middle_slots = [shop for shop in shops if "Middle" in shop]
right_slots = [shop for shop in shops if "Right" in shop]
for shop in left_slots:
ctx.shop_slots_left |= get_shop_bit_from_name(shop)
for shop in middle_slots:
ctx.shop_slots_middle |= get_shop_bit_from_name(shop)
for shop in right_slots:
ctx.shop_slots_right |= get_shop_bit_from_name(shop)
def get_shop_bit_from_name(location_name):
if "Potion" in location_name:
return Rom.potion_shop
elif "Arrow" in location_name:
return Rom.arrow_shop
elif "Shield" in location_name:
return Rom.shield_shop
elif "Ring" in location_name:
return Rom.ring_shop
elif "Candle" in location_name:
return Rom.candle_shop
elif "Take" in location_name:
return Rom.take_any
return 0 # this should never be hit
async def parse_locations(locations_array, ctx: ZeldaContext, force: bool, zone="None"):
if locations_array == ctx.locations_array and not force:
return
else:
# print("New values")
ctx.locations_array = locations_array
locations_checked = []
location = None
for location in ctx.missing_locations:
location_name = lookup_any_location_id_to_name[location]
if location_name in Locations.overworld_locations and zone == "overworld":
status = locations_array[Locations.major_location_offsets[location_name]]
if location_name == "Ocean Heart Container":
status = locations_array[ctx.overworld_item]
if location_name == "Armos Knights":
status = locations_array[ctx.armos_item]
if status & 0x10:
ctx.locations_checked.add(location)
locations_checked.append(location)
elif location_name in Locations.underworld1_locations and zone == "underworld1":
status = locations_array[Locations.floor_location_game_offsets_early[location_name]]
if status & 0x10:
ctx.locations_checked.add(location)
locations_checked.append(location)
elif location_name in Locations.underworld2_locations and zone == "underworld2":
status = locations_array[Locations.floor_location_game_offsets_late[location_name]]
if status & 0x10:
ctx.locations_checked.add(location)
locations_checked.append(location)
elif (location_name in Locations.shop_locations or "Take" in location_name) and zone == "caves":
shop_bit = get_shop_bit_from_name(location_name)
slot = 0
context_slot = 0
if "Left" in location_name:
slot = "slot1"
context_slot = 0
elif "Middle" in location_name:
slot = "slot2"
context_slot = 1
elif "Right" in location_name:
slot = "slot3"
context_slot = 2
if locations_array[slot] & shop_bit > 0:
locations_checked.append(location)
ctx.shop_slots[context_slot] |= shop_bit
if locations_array["takeAnys"] and locations_array["takeAnys"] >= 4:
if "Take Any" in location_name:
short_name = None
if "Left" in location_name:
short_name = "TakeAnyLeft"
elif "Middle" in location_name:
short_name = "TakeAnyMiddle"
elif "Right" in location_name:
short_name = "TakeAnyRight"
if short_name is not None:
item_code = ctx.slot_data[short_name]
if item_code > 0:
ctx.bonus_items.append(item_code)
locations_checked.append(location)
if locations_checked:
await ctx.send_msgs([
{"cmd": "LocationChecks",
"locations": locations_checked}
])
async def nes_sync_task(ctx: ZeldaContext):
logger.info("Starting nes connector. Use /nes for status information")
while not ctx.exit_event.is_set():
error_status = None
if ctx.nes_streams:
(reader, writer) = ctx.nes_streams
msg = get_payload(ctx).encode()
writer.write(msg)
writer.write(b'\n')
try:
await asyncio.wait_for(writer.drain(), timeout=1.5)
try:
# Data will return a dict with up to two fields:
# 1. A keepalive response of the Players Name (always)
# 2. An array representing the memory values of the locations area (if in game)
data = await asyncio.wait_for(reader.readline(), timeout=5)
data_decoded = json.loads(data.decode())
if data_decoded["overworldHC"] is not None:
ctx.overworld_item = data_decoded["overworldHC"]
if data_decoded["overworldPB"] is not None:
ctx.armos_item = data_decoded["overworldPB"]
if data_decoded['gameMode'] == 19 and ctx.finished_game == False:
await ctx.send_msgs([
{"cmd": "StatusUpdate",
"status": 30}
])
ctx.finished_game = True
if ctx.game is not None and 'overworld' in data_decoded:
# Not just a keep alive ping, parse
asyncio.create_task(parse_locations(data_decoded['overworld'], ctx, False, "overworld"))
if ctx.game is not None and 'underworld1' in data_decoded:
asyncio.create_task(parse_locations(data_decoded['underworld1'], ctx, False, "underworld1"))
if ctx.game is not None and 'underworld2' in data_decoded:
asyncio.create_task(parse_locations(data_decoded['underworld2'], ctx, False, "underworld2"))
if ctx.game is not None and 'caves' in data_decoded:
asyncio.create_task(parse_locations(data_decoded['caves'], ctx, False, "caves"))
if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '':
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate"
"the ROM using the same link but adding your slot name")
if ctx.awaiting_rom:
await ctx.server_auth(False)
reconcile_shops(ctx)
except asyncio.TimeoutError:
logger.debug("Read Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.nes_streams = None
except ConnectionResetError as e:
logger.debug("Read failed due to Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.nes_streams = None
except TimeoutError:
logger.debug("Connection Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.nes_streams = None
except ConnectionResetError:
logger.debug("Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.nes_streams = None
if ctx.nes_status == CONNECTION_TENTATIVE_STATUS:
if not error_status:
logger.info("Successfully Connected to NES")
ctx.nes_status = CONNECTION_CONNECTED_STATUS
else:
ctx.nes_status = f"Was tentatively connected but error occured: {error_status}"
elif error_status:
ctx.nes_status = error_status
logger.info("Lost connection to nes and attempting to reconnect. Use /nes for status updates")
else:
try:
logger.debug("Attempting to connect to NES")
ctx.nes_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 52980), timeout=10)
ctx.nes_status = CONNECTION_TENTATIVE_STATUS
except TimeoutError:
logger.debug("Connection Timed Out, Trying Again")
ctx.nes_status = CONNECTION_TIMING_OUT_STATUS
continue
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.nes_status = CONNECTION_REFUSED_STATUS
continue
if __name__ == '__main__':
# Text Mode to use !hint and such with games that have no text entry
Utils.init_logging("ZeldaClient")
options = Utils.get_options()
DISPLAY_MSGS = options["tloz_options"]["display_msgs"]
async def run_game(romfile: str) -> None:
auto_start = typing.cast(typing.Union[bool, str],
Utils.get_options()["tloz_options"].get("rom_start", True))
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
elif isinstance(auto_start, str) and os.path.isfile(auto_start):
subprocess.Popen([auto_start, romfile],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
async def main(args):
if args.diff_file:
import Patch
logging.info("Patch file was supplied. Creating nes rom..")
meta, romfile = Patch.create_rom_file(args.diff_file)
if "server" in meta:
args.connect = meta["server"]
logging.info(f"Wrote rom file to {romfile}")
async_start(run_game(romfile))
ctx = ZeldaContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
ctx.nes_sync_task = asyncio.create_task(nes_sync_task(ctx), name="NES Sync")
await ctx.exit_event.wait()
ctx.server_address = None
await ctx.shutdown()
if ctx.nes_sync_task:
await ctx.nes_sync_task
import colorama
parser = get_base_parser()
parser.add_argument('diff_file', default="", type=str, nargs="?",
help='Path to a Archipelago Binary Patch file')
args = parser.parse_args()
colorama.init()
asyncio.run(main(args))
colorama.deinit()

View File

@@ -1,505 +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
known_name: Optional[str]
""" This is almost the same as `auth` except `auth` is reset to `None` when server disconnects, and this isn't. """
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.known_name = None
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.known_name:
ctx.auth = name
# 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
ctx.known_name = 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()

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