Compare commits

..

1 Commits

Author SHA1 Message Date
black-sliver
53ef2aa786 setup.py: downgrade cx_freeze (#865)
Latest cx_freeze breaks tkinter/tcl on linux
2022-09-13 08:36:09 +02:00
555 changed files with 28384 additions and 80558 deletions

View File

@@ -30,7 +30,7 @@ jobs:
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
@@ -82,7 +82,7 @@ jobs:
"${{ env.PYTHON }}" -m venv venv
source venv/bin/activate
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 ..

View File

@@ -37,4 +37,4 @@ jobs:
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
- name: Unittests
run: |
pytest
pytest test

10
.gitignore vendored
View File

@@ -4,7 +4,6 @@
*_Spoiler.txt
*.bmbp
*.apbp
*.apl2ac
*.apm3
*.apmc
*.apz5
@@ -14,10 +13,6 @@
*.z64
*.n64
*.nes
*.sms
*.gb
*.gbc
*.gba
*.wixobj
*.lck
*.db3
@@ -49,7 +44,7 @@ Output Logs/
/freeze_requirements.txt
/Archipelago.zip
/setup.ini
/installdelete.iss
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -130,13 +125,12 @@ ipython_config.py
# Environments
.env
.venv*
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
.code-workspace
# Spyder project settings
.spyderproject

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,18 +44,16 @@ 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:
@@ -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
@@ -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
@@ -265,18 +237,13 @@ class CommonContext:
"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,12 +297,6 @@ class CommonContext:
return self.slot in self.slot_info[slot].group_members
return False
def is_uninteresting_item_send(self, print_json_packet: dict) -> bool:
"""Helper function for filtering out ItemSend prints that do not concern the local player."""
return print_json_packet.get("type", "") == "ItemSend" \
and not self.slot_concerns_self(print_json_packet["receiving"]) \
and not self.slot_concerns_self(print_json_packet["item"].player)
def on_print(self, args: dict):
logger.info(args["text"])
@@ -378,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:
@@ -441,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", "")
@@ -472,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:
@@ -492,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."""
@@ -535,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
@@ -548,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://")
@@ -563,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)
@@ -575,25 +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 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
@@ -704,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"]
@@ -785,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.')
@@ -799,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:
@@ -818,6 +754,7 @@ if __name__ == '__main__':
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,6 @@ 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
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -104,15 +81,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):
text = self.factorio_json_text_parser(copy.deepcopy(args["data"]))
if not text.startswith(self.player_names[self.slot] + ":"):
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
@@ -123,15 +97,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']}")
@@ -144,7 +109,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":
@@ -158,45 +123,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
@@ -214,6 +140,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():
@@ -235,7 +162,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}])
@@ -244,14 +170,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[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:
@@ -259,7 +185,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}],
@@ -269,7 +195,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}]
}]))
@@ -285,8 +211,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()
@@ -319,7 +243,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()
@@ -332,25 +256,12 @@ 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):
@@ -371,34 +282,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):
@@ -472,8 +361,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:
@@ -526,12 +413,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.")

478
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(
@@ -145,216 +136,33 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
itempool.extend(unplaced_items)
def remaining_fill(world: MultiWorld,
locations: typing.List[Location],
itempool: typing.List[Item]) -> None:
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
while locations and itempool:
item_to_place = itempool.pop()
spot_to_fill: typing.Optional[Location] = None
for i, location in enumerate(locations):
if location.item_rule(item_to_place):
# popping by index is faster than removing by content,
spot_to_fill = locations.pop(i)
# skipping a scan for the element
break
else:
# we filled all reachable spots.
# try swapping this item with previously placed items
for (i, location) in enumerate(placements):
placed_item = location.item
# Unplaceable items can sometimes be swapped infinitely. Limit the
# number of times we will swap an individual item to prevent this
if swapped_items[placed_item.player,
placed_item.name] > 1:
continue
location.item = None
placed_item.location = None
if location.item_rule(item_to_place):
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
swapped_items[placed_item.player,
placed_item.name] += 1
itempool.append(placed_item)
break
# Item can't be placed here, restore original item
location.item = placed_item
placed_item.location = location
if spot_to_fill is None:
# Can't place this item, move on to the next
unplaced_items.append(item_to_place)
continue
world.push_item(spot_to_fill, item_to_place, False)
placements.append(spot_to_fill)
if unplaced_items and locations:
# There are leftover unplaceable items and locations that won't accept them
raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
itempool.extend(unplaced_items)
def fast_fill(world: MultiWorld,
item_pool: typing.List[Item],
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
placing = min(len(item_pool), len(fill_locations))
for item, location in zip(item_pool, fill_locations):
world.push_item(location, item, False)
return item_pool[placing:], fill_locations[placing:]
def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]):
maximum_exploration_state = sweep_from_pool(state, pool)
minimal_players = {player for player in world.player_ids if world.accessibility[player] == "minimal"}
unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and
not location.can_reach(maximum_exploration_state)]
for location in unreachable_locations:
if (location.item is not None and location.item.advancement and location.address is not None and not
location.locked and location.item.player not in minimal_players):
pool.append(location.item)
state.remove(location.item)
location.item = None
location.event = False
if location in state.events:
state.events.remove(location)
locations.append(location)
if pool and locations:
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
fill_restrictive(world, state, locations, pool)
def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations):
maximum_exploration_state = sweep_from_pool(state)
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
if unreachable_locations:
def forbid_important_item_rule(item: Item):
return not ((item.classification & 0b0011) and world.accessibility[item.player] != 'minimal')
for location in unreachable_locations:
add_item_rule(location, forbid_important_item_rule)
def distribute_early_items(world: MultiWorld,
fill_locations: typing.List[Location],
itempool: typing.List[Item]) -> typing.Tuple[typing.List[Location], typing.List[Item]]:
""" returns new fill_locations and itempool """
early_items_count: typing.Dict[typing.Tuple[str, int], typing.List[int]] = {}
for player in world.player_ids:
items = itertools.chain(world.early_items[player], world.local_early_items[player])
for item in items:
early_items_count[item, player] = [world.early_items[player].get(item, 0),
world.local_early_items[player].get(item, 0)]
if early_items_count:
early_locations: typing.List[Location] = []
early_priority_locations: typing.List[Location] = []
loc_indexes_to_remove: typing.Set[int] = set()
base_state = world.state.copy()
base_state.sweep_for_events(locations=(loc for loc in world.get_filled_locations() if loc.address is None))
for i, loc in enumerate(fill_locations):
if loc.can_reach(base_state):
if loc.progress_type == LocationProgressType.PRIORITY:
early_priority_locations.append(loc)
else:
early_locations.append(loc)
loc_indexes_to_remove.add(i)
fill_locations = [loc for i, loc in enumerate(fill_locations) if i not in loc_indexes_to_remove]
early_prog_items: typing.List[Item] = []
early_rest_items: typing.List[Item] = []
early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
item_indexes_to_remove: typing.Set[int] = set()
for i, item in enumerate(itempool):
if (item.name, item.player) in early_items_count:
if item.advancement:
if early_items_count[item.name, item.player][1]:
early_local_prog_items[item.player].append(item)
early_items_count[item.name, item.player][1] -= 1
else:
early_prog_items.append(item)
early_items_count[item.name, item.player][0] -= 1
else:
if early_items_count[item.name, item.player][1]:
early_local_rest_items[item.player].append(item)
early_items_count[item.name, item.player][1] -= 1
else:
early_rest_items.append(item)
early_items_count[item.name, item.player][0] -= 1
item_indexes_to_remove.add(i)
if early_items_count[item.name, item.player] == [0, 0]:
del early_items_count[item.name, item.player]
if len(early_items_count) == 0:
break
itempool = [item for i, item in enumerate(itempool) if i not in item_indexes_to_remove]
for player in world.player_ids:
player_local = early_local_rest_items[player]
fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player],
player_local, lock=True, allow_partial=True)
if player_local:
logging.warning(f"Could not fulfill rules of early items: {player_local}")
early_rest_items.extend(early_local_rest_items[player])
early_locations = [loc for loc in early_locations if not loc.item]
fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True, allow_partial=True)
early_locations += early_priority_locations
for player in world.player_ids:
player_local = early_local_prog_items[player]
fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player],
player_local, lock=True, allow_partial=True)
if player_local:
logging.warning(f"Could not fulfill rules of early items: {player_local}")
early_prog_items.extend(player_local)
early_locations = [loc for loc in early_locations if not loc.item]
fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True, allow_partial=True)
unplaced_early_items = early_rest_items + early_prog_items
if unplaced_early_items:
logging.warning("Ran out of early locations for early items. Failed to place "
f"{unplaced_early_items} early.")
itempool += unplaced_early_items
fill_locations.extend(early_locations)
world.random.shuffle(fill_locations)
return fill_locations, itempool
def distribute_items_restrictive(world: MultiWorld) -> None:
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] = []
nonexcludeditempool: typing.List[Item] = []
localrestitempool: typing.Dict[int, typing.List[Item]] = {player: [] for player in range(1, world.players + 1)}
nonlocalrestitempool: typing.List[Item] = []
restitempool: typing.List[Item] = []
for item in itempool:
if item.advancement:
progitempool.append(item)
elif item.useful:
usefulitempool.append(item)
elif item.useful: # this only gets nonprogression items which should not appear in excluded locations
nonexcludeditempool.append(item)
elif item.name in world.local_items[item.player].value:
localrestitempool[item.player].append(item)
elif item.name in world.non_local_items[item.player].value:
nonlocalrestitempool.append(item)
else:
filleritempool.append(item)
restitempool.append(item)
call_all(world, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations)
call_all(world, "fill_hook", progitempool, nonexcludeditempool,
localrestitempool, nonlocalrestitempool, restitempool, fill_locations)
locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
loc_type: [] for loc_type in LocationProgressType}
@@ -366,44 +174,60 @@ 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
if nonexcludeditempool:
world.random.shuffle(defaultlocations)
# needs logical fill to not conflict with local items
fill_restrictive(
world, world.state, defaultlocations, nonexcludeditempool)
if nonexcludeditempool:
raise FillError(
f'Not enough locations for non-excluded items. There are {len(nonexcludeditempool)} more items than locations')
inaccessible_location_rules(world, world.state, defaultlocations)
defaultlocations = defaultlocations + excludedlocations
world.random.shuffle(defaultlocations)
remaining_fill(world, excludedlocations, filleritempool)
if excludedlocations:
raise FillError(
f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items")
if any(localrestitempool.values()): # we need to make sure some fills are limited to certain worlds
local_locations: typing.Dict[int, typing.List[Location]] = {player: [] for player in world.player_ids}
for location in defaultlocations:
local_locations[location.player].append(location)
for player_locations in local_locations.values():
world.random.shuffle(player_locations)
restitempool = usefulitempool + filleritempool
for player, items in localrestitempool.items(): # items already shuffled
player_local_locations = local_locations[player]
for item_to_place in items:
if not player_local_locations:
logging.warning(f"Ran out of local locations for player {player}, "
f"cannot place {item_to_place}.")
break
spot_to_fill = player_local_locations.pop()
world.push_item(spot_to_fill, item_to_place, False)
defaultlocations.remove(spot_to_fill)
remaining_fill(world, defaultlocations, restitempool)
for item_to_place in nonlocalrestitempool:
for i, location in enumerate(defaultlocations):
if location.player != item_to_place.player:
world.push_item(defaultlocations.pop(i), item_to_place, False)
break
else:
raise Exception(f"Could not place non_local_item {item_to_place} among {defaultlocations}. "
f"Too many non-local items for too few remaining locations.")
unplaced = restitempool
world.random.shuffle(defaultlocations)
restitempool, defaultlocations = fast_fill(
world, restitempool, defaultlocations)
unplaced = progitempool + restitempool
unfilled = defaultlocations
if unplaced or unfilled:
@@ -417,6 +241,15 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
logging.info(f'Per-Player counts: {print_data})')
def fast_fill(world: MultiWorld,
item_pool: typing.List[Item],
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
placing = min(len(item_pool), len(fill_locations))
for item, location in zip(item_pool, fill_locations):
world.push_item(location, item, False)
return item_pool[placing:], fill_locations[placing:]
def flood_items(world: MultiWorld) -> None:
# get items to distribute
world.random.shuffle(world.itempool)
@@ -693,17 +526,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 +541,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 +578,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,11 +613,38 @@ 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(location for location in world.get_unfilled_locations_for_players(locations,
worlds))

View File

@@ -23,6 +23,7 @@ from worlds.alttp.EntranceRandomizer import parse_arguments
from Main import main as ERmain
from BaseClasses import seeddigits, get_seed
import Options
from worlds.alttp import Bosses
from worlds.alttp.Text import TextTable
from worlds.AutoWorld import AutoWorldRegister
import copy
@@ -154,12 +155,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: "
@@ -233,8 +233,8 @@ def main(args=None, callback=ERmain):
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
@@ -317,11 +317,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]
@@ -337,6 +337,19 @@ def prefer_int(input_data: str) -> Union[str, int]:
return input_data
available_boss_names: Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
{'Agahnim', 'Agahnim2', 'Ganon'}}
available_boss_locations: Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in
Bosses.boss_location_table}
boss_shuffle_options = {None: 'none',
'none': 'none',
'basic': 'basic',
'full': 'full',
'chaos': 'chaos',
'singularity': 'singularity'
}
goals = {
'ganon': 'ganon',
'crystals': 'crystals',
@@ -378,7 +391,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",
@@ -443,7 +456,42 @@ 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: PlandoSettings):
def get_plando_bosses(boss_shuffle: str, plando_options: Set[str]) -> str:
if boss_shuffle in boss_shuffle_options:
return boss_shuffle_options[boss_shuffle]
elif PlandoSettings.bosses in plando_options:
options = boss_shuffle.lower().split(";")
remainder_shuffle = "none" # vanilla
bosses = []
for boss in options:
if boss in boss_shuffle_options:
remainder_shuffle = boss_shuffle_options[boss]
elif "-" in boss:
loc, boss_name = boss.split("-")
if boss_name not in available_boss_names:
raise ValueError(f"Unknown Boss name {boss_name}")
if loc not in available_boss_locations:
raise ValueError(f"Unknown Boss Location {loc}")
level = ''
if loc.split(" ")[-1] in {"top", "middle", "bottom"}:
# split off level
loc = loc.split(" ")
level = f" {loc[-1]}"
loc = " ".join(loc[:-1])
loc = loc.title().replace("Of", "of")
if not Bosses.can_place_boss(boss_name.title(), loc, level):
raise ValueError(f"Cannot place {boss_name} at {loc}{level}")
bosses.append(boss)
elif boss not in available_boss_names:
raise ValueError(f"Unknown Boss name or Boss shuffle option {boss}.")
else:
bosses.append(boss)
return ";".join(bosses + [remainder_shuffle])
else:
raise Exception(f"Boss Shuffle {boss_shuffle} is unknown and boss plando is turned off.")
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option)):
if option_key in game_weights:
try:
if not option.supports_weighting:
@@ -454,9 +502,10 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
except Exception as e:
raise Exception(f"Error generating option {option_key} in {ret.game}") from e
else:
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
if hasattr(player_option, "verify"):
player_option.verify(AutoWorldRegister.world_types[ret.game])
else:
setattr(ret, option_key, option.from_any(option.default)) # call the from_any here to support default "random"
setattr(ret, option_key, option(option.default))
def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings.bosses):
@@ -500,12 +549,11 @@ def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings
if ret.game in AutoWorldRegister.world_types:
for option_key, option in world_type.option_definitions.items():
handle_option(ret, game_weights, option_key, option, plando_options)
handle_option(ret, game_weights, option_key, option)
for option_key, option in Options.per_game_common_options.items():
# 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):
handle_option(ret, game_weights, option_key, option, plando_options)
if not (option_key in Options.common_options and option_key not in game_weights):
handle_option(ret, game_weights, option_key, option)
if PlandoSettings.items in plando_options:
ret.plando_items = game_weights.get("plando_items", [])
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
@@ -588,6 +636,8 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
ret.item_functionality = get_choice_legacy('item_functionality', weights)
boss_shuffle = get_choice_legacy('boss_shuffle', weights)
ret.shufflebosses = get_plando_bosses(boss_shuffle, plando_options)
ret.enemy_damage = {None: 'default',
'default': 'default',

View File

@@ -132,7 +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')),
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3')),
Component('LttP Adjuster', 'LttPAdjuster'),
# Factorio
Component('Factorio Client', 'FactorioClient'),
@@ -145,15 +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')),
# ChecksFinder
Component('ChecksFinder Client', 'ChecksFinderClient'),
# Starcraft 2
Component('Starcraft 2 Client', 'Starcraft2Client'),
# 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,9 +26,7 @@ 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):
@@ -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)

292
Main.py
View File

@@ -1,4 +1,5 @@
import collections
from itertools import zip_longest, chain
import logging
import os
import time
@@ -7,15 +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, RegionType, LocationProgressType, Location
import worlds
from worlds.alttp.Regions import is_main_entrance
from BaseClasses import MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location
from worlds.alttp.Items import item_name_groups
from worlds.alttp.Regions import lookup_vanilla_location_to_entrance
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
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 = (
@@ -79,30 +80,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
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")
@@ -115,6 +101,19 @@ 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")
@@ -122,14 +121,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
AutoWorld.call_all(world, "create_items")
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()
@@ -146,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:
@@ -159,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
@@ -171,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)
@@ -209,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)])
@@ -260,9 +249,24 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
output_file_futures.append(
pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
def get_entrance_to_region(region: Region):
for entrance in region.entrances:
if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld, RegionType.Generic):
return entrance
for entrance in region.entrances: # BFS might be better here, trying DFS for now.
return get_entrance_to_region(entrance.parent_region)
# collect ER hint info
er_hint_data: Dict[int, Dict[int, str]] = {}
AutoWorld.call_all(world, 'extend_hint_information', er_hint_data)
er_hint_data = {player: {} for player in world.get_game_players("A Link to the Past") if
world.shuffle[player] != "vanilla" or world.retro_caves[player]}
for region in world.regions:
if region.player in er_hint_data and region.locations:
main_entrance = get_entrance_to_region(region)
for location in region.locations:
if type(location.address) == int: # skips events and crystals
if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name:
er_hint_data[region.player][location.address] = main_entrance.name
checks_in_area = {player: {area: list() for area in ordered_areas}
for player in range(1, world.players + 1)}
@@ -272,23 +276,22 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for location in world.get_filled_locations():
if type(location.address) is int:
main_entrance = get_entrance_to_region(location.parent_region)
if location.game != "A Link to the Past":
checks_in_area[location.player]["Light World"].append(location.address)
else:
main_entrance = location.parent_region.get_connecting_entrance(is_main_entrance)
if location.parent_region.dungeon:
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
'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 == RegionType.LightWorld:
checks_in_area[location.player]["Light World"].append(location.address)
elif location.parent_region.type == RegionType.DarkWorld:
checks_in_area[location.player]["Dark World"].append(location.address)
elif main_entrance.parent_region.type == RegionType.LightWorld:
checks_in_area[location.player]["Light World"].append(location.address)
elif main_entrance.parent_region.type == RegionType.DarkWorld:
checks_in_area[location.player]["Dark World"].append(location.address)
elif location.parent_region.dungeon:
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
'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 == RegionType.LightWorld:
checks_in_area[location.player]["Light World"].append(location.address)
elif location.parent_region.type == RegionType.DarkWorld:
checks_in_area[location.player]["Dark World"].append(location.address)
elif main_entrance.parent_region.type == RegionType.LightWorld:
checks_in_area[location.player]["Light World"].append(location.address)
elif main_entrance.parent_region.type == RegionType.DarkWorld:
checks_in_area[location.player]["Dark World"].append(location.address)
checks_in_area[location.player]["Total"] += 1
oldmancaves = []
@@ -302,7 +305,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
player = region.player
location_id = SHOP_ID_START + total_shop_slots + index
main_entrance = region.get_connecting_entrance(is_main_entrance)
main_entrance = get_entrance_to_region(region)
if main_entrance.parent_region.type == RegionType.LightWorld:
checks_in_area[player]["Light World"].append(location_id)
else:
@@ -337,6 +340,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player, world_precollected in world.precollected_items.items()}
precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))}
for slot in world.player_ids:
slot_data[slot] = world.worlds[slot].fill_slot_data()
@@ -367,19 +371,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
multidata = {
"slot_data": slot_data,
"slot_info": slot_info,
"names": names, # TODO: remove around 0.2.5 in favor of slot_info
"games": games, # TODO: remove around 0.2.5 in favor of slot_info
"connect_names": {name: (0, player) for player, name in world.player_name.items()},
"remote_items": {player for player in world.player_ids if
world.worlds[player].remote_items},
"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,
@@ -389,8 +390,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)
@@ -416,7 +416,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))
@@ -430,3 +430,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

@@ -13,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():
@@ -39,25 +37,11 @@ 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 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]
if any(compare in egg for compare in ("==", ">=", ">", "<", "<=", "!=")):
line = egg
else:
egg = ""
if "@" in rest and not line:
raise ValueError("Can't deduce version from requirement")
elif not line:
# from filename
rest = rest.replace(".zip", "-").replace(".tar.gz", "-")
name, version, _ = rest.split("-", 2)
line = f'{egg or name}=={version}'
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 requirements:
requirement = str(requirement)

View File

@@ -16,7 +16,6 @@ import pickle
import itertools
import time
import operator
import hashlib
import ModuleUpdate
@@ -32,7 +31,7 @@ except ImportError:
import NetUtils
import Utils
from Utils import version_tuple, restricted_loads, Version, async_start
from Utils import version_tuple, restricted_loads, Version
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
SlotType
@@ -59,12 +58,6 @@ modify_functions = {
}
def get_saving_second(seed_name: str, interval: int = 60) -> int:
# save at expected times so other systems using savegame can expect it
# represents the target second of the auto_save_interval at which to save
return int(hashlib.sha256(seed_name.encode()).hexdigest(), 16) % interval
class Client(Endpoint):
version = Version(0, 0, 0)
tags: typing.List[str] = []
@@ -127,15 +120,12 @@ class Context:
groups: typing.Dict[int, typing.Set[int]]
save_version = 2
stored_data: typing.Dict[str, object]
read_data: typing.Dict[str, object]
stored_data_notification_clients: typing.Dict[str, typing.Set[Client]]
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
forced_auto_forfeits: typing.Dict[str, bool]
non_hintable_names: typing.Dict[str, typing.Set[str]]
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
hint_cost: int, item_cheat: bool, forfeit_mode: str = "disabled", collect_mode="disabled",
@@ -155,6 +145,8 @@ class Context:
self.player_name_lookup: typing.Dict[str, team_slot] = {}
self.connect_names = {} # names of slots clients can connect to
self.allow_forfeits = {}
self.remote_items = set()
self.remote_start_inventory = set()
# player location_id item_id target_player_id
self.locations = {}
self.host = host
@@ -198,16 +190,16 @@ class Context:
self.random = random.Random()
self.stored_data = {}
self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet)
self.read_data = {}
# init empty to satisfy linter, I suppose
self.gamespackage = {}
self.item_name_groups = {}
self.all_item_and_group_names = {}
self.forced_auto_forfeits = collections.defaultdict(lambda: False)
self.non_hintable_names = collections.defaultdict(frozenset)
self.non_hintable_names = {}
self._load_game_data()
self._init_game_data()
# Datapackage retrieval
def _load_game_data(self):
@@ -229,11 +221,11 @@ class Context:
self.all_item_and_group_names[game_name] = \
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None
def item_names_for_game(self, game: str) -> typing.Dict[str, int]:
return self.gamespackage[game]["item_name_to_id"]
def location_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
return self.gamespackage[game]["location_name_to_id"] if game in self.gamespackage else None
def location_names_for_game(self, game: str) -> typing.Dict[str, int]:
return self.gamespackage[game]["location_name_to_id"]
# General networking
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bool:
@@ -280,16 +272,16 @@ class Context:
def broadcast_all(self, msgs: typing.List[dict]):
msgs = self.dumper(msgs)
endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth)
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
asyncio.create_task(self.broadcast_send_encoded_msgs(endpoints, msgs))
def broadcast_team(self, team: int, msgs: typing.List[dict]):
msgs = self.dumper(msgs)
endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values()))
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
asyncio.create_task(self.broadcast_send_encoded_msgs(endpoints, msgs))
def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
msgs = self.dumper(msgs)
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
asyncio.create_task(self.broadcast_send_encoded_msgs(endpoints, msgs))
async def disconnect(self, endpoint: Client):
if endpoint in self.endpoints:
@@ -309,18 +301,18 @@ class Context:
return
logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
if client.version >= print_command_compatability_threshold:
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }]}]))
asyncio.create_task(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }]}]))
else:
async_start(self.send_msgs(client, [{"cmd": "Print", "text": text}]))
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text}]))
def notify_client_multiple(self, client: Client, texts: typing.List[str]):
if not client.auth:
return
if client.version >= print_command_compatability_threshold:
async_start(self.send_msgs(client,
asyncio.create_task(self.send_msgs(client,
[{"cmd": "PrintJSON", "data": [{ "text": text }]} for text in texts]))
else:
async_start(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts]))
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts]))
# loading
@@ -349,7 +341,7 @@ class Context:
return restricted_loads(zlib.decompress(data[1:]))
def _load(self, decoded_obj: dict, use_embedded_server_options: bool):
self.read_data = {}
mdata_ver = decoded_obj["minimum_versions"]["server"]
if mdata_ver > Utils.version_tuple:
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
@@ -366,15 +358,13 @@ class Context:
self.clients[team][player] = []
self.player_names[team, player] = name
self.player_name_lookup[name] = team, player
self.read_data[f"hints_{team}_{player}"] = lambda local_team=team, local_player=player: \
list(self.get_rechecked_hints(local_team, local_player))
self.seed_name = decoded_obj["seed_name"]
self.random.seed(self.seed_name)
self.connect_names = decoded_obj['connect_names']
self.remote_items = decoded_obj['remote_items']
self.remote_start_inventory = decoded_obj.get('remote_start_inventory', decoded_obj['remote_items'])
self.locations = decoded_obj['locations']
self.slot_data = decoded_obj['slot_data']
for slot, data in self.slot_data.items():
self.read_data[f"slot_data_{slot}"] = lambda data=data: data
self.er_hint_data = {int(player): {int(address): name for address, name in loc_data.items()}
for player, loc_data in decoded_obj["er_hint_data"].items()}
@@ -415,16 +405,6 @@ class Context:
server_options = decoded_obj.get("server_options", {})
self._set_options(server_options)
# custom datapackage
for game_name, data in decoded_obj.get("datapackage", {}).items():
logging.info(f"Loading custom datapackage for game {game_name}")
self.gamespackage[game_name] = data
self.item_name_groups[game_name] = data["item_name_groups"]
del data["item_name_groups"] # remove from datapackage, but keep in self.item_name_groups
self._init_game_data()
for game_name, data in self.item_name_groups.items():
self.read_data[f"item_name_groups_{game_name}"] = lambda lgame=game_name: self.item_name_groups[lgame]
# saving
def save(self, now=False) -> bool:
@@ -470,16 +450,10 @@ class Context:
def _start_async_saving(self):
if not self.auto_saver_thread:
def save_regularly():
# time.time() is platform dependent, so using the expensive datetime method instead
def get_datetime_second():
now = datetime.datetime.now()
return now.second + now.microsecond * 0.000001
second = get_saving_second(self.seed_name, self.auto_save_interval)
import time
while not self.exit_event.is_set():
try:
next_wakeup = (second - get_datetime_second()) % self.auto_save_interval
time.sleep(max(1.0, next_wakeup))
time.sleep(self.auto_save_interval)
if self.save_dirty:
logging.debug("Saving via thread.")
self._save()
@@ -557,7 +531,7 @@ class Context:
if "stored_data" in savedata:
self.stored_data = savedata["stored_data"]
# count items and slots from lists for items_handling = remote
# count items and slots from lists for item_handling = remote
logging.info(
f'Loaded save file with {sum([len(v) for k, v in self.received_items.items() if k[2]])} received items '
f'for {sum(k[2] for k in self.received_items)} players')
@@ -569,17 +543,12 @@ class Context:
return max(0, int(self.hint_cost * 0.01 * len(self.locations[slot])))
return 0
def recheck_hints(self, team: typing.Optional[int] = None, slot: typing.Optional[int] = None):
for hint_team, hint_slot in self.hints:
if (team is None or team == hint_team) and (slot is None or slot == hint_slot):
self.hints[hint_team, hint_slot] = {
hint.re_check(self, hint_team) for hint in
self.hints[hint_team, hint_slot]
}
def get_rechecked_hints(self, team: int, slot: int):
self.recheck_hints(team, slot)
return self.hints[team, slot]
def recheck_hints(self):
for team, slot in self.hints:
self.hints[team, slot] = {
hint.re_check(self, team) for hint in
self.hints[team, slot]
}
def get_players_package(self):
return [NetworkPlayer(t, p, self.get_aliased_name(t, p), n) for (t, p), n in self.player_names.items()]
@@ -614,44 +583,6 @@ class Context:
else:
return self.player_names[team, slot]
def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False):
"""Send and remember hints."""
if only_new:
hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]]
if not hints:
return
new_hint_events: typing.Set[int] = set()
concerns = collections.defaultdict(list)
for hint in sorted(hints, key=operator.attrgetter('found'), reverse=True):
data = (hint, hint.as_network_message())
for player in self.slot_set(hint.receiving_player):
concerns[player].append(data)
if not hint.local and data not in concerns[hint.finding_player]:
concerns[hint.finding_player].append(data)
# remember hints in all cases
if not hint.found:
# since hints are bidirectional, finding player and receiving player,
# we can check once if hint already exists
if hint not in self.hints[team, hint.finding_player]:
self.hints[team, hint.finding_player].add(hint)
new_hint_events.add(hint.finding_player)
for player in self.slot_set(hint.receiving_player):
self.hints[team, player].add(hint)
new_hint_events.add(player)
logging.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint)))
for slot in new_hint_events:
self.on_new_hint(team, slot)
for slot, hint_data in concerns.items():
clients = self.clients[team].get(slot)
if not clients:
continue
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
for client in clients:
async_start(self.send_msgs(client, client_hints))
# "events"
def on_goal_achieved(self, client: Client):
finished_msg = f'{self.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1})' \
f' has completed their goal.'
@@ -664,11 +595,38 @@ class Context:
forfeit_player(self, client.team, client.slot)
self.save() # save goal completion flag
def on_new_hint(self, team: int, slot: int):
key: str = f"_read_hints_{team}_{slot}"
targets: typing.Set[Client] = set(self.stored_data_notification_clients[key])
if targets:
self.broadcast(targets, [{"cmd": "SetReply", "key": key, "value": self.hints[team, slot]}])
def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False):
"""Send and remember hints."""
if only_new:
hints = [hint for hint in hints if hint not in ctx.hints[team, hint.finding_player]]
if not hints:
return
concerns = collections.defaultdict(list)
for hint in sorted(hints, key=operator.attrgetter('found'), reverse=True):
data = (hint, hint.as_network_message())
for player in ctx.slot_set(hint.receiving_player):
concerns[player].append(data)
if not hint.local and data not in concerns[hint.finding_player]:
concerns[hint.finding_player].append(data)
# remember hints in all cases
if not hint.found:
# since hints are bidirectional, finding player and receiving player,
# we can check once if hint already exists
if hint not in ctx.hints[team, hint.finding_player]:
ctx.hints[team, hint.finding_player].add(hint)
for player in ctx.slot_set(hint.receiving_player):
ctx.hints[team, player].add(hint)
logging.info("Notice (Team #%d): %s" % (team + 1, format_hint(ctx, team, hint)))
for slot, hint_data in concerns.items():
clients = ctx.clients[team].get(slot)
if not clients:
continue
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
for client in clients:
asyncio.create_task(ctx.send_msgs(client, client_hints))
def update_aliases(ctx: Context, team: int):
@@ -677,7 +635,7 @@ def update_aliases(ctx: Context, team: int):
for clients in ctx.clients[team].values():
for client in clients:
async_start(ctx.send_encoded_msgs(client, cmd))
asyncio.create_task(ctx.send_encoded_msgs(client, cmd))
async def server(websocket, path: str = "/", ctx: Context = None):
@@ -717,7 +675,10 @@ async def on_client_connected(ctx: Context, client: Client):
await ctx.send_msgs(client, [{
'cmd': 'RoomInfo',
'password': bool(ctx.password),
'games': {ctx.games[x] for x in range(1, len(ctx.games) + 1)},
# TODO remove around 0.4
'players': players,
# TODO convert to list of games present in 0.4
'games': [ctx.games[x] for x in range(1, len(ctx.games) + 1)],
# tags are for additional features in the communication.
# Name them by feature or fork, as you feel is appropriate.
'tags': ctx.tags,
@@ -725,6 +686,8 @@ async def on_client_connected(ctx: Context, client: Client):
'permissions': get_permissions(ctx),
'hint_cost': ctx.hint_cost,
'location_check_points': ctx.location_check_points,
'datapackage_version': sum(game_data["version"] for game_data in ctx.gamespackage.values())
if all(game_data["version"] for game_data in ctx.gamespackage.values()) else 0,
'datapackage_versions': {game: game_data["version"] for game, game_data
in ctx.gamespackage.items()},
'seed_name': ctx.seed_name,
@@ -850,7 +813,7 @@ def send_new_items(ctx: Context):
items = get_received_items(ctx, team, slot, client.remote_items)
if len(start_inventory) + len(items) > client.send_index:
first_new_item = max(0, client.send_index - len(start_inventory))
async_start(ctx.send_msgs(client, [{
asyncio.create_task(ctx.send_msgs(client, [{
"cmd": "ReceivedItems",
"index": client.send_index,
"items": start_inventory[client.send_index:] + items[first_new_item:]}]))
@@ -937,14 +900,14 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
ctx.save()
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str]) -> typing.List[NetUtils.Hint]:
def collect_hints(ctx: Context, team: int, slot: int, item_name: str) -> typing.List[NetUtils.Hint]:
hints = []
slots: typing.Set[int] = {slot}
for group_id, group in ctx.groups.items():
if slot in group:
slots.add(group_id)
seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item]
seeked_item_id = ctx.item_names_for_game(ctx.games[slot])[item_name]
for finding_player, check_data in ctx.locations.items():
for location_id, (item_id, receiving_player, item_flags) in check_data.items():
if receiving_player in slots and item_id == seeked_item_id:
@@ -1034,11 +997,7 @@ class CommandMeta(type):
return super(CommandMeta, cls).__new__(cls, name, bases, attrs)
_Return = typing.TypeVar("_Return")
# TODO: when python 3.10 is lowest supported, typing.ParamSpec
def mark_raw(function: typing.Callable[[typing.Any], _Return]) -> typing.Callable[[typing.Any], _Return]:
def mark_raw(function):
function.raw_text = True
return function
@@ -1126,7 +1085,7 @@ class CommonCommandProcessor(CommandProcessor):
timer = int(seconds, 10)
except ValueError:
timer = 10
async_start(countdown(self.ctx, timer))
asyncio.create_task(countdown(self.ctx, timer))
return True
def _cmd_options(self):
@@ -1169,15 +1128,13 @@ class ClientMessageProcessor(CommonCommandProcessor):
output = f"!admin {command}"
if output.lower().startswith(
"!admin login"): # disallow others from seeing the supplied password, whether it is correct.
"!admin login"): # disallow others from seeing the supplied password, whether or not it is correct.
output = f"!admin login {('*' * random.randint(4, 16))}"
elif output.lower().startswith(
# disallow others from knowing what the new remote administration password is.
"!admin /option server_password"):
"!admin /option server_password"): # disallow others from knowing what the new remote administration password is.
output = f"!admin /option server_password {('*' * random.randint(4, 16))}"
# Otherwise notify the others what is happening.
self.ctx.notify_all(self.ctx.get_aliased_name(self.client.team,
self.client.slot) + ': ' + output)
self.client.slot) + ': ' + output) # Otherwise notify the others what is happening.
if not self.ctx.server_password:
self.output("Sorry, Remote administration is disabled")
@@ -1185,8 +1142,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
if not command:
if self.is_authenticated():
self.output("Usage: !admin [Server command].\nUse !admin /help for help.\n"
"Use !admin logout to log out of the current session.")
self.output(
"Usage: !admin [Server command].\nUse !admin /help for help.\nUse !admin logout to log out of the current session.")
else:
self.output("Usage: !admin login [password]")
return True
@@ -1370,43 +1327,21 @@ class ClientMessageProcessor(CommonCommandProcessor):
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
points_available = get_client_points(self.ctx, self.client)
cost = self.ctx.get_hint_cost(self.client.slot)
if not input_text:
hints = {hint.re_check(self.ctx, self.client.team) for hint in
self.ctx.hints[self.client.team, self.client.slot]}
self.ctx.hints[self.client.team, self.client.slot] = hints
self.ctx.notify_hints(self.client.team, list(hints))
notify_hints(self.ctx, self.client.team, list(hints))
self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. "
f"You have {points_available} points.")
return True
elif input_text.isnumeric():
game = self.ctx.games[self.client.slot]
hint_id = int(input_text)
hint_name = self.ctx.item_names[hint_id] \
if not for_location and hint_id in self.ctx.item_names \
else self.ctx.location_names[hint_id] \
if for_location and hint_id in self.ctx.location_names \
else None
if hint_name in self.ctx.non_hintable_names[game]:
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
hints = []
elif not for_location:
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id)
else:
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id)
else:
game = self.ctx.games[self.client.slot]
if game not in self.ctx.all_item_and_group_names:
self.output("Can't look up item/location for unknown game. Hint for ID instead.")
return False
names = self.ctx.location_names_for_game(game) \
if for_location else \
self.ctx.all_item_and_group_names[game]
hint_name, usable, response = get_intended_text(input_text, names)
hint_name, usable, response = get_intended_text(input_text,
names)
if usable:
if hint_name in self.ctx.non_hintable_names[game]:
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
@@ -1420,74 +1355,63 @@ class ClientMessageProcessor(CommonCommandProcessor):
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
else: # location name
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
cost = self.ctx.get_hint_cost(self.client.slot)
if hints:
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
old_hints = set(hints) - new_hints
if old_hints:
notify_hints(self.ctx, self.client.team, list(old_hints))
if not new_hints:
self.output("Hint was previously used, no points deducted.")
if new_hints:
found_hints = [hint for hint in new_hints if hint.found]
not_found_hints = [hint for hint in new_hints if not hint.found]
if not not_found_hints: # everything's been found, no need to pay
can_pay = 1000
elif cost:
can_pay = int((points_available // cost) > 0) # limit to 1 new hint per call
else:
can_pay = 1000
self.ctx.random.shuffle(not_found_hints)
# By popular vote, make hints prefer non-local placements
not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player))
hints = found_hints
while can_pay > 0:
if not not_found_hints:
break
hint = not_found_hints.pop()
hints.append(hint)
can_pay -= 1
self.ctx.hints_used[self.client.team, self.client.slot] += 1
points_available = get_client_points(self.ctx, self.client)
if not_found_hints:
if hints and cost and int((points_available // cost) == 0):
self.output(
f"There may be more hintables, however, you cannot afford to pay for any more. "
f" You have {points_available} and need at least "
f"{self.ctx.get_hint_cost(self.client.slot)}.")
elif hints:
self.output(
"There may be more hintables, you can rerun the command to find more.")
else:
self.output(f"You can't afford the hint. "
f"You have {points_available} points and need at least "
f"{self.ctx.get_hint_cost(self.client.slot)}.")
notify_hints(self.ctx, self.client.team, hints)
self.ctx.save()
return True
else:
self.output("Nothing found. Item/Location may not exist.")
return False
else:
self.output(response)
return False
if hints:
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
old_hints = set(hints) - new_hints
if old_hints:
self.ctx.notify_hints(self.client.team, list(old_hints))
if not new_hints:
self.output("Hint was previously used, no points deducted.")
if new_hints:
found_hints = [hint for hint in new_hints if hint.found]
not_found_hints = [hint for hint in new_hints if not hint.found]
if not not_found_hints: # everything's been found, no need to pay
can_pay = 1000
elif cost:
can_pay = int((points_available // cost) > 0) # limit to 1 new hint per call
else:
can_pay = 1000
self.ctx.random.shuffle(not_found_hints)
# By popular vote, make hints prefer non-local placements
not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player))
hints = found_hints
while can_pay > 0:
if not not_found_hints:
break
hint = not_found_hints.pop()
hints.append(hint)
can_pay -= 1
self.ctx.hints_used[self.client.team, self.client.slot] += 1
points_available = get_client_points(self.ctx, self.client)
if not_found_hints:
if hints and cost and int((points_available // cost) == 0):
self.output(
f"There may be more hintables, however, you cannot afford to pay for any more. "
f" You have {points_available} and need at least "
f"{self.ctx.get_hint_cost(self.client.slot)}.")
elif hints:
self.output(
"There may be more hintables, you can rerun the command to find more.")
else:
self.output(f"You can't afford the hint. "
f"You have {points_available} points and need at least "
f"{self.ctx.get_hint_cost(self.client.slot)}.")
self.ctx.notify_hints(self.client.team, hints)
self.ctx.save()
return True
else:
if points_available >= cost:
if for_location:
self.output(f"Nothing found for recognized location name \"{hint_name}\". "
f"Location appears to not exist in this multiworld.")
else:
self.output(f"Nothing found for recognized item name \"{hint_name}\". "
f"Item appears to not exist in this multiworld.")
else:
self.output(f"You can't afford the hint. "
f"You have {points_available} points and need at least "
f"{self.ctx.get_hint_cost(self.client.slot)}.")
return False
@mark_raw
def _cmd_hint(self, item_name: str = "") -> bool:
"""Use !hint {item_name},
@@ -1555,16 +1479,27 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
else:
team, slot = ctx.connect_names[args['name']]
game = ctx.games[slot]
ignore_game = ("TextOnly" in args["tags"] or "Tracker" in args["tags"]) and not args.get("game")
ignore_game = "IgnoreGame" in args["tags"] or ( # IgnoreGame is deprecated. TODO: remove after 0.3.3?
("TextOnly" in args["tags"] or "Tracker" in args["tags"]) and not args.get("game"))
if not ignore_game and args['game'] != game:
errors.add('InvalidGame')
minver = min_client_version if ignore_game else ctx.minimum_client_versions[slot]
if minver > args['version']:
errors.add('IncompatibleVersion')
try:
client.items_handling = args['items_handling']
except (ValueError, TypeError):
errors.add('InvalidItemsHandling')
if args.get('items_handling', None) is None:
# fall back to load from multidata
client.no_items = False
client.remote_items = slot in ctx.remote_items
client.remote_start_inventory = slot in ctx.remote_start_inventory
await ctx.send_msgs(client, [{
"cmd": "Print", "text":
"Warning: Client is not sending items_handling flags, "
"which will not be supported in the future."}])
else:
try:
client.items_handling = args['items_handling']
except (ValueError, TypeError):
errors.add('InvalidItemsHandling')
# only exact version match allowed
if ctx.compatibility == 0 and args['version'] != version_tuple:
@@ -1586,15 +1521,15 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
client.version = args['version']
client.tags = args['tags']
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
connected_packet = {
reply = [{
"cmd": "Connected",
"team": client.team, "slot": client.slot,
"players": ctx.get_players_package(),
"missing_locations": get_missing_checks(ctx, team, slot),
"checked_locations": get_checked_checks(ctx, team, slot),
"slot_data": ctx.slot_data[client.slot],
"slot_info": ctx.slot_info
}
reply = [connected_packet]
}]
start_inventory = get_start_inventory(ctx, slot, client.remote_start_inventory)
items = get_received_items(ctx, client.team, client.slot, client.remote_items)
if (start_inventory or items) and not client.no_items:
@@ -1603,8 +1538,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
if not client.auth: # if this was a Re-Connect, don't print to console
client.auth = True
await on_client_joined(ctx, client)
if args.get("slot_data", True):
connected_packet["slot_data"] = ctx.slot_data[client.slot]
await ctx.send_msgs(client, reply)
elif cmd == "GetDataPackage":
@@ -1692,7 +1626,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
if create_as_hint:
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
locs.append(NetworkItem(target_item, location, target_player, flags))
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2)
notify_hints(ctx, client.team, hints, only_new=create_as_hint == 2)
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
elif cmd == 'StatusUpdate':
@@ -1726,15 +1660,11 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
return
args["cmd"] = "Retrieved"
keys = args["keys"]
args["keys"] = {
key: ctx.read_data.get(key[6:], lambda: None)() if key.startswith("_read_") else
ctx.stored_data.get(key, None)
for key in keys
}
args["keys"] = {key: ctx.stored_data.get(key, None) for key in keys}
await ctx.send_msgs(client, [args])
elif cmd == "Set":
if "key" not in args or args["key"].startswith("_read_") or \
if "key" not in args or \
"operations" not in args or not type(args["operations"]) == list:
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments",
"text": 'Set', "original_cmd": cmd}])
@@ -1808,7 +1738,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
def _cmd_exit(self) -> bool:
"""Shutdown the server"""
async_start(self.ctx.server.ws_server._close())
asyncio.create_task(self.ctx.server.ws_server._close())
if self.ctx.shutdown_task:
self.ctx.shutdown_task.cancel()
self.ctx.exit_event.set()
@@ -1839,33 +1769,14 @@ class ServerCommandProcessor(CommonCommandProcessor):
self.output(response)
return False
def resolve_player(self, input_name: str) -> typing.Optional[typing.Tuple[int, int, str]]:
""" returns (team, slot, player name) """
# TODO: clean up once we disallow multidata < 0.3.6, which has CI unique names
# first match case
for (team, slot), name in self.ctx.player_names.items():
if name == input_name:
return team, slot, name
# if no case-sensitive match, then match without case only if there's only 1 match
input_lower = input_name.lower()
match: typing.Optional[typing.Tuple[int, int, str]] = None
for (team, slot), name in self.ctx.player_names.items():
lowered = name.lower()
if lowered == input_lower:
if match:
return None # ambiguous input_name
match = (team, slot, name)
return match
@mark_raw
def _cmd_collect(self, player_name: str) -> bool:
"""Send out the remaining items to player."""
player = self.resolve_player(player_name)
if player:
team, slot, _ = player
collect_player(self.ctx, team, slot)
return True
seeked_player = player_name.lower()
for (team, slot), name in self.ctx.player_names.items():
if name.lower() == seeked_player:
collect_player(self.ctx, team, slot)
return True
self.output(f"Could not find player {player_name} to collect")
return False
@@ -1878,11 +1789,11 @@ class ServerCommandProcessor(CommonCommandProcessor):
@mark_raw
def _cmd_forfeit(self, player_name: str) -> bool:
"""Send out the remaining items from a player to their intended recipients."""
player = self.resolve_player(player_name)
if player:
team, slot, _ = player
forfeit_player(self.ctx, team, slot)
return True
seeked_player = player_name.lower()
for (team, slot), name in self.ctx.player_names.items():
if name.lower() == seeked_player:
forfeit_player(self.ctx, team, slot)
return True
self.output(f"Could not find player {player_name} to release")
return False
@@ -1890,12 +1801,12 @@ class ServerCommandProcessor(CommonCommandProcessor):
@mark_raw
def _cmd_allow_forfeit(self, player_name: str) -> bool:
"""Allow the specified player to use the !release command."""
player = self.resolve_player(player_name)
if player:
team, slot, name = player
self.ctx.allow_forfeits[(team, slot)] = True
self.output(f"Player {name} is now allowed to use the !release command at any time.")
return True
seeked_player = player_name.lower()
for (team, slot), name in self.ctx.player_names.items():
if name.lower() == seeked_player:
self.ctx.allow_forfeits[(team, slot)] = True
self.output(f"Player {player_name} is now allowed to use the !release command at any time.")
return True
self.output(f"Could not find player {player_name} to allow the !release command for.")
return False
@@ -1903,12 +1814,13 @@ class ServerCommandProcessor(CommonCommandProcessor):
@mark_raw
def _cmd_forbid_forfeit(self, player_name: str) -> bool:
""""Disallow the specified player from using the !release command."""
player = self.resolve_player(player_name)
if player:
team, slot, name = player
self.ctx.allow_forfeits[(team, slot)] = False
self.output(f"Player {name} has to follow the server restrictions on use of the !release command.")
return True
seeked_player = player_name.lower()
for (team, slot), name in self.ctx.player_names.items():
if name.lower() == seeked_player:
self.ctx.allow_forfeits[(team, slot)] = False
self.output(
f"Player {player_name} has to follow the server restrictions on use of the !release command.")
return True
self.output(f"Could not find player {player_name} to forbid the !release command for.")
return False
@@ -1942,64 +1854,25 @@ class ServerCommandProcessor(CommonCommandProcessor):
"""Sends an item to the specified player"""
return self._cmd_send_multiple(1, player_name, *item_name)
def _cmd_send_location(self, player_name: str, *location_name: str) -> bool:
"""Send out item from a player's location as though they checked it"""
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
if usable:
team, slot = self.ctx.player_name_lookup[seeked_player]
game = self.ctx.games[slot]
full_name = " ".join(location_name)
if full_name.isnumeric():
location, usable, response = int(full_name), True, None
elif self.ctx.location_names_for_game(game) is not None:
location, usable, response = get_intended_text(full_name, self.ctx.location_names_for_game(game))
else:
self.output("Can't look up location for unknown game. Send by ID instead.")
return False
if usable:
if isinstance(location, int):
register_location_checks(self.ctx, team, slot, [location])
else:
seeked_location: int = self.ctx.location_names_for_game(self.ctx.games[slot])[location]
register_location_checks(self.ctx, team, slot, [seeked_location])
return True
else:
self.output(response)
return False
else:
self.output(response)
return False
def _cmd_hint(self, player_name: str, *item_name: str) -> bool:
"""Send out a hint for a player's item to their team"""
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
if usable:
team, slot = self.ctx.player_name_lookup[seeked_player]
item_name = " ".join(item_name)
game = self.ctx.games[slot]
full_name = " ".join(item_name)
if full_name.isnumeric():
item, usable, response = int(full_name), True, None
elif game in self.ctx.all_item_and_group_names:
item, usable, response = get_intended_text(full_name, self.ctx.all_item_and_group_names[game])
else:
self.output("Can't look up item for unknown game. Hint for ID instead.")
return False
item_name, usable, response = get_intended_text(item_name, self.ctx.all_item_and_group_names[game])
if usable:
if game in self.ctx.item_name_groups and item in self.ctx.item_name_groups[game]:
if item_name in self.ctx.item_name_groups[game]:
hints = []
for item_name_from_group in self.ctx.item_name_groups[game][item]:
for item_name_from_group in self.ctx.item_name_groups[game][item_name]:
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
else: # item name or id
hints = collect_hints(self.ctx, team, slot, item)
else: # item name
hints = collect_hints(self.ctx, team, slot, item_name)
if hints:
self.ctx.notify_hints(team, hints)
notify_hints(self.ctx, team, hints)
else:
self.output("No hints found.")
@@ -2017,24 +1890,13 @@ class ServerCommandProcessor(CommonCommandProcessor):
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
if usable:
team, slot = self.ctx.player_name_lookup[seeked_player]
game = self.ctx.games[slot]
full_name = " ".join(location_name)
if full_name.isnumeric():
location, usable, response = int(full_name), True, None
elif self.ctx.location_names_for_game(game) is not None:
location, usable, response = get_intended_text(full_name, self.ctx.location_names_for_game(game))
else:
self.output("Can't look up location for unknown game. Hint for ID instead.")
return False
location_name = " ".join(location_name)
location_name, usable, response = get_intended_text(location_name,
self.ctx.location_names_for_game(self.ctx.games[slot]))
if usable:
if isinstance(location, int):
hints = collect_hint_location_id(self.ctx, team, slot, location)
else:
hints = collect_hint_location_name(self.ctx, team, slot, location)
hints = collect_hint_location_name(self.ctx, team, slot, location_name)
if hints:
self.ctx.notify_hints(team, hints)
notify_hints(self.ctx, team, hints)
else:
self.output("No hints found.")
return True
@@ -2152,7 +2014,7 @@ async def auto_shutdown(ctx, to_cancel=None):
await asyncio.sleep(ctx.auto_shutdown)
while not ctx.exit_event.is_set():
if not ctx.client_activity_timers.values():
async_start(ctx.server.ws_server._close())
asyncio.create_task(ctx.server.ws_server._close())
ctx.exit_event.set()
if to_cancel:
for task in to_cancel:
@@ -2163,7 +2025,7 @@ async def auto_shutdown(ctx, to_cancel=None):
delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity
seconds = ctx.auto_shutdown - delta.total_seconds()
if seconds < 0:
async_start(ctx.server.ws_server._close())
asyncio.create_task(ctx.server.ws_server._close())
ctx.exit_event.set()
if to_cancel:
for task in to_cancel:
@@ -2182,28 +2044,15 @@ async def main(args: argparse.Namespace):
args.auto_shutdown, args.compatibility, args.log_network)
data_filename = args.multidata
if not data_filename:
try:
try:
if not data_filename:
filetypes = (("Multiworld data", (".archipelago", ".zip")),)
data_filename = Utils.open_filename("Select multiworld data", filetypes)
except Exception as e:
if isinstance(e, ImportError) or (e.__class__.__name__ == "TclError" and "no display" in str(e)):
if not isinstance(e, ImportError):
logging.error(f"Failed to load tkinter ({e})")
logging.info("Pass a multidata filename on command line to run headless.")
exit(1)
raise
if not data_filename:
logging.info("No file selected. Exiting.")
exit(1)
try:
ctx.load(data_filename, args.use_embedded_options)
except Exception as e:
logging.exception(f"Failed to read multiworld data ({e})")
logging.exception('Failed to read multiworld data (%s)' % e)
raise
ctx.init_save(not args.disable_save)

View File

@@ -100,7 +100,7 @@ _encode = JSONEncoder(
).encode
def encode(obj: typing.Any) -> str:
def encode(obj):
return _encode(_scan_for_TypedTuples(obj))

View File

@@ -3,7 +3,6 @@ import argparse
import logging
import random
import os
import zipfile
from itertools import chain
from BaseClasses import MultiWorld
@@ -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,6 +1,5 @@
from __future__ import annotations
import abc
from copy import deepcopy
import math
import numbers
import typing
@@ -27,31 +26,15 @@ class AssembleOptions(abc.ABCMeta):
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
options.update(new_options)
# apply aliases, without name_lookup
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("alias_")}
assert "random" not in aliases, "Choice option 'random' cannot be manually assigned."
# auto-alias Off and On being parsed as True and False
if "off" in options:
options["false"] = options["off"]
if "on" in options:
options["true"] = options["on"]
options.update(aliases)
if "verify" not in attrs:
# not overridden by class -> look up bases
verifiers = [f for f in (getattr(base, "verify", None) for base in bases) if f]
if len(verifiers) > 1: # verify multiple bases/mixins
def verify(self, *args, **kwargs) -> None:
for f in verifiers:
f(self, *args, **kwargs)
attrs["verify"] = verify
else:
assert verifiers, "class Option is supposed to implement def verify"
# auto-validate schema on __init__
if "schema" in attrs.keys():
@@ -79,9 +62,6 @@ class AssembleOptions(abc.ABCMeta):
return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
@abc.abstractclassmethod
def from_any(cls, value: typing.Any) -> "Option[typing.Any]": ...
T = typing.TypeVar('T')
@@ -132,44 +112,8 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
def from_any(cls, data: typing.Any) -> Option[T]:
raise NotImplementedError
if typing.TYPE_CHECKING:
from Generate import PlandoSettings
from worlds.AutoWorld import World
def verify(self, world: World, player_name: str, plando_options: PlandoSettings) -> None:
pass
else:
def verify(self, *args, **kwargs) -> None:
pass
class FreeText(Option):
"""Text option that allows users to enter strings.
Needs to be validated by the world or option definition."""
def __init__(self, value: str):
assert isinstance(value, str), "value of FreeText must be a string"
self.value = value
@property
def current_key(self) -> str:
return self.value
@classmethod
def from_text(cls, text: str) -> FreeText:
return cls(text)
@classmethod
def from_any(cls, data: typing.Any) -> FreeText:
return cls.from_text(str(data))
@classmethod
def get_option_name(cls, value: T) -> str:
return value
class NumericOption(Option[int], numbers.Integral):
default = 0
# 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)
@@ -424,170 +368,6 @@ class Choice(NumericOption):
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
class TextChoice(Choice):
"""Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string"""
def __init__(self, value: typing.Union[str, int]):
assert isinstance(value, str) or isinstance(value, int), \
f"{value} is not a valid option for {self.__class__.__name__}"
self.value = value
@property
def current_key(self) -> str:
if isinstance(self.value, str):
return self.value
else:
return self.name_lookup[self.value]
@classmethod
def from_text(cls, text: str) -> TextChoice:
if text.lower() == "random": # chooses a random defined option but won't use any free text options
return cls(random.choice(list(cls.name_lookup)))
for option_name, value in cls.options.items():
if option_name.lower() == text.lower():
return cls(value)
return cls(text)
@classmethod
def get_option_name(cls, value: T) -> str:
if isinstance(value, str):
return value
return cls.name_lookup[value]
def __eq__(self, other: typing.Any):
if isinstance(other, self.__class__):
return other.value == self.value
elif isinstance(other, str):
if other in self.options:
return other == self.current_key
return other == self.value
elif isinstance(other, int):
assert other in self.name_lookup, f"compared against an int that could never be equal. {self} == {other}"
return other == self.value
elif isinstance(other, bool):
return other == bool(self.value)
else:
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
class BossMeta(AssembleOptions):
def __new__(mcs, name, bases, attrs):
if name != "PlandoBosses":
assert "bosses" in attrs, f"Please define valid bosses for {name}"
attrs["bosses"] = frozenset((boss.lower() for boss in attrs["bosses"]))
assert "locations" in attrs, f"Please define valid locations for {name}"
attrs["locations"] = frozenset((location.lower() for location in attrs["locations"]))
cls = super().__new__(mcs, name, bases, attrs)
assert not cls.duplicate_bosses or "singularity" in cls.options, f"Please define option_singularity for {name}"
return cls
class PlandoBosses(TextChoice, metaclass=BossMeta):
"""Generic boss shuffle option that supports plando. Format expected is
'location1-boss1;location2-boss2;shuffle_mode'.
If shuffle_mode is not provided in the string, this will be the default shuffle mode. Must override can_place_boss,
which passes a plando boss and location. Check if the placement is valid for your game here."""
bosses: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]]
locations: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]]
duplicate_bosses: bool = False
@classmethod
def from_text(cls, text: str):
# set all of our text to lower case for name checking
text = text.lower()
if text == "random":
return cls(random.choice(list(cls.options.values())))
for option_name, value in cls.options.items():
if option_name == text:
return cls(value)
options = text.split(";")
# since plando exists in the option verify the plando values given are valid
cls.validate_plando_bosses(options)
return cls.get_shuffle_mode(options)
@classmethod
def get_shuffle_mode(cls, option_list: typing.List[str]):
# find out what mode of boss shuffle we should use for placing bosses after plando
# and add as a string to look nice in the spoiler
if "random" in option_list:
shuffle = random.choice(list(cls.options))
option_list.remove("random")
options = ";".join(option_list) + f";{shuffle}"
boss_class = cls(options)
else:
for option in option_list:
if option in cls.options:
options = ";".join(option_list)
break
else:
if cls.duplicate_bosses and len(option_list) == 1:
if cls.valid_boss_name(option_list[0]):
# this doesn't exist in this class but it's a forced option for classes where this is called
options = option_list[0] + ";singularity"
else:
options = option_list[0] + f";{cls.name_lookup[cls.default]}"
else:
options = ";".join(option_list) + f";{cls.name_lookup[cls.default]}"
boss_class = cls(options)
return boss_class
@classmethod
def validate_plando_bosses(cls, options: typing.List[str]) -> None:
used_locations = []
used_bosses = []
for option in options:
# check if a shuffle mode was provided in the incorrect location
if option == "random" or option in cls.options:
if option != options[-1]:
raise ValueError(f"{option} option must be at the end of the boss_shuffle options!")
elif "-" in option:
location, boss = option.split("-")
if location in used_locations:
raise ValueError(f"Duplicate Boss Location {location} not allowed.")
if not cls.duplicate_bosses and boss in used_bosses:
raise ValueError(f"Duplicate Boss {boss} not allowed.")
used_locations.append(location)
used_bosses.append(boss)
if not cls.valid_boss_name(boss):
raise ValueError(f"{boss.title()} is not a valid boss name.")
if not cls.valid_location_name(location):
raise ValueError(f"{location.title()} is not a valid boss location name.")
if not cls.can_place_boss(boss, location):
raise ValueError(f"{location.title()} is not a valid location for {boss.title()} to be placed.")
else:
if cls.duplicate_bosses:
if not cls.valid_boss_name(option):
raise ValueError(f"{option} is not a valid boss name.")
else:
raise ValueError(f"{option.title()} is not formatted correctly.")
@classmethod
def can_place_boss(cls, boss: str, location: str) -> bool:
raise NotImplementedError
@classmethod
def valid_boss_name(cls, value: str) -> bool:
return value in cls.bosses
@classmethod
def valid_location_name(cls, value: str) -> bool:
return value in cls.locations
def verify(self, world, player_name: str, plando_options) -> None:
if isinstance(self.value, int):
return
from Generate import PlandoSettings
if not(PlandoSettings.bosses & plando_options):
import logging
# plando is disabled but plando options were given so pull the option and change it to an int
option = self.value.split(";")[-1]
self.value = self.options[option]
logging.warning(f"The plando bosses module is turned off, so {self.name_lookup[self.value].title()} "
f"boss shuffle will be used for player {player_name}.")
class Range(NumericOption):
range_start = 0
range_end = 1
@@ -605,7 +385,7 @@ class Range(NumericOption):
if text.startswith("random"):
return cls.weighted_range(text)
elif text == "default" and hasattr(cls, "default"):
return cls.from_any(cls.default)
return cls(cls.default)
elif text == "high":
return cls(cls.range_end)
elif text == "low":
@@ -616,7 +396,7 @@ class Range(NumericOption):
and text in ("true", "false"):
# these are the conditions where "true" and "false" make sense
if text == "true":
return cls.from_any(cls.default)
return cls(cls.default)
else: # "false"
return cls(0)
return cls(int(text))
@@ -727,7 +507,7 @@ class VerifyKeys:
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
f"Allowed keys: {cls.valid_keys}.")
def verify(self, world, player_name: str, plando_options) -> None:
def verify(self, world):
if self.convert_name_groups and self.verify_item_name:
new_value = type(self.value)() # empty container of whatever value is
for item_name in self.value:
@@ -750,11 +530,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:
@@ -781,11 +561,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
@@ -807,11 +587,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
@@ -820,7 +600,10 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
@classmethod
def from_any(cls, data: typing.Any):
if isinstance(data, (list, set, frozenset)):
if type(data) == list:
cls.verify_keys(data)
return cls(data)
elif type(data) == set:
cls.verify_keys(data)
return cls(data)
return cls.from_text(str(data))
@@ -850,7 +633,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
@@ -927,8 +710,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)]
}
])
@@ -950,9 +732,8 @@ class ItemLinks(OptionList):
pool |= {item_name}
return pool
def verify(self, world, player_name: str, plando_options) -> None:
link: dict
super(ItemLinks, self).verify(world, player_name, plando_options)
def verify(self, world):
super(ItemLinks, self).verify(world)
existing_links = set()
for link in self.value:
if link["name"] in existing_links:
@@ -976,9 +757,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,335 +0,0 @@
import asyncio
import json
import time
import os
import bsdiff4
import subprocess
import zipfile
from asyncio import StreamReader, StreamWriter
from typing import List
import Utils
from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
get_base_parser
from worlds.pokemon_rb.locations import location_data
from worlds.pokemon_rb.rom import RedDeltaPatch, BlueDeltaPatch
location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}}
location_bytes_bits = {}
for location in location_data:
if location.ram_address is not None:
if type(location.ram_address) == list:
location_map[type(location.ram_address).__name__][(location.ram_address[0].flag, location.ram_address[1].flag)] = location.address
location_bytes_bits[location.address] = [{'byte': location.ram_address[0].byte, 'bit': location.ram_address[0].bit},
{'byte': location.ram_address[1].byte, 'bit': location.ram_address[1].bit}]
else:
location_map[type(location.ram_address).__name__][location.ram_address.flag] = location.address
location_bytes_bits[location.address] = {'byte': location.ram_address.byte, 'bit': location.ram_address.bit}
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart pkmn_rb.lua"
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure pkmn_rb.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart pkmn_rb.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
DISPLAY_MSGS = True
SCRIPT_VERSION = 1
class GBCommandProcessor(ClientCommandProcessor):
def __init__(self, ctx: CommonContext):
super().__init__(ctx)
def _cmd_gb(self):
"""Check Gameboy Connection State"""
if isinstance(self.ctx, GBContext):
logger.info(f"Gameboy Status: {self.ctx.gb_status}")
class GBContext(CommonContext):
command_processor = GBCommandProcessor
game = 'Pokemon Red and Blue'
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.gb_streams: (StreamReader, StreamWriter) = None
self.gb_sync_task = None
self.messages = {}
self.locations_array = None
self.gb_status = CONNECTION_INITIAL_STATUS
self.awaiting_rom = False
self.display_msgs = True
self.deathlink_pending = False
self.set_deathlink = False
self.client_compatibility_mode = 0
self.items_handling = 0b001
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(GBContext, self).server_auth(password_requested)
if not self.auth:
self.awaiting_rom = True
logger.info('Awaiting connection to Bizhawk to get Player information')
return
await self.send_connect()
def _set_message(self, msg: str, msg_id: int):
if DISPLAY_MSGS:
self.messages[(time.time(), msg_id)] = msg
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
self.locations_array = None
if 'death_link' in args['slot_data'] and args['slot_data']['death_link']:
self.set_deathlink = True
elif cmd == "RoomInfo":
self.seed_name = args['seed_name']
elif cmd == 'Print':
msg = args['text']
if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "ReceivedItems":
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
self._set_message(msg, SYSTEM_MESSAGE_ID)
def on_deathlink(self, data: dict):
self.deathlink_pending = True
super().on_deathlink(data)
def run_gui(self):
from kvui import GameManager
class GBManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Pokémon Client"
self.ui = GBManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def get_payload(ctx: GBContext):
current_time = time.time()
ret = json.dumps(
{
"items": [item.item for item in ctx.items_received],
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
if key[0] > current_time - 10},
"deathlink": ctx.deathlink_pending
}
)
ctx.deathlink_pending = False
return ret
async def parse_locations(data: List, ctx: GBContext):
locations = []
flags = {"EventFlag": data[:0x140], "Missable": data[0x140:0x140 + 0x20],
"Hidden": data[0x140 + 0x20: 0x140 + 0x20 + 0x0E], "Rod": data[0x140 + 0x20 + 0x0E:]}
if len(flags['Rod']) > 1:
return
for flag_type, loc_map in location_map.items():
for flag, loc_id in loc_map.items():
if flag_type == "list":
if (flags["EventFlag"][location_bytes_bits[loc_id][0]['byte']] & 1 << location_bytes_bits[loc_id][0]['bit']
and flags["Missable"][location_bytes_bits[loc_id][1]['byte']] & 1 << location_bytes_bits[loc_id][1]['bit']):
locations.append(loc_id)
elif flags[flag_type][location_bytes_bits[loc_id]['byte']] & 1 << location_bytes_bits[loc_id]['bit']:
locations.append(loc_id)
if flags["EventFlag"][280] & 1 and not ctx.finished_game:
await ctx.send_msgs([
{"cmd": "StatusUpdate",
"status": 30}
])
ctx.finished_game = True
if locations == ctx.locations_array:
return
ctx.locations_array = locations
if locations is not None:
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": locations}])
async def gb_sync_task(ctx: GBContext):
logger.info("Starting GB connector. Use /gb for status information")
while not ctx.exit_event.is_set():
error_status = None
if ctx.gb_streams:
(reader, writer) = ctx.gb_streams
msg = get_payload(ctx).encode()
writer.write(msg)
writer.write(b'\n')
try:
await asyncio.wait_for(writer.drain(), timeout=1.5)
try:
# Data will return a dict with up to two fields:
# 1. A keepalive response of the Players Name (always)
# 2. An array representing the memory values of the locations area (if in game)
data = await asyncio.wait_for(reader.readline(), timeout=5)
data_decoded = json.loads(data.decode())
if 'scriptVersion' not in data_decoded or data_decoded['scriptVersion'] != SCRIPT_VERSION:
msg = "You are connecting with an incompatible Lua script version. Ensure your connector Lua " \
"and PokemonClient are from the same Archipelago installation."
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
ctx.client_compatibility_mode = data_decoded['clientCompatibilityVersion']
if ctx.client_compatibility_mode == 0:
ctx.items_handling = 0b101 # old patches will not have local start inventory, must be requested
if ctx.seed_name and ctx.seed_name != ''.join([chr(i) for i in data_decoded['seedName'] if i != 0]):
msg = "The server is running a different multiworld than your client is. (invalid seed_name)"
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
ctx.seed_name = ''.join([chr(i) for i in data_decoded['seedName'] if i != 0])
if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '':
msg = "Invalid ROM detected. No player name built into the ROM."
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
if ctx.awaiting_rom:
await ctx.server_auth(False)
if 'locations' in data_decoded and ctx.game and ctx.gb_status == CONNECTION_CONNECTED_STATUS \
and not error_status and ctx.auth:
# Not just a keep alive ping, parse
async_start(parse_locations(data_decoded['locations'], ctx))
if 'deathLink' in data_decoded and data_decoded['deathLink'] and 'DeathLink' in ctx.tags:
await ctx.send_death(ctx.auth + " is out of usable Pokémon! " + ctx.auth + " blacked out!")
if ctx.set_deathlink:
await ctx.update_death_link(True)
except asyncio.TimeoutError:
logger.debug("Read Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.gb_streams = None
except ConnectionResetError as e:
logger.debug("Read failed due to Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.gb_streams = None
except TimeoutError:
logger.debug("Connection Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.gb_streams = None
except ConnectionResetError:
logger.debug("Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.gb_streams = None
if ctx.gb_status == CONNECTION_TENTATIVE_STATUS:
if not error_status:
logger.info("Successfully Connected to Gameboy")
ctx.gb_status = CONNECTION_CONNECTED_STATUS
else:
ctx.gb_status = f"Was tentatively connected but error occured: {error_status}"
elif error_status:
ctx.gb_status = error_status
logger.info("Lost connection to Gameboy and attempting to reconnect. Use /gb for status updates")
else:
try:
logger.debug("Attempting to connect to Gameboy")
ctx.gb_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 17242), timeout=10)
ctx.gb_status = CONNECTION_TENTATIVE_STATUS
except TimeoutError:
logger.debug("Connection Timed Out, Trying Again")
ctx.gb_status = CONNECTION_TIMING_OUT_STATUS
continue
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.gb_status = CONNECTION_REFUSED_STATUS
continue
async def run_game(romfile):
auto_start = Utils.get_options()["pokemon_rb_options"].get("rom_start", True)
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
elif os.path.isfile(auto_start):
subprocess.Popen([auto_start, romfile],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
async def patch_and_run_game(game_version, patch_file, ctx):
base_name = os.path.splitext(patch_file)[0]
comp_path = base_name + '.gb'
if game_version == "blue":
delta_patch = BlueDeltaPatch
else:
delta_patch = RedDeltaPatch
try:
base_rom = delta_patch.get_source_data()
except Exception as msg:
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
with zipfile.ZipFile(patch_file, 'r') as patch_archive:
with patch_archive.open('delta.bsdiff4', 'r') as stream:
patch = stream.read()
patched_rom_data = bsdiff4.patch(base_rom, patch)
with open(comp_path, "wb") as patched_rom_file:
patched_rom_file.write(patched_rom_data)
async_start(run_game(comp_path))
if __name__ == '__main__':
Utils.init_logging("PokemonClient")
options = Utils.get_options()
async def main():
parser = get_base_parser()
parser.add_argument('patch_file', default="", type=str, nargs="?",
help='Path to an APRED or APBLUE patch file')
args = parser.parse_args()
ctx = GBContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
ctx.gb_sync_task = asyncio.create_task(gb_sync_task(ctx), name="GB Sync")
if args.patch_file:
ext = args.patch_file.split(".")[len(args.patch_file.split(".")) - 1].lower()
if ext == "apred":
logger.info("APRED file supplied, beginning patching process...")
async_start(patch_and_run_game("red", args.patch_file, ctx))
elif ext == "apblue":
logger.info("APBLUE file supplied, beginning patching process...")
async_start(patch_and_run_game("blue", args.patch_file, ctx))
else:
logger.warning(f"Unknown patch file extension {ext}")
await ctx.exit_event.wait()
ctx.server_address = None
await ctx.shutdown()
if ctx.gb_sync_task:
await ctx.gb_sync_task
import colorama
colorama.init()
asyncio.run(main())
colorama.deinit()

View File

@@ -28,12 +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
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

@@ -12,9 +12,21 @@ import typing
import queue
from pathlib import Path
# CommonClient import first to trigger ModuleUpdater
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
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")
@@ -22,21 +34,10 @@ if __name__ == "__main__":
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
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
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
@@ -114,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, force: bool = False) -> bool:
"""Download the most recent release of the necessary files for playing SC2 with
Archipelago. force should be True or False. force=True will overwrite your 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=force)
if tempzip != '':
try:
import zipfile
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
@@ -155,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
@@ -182,25 +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.
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.")
# 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
@@ -312,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
@@ -329,58 +292,42 @@ 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))
# Map is completed
for mission in categories[category]:
text: str = mission
tooltip: str = ""
mission_id: int = self.ctx.mission_req_table[mission].id
text = mission
tooltip = ""
# Map has uncollected locations
if mission in unfinished_missions:
text = f"[color=6495ED]{text}[/color]"
tooltip = f"Uncollected locations:\n"
tooltip += "\n".join([self.ctx.location_names[loc] for loc in
self.ctx.locations_for_mission(mission)
if loc in self.ctx.missing_locations])
elif mission in available_missions:
text = f"[color=FFFFFF]{text}[/color]"
# Map requirements not met
else:
text = f"[color=a9a9a9]{text}[/color]"
tooltip = f"Requires: "
if self.ctx.mission_req_table[mission].required_world:
if len(self.ctx.mission_req_table[mission].required_world) > 0:
tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission - 1] for
req_mission in
self.ctx.mission_req_table[mission].required_world)
if self.ctx.mission_req_table[mission].number:
if self.ctx.mission_req_table[mission].number > 0:
tooltip += " and "
if self.ctx.mission_req_table[mission].number:
if self.ctx.mission_req_table[mission].number > 0:
tooltip += f"{self.ctx.mission_req_table[mission].number} missions completed"
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"
tooltip += f"Uncollected locations:\n"
tooltip += "\n".join(remaining_location_names)
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=""))
@@ -407,9 +354,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()
@@ -489,13 +435,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:
@@ -609,7 +552,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',
@@ -639,13 +582,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])}/"
@@ -772,14 +708,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
@@ -797,18 +732,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
@@ -866,12 +790,7 @@ def check_game_install_path() -> bool:
with open(einfo) as f:
content = f.read()
if content:
try:
base = re.search(r" = (.*)Versions", content).group(1)
except AttributeError:
sc2_logger.warning(f"Found {einfo}, but it was empty. Run SC2 through the Blizzard launcher, then "
f"try again.")
return False
base = re.search(r" = (.*)Versions", content).group(1)
if os.path.exists(base):
executable = sc2.paths.latest_executeble(Path(base).expanduser() / "Versions")
@@ -888,58 +807,22 @@ def check_game_install_path() -> bool:
else:
sc2_logger.warning(f"{einfo} pointed to {base}, but we could not find an SC2 install there.")
else:
sc2_logger.warning(f"Couldn't find {einfo}. Run SC2 through the Blizzard launcher, then try again. "
f"If that fails, please run /set_path with your SC2 install directory.")
sc2_logger.warning(f"Couldn't find {einfo}. Please run /set_path with your SC2 install directory.")
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:
@@ -978,64 +861,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:
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())

115
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, ClassVar, Coroutine, Optional, Set
from yaml import load, load_all, dump, SafeLoader
try:
@@ -38,7 +35,7 @@ class Version(typing.NamedTuple):
build: int
__version__ = "0.3.7"
__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"])
@@ -220,11 +217,8 @@ def get_public_ipv6() -> str:
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,
@@ -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,36 +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",
"sni": "SNI",
"rom_start": True,
},
}
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)
@@ -334,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]
@@ -377,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
@@ -416,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:
@@ -457,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)
@@ -466,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,
@@ -495,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.info(f"Deleted old logfile {file.path}")
import threading
threading.Thread(target=_cleanup, name="LogCleaner").start()
import platform
logging.info(
f"Archipelago ({__version__}) logging initialized"
f" on {platform.platform()}"
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
)
logging.info(f"Archipelago ({__version__}) logging initialized.")
def stream_input(stream, queue):
@@ -669,32 +623,3 @@ def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset
else:
return element.lower()
return sorted(data, key=lambda i: sorter(key(i)) if key else sorter(i))
def read_snes_rom(stream: BinaryIO, strip_header: bool = True) -> bytearray:
"""Reads rom into bytearray and optionally strips off any smc header"""
buffer = bytearray(stream.read())
if strip_header and len(buffer) % 0x400 == 0x200:
return buffer[0x200:]
return buffer
_faf_tasks: "Set[asyncio.Task[None]]" = set()
def async_start(co: Coroutine[typing.Any, typing.Any, bool], name: Optional[str] = None) -> None:
"""
Use this to start a task when you don't keep a reference to it or immediately await it,
to prevent early garbage collection. "fire-and-forget"
"""
# https://docs.python.org/3.10/library/asyncio-task.html#asyncio.create_task
# Python docs:
# ```
# Important: Save a reference to the result of [asyncio.create_task],
# to avoid a task disappearing mid-execution.
# ```
# This implementation follows the pattern given in that documentation.
task = asyncio.create_task(co, name=name)
_faf_tasks.add(task)
task.add_done_callback(_faf_tasks.discard)

View File

@@ -1,4 +1,5 @@
import os
import sys
import multiprocessing
import logging
import typing

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')
@@ -31,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
@@ -74,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

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
@@ -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."

View File

@@ -1,23 +1,21 @@
from __future__ import annotations
import asyncio
import collections
import datetime
import functools
import logging
import pickle
import random
import websockets
import asyncio
import socket
import threading
import time
import websockets
from pony.orm import db_session, commit, select
import random
import pickle
import logging
import datetime
import Utils
from .models import db_session, Room, select, commit, Command, db
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):
@@ -51,8 +49,6 @@ class DBCommandProcessor(ServerCommandProcessor):
class WebHostContext(Context):
room_id: int
def __init__(self, static_server_data: dict):
# static server data is used during _load_game_data to load required data,
# without needing to import worlds system, which takes quite a bit of memory
@@ -66,8 +62,6 @@ 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):
cmdprocessor = DBCommandProcessor(self)
@@ -184,12 +178,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>")
@@ -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>")
@@ -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 worlds.alttp.EntranceRandomizer import parse_arguments
from Main import main as ERmain
from BaseClasses import seeddigits, get_seed
from Generate import handle_name, PlandoSettings
from Main import main as ERmain
from Utils import __version__
import pickle
from .models import Generation, STATE_ERROR, STATE_QUEUED, commit, db_session, Seed, UUID
from WebHostLib import app
from worlds.alttp.EntranceRandomizer import parse_arguments
from .check import get_yaml_data, roll_options
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
from .upload import upload_zip_to_db
@@ -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,7 +113,7 @@ 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
@@ -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

@@ -3,11 +3,10 @@ import os
import jinja2.exceptions
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
from pony.orm import count, commit, db_session
from .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):
@@ -152,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')

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

@@ -2,6 +2,6 @@ flask>=2.2.2
pony>=0.7.16
waitress>=2.1.2
Flask-Caching>=2.0.1
Flask-Compress>=1.13
Flask-Limiter>=2.8.1
bokeh>=3.0.2
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

@@ -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

@@ -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

@@ -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':
@@ -230,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) => {
@@ -240,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
@@ -250,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:
@@ -289,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) ?
@@ -315,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

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

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{

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

@@ -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,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>

View File

@@ -9,13 +9,13 @@
{% 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,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

@@ -20,16 +20,12 @@
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['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

@@ -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,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

@@ -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

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

View File

@@ -25,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>
@@ -44,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" -%}
@@ -78,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>
@@ -121,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 -%}
@@ -153,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>

View File

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

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

BIN
data/basepatch.apbp Normal file

Binary file not shown.

Binary file not shown.

View File

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

View File

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

Binary file not shown.

View File

@@ -1,389 +0,0 @@
--
-- json.lua
--
-- Copyright (c) 2015 rxi
--
-- This library is free software; you can redistribute it and/or modify it
-- under the terms of the MIT license. See LICENSE for details.
--
local json = { _version = "0.1.0" }
-------------------------------------------------------------------------------
-- Encode
-------------------------------------------------------------------------------
local encode
function error(err)
print(err)
end
local escape_char_map = {
[ "\\" ] = "\\\\",
[ "\"" ] = "\\\"",
[ "\b" ] = "\\b",
[ "\f" ] = "\\f",
[ "\n" ] = "\\n",
[ "\r" ] = "\\r",
[ "\t" ] = "\\t",
}
local escape_char_map_inv = { [ "\\/" ] = "/" }
for k, v in pairs(escape_char_map) do
escape_char_map_inv[v] = k
end
local function escape_char(c)
return escape_char_map[c] or string.format("\\u%04x", c:byte())
end
local function encode_nil(val)
return "null"
end
local function encode_table(val, stack)
local res = {}
stack = stack or {}
-- Circular reference?
if stack[val] then error("circular reference") end
stack[val] = true
if val[1] ~= nil or next(val) == nil then
-- Treat as array -- check keys are valid and it is not sparse
local n = 0
for k in pairs(val) do
if type(k) ~= "number" then
error("invalid table: mixed or invalid key types")
end
n = n + 1
end
if n ~= #val then
print("invalid table: sparse array")
print(n)
print("VAL:")
print(val)
print("STACK:")
print(stack)
end
-- Encode
for i, v in ipairs(val) do
table.insert(res, encode(v, stack))
end
stack[val] = nil
return "[" .. table.concat(res, ",") .. "]"
else
-- Treat as an object
for k, v in pairs(val) do
if type(k) ~= "string" then
error("invalid table: mixed or invalid key types")
end
table.insert(res, encode(k, stack) .. ":" .. encode(v, stack))
end
stack[val] = nil
return "{" .. table.concat(res, ",") .. "}"
end
end
local function encode_string(val)
return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"'
end
local function encode_number(val)
-- Check for NaN, -inf and inf
if val ~= val or val <= -math.huge or val >= math.huge then
error("unexpected number value '" .. tostring(val) .. "'")
end
return string.format("%.14g", val)
end
local type_func_map = {
[ "nil" ] = encode_nil,
[ "table" ] = encode_table,
[ "string" ] = encode_string,
[ "number" ] = encode_number,
[ "boolean" ] = tostring,
}
encode = function(val, stack)
local t = type(val)
local f = type_func_map[t]
if f then
return f(val, stack)
end
error("unexpected type '" .. t .. "'")
end
function json.encode(val)
return ( encode(val) )
end
-------------------------------------------------------------------------------
-- Decode
-------------------------------------------------------------------------------
local parse
local function create_set(...)
local res = {}
for i = 1, select("#", ...) do
res[ select(i, ...) ] = true
end
return res
end
local space_chars = create_set(" ", "\t", "\r", "\n")
local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",")
local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u")
local literals = create_set("true", "false", "null")
local literal_map = {
[ "true" ] = true,
[ "false" ] = false,
[ "null" ] = nil,
}
local function next_char(str, idx, set, negate)
for i = idx, #str do
if set[str:sub(i, i)] ~= negate then
return i
end
end
return #str + 1
end
local function decode_error(str, idx, msg)
--local line_count = 1
--local col_count = 1
--for i = 1, idx - 1 do
-- col_count = col_count + 1
-- if str:sub(i, i) == "\n" then
-- line_count = line_count + 1
-- col_count = 1
-- end
-- end
-- emu.message( string.format("%s at line %d col %d", msg, line_count, col_count) )
end
local function codepoint_to_utf8(n)
-- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa
local f = math.floor
if n <= 0x7f then
return string.char(n)
elseif n <= 0x7ff then
return string.char(f(n / 64) + 192, n % 64 + 128)
elseif n <= 0xffff then
return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128)
elseif n <= 0x10ffff then
return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128,
f(n % 4096 / 64) + 128, n % 64 + 128)
end
error( string.format("invalid unicode codepoint '%x'", n) )
end
local function parse_unicode_escape(s)
local n1 = tonumber( s:sub(3, 6), 16 )
local n2 = tonumber( s:sub(9, 12), 16 )
-- Surrogate pair?
if n2 then
return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000)
else
return codepoint_to_utf8(n1)
end
end
local function parse_string(str, i)
local has_unicode_escape = false
local has_surrogate_escape = false
local has_escape = false
local last
for j = i + 1, #str do
local x = str:byte(j)
if x < 32 then
decode_error(str, j, "control character in string")
end
if last == 92 then -- "\\" (escape char)
if x == 117 then -- "u" (unicode escape sequence)
local hex = str:sub(j + 1, j + 5)
if not hex:find("%x%x%x%x") then
decode_error(str, j, "invalid unicode escape in string")
end
if hex:find("^[dD][89aAbB]") then
has_surrogate_escape = true
else
has_unicode_escape = true
end
else
local c = string.char(x)
if not escape_chars[c] then
decode_error(str, j, "invalid escape char '" .. c .. "' in string")
end
has_escape = true
end
last = nil
elseif x == 34 then -- '"' (end of string)
local s = str:sub(i + 1, j - 1)
if has_surrogate_escape then
s = s:gsub("\\u[dD][89aAbB]..\\u....", parse_unicode_escape)
end
if has_unicode_escape then
s = s:gsub("\\u....", parse_unicode_escape)
end
if has_escape then
s = s:gsub("\\.", escape_char_map_inv)
end
return s, j + 1
else
last = x
end
end
decode_error(str, i, "expected closing quote for string")
end
local function parse_number(str, i)
local x = next_char(str, i, delim_chars)
local s = str:sub(i, x - 1)
local n = tonumber(s)
if not n then
decode_error(str, i, "invalid number '" .. s .. "'")
end
return n, x
end
local function parse_literal(str, i)
local x = next_char(str, i, delim_chars)
local word = str:sub(i, x - 1)
if not literals[word] then
decode_error(str, i, "invalid literal '" .. word .. "'")
end
return literal_map[word], x
end
local function parse_array(str, i)
local res = {}
local n = 1
i = i + 1
while 1 do
local x
i = next_char(str, i, space_chars, true)
-- Empty / end of array?
if str:sub(i, i) == "]" then
i = i + 1
break
end
-- Read token
x, i = parse(str, i)
res[n] = x
n = n + 1
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "]" then break end
if chr ~= "," then decode_error(str, i, "expected ']' or ','") end
end
return res, i
end
local function parse_object(str, i)
local res = {}
i = i + 1
while 1 do
local key, val
i = next_char(str, i, space_chars, true)
-- Empty / end of object?
if str:sub(i, i) == "}" then
i = i + 1
break
end
-- Read key
if str:sub(i, i) ~= '"' then
decode_error(str, i, "expected string for key")
end
key, i = parse(str, i)
-- Read ':' delimiter
i = next_char(str, i, space_chars, true)
if str:sub(i, i) ~= ":" then
decode_error(str, i, "expected ':' after key")
end
i = next_char(str, i + 1, space_chars, true)
-- Read value
val, i = parse(str, i)
-- Set
res[key] = val
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "}" then break end
if chr ~= "," then decode_error(str, i, "expected '}' or ','") end
end
return res, i
end
local char_func_map = {
[ '"' ] = parse_string,
[ "0" ] = parse_number,
[ "1" ] = parse_number,
[ "2" ] = parse_number,
[ "3" ] = parse_number,
[ "4" ] = parse_number,
[ "5" ] = parse_number,
[ "6" ] = parse_number,
[ "7" ] = parse_number,
[ "8" ] = parse_number,
[ "9" ] = parse_number,
[ "-" ] = parse_number,
[ "t" ] = parse_literal,
[ "f" ] = parse_literal,
[ "n" ] = parse_literal,
[ "[" ] = parse_array,
[ "{" ] = parse_object,
}
parse = function(str, idx)
local chr = str:sub(idx, idx)
local f = char_func_map[chr]
if f then
return f(str, idx)
end
decode_error(str, idx, "unexpected character '" .. chr .. "'")
end
function json.decode(str)
if type(str) ~= "string" then
error("expected argument of type string, got " .. type(str))
end
return ( parse(str, next_char(str, 1, space_chars, true)) )
end
return json

View File

@@ -1,229 +0,0 @@
local socket = require("socket")
local json = require('json')
local math = require('math')
local STATE_OK = "Ok"
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
local STATE_UNINITIALIZED = "Uninitialized"
local SCRIPT_VERSION = 1
local APIndex = 0x1A6E
local APDeathLinkAddress = 0x00FD
local APItemAddress = 0x00FF
local EventFlagAddress = 0x1735
local MissableAddress = 0x161A
local HiddenItemsAddress = 0x16DE
local RodAddress = 0x1716
local InGame = 0x1A71
local ClientCompatibilityAddress = 0xFF00
local ItemsReceived = nil
local playerName = nil
local seedName = nil
local deathlink_rec = nil
local deathlink_send = false
local prevstate = ""
local curstate = STATE_UNINITIALIZED
local gbSocket = nil
local frame = 0
local u8 = nil
local wU8 = nil
local u16
local function defineMemoryFunctions()
local memDomain = {}
local domains = memory.getmemorydomainlist()
memDomain["rom"] = function() memory.usememorydomain("ROM") end
memDomain["wram"] = function() memory.usememorydomain("WRAM") end
return memDomain
end
local memDomain = defineMemoryFunctions()
u8 = memory.read_u8
wU8 = memory.write_u8
u16 = memory.read_u16_le
function uRange(address, bytes)
data = memory.readbyterange(address - 1, bytes + 1)
data[0] = nil
return data
end
function table.empty (self)
for _, _ in pairs(self) do
return false
end
return true
end
function slice (tbl, s, e)
local pos, new = 1, {}
for i = s + 1, e do
new[pos] = tbl[i]
pos = pos + 1
end
return new
end
function processBlock(block)
if block == nil then
return
end
local itemsBlock = block["items"]
memDomain.wram()
if itemsBlock ~= nil then
ItemsReceived = itemsBlock
end
deathlink_rec = block["deathlink"]
end
function difference(a, b)
local aa = {}
for k,v in pairs(a) do aa[v]=true end
for k,v in pairs(b) do aa[v]=nil end
local ret = {}
local n = 0
for k,v in pairs(a) do
if aa[v] then n=n+1 ret[n]=v end
end
return ret
end
function generateLocationsChecked()
memDomain.wram()
events = uRange(EventFlagAddress, 0x140)
missables = uRange(MissableAddress, 0x20)
hiddenitems = uRange(HiddenItemsAddress, 0x0E)
rod = u8(RodAddress)
data = {}
table.foreach(events, function(k, v) table.insert(data, v) end)
table.foreach(missables, function(k, v) table.insert(data, v) end)
table.foreach(hiddenitems, function(k, v) table.insert(data, v) end)
table.insert(data, rod)
return data
end
local function arrayEqual(a1, a2)
if #a1 ~= #a2 then
return false
end
for i, v in ipairs(a1) do
if v ~= a2[i] then
return false
end
end
return true
end
function receive()
l, e = gbSocket:receive()
if e == 'closed' then
if curstate == STATE_OK then
print("Connection closed")
end
curstate = STATE_UNINITIALIZED
return
elseif e == 'timeout' then
return
elseif e ~= nil then
print(e)
curstate = STATE_UNINITIALIZED
return
end
if l ~= nil then
processBlock(json.decode(l))
end
-- Determine Message to send back
memDomain.rom()
newPlayerName = uRange(0xFFF0, 0x10)
newSeedName = uRange(0xFFDB, 21)
if (playerName ~= nil and not arrayEqual(playerName, newPlayerName)) or (seedName ~= nil and not arrayEqual(seedName, newSeedName)) then
print("ROM changed, quitting")
curstate = STATE_UNINITIALIZED
return
end
playerName = newPlayerName
seedName = newSeedName
local retTable = {}
retTable["scriptVersion"] = SCRIPT_VERSION
retTable["clientCompatibilityVersion"] = u8(ClientCompatibilityAddress)
retTable["playerName"] = playerName
retTable["seedName"] = seedName
memDomain.wram()
if u8(InGame) == 0xAC then
retTable["locations"] = generateLocationsChecked()
end
retTable["deathLink"] = deathlink_send
deathlink_send = false
msg = json.encode(retTable).."\n"
local ret, error = gbSocket:send(msg)
if ret == nil then
print(error)
elseif curstate == STATE_INITIAL_CONNECTION_MADE then
curstate = STATE_TENTATIVELY_CONNECTED
elseif curstate == STATE_TENTATIVELY_CONNECTED then
print("Connected!")
curstate = STATE_OK
end
end
function main()
if (is23Or24Or25 or is26To28) == false then
print("Must use a version of bizhawk 2.3.1 or higher")
return
end
server, error = socket.bind('localhost', 17242)
while true do
frame = frame + 1
if not (curstate == prevstate) then
print("Current state: "..curstate)
prevstate = curstate
end
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
if (frame % 5 == 0) then
receive()
if u8(InGame) == 0xAC and u8(APItemAddress) == 0x00 then
ItemIndex = u16(APIndex)
if deathlink_rec == true then
wU8(APDeathLinkAddress, 1)
elseif u8(APDeathLinkAddress) == 3 then
wU8(APDeathLinkAddress, 0)
deathlink_send = true
end
if ItemsReceived[ItemIndex + 1] ~= nil then
wU8(APItemAddress, ItemsReceived[ItemIndex + 1] - 172000000)
end
end
end
elseif (curstate == STATE_UNINITIALIZED) then
if (frame % 60 == 0) then
print("Waiting for client.")
emu.frameadvance()
server:settimeout(2)
print("Attempting to connect")
local client, timeout = server:accept()
if timeout == nil then
curstate = STATE_INITIAL_CONNECTION_MADE
gbSocket = client
gbSocket:settimeout(0)
end
end
end
emu.frameadvance()
end
end
main()

View File

@@ -1,132 +0,0 @@
-----------------------------------------------------------------------------
-- LuaSocket helper module
-- Author: Diego Nehab
-- RCS ID: $Id: socket.lua,v 1.22 2005/11/22 08:33:29 diego Exp $
-----------------------------------------------------------------------------
-----------------------------------------------------------------------------
-- Declare module and import dependencies
-----------------------------------------------------------------------------
local base = _G
local string = require("string")
local math = require("math")
local socket = require("socket.core")
module("socket")
-----------------------------------------------------------------------------
-- Exported auxiliar functions
-----------------------------------------------------------------------------
function connect(address, port, laddress, lport)
local sock, err = socket.tcp()
if not sock then return nil, err end
if laddress then
local res, err = sock:bind(laddress, lport, -1)
if not res then return nil, err end
end
local res, err = sock:connect(address, port)
if not res then return nil, err end
return sock
end
function bind(host, port, backlog)
local sock, err = socket.tcp()
if not sock then return nil, err end
sock:setoption("reuseaddr", true)
local res, err = sock:bind(host, port)
if not res then return nil, err end
res, err = sock:listen(backlog)
if not res then return nil, err end
return sock
end
try = newtry()
function choose(table)
return function(name, opt1, opt2)
if base.type(name) ~= "string" then
name, opt1, opt2 = "default", name, opt1
end
local f = table[name or "nil"]
if not f then base.error("unknown key (".. base.tostring(name) ..")", 3)
else return f(opt1, opt2) end
end
end
-----------------------------------------------------------------------------
-- Socket sources and sinks, conforming to LTN12
-----------------------------------------------------------------------------
-- create namespaces inside LuaSocket namespace
sourcet = {}
sinkt = {}
BLOCKSIZE = 2048
sinkt["close-when-done"] = function(sock)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function(self, chunk, err)
if not chunk then
sock:close()
return 1
else return sock:send(chunk) end
end
})
end
sinkt["keep-open"] = function(sock)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function(self, chunk, err)
if chunk then return sock:send(chunk)
else return 1 end
end
})
end
sinkt["default"] = sinkt["keep-open"]
sink = choose(sinkt)
sourcet["by-length"] = function(sock, length)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function()
if length <= 0 then return nil end
local size = math.min(socket.BLOCKSIZE, length)
local chunk, err = sock:receive(size)
if err then return nil, err end
length = length - string.len(chunk)
return chunk
end
})
end
sourcet["until-closed"] = function(sock)
local done
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function()
if done then return nil end
local chunk, err, partial = sock:receive(socket.BLOCKSIZE)
if not err then return chunk
elseif err == "closed" then
sock:close()
done = 1
return partial
else return nil, err end
end
})
end
sourcet["default"] = sourcet["until-closed"]
source = choose(sourcet)

View File

@@ -221,7 +221,7 @@ Starting with version 4 of the APBP format, this is a ZIP file containing metada
files required by the game / patching process. For ROM-based games the ZIP will include a `delta.bsdiff4` which is the
bsdiff between the original and the randomized ROM.
To make using APBP easy, they can be generated by inheriting from `worlds.Files.APDeltaPatch`.
To make using APBP easy, they can be generated by inheriting from `Patch.APDeltaPatch`.
### Mod files
Games which support modding will usually just let you drag and drop the mods files into a folder somewhere.
@@ -230,7 +230,7 @@ They can either be generic and modify the game using a seed or `slot_data` from
generated per seed.
If the mod is generated by AP and is installed from a ZIP file, it may be possible to include APBP metadata for easy
integration into the Webhost by inheriting from `worlds.Files.APContainer`.
integration into the Webhost by inheriting from `Patch.APContainer`.
## Archipelago Integration

View File

@@ -2,7 +2,7 @@
Archipelago depends on worlds to provide game-specific details like items, locations and output generation.
Those are located in the `worlds/` folder (source) or `<insall dir>/lib/worlds/` (when installed).
See [world api.md](world%20api.md) for details.
See [world api.md](world api.md) for details.
apworld provides a way to package and ship a world that is not part of the main distribution by placing a `*.apworld`
file into the worlds folder.
@@ -23,10 +23,3 @@ No metadata is specified yet.
## Extra Data
The zip can contain arbitrary files in addition what was specified above.
## Caveats
Imports from other files inside the apworld have to use relative imports.
Imports from AP base have to use absolute imports, e.g. Options.py and worlds/AutoWorld.py.

View File

@@ -8,5 +8,5 @@ Contributions are welcome. We have a few requests of any new contributors.
Otherwise, we tend to judge code on a case to case basis.
For adding a new game to Archipelago and other documentation on how Archipelago functions, please see
[the docs folder](/docs/) for the relevant information and feel free to ask any questions in the #archipelago-dev
[the docs folder](docs/) for the relevant information and feel free to ask any questions in the #archipelago-dev
channel in our [Discord](https://archipelago.gg/discord).

View File

@@ -21,11 +21,10 @@ There are also a number of community-supported libraries available that implemen
| | [Archipelago SNIClient](https://github.com/ArchipelagoMW/Archipelago/blob/main/SNIClient.py) | For Super Nintendo Game Support; Utilizes [SNI](https://github.com/alttpo/sni). |
| JVM (Java / Kotlin) | [Archipelago.MultiClient.Java](https://github.com/ArchipelagoMW/Archipelago.MultiClient.Java) | |
| .NET (C# / C++ / F# / VB.NET) | [Archipelago.MultiClient.Net](https://www.nuget.org/packages/Archipelago.MultiClient.Net) | |
| C++ | [apclientpp](https://github.com/black-sliver/apclientpp) | header-only |
| C++ | [apclientpp](https://github.com/black-sliver/apclientpp) | almost-header-only |
| | [APCpp](https://github.com/N00byKing/APCpp) | CMake |
| JavaScript / TypeScript | [archipelago.js](https://www.npmjs.com/package/archipelago.js) | Browser and Node.js Supported |
| Haxe | [hxArchipelago](https://lib.haxe.org/p/hxArchipelago) | |
| Rust | [ArchipelagoRS](https://github.com/ryanisaacg/archipelago_rs) | |
## Synchronizing Items
When the client receives a [ReceivedItems](#ReceivedItems) packet, if the `index` argument does not match the next index that the client expects then it is expected that the client will re-sync items with the server. This can be accomplished by sending the server a [Sync](#Sync) packet and then a [LocationChecks](#LocationChecks) packet.
@@ -74,6 +73,7 @@ Sent to clients when they connect to an Archipelago server.
| hint_cost | int | The amount of points it costs to receive a hint from the server. |
| location_check_points | int | The amount of hint points you receive per item/location check completed. ||
| games | list\[str\] | List of games present in this multiworld. |
| datapackage_version | int | Sum of individual games' datapackage version. Deprecated. Use `datapackage_versions` instead. |
| datapackage_versions | dict\[str, int\] | Data versions of the individual games' data packages the server will send. Used to decide which games' caches are outdated. See [Data Package Contents](#Data-Package-Contents). |
| seed_name | str | uniquely identifying name of this generation |
| time | float | Unix time stamp of "now". Send for time synchronization if wanted for things like the DeathLink Bounce. |
@@ -120,15 +120,15 @@ InvalidItemsHandling indicates a wrong value type or flag combination was sent.
### Connected
Sent to clients when the connection handshake is successfully completed.
#### Arguments
| Name | Type | Notes |
|-------------------|------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------|
| team | int | Your team number. See [NetworkPlayer](#NetworkPlayer) for more info on team number. |
| slot | int | Your slot number on your team. See [NetworkPlayer](#NetworkPlayer) for more info on the slot number. |
| players | list\[[NetworkPlayer](#NetworkPlayer)\] | List denoting other players in the multiworld, whether connected or not. |
| missing_locations | list\[int\] | Contains ids of remaining locations that need to be checked. Useful for trackers, among other things. |
| checked_locations | list\[int\] | Contains ids of all locations that have been checked. Useful for trackers, among other things. Location ids are in the range of ± 2<sup>53</sup>-1. |
| slot_data | dict\[str, any\] | Contains a json object for slot related data, differs per game. Empty if not required. Not present if slot_data in [Connect](#Connect) is false. |
| slot_info | dict\[int, [NetworkSlot](#NetworkSlot)\] | maps each slot to a [NetworkSlot](#NetworkSlot) information |
| Name | Type | Notes |
| ---- | ---- | ----- |
| team | int | Your team number. See [NetworkPlayer](#NetworkPlayer) for more info on team number. |
| slot | int | Your slot number on your team. See [NetworkPlayer](#NetworkPlayer) for more info on the slot number. |
| players | list\[[NetworkPlayer](#NetworkPlayer)\] | List denoting other players in the multiworld, whether connected or not. |
| missing_locations | list\[int\] | Contains ids of remaining locations that need to be checked. Useful for trackers, among other things. |
| checked_locations | list\[int\] | Contains ids of all locations that have been checked. Useful for trackers, among other things. Location ids are in the range of ± 2<sup>53</sup>-1. |
| slot_data | dict | Contains a json object for slot related data, differs per game. Empty if not required. |
| slot_info | dict\[int, [NetworkSlot](#NetworkSlot)\] | maps each slot to a [NetworkSlot](#NetworkSlot) information |
### ReceivedItems
Sent to clients when they receive an item.
@@ -234,18 +234,16 @@ Sent to clients as a response the a [Get](#Get) package.
| ---- | ---- | ----- |
| keys | dict\[str\, any] | A key-value collection containing all the values for the keys requested in the [Get](#Get) package. |
If a requested key was not present in the server's data, the associated value will be `null`.
Additional arguments added to the [Get](#Get) package that triggered this [Retrieved](#Retrieved) will also be passed along.
### SetReply
Sent to clients in response to a [Set](#Set) package if want_reply was set to true, or if the client has registered to receive updates for a certain key using the [SetNotify](#SetNotify) package. SetReply packages are sent even if a [Set](#Set) package did not alter the value for the key.
#### Arguments
| Name | Type | Notes |
|----------------|------|--------------------------------------------------------------------------------------------|
| key | str | The key that was updated. |
| value | any | The new value for the key. |
| original_value | any | The value the key had before it was updated. Not present on "_read" prefixed special keys. |
| Name | Type | Notes |
| ---- | ---- | ----- |
| key | str | The key that was updated. |
| value | any | The new value for the key. |
| original_value | any | The value the key had before it was updated. |
Additional arguments added to the [Set](#Set) package that triggered this [SetReply](#SetReply) will also be passed along.
@@ -268,16 +266,15 @@ These packets are sent purely from client to server. They are not accepted by cl
Sent by the client to initiate a connection to an Archipelago game session.
#### Arguments
| Name | Type | Notes |
|----------------|-----------------------------------|----------------------------------------------------------------------------------------------|
| password | str | If the game session requires a password, it should be passed here. |
| game | str | The name of the game the client is playing. Example: `A Link to the Past` |
| name | str | The player name for this client. |
| uuid | str | Unique identifier for player client. |
| version | [NetworkVersion](#NetworkVersion) | An object representing the Archipelago version this client supports. |
| items_handling | int | Flags configuring which items should be sent by the server. Read below for individual flags. |
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. [Tags](#Tags) |
| slot_data | bool | If true, the Connect answer will contain slot_data |
| Name | Type | Notes |
| ---- | ---- | ----- |
| password | str | If the game session requires a password, it should be passed here. |
| game | str | The name of the game the client is playing. Example: `A Link to the Past` |
| name | str | The player name for this client. |
| uuid | str | Unique identifier for player client. |
| version | [NetworkVersion](#NetworkVersion) | An object representing the Archipelago version this client supports. |
| items_handling | int | Flags configuring which items should be sent by the server. Read below for individual flags. |
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. [Tags](#Tags) |
#### items_handling flags
| Value | Meaning |
@@ -367,23 +364,14 @@ Used to request a single or multiple values from the server's data storage, see
Additional arguments sent in this package will also be added to the [Retrieved](#Retrieved) package it triggers.
Some special keys exist with specific return data, all of them have the prefix `_read_`, so `hints_{team}_{slot}` is `_read_hints_{team}_{slot}`.
| Name | Type | Notes |
|-------------------------------|--------------------------|---------------------------------------------------|
| hints_{team}_{slot} | list\[[Hint](#Hint)\] | All Hints belonging to the requested Player. |
| slot_data_{slot} | dict\[str, any\] | slot_data belonging to the requested slot. |
| item_name_groups_{game_name} | dict\[str, list\[str\]\] | item_name_groups belonging to the requested game. |
### Set
Used to write data to the server's data storage, that data can then be shared across worlds or just saved for later. Values for keys in the data storage can be retrieved with a [Get](#Get) package, or monitored with a [SetNotify](#SetNotify) package.
Keys that start with `_read_` cannot be set.
#### Arguments
| Name | Type | Notes |
|------------|-------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------|
| key | str | The key to manipulate. Can never start with "_read". |
| default | any | The default value to use in case the key has no value on the server. |
| want_reply | bool | If true, the server will send a [SetReply](#SetReply) response back to the client. |
| Name | Type | Notes |
| ------ | ----- | ------ |
| key | str | The key to manipulate. |
| default | any | The default value to use in case the key has no value on the server. |
| want_reply | bool | If set, the server will send a [SetReply](#SetReply) response back to the client. |
| operations | list\[[DataStorageOperation](#DataStorageOperation)\] | Operations to apply to the value, multiple operations can be present and they will be executed in order of appearance. |
Additional arguments sent in this package will also be added to the [SetReply](#SetReply) package it triggers.
@@ -600,20 +588,6 @@ class Permission(enum.IntEnum):
auto_enabled = 0b111 # 7, forces use after goal completion, allows manual use any time
```
### Hint
An object representing a Hint.
```python
import typing
class Hint(typing.NamedTuple):
receiving_player: int
finding_player: int
location: int
item: int
found: bool
entrance: str = ""
item_flags: int = 0
```
### Data Package Contents
A data package is a JSON object which may contain arbitrary metadata to enable a client to interact with the Archipelago server most easily. Currently, this package is used to send ID to name mappings so that clients need not maintain their own mappings.
@@ -645,6 +619,7 @@ Tags are represented as a list of strings, the common Client tags follow:
| Name | Notes |
|------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| AP | Signifies that this client is a reference client, its usefulness is mostly in debugging to compare client behaviours more easily. |
| IgnoreGame | Deprecated. See Tracker and TextOnly. Tells the server to ignore the "game" attribute in the [Connect](#Connect) packet. |
| DeathLink | Client participates in the DeathLink mechanic, therefore will send and receive DeathLink bounce packets |
| Tracker | Tells the server that this client will not send locations and is actually a Tracker. When specified and used with empty or null `game` in [Connect](#connect), game and game's version validation will be skipped. |
| TextOnly | Tells the server that this client will not send locations and is intended for chat. When specified and used with empty or null `game` in [Connect](#connect), game and game's version validation will be skipped. |

View File

@@ -16,14 +16,6 @@ Then run any of the starting point scripts, like Generate.py, and the included M
required modules and after pressing enter proceed to install everything automatically.
After this, you should be able to run the programs.
* With yaml(s) in the `Players` folder, `Generate.py` will generate the multiworld archive.
* `MultiServer.py`, with the filename of the generated archive as a command line parameter, will host the multiworld locally.
* `--log_network` is a command line parameter useful for debugging.
* `WebHost.py` will host the website on your computer.
* You can copy `docs/webhost configuration sample.yaml` to `config.yaml`
to change WebHost options (like the web hosting port number).
* As a side effect, `WebHost.py` creates the template yamls for all the games in `WebHostLib/static/generated`.
## Windows

View File

@@ -15,9 +15,7 @@
* Strings in worlds should use double quotes as well, but imported code may differ.
* Prefer [format string literals](https://peps.python.org/pep-0498/) over string concatenation,
use single quotes inside them: `f"Like {dct['key']}"`
* Use type annotations where possible for function signatures and class members.
* Use type annotations where appropriate for local variables (e.g. `var: List[int] = []`, or when the
type is hard or impossible to deduce.) Clear annotations help developers look up and validate API calls.
* Use type annotation where possible.
## Markdown

View File

@@ -102,18 +102,13 @@ Locations are places where items can be located in your game. This may be chests
or boss drops for RPG-like games but could also be progress in a research tree.
Each location has a `name` and an `id` (a.k.a. "code" or "address"), is placed
in a Region, has access rules and a classification.
The name needs to be unique in each game and must not be numeric (has to
contain least 1 letter or symbol). The ID needs to be unique across all games
and is best in the same range as the item IDs.
in a Region and has access rules.
The name needs to be unique in each game, the ID needs to be unique across all
games and is best in the same range as the item IDs.
World-specific IDs are 1 to 2<sup>53</sup>-1, IDs ≤ 0 are global and reserved.
Special locations with ID `None` can hold events.
Classification is one of `LocationProgressType.DEFAULT`, `PRIORITY` or `EXCLUDED`.
The Fill algorithm will fill priority first, giving higher chance of it being
required, and not place progression or useful items in excluded locations.
### Items
Items are all things that can "drop" for your game. This may be RPG items like
@@ -126,9 +121,6 @@ their world. Progression items will be assigned to locations with higher
priority and moved around to meet defined rules and accomplish progression
balancing.
The name needs to be unique in each game, meaning a duplicate item has the
same ID. Name must not be numeric (has to contain at least 1 letter or symbol).
Special items with ID `None` can mark events (read below).
Other classifications include
@@ -196,17 +188,15 @@ the `/worlds` directory. The starting point for the package is `__init.py__`.
Conventionally, your world class is placed in that file.
World classes must inherit from the `World` class in `/worlds/AutoWorld.py`,
which can be imported as `worlds.AutoWorld.World` from your package.
which can be imported as `..AutoWorld.World` from your package.
AP will pick up your world automatically due to the `AutoWorld` implementation.
### Requirements
If your world needs specific python packages, they can be listed in
`world/[world_name]/requirements.txt`. ModuleUpdate.py will automatically
pick up and install them.
See [pip documentation](https://pip.pypa.io/en/stable/cli/pip_install/#requirements-file-format).
`world/[world_name]/requirements.txt`.
See [pip documentation](https://pip.pypa.io/en/stable/cli/pip_install/#requirements-file-format)
### Relative Imports
@@ -219,10 +209,6 @@ e.g. `from .Options import mygame_options` from your `__init__.py` will load
When imported names pile up it may be easier to use `from . import Options`
and access the variable as `Options.mygame_options`.
Imports from directories outside your world should use absolute imports.
Correct use of relative / absolute imports is required for zipped worlds to
function, see [apworld specification.md](apworld%20specification.md).
### Your Item Type
Each world uses its own subclass of `BaseClasses.Item`. The constuctor can be
@@ -288,12 +274,14 @@ Define a property `option_<name> = <number>` per selectable value and
`default = <number>` to set the default selection. Aliases can be set by
defining a property `alias_<name> = <same number>`.
One special case where aliases are required is when option name is `yes`, `no`,
`on` or `off` because they parse to `True` or `False`:
```python
option_off = 0
option_on = 1
option_some = 2
alias_disabled = 0
alias_enabled = 1
alias_false = 0
alias_true = 1
default = 0
```
@@ -335,7 +323,7 @@ mygame_options: typing.Dict[str, type(Option)] = {
```python
# __init__.py
from worlds.AutoWorld import World
from ..AutoWorld import World
from .Options import mygame_options # import the options dict
class MyGameWorld(World):
@@ -343,6 +331,18 @@ class MyGameWorld(World):
option_definitions = mygame_options # assign the options dict to the world
#...
```
### Local or Remote
A world with `remote_items` set to `True` gets all items items from the server
and no item from the local game. So for an RPG opening a chest would not add
any item to your inventory, instead the server will send you what was in that
chest. The advantage is that a generic mod can be used that does not need to
know anything about the seed.
A world with `remote_items` set to `False` will locally reward its local items.
For console games this can remove delay and make script/animation/dialog flow
more natural. These games typically have been edited to 'bake in' the items.
### A World Class Skeleton
@@ -352,7 +352,7 @@ class MyGameWorld(World):
from .Options import mygame_options # the options we defined earlier
from .Items import mygame_items # data used below to add items to the World
from .Locations import mygame_locations # same as above
from worlds.AutoWorld import World
from ..AutoWorld import World
from BaseClasses import Region, Location, Entrance, Item, RegionType, ItemClassification
from Utils import get_options, output_path
@@ -367,6 +367,8 @@ class MyGameWorld(World):
game: str = "My Game" # name of the game/world
option_definitions = mygame_options # options the player can set
topology_present: bool = True # show path to required location checks in spoiler
remote_items: bool = False # True if all items come from the server
remote_start_inventory: bool = False # True if start inventory comes from the server
# data_version is used to signal that items, locations or their names
# changed. Set this to 0 during development so other games' clients do not
@@ -401,13 +403,17 @@ The world has to provide the following things for generation
* additions to the item pool
* additions to the regions list: at least one called "Menu"
* locations placed inside those regions
* a `def create_item(self, item: str) -> MyGameItem` to create any item on demand
* applying `self.world.push_precollected` for start inventory
* a `def create_item(self, item: str) -> MyGameItem` for plando/manual placing
* applying `self.world.precollected_items` for plando/start inventory
if not using a `remote_start_inventory`
* a `def generate_output(self, output_directory: str)` that creates the output
files if there is output to be generated. When this is
called, `self.world.get_locations(self.player)` has all locations for the player, with
attribute `item` pointing to the item.
`location.item.player` can be used to see if it's a local item.
if there is output to be generated. If only items are randomized and
`remote_items = True` it is possible to have a generic mod and output
generation can be skipped. In all other cases this is required. When this is
called, `self.world.get_locations()` has all locations for all players, with
properties `item` pointing to the item and `player` identifying the player.
`self.world.get_filled_locations(self.player)` will filter for this world.
`item.player` can be used to see if it's a local item.
In addition, the following methods can be implemented and attributes can be set
@@ -415,13 +421,12 @@ In addition, the following methods can be implemented and attributes can be set
called per player before any items or locations are created. You can set
properties on your world here. Already has access to player options and RNG.
* `def create_regions(self)`
called to place player's regions and their locations into the MultiWorld's regions list. If it's
called to place player's regions into the MultiWorld's regions list. If it's
hard to separate, this can be done during `generate_early` or `basic` as well.
* `def create_items(self)`
called to place player's items into the MultiWorld's itempool.
* `def set_rules(self)`
called to set access and item rules on locations and entrances.
Locations have to be defined before this, or rule application can miss them.
called to set access and item rules on locations and entrances.
* `def generate_basic(self)`
called after the previous steps. Some placement and player specific
randomizations can be done here. After this step all regions and items have
@@ -442,7 +447,7 @@ In addition, the following methods can be implemented and attributes can be set
```python
def generate_early(self) -> None:
# read player settings to world instance
self.final_boss_hp = self.multiworld.final_boss_hp[self.player].value
self.final_boss_hp = self.world.final_boss_hp[self.player].value
```
#### create_item
@@ -477,19 +482,19 @@ def create_items(self) -> None:
# If an item can't have duplicates it has to be excluded manually.
# List of items to exclude, as a copy since it will be destroyed below
exclude = [item for item in self.multiworld.precollected_items[self.player]]
exclude = [item for item in self.world.precollected_items[self.player]]
for item in map(self.create_item, mygame_items):
if item in exclude:
exclude.remove(item) # this is destructive. create unique list above
self.multiworld.itempool.append(self.create_item("nothing"))
self.world.itempool.append(self.create_item("nothing"))
else:
self.multiworld.itempool.append(item)
self.world.itempool.append(item)
# itempool and number of locations should match up.
# If this is not the case we want to fill the itempool with junk.
junk = 0 # calculate this based on player settings
self.multiworld.itempool += [self.create_item("nothing") for _ in range(junk)]
self.world.itempool += [self.create_item("nothing") for _ in range(junk)]
```
#### create_regions
@@ -498,30 +503,30 @@ def create_items(self) -> None:
def create_regions(self) -> None:
# Add regions to the multiworld. "Menu" is the required starting point.
# Arguments to Region() are name, type, human_readable_name, player, world
r = Region("Menu", RegionType.Generic, "Menu", self.player, self.multiworld)
r = Region("Menu", RegionType.Generic, "Menu", self.player, self.world)
# Set Region.exits to a list of entrances that are reachable from region
r.exits = [Entrance(self.player, "New game", r)] # or use r.exits.append
# Append region to MultiWorld's regions
self.multiworld.regions.append(r) # or use += [r...]
self.world.regions.append(r) # or use += [r...]
r = Region("Main Area", RegionType.Generic, "Main Area", self.player, self.multiworld)
r = Region("Main Area", RegionType.Generic, "Main Area", self.player, self.world)
# Add main area's locations to main area (all but final boss)
r.locations = [MyGameLocation(self.player, location.name,
self.location_name_to_id[location.name], r)]
r.exits = [Entrance(self.player, "Boss Door", r)]
self.multiworld.regions.append(r)
self.world.regions.append(r)
r = Region("Boss Room", RegionType.Generic, "Boss Room", self.player, self.multiworld)
r = Region("Boss Room", RegionType.Generic, "Boss Room", self.player, self.world)
# add event to Boss Room
r.locations = [MyGameLocation(self.player, "Final Boss", None, r)]
self.multiworld.regions.append(r)
self.world.regions.append(r)
# If entrances are not randomized, they should be connected here, otherwise
# they can also be connected at a later stage.
self.multiworld.get_entrance("New Game", self.player)
.connect(self.multiworld.get_region("Main Area", self.player))
self.multiworld.get_entrance("Boss Door", self.player)
.connect(self.multiworld.get_region("Boss Room", self.player))
self.world.get_entrance("New Game", self.player)\
.connect(self.world.get_region("Main Area", self.player))
self.world.get_entrance("Boss Door", self.player)\
.connect(self.world.get_region("Boss Room", self.player))
# If setting location access rules from data is easier here, set_rules can
# possibly omitted.
@@ -532,14 +537,14 @@ def create_regions(self) -> None:
```python
def generate_basic(self) -> None:
# place "Victory" at "Final Boss" and set collection as win condition
self.multiworld.get_location("Final Boss", self.player)
self.world.get_location("Final Boss", self.player)\
.place_locked_item(self.create_event("Victory"))
self.multiworld.completion_condition[self.player] =
self.world.completion_condition[self.player] = \
lambda state: state.has("Victory", self.player)
# place item Herb into location Chest1 for some reason
item = self.create_item("Herb")
self.multiworld.get_location("Chest1", self.player).place_locked_item(item)
self.world.get_location("Chest1", self.player).place_locked_item(item)
# in most cases it's better to do this at the same time the itempool is
# filled to avoid accidental duplicates:
# manually placed and still in the itempool
@@ -548,45 +553,44 @@ def generate_basic(self) -> None:
### Setting Rules
```python
from worlds.generic.Rules import add_rule, set_rule, forbid_item
from ..generic.Rules import add_rule, set_rule, forbid_item
from Items import get_item_type
def set_rules(self) -> None:
# For some worlds this step can be omitted if either a Logic mixin
# (see below) is used, it's easier to apply the rules from data during
# location generation or everything is in generate_basic
# set a simple rule for an region
set_rule(self.multiworld.get_entrance("Boss Door", self.player),
set_rule(self.world.get_entrance("Boss Door", self.player),
lambda state: state.has("Boss Key", self.player))
# combine rules to require two items
add_rule(self.multiworld.get_location("Chest2", self.player),
add_rule(self.world.get_location("Chest2", self.player),
lambda state: state.has("Sword", self.player))
add_rule(self.multiworld.get_location("Chest2", self.player),
add_rule(self.world.get_location("Chest2", self.player),
lambda state: state.has("Shield", self.player))
# or simply combine yourself
set_rule(self.multiworld.get_location("Chest2", self.player),
set_rule(self.world.get_location("Chest2", self.player),
lambda state: state.has("Sword", self.player) and
state.has("Shield", self.player))
# require two of an item
set_rule(self.multiworld.get_location("Chest3", self.player),
set_rule(self.world.get_location("Chest3", self.player),
lambda state: state.has("Key", self.player, 2))
# require one item from an item group
add_rule(self.multiworld.get_location("Chest3", self.player),
add_rule(self.world.get_location("Chest3", self.player),
lambda state: state.has_group("weapons", self.player))
# state also has .item_count() for items, .has_any() and.has_all() for sets
# and .count_group() for groups
# set_rule is likely to be a bit faster than add_rule
# disallow placing a specific local item at a specific location
forbid_item(self.multiworld.get_location("Chest4", self.player), "Sword")
forbid_item(self.world.get_location("Chest4", self.player), "Sword")
# disallow placing items with a specific property
add_item_rule(self.multiworld.get_location("Chest5", self.player),
add_item_rule(self.world.get_location("Chest5", self.player),
lambda item: get_item_type(item) == "weapon")
# get_item_type needs to take player/world into account
# if MyGameItem has a type property, a more direct implementation would be
add_item_rule(self.multiworld.get_location("Chest5", self.player),
add_item_rule(self.world.get_location("Chest5", self.player),
lambda item: item.player != self.player or\
item.my_type == "weapon")
# location.item_rule = ... is likely to be a bit faster
@@ -599,16 +603,14 @@ implement more complex logic in logic mixins, even if there is no need to add
properties to the `BaseClasses.CollectionState` state object.
When importing a file that defines a class that inherits from
`worlds.AutoWorld.LogicMixin` the state object's class is automatically extended by
`..AutoWorld.LogicMixin` the state object's class is automatically extended by
the mixin's members. These members should be prefixed with underscore following
the name of the implementing world. This is due to sharing a namespace with all
other logic mixins.
Typical uses are defining methods that are used instead of `state.has`
in lambdas, e.g.`state.mygame_has(custom, player)` or recurring checks
like `state.mygame_can_do_something(player)` to simplify lambdas.
Private members, only accessible from mixins, should start with `_mygame_`,
public members with `mygame_`.
in lambdas, e.g.`state._mygame_has(custom, world, player)` or recurring checks
like `state._mygame_can_do_something(world, player)` to simplify lambdas.
More advanced uses could be to add additional variables to the state object,
override `World.collect(self, state, item)` and `remove(self, state, item)`
@@ -620,26 +622,25 @@ Please do this with caution and only when neccessary.
```python
# Logic.py
from worlds.AutoWorld import LogicMixin
from ..AutoWorld import LogicMixin
class MyGameLogic(LogicMixin):
def mygame_has_key(self, player: int):
def _mygame_has_key(self, world: MultiWorld, player: int):
# Arguments above are free to choose
# MultiWorld can be accessed through self.world, explicitly passing in
# MyGameWorld instance for easy options access is also a valid approach
# it may make sense to use World as argument instead of MultiWorld
return self.has("key", player) # or whatever
```
```python
# __init__.py
from worlds.generic.Rules import set_rule
from ..generic.Rules import set_rule
import .Logic # apply the mixin by importing its file
class MyGameWorld(World):
# ...
def set_rules(self):
set_rule(self.world.get_location("A Door", self.player),
lamda state: state.mygame_has_key(self.player))
lamda state: state._mygame_has_key(self.world, self.player))
```
### Generate Output
@@ -647,34 +648,32 @@ class MyGameWorld(World):
```python
from .Mod import generate_mod
def generate_output(self, output_directory: str):
# How to generate the mod or ROM highly depends on the game
# if the mod is written in Lua, Jinja can be used to fill a template
# if the mod reads a json file, `json.dump()` can be used to generate that
# code below is a dummy
data = {
"seed": self.multiworld.seed_name, # to verify the server's multiworld
"slot": self.multiworld.player_name[self.player], # to connect to server
"seed": self.world.seed_name, # to verify the server's multiworld
"slot": self.world.player_name[self.player], # to connect to server
"items": {location.name: location.item.name
if location.item.player == self.player else "Remote"
for location in self.multiworld.get_filled_locations(self.player)},
for location in self.world.get_filled_locations(self.player)},
# store start_inventory from player's .yaml
# make sure to mark as not remote_start_inventory when connecting if stored in rom/mod
"starter_items": [item.name for item
in self.multiworld.precollected_items[self.player]],
in self.world.precollected_items[self.player]],
"final_boss_hp": self.final_boss_hp,
# store option name "easy", "normal" or "hard" for difficuly
"difficulty": self.multiworld.difficulty[self.player].current_key,
"difficulty": self.world.difficulty[self.player].current_key,
# store option value True or False for fixing a glitch
"fix_xyz_glitch": self.multiworld.fix_xyz_glitch[self.player].value
"fix_xyz_glitch": self.world.fix_xyz_glitch[self.player].value
}
# point to a ROM specified by the installation
src = Utils.get_options()["mygame_options"]["rom_file"]
# or point to worlds/mygame/data/mod_template
src = os.path.join(os.path.dirname(__file__), "data", "mod_template")
# generate output path
mod_name = f"AP-{self.multiworld.seed_name}-P{self.player}-{self.multiworld.player_name[self.player]}"
mod_name = f"AP-{self.world.seed_name}-P{self.player}-{self.world.player_name[self.player]}"
out_file = os.path.join(output_directory, mod_name + ".zip")
# generate the file
generate_mod(src, out_file, data)

View File

@@ -68,10 +68,9 @@ generator:
meta_file_path: "meta.yaml"
# Create a spoiler file
# 0 -> None
# 1 -> Spoiler without playthrough or paths to playthrough required items
# 2 -> Spoiler with playthrough (viable solution to goals)
# 3 -> Spoiler with playthrough and traversal paths towards items
spoiler: 3
# 1 -> Spoiler without playthrough
# 2 -> Full spoiler
spoiler: 2
# Glitch to Triforce room from Ganon
# When disabled, you have to have a weapon that can hurt ganon (master sword or swordless/easy item functionality + hammer)
# and have completed the goal required for killing ganon to be able to access the triforce room.
@@ -83,30 +82,28 @@ generator:
# List of options that can be plando'd. Can be combined, for example "bosses, items"
# Available options: bosses, items, texts, connections
plando_options: "bosses"
sni_options:
# Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
sni_path: "SNI"
# Set this to false to never autostart a rom (such as after patching)
# True for operating system default program
# Alternatively, a path to a program to open the .sfc file with
snes_rom_start: true
lttp_options:
# File name of the v1.0 J rom
rom_file: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"
lufia2ac_options:
# File name of the US rom
rom_file: "Lufia II - Rise of the Sinistrals (USA).sfc"
# Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
sni: "SNI"
# Set this to false to never autostart a rom (such as after patching)
# True for operating system default program
# Alternatively, a path to a program to open the .sfc file with
rom_start: true
sm_options:
# File name of the v1.0 J rom
rom_file: "Super Metroid (JU).sfc"
# Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
sni: "SNI"
# Set this to false to never autostart a rom (such as after patching)
# True for operating system default program
# Alternatively, a path to a program to open the .sfc file with
rom_start: true
factorio_options:
executable: "factorio/bin/x64/factorio"
# by default, no settings are loaded if this file does not exist. If this file does exist, then it will be used.
# server_settings: "factorio\\data\\server-settings.json"
# Whether to filter item send messages displayed in-game to only those that involve you.
filter_item_sends: false
# Whether to send chat messages from players on the Factorio server to Archipelago.
bridge_chat_out: true
minecraft_options:
forge_directory: "Minecraft Forge server"
max_heap_size: "2G"
@@ -125,26 +122,19 @@ soe_options:
rom_file: "Secret of Evermore (USA).sfc"
ffr_options:
display_msgs: true
dkc3_options:
# File name of the DKC3 US rom
rom_file: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"
smw_options:
# File name of the SMW US rom
rom_file: "Super Mario World (USA).sfc"
pokemon_rb_options:
# File names of the Pokemon Red and Blue roms
red_rom_file: "Pokemon Red (UE) [S][!].gb"
blue_rom_file: "Pokemon Blue (UE) [S][!].gb"
# Set this to false to never autostart a rom (such as after patching)
# True for operating system default program
# Alternatively, a path to a program to open the .gb file with
rom_start: true
zillion_options:
# File name of the Zillion US rom
rom_file: "Zillion (UE) [!].sms"
smz3_options:
# Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
sni: "SNI"
# Set this to false to never autostart a rom (such as after patching)
# True for operating system default program
# Alternatively, a path to a program to open the .sfc file with
# 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"
rom_start: true
dkc3_options:
# File name of the DKC3 US rom
rom_file: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"
# Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
sni: "SNI"
# Set this to false to never autostart a rom (such as after patching)
# True for operating system default program
# Alternatively, a path to a program to open the .sfc file with
rom_start: true

View File

@@ -55,32 +55,21 @@ Name: "core"; Description: "Core Files"; Types: full hosting playing
Name: "generator"; Description: "Generator"; Types: full hosting
Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
Name: "generator/dkc3"; Description: "Donkey Kong Country 3 ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
Name: "generator/smw"; Description: "Super Mario World ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
Name: "generator/l2ac"; Description: "Lufia II Ancient Cave ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 2621440; Flags: disablenouninstallwarning
Name: "generator/lttp"; Description: "A Link to the Past ROM Setup and Enemizer"; Types: full hosting; ExtraDiskSpaceRequired: 5191680
Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296; Flags: disablenouninstallwarning
Name: "generator/zl"; Description: "Zillion ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 150000; Flags: disablenouninstallwarning
Name: "generator/pkmn_r"; Description: "Pokemon Red ROM Setup"; Types: full hosting
Name: "generator/pkmn_b"; Description: "Pokemon Blue ROM Setup"; Types: full hosting
Name: "server"; Description: "Server"; Types: full hosting
Name: "client"; Description: "Clients"; Types: full playing
Name: "client/sni"; Description: "SNI Client"; Types: full playing
Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/sni/dkc3"; Description: "SNI Client - Donkey Kong Country 3 Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/sni/smw"; Description: "SNI Client - Super Mario World Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/sni/l2ac"; Description: "SNI Client - Lufia II Ancient Cave Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/factorio"; Description: "Factorio"; Types: full playing
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
Name: "client/oot"; Description: "Ocarina of Time"; Types: full playing
Name: "client/ff1"; Description: "Final Fantasy 1"; Types: full playing
Name: "client/pkmn"; Description: "Pokemon Client"
Name: "client/pkmn/red"; Description: "Pokemon Client - Pokemon Red Setup"; Types: full playing; ExtraDiskSpaceRequired: 1048576
Name: "client/pkmn/blue"; Description: "Pokemon Client - Pokemon Blue Setup"; Types: full playing; ExtraDiskSpaceRequired: 1048576
Name: "client/cf"; Description: "ChecksFinder"; Types: full playing
Name: "client/sc2"; Description: "Starcraft 2"; Types: full playing
Name: "client/zl"; Description: "Zillion"; Types: full playing
Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing
[Dirs]
@@ -90,13 +79,8 @@ NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-mod
Source: "{code:GetROMPath}"; DestDir: "{app}"; DestName: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"; Flags: external; Components: client/sni/lttp or generator/lttp
Source: "{code:GetSMROMPath}"; DestDir: "{app}"; DestName: "Super Metroid (JU).sfc"; Flags: external; Components: client/sni/sm or generator/sm
Source: "{code:GetDKC3ROMPath}"; DestDir: "{app}"; DestName: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"; Flags: external; Components: client/sni/dkc3 or generator/dkc3
Source: "{code:GetSMWROMPath}"; DestDir: "{app}"; DestName: "Super Mario World (USA).sfc"; Flags: external; Components: client/sni/smw or generator/smw
Source: "{code:GetSoEROMPath}"; DestDir: "{app}"; DestName: "Secret of Evermore (USA).sfc"; Flags: external; Components: generator/soe
Source: "{code:GetL2ACROMPath}"; DestDir: "{app}"; DestName: "Lufia II - Rise of the Sinistrals (USA).sfc"; Flags: external; Components: generator/l2ac
Source: "{code:GetOoTROMPath}"; DestDir: "{app}"; DestName: "The Legend of Zelda - Ocarina of Time.z64"; Flags: external; Components: client/oot or generator/oot
Source: "{code:GetZlROMPath}"; DestDir: "{app}"; DestName: "Zillion (UE) [!].sms"; Flags: external; Components: client/zl or generator/zl
Source: "{code:GetRedROMPath}"; DestDir: "{app}"; DestName: "Pokemon Red (UE) [S][!].gb"; Flags: external; Components: client/pkmn/red or generator/pkmn_r
Source: "{code:GetBlueROMPath}"; DestDir: "{app}"; DestName: "Pokemon Blue (UE) [S][!].gb"; Flags: external; Components: client/pkmn/blue or generator/pkmn_b
Source: "{#source_path}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI, EnemizerCLI, Archipelago*.exe"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#source_path}\SNI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\SNI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: client/sni
Source: "{#source_path}\EnemizerCLI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\EnemizerCLI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: generator/lttp
@@ -110,9 +94,7 @@ Source: "{#source_path}\ArchipelagoLttPAdjuster.exe"; DestDir: "{app}"; Flags: i
Source: "{#source_path}\ArchipelagoMinecraftClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/minecraft
Source: "{#source_path}\ArchipelagoOoTClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot
Source: "{#source_path}\ArchipelagoOoTAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot
Source: "{#source_path}\ArchipelagoZillionClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/zl
Source: "{#source_path}\ArchipelagoFF1Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ff1
Source: "{#source_path}\ArchipelagoPokemonClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/pkmn
Source: "{#source_path}\ArchipelagoChecksFinderClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/cf
Source: "{#source_path}\ArchipelagoStarcraft2Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sc2
Source: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall
@@ -125,9 +107,7 @@ Name: "{group}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.e
Name: "{group}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Components: client/factorio
Name: "{group}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Components: client/minecraft
Name: "{group}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Components: client/oot
Name: "{group}\{#MyAppName} Zillion Client"; Filename: "{app}\ArchipelagoZillionClient.exe"; Components: client/zl
Name: "{group}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Components: client/ff1
Name: "{group}\{#MyAppName} Pokemon Client"; Filename: "{app}\ArchipelagoPokemonClient.exe"; Components: client/pkmn
Name: "{group}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Components: client/cf
Name: "{group}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Components: client/sc2
@@ -137,9 +117,7 @@ Name: "{commondesktop}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNI
Name: "{commondesktop}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Tasks: desktopicon; Components: client/factorio
Name: "{commondesktop}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Tasks: desktopicon; Components: client/minecraft
Name: "{commondesktop}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Tasks: desktopicon; Components: client/oot
Name: "{commondesktop}\{#MyAppName} Zillion Client"; Filename: "{app}\ArchipelagoZillionClient.exe"; Tasks: desktopicon; Components: client/zl
Name: "{commondesktop}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Tasks: desktopicon; Components: client/ff1
Name: "{commondesktop}\{#MyAppName} Pokemon Client"; Filename: "{app}\ArchipelagoPokemonClient.exe"; Tasks: desktopicon; Components: client/pkmn
Name: "{commondesktop}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Tasks: desktopicon; Components: client/cf
Name: "{commondesktop}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Tasks: desktopicon; Components: client/sc2
@@ -155,7 +133,6 @@ Type: dirifempty; Name: "{app}"
[InstallDelete]
Type: files; Name: "{app}\ArchipelagoLttPClient.exe"
Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy*"
#include "installdelete.iss"
[Registry]
@@ -174,16 +151,6 @@ Root: HKCR; Subkey: "{#MyAppName}dkc3patch"; ValueData: "Arc
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apsmw"; ValueData: "{#MyAppName}smwpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smwpatch"; ValueData: "Archipelago Super Mario World Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smwpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smwpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apzl"; ValueData: "{#MyAppName}zlpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/zl
Root: HKCR; Subkey: "{#MyAppName}zlpatch"; ValueData: "Archipelago Zillion Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/zl
Root: HKCR; Subkey: "{#MyAppName}zlpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoZillionClient.exe,0"; ValueType: string; ValueName: ""; Components: client/zl
Root: HKCR; Subkey: "{#MyAppName}zlpatch\shell\open\command"; ValueData: """{app}\ArchipelagoZillionClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/zl
Root: HKCR; Subkey: ".apsmz3"; ValueData: "{#MyAppName}smz3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smz3patch"; ValueData: "Archipelago SMZ3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smz3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
@@ -194,11 +161,6 @@ Root: HKCR; Subkey: "{#MyAppName}soepatch"; ValueData: "Arch
Root: HKCR; Subkey: "{#MyAppName}soepatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}soepatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apl2ac"; ValueData: "{#MyAppName}l2acpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}l2acpatch"; ValueData: "Archipelago Lufia II Ancient Cave Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}l2acpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}l2acpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apmc"; ValueData: "{#MyAppName}mcdata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/minecraft
Root: HKCR; Subkey: "{#MyAppName}mcdata"; ValueData: "Archipelago Minecraft Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/minecraft
Root: HKCR; Subkey: "{#MyAppName}mcdata\DefaultIcon"; ValueData: "{app}\ArchipelagoMinecraftClient.exe,0"; ValueType: string; ValueName: ""; Components: client/minecraft
@@ -209,16 +171,6 @@ Root: HKCR; Subkey: "{#MyAppName}n64zpf"; ValueData: "Archip
Root: HKCR; Subkey: "{#MyAppName}n64zpf\DefaultIcon"; ValueData: "{app}\ArchipelagoOoTClient.exe,0"; ValueType: string; ValueName: ""; Components: client/oot
Root: HKCR; Subkey: "{#MyAppName}n64zpf\shell\open\command"; ValueData: """{app}\ArchipelagoOoTClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/oot
Root: HKCR; Subkey: ".apred"; ValueData: "{#MyAppName}pkmnrpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/pkmn
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch"; ValueData: "Archipelago Pokemon Red Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/pkmn
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn
Root: HKCR; Subkey: ".apblue"; ValueData: "{#MyAppName}pkmnbpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/pkmn
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch"; ValueData: "Archipelago Pokemon Blue Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/pkmn
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: server
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: server
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; Components: server
@@ -244,7 +196,7 @@ begin
begin
// Is the installed version at least the packaged one ?
Log('VC Redist x64 Version : found ' + strVersion);
Result := (CompareStr(strVersion, 'v14.32.31332') < 0);
Result := (CompareStr(strVersion, 'v14.29.30037') < 0);
end
else
begin
@@ -265,27 +217,12 @@ var SMRomFilePage: TInputFileWizardPage;
var dkc3rom: string;
var DKC3RomFilePage: TInputFileWizardPage;
var smwrom: string;
var SMWRomFilePage: TInputFileWizardPage;
var soerom: string;
var SoERomFilePage: TInputFileWizardPage;
var l2acrom: string;
var L2ACROMFilePage: TInputFileWizardPage;
var ootrom: string;
var OoTROMFilePage: TInputFileWizardPage;
var zlrom: string;
var ZlROMFilePage: TInputFileWizardPage;
var redrom: string;
var RedROMFilePage: TInputFileWizardPage;
var bluerom: string;
var BlueROMFilePage: TInputFileWizardPage;
function GetSNESMD5OfFile(const rom: string): string;
var data: AnsiString;
begin
@@ -299,15 +236,6 @@ begin
end;
end;
function GetSMSMD5OfFile(const rom: string): string;
var data: AnsiString;
begin
if LoadStringFromFile(rom, data) then
begin
Result := GetMD5OfString(data);
end;
end;
function CheckRom(name: string; hash: string): string;
var rom: string;
begin
@@ -327,25 +255,6 @@ begin
end;
end;
function CheckSMSRom(name: string; hash: string): string;
var rom: string;
begin
log('Handling ' + name)
rom := FileSearch(name, WizardDirValue());
if Length(rom) > 0 then
begin
log('existing ROM found');
log(IntToStr(CompareStr(GetSMSMD5OfFile(rom), hash)));
if CompareStr(GetSMSMD5OfFile(rom), hash) = 0 then
begin
log('existing ROM verified');
Result := rom;
exit;
end;
log('existing ROM failed verification');
end;
end;
function AddRomPage(name: string): TInputFileWizardPage;
begin
Result :=
@@ -361,37 +270,6 @@ begin
'.sfc');
end;
function AddGBRomPage(name: string): TInputFileWizardPage;
begin
Result :=
CreateInputFilePage(
wpSelectComponents,
'Select ROM File',
'Where is your ' + name + ' located?',
'Select the file, then click Next.');
Result.Add(
'Location of ROM file:',
'GB ROM files|*.gb;*.gbc|All files|*.*',
'.gb');
end;
function AddSMSRomPage(name: string): TInputFileWizardPage;
begin
Result :=
CreateInputFilePage(
wpSelectComponents,
'Select ROM File',
'Where is your ' + name + ' located?',
'Select the file, then click Next.');
Result.Add(
'Location of ROM file:',
'SMS ROM files|*.sms|All files|*.*',
'.sms');
end;
procedure AddOoTRomPage();
begin
ootrom := FileSearch('The Legend of Zelda - Ocarina of Time.z64', WizardDirValue());
@@ -430,16 +308,10 @@ begin
Result := not (SMROMFilePage.Values[0] = '')
else if (assigned(DKC3ROMFilePage)) and (CurPageID = DKC3ROMFilePage.ID) then
Result := not (DKC3ROMFilePage.Values[0] = '')
else if (assigned(SMWROMFilePage)) and (CurPageID = SMWROMFilePage.ID) then
Result := not (SMWROMFilePage.Values[0] = '')
else if (assigned(SoEROMFilePage)) and (CurPageID = SoEROMFilePage.ID) then
Result := not (SoEROMFilePage.Values[0] = '')
else if (assigned(L2ACROMFilePage)) and (CurPageID = L2ACROMFilePage.ID) then
Result := not (L2ACROMFilePage.Values[0] = '')
else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then
Result := not (OoTROMFilePage.Values[0] = '')
else if (assigned(ZlROMFilePage)) and (CurPageID = ZlROMFilePage.ID) then
Result := not (ZlROMFilePage.Values[0] = '')
else
Result := True;
end;
@@ -492,22 +364,6 @@ begin
Result := '';
end;
function GetSMWROMPath(Param: string): string;
begin
if Length(smwrom) > 0 then
Result := smwrom
else if Assigned(SMWRomFilePage) then
begin
R := CompareStr(GetSNESMD5OfFile(SMWROMFilePage.Values[0]), 'cdd3c8c37322978ca8669b34bc89c804')
if R <> 0 then
MsgBox('Super Mario World ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := SMWROMFilePage.Values[0]
end
else
Result := '';
end;
function GetSoEROMPath(Param: string): string;
begin
if Length(soerom) > 0 then
@@ -540,70 +396,6 @@ begin
Result := '';
end;
function GetL2ACROMPath(Param: string): string;
begin
if Length(l2acrom) > 0 then
Result := l2acrom
else if Assigned(L2ACROMFilePage) then
begin
R := CompareStr(GetSNESMD5OfFile(L2ACROMFilePage.Values[0]), '6efc477d6203ed2b3b9133c1cd9e9c5d')
if R <> 0 then
MsgBox('Lufia II ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := L2ACROMFilePage.Values[0]
end
else
Result := '';
end;
function GetZlROMPath(Param: string): string;
begin
if Length(zlrom) > 0 then
Result := zlrom
else if Assigned(ZlROMFilePage) then
begin
R := CompareStr(GetMD5OfFile(ZlROMFilePage.Values[0]), 'd4bf9e7bcf9a48da53785d2ae7bc4270');
if R <> 0 then
MsgBox('Zillion ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := ZlROMFilePage.Values[0]
end
else
Result := '';
end;
function GetRedROMPath(Param: string): string;
begin
if Length(redrom) > 0 then
Result := redrom
else if Assigned(RedRomFilePage) then
begin
R := CompareStr(GetMD5OfFile(RedROMFilePage.Values[0]), '3d45c1ee9abd5738df46d2bdda8b57dc')
if R <> 0 then
MsgBox('Pokemon Red ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := RedROMFilePage.Values[0]
end
else
Result := '';
end;
function GetBlueROMPath(Param: string): string;
begin
if Length(bluerom) > 0 then
Result := bluerom
else if Assigned(BlueRomFilePage) then
begin
R := CompareStr(GetMD5OfFile(BlueROMFilePage.Values[0]), '50927e843568814f7ed45ec4f944bd8b')
if R <> 0 then
MsgBox('Pokemon Blue ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := BlueROMFilePage.Values[0]
end
else
Result := '';
end;
procedure InitializeWizard();
begin
AddOoTRomPage();
@@ -620,29 +412,9 @@ begin
if Length(dkc3rom) = 0 then
DKC3RomFilePage:= AddRomPage('Donkey Kong Country 3 - Dixie Kong''s Double Trouble! (USA) (En,Fr).sfc');
smwrom := CheckRom('Super Mario World (USA).sfc', 'cdd3c8c37322978ca8669b34bc89c804');
if Length(smwrom) = 0 then
SMWRomFilePage:= AddRomPage('Super Mario World (USA).sfc');
soerom := CheckRom('Secret of Evermore (USA).sfc', '6e9c94511d04fac6e0a1e582c170be3a');
if Length(soerom) = 0 then
SoEROMFilePage:= AddRomPage('Secret of Evermore (USA).sfc');
zlrom := CheckSMSRom('Zillion (UE) [!].sms', 'd4bf9e7bcf9a48da53785d2ae7bc4270');
if Length(zlrom) = 0 then
ZlROMFilePage:= AddSMSRomPage('Zillion (UE) [!].sms');
redrom := CheckRom('Pokemon Red (UE) [S][!].gb','3d45c1ee9abd5738df46d2bdda8b57dc');
if Length(redrom) = 0 then
RedROMFilePage:= AddGBRomPage('Pokemon Red (UE) [S][!].gb');
bluerom := CheckRom('Pokemon Blue (UE) [S][!].gb','50927e843568814f7ed45ec4f944bd8b');
if Length(bluerom) = 0 then
BlueROMFilePage:= AddGBRomPage('Pokemon Blue (UE) [S][!].gb');
l2acrom := CheckRom('Lufia II - Rise of the Sinistrals (USA).sfc', '6efc477d6203ed2b3b9133c1cd9e9c5d');
if Length(l2acrom) = 0 then
L2ACROMFilePage:= AddRomPage('Lufia II - Rise of the Sinistrals (USA).sfc');
end;
@@ -655,18 +427,8 @@ begin
Result := not (WizardIsComponentSelected('client/sni/sm') or WizardIsComponentSelected('generator/sm'));
if (assigned(DKC3ROMFilePage)) and (PageID = DKC3ROMFilePage.ID) then
Result := not (WizardIsComponentSelected('client/sni/dkc3') or WizardIsComponentSelected('generator/dkc3'));
if (assigned(SMWROMFilePage)) and (PageID = SMWROMFilePage.ID) then
Result := not (WizardIsComponentSelected('client/sni/smw') or WizardIsComponentSelected('generator/smw'));
if (assigned(L2ACROMFilePage)) and (PageID = L2ACROMFilePage.ID) then
Result := not (WizardIsComponentSelected('client/sni/l2ac') or WizardIsComponentSelected('generator/l2ac'));
if (assigned(SoEROMFilePage)) and (PageID = SoEROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/soe'));
if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/oot') or WizardIsComponentSelected('client/oot'));
if (assigned(ZlROMFilePage)) and (PageID = ZlROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/zl') or WizardIsComponentSelected('client/zl'));
if (assigned(RedROMFilePage)) and (PageID = RedROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/pkmn_r') or WizardIsComponentSelected('client/pkmn/red'));
if (assigned(BlueROMFilePage)) and (PageID = BlueROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/pkmn_b') or WizardIsComponentSelected('client/pkmn/blue'));
end;
end;

46
kvui.py
View File

@@ -28,7 +28,6 @@ from kivy.factory import Factory
from kivy.properties import BooleanProperty, ObjectProperty
from kivy.uix.button import Button
from kivy.uix.gridlayout import GridLayout
from kivy.uix.layout import Layout
from kivy.uix.textinput import TextInput
from kivy.uix.recycleview import RecycleView
from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem
@@ -49,7 +48,6 @@ fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, dura
from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType
from Utils import async_start
if typing.TYPE_CHECKING:
import CommonClient
@@ -301,9 +299,6 @@ class GameManager(App):
base_title: str = "Archipelago Client"
last_autofillable_command: str
main_area_container: GridLayout
""" subclasses can add more columns beside the tabs """
def __init__(self, ctx: context_type):
self.title = self.base_title
self.ctx = ctx
@@ -330,7 +325,7 @@ class GameManager(App):
super(GameManager, self).__init__()
def build(self) -> Layout:
def build(self):
self.container = ContainerLayout()
self.grid = MainLayout()
@@ -339,12 +334,9 @@ class GameManager(App):
# top part
server_label = ServerLabel()
self.connect_layout.add_widget(server_label)
self.server_connect_bar = ConnectBarTextInput(text=self.ctx.suggested_address or "archipelago.gg:", size_hint_y=None,
self.server_connect_bar = ConnectBarTextInput(text=self.ctx.server_address or "archipelago.gg", size_hint_y=None,
height=30, multiline=False, write_tab=False)
def connect_bar_validate(sender):
if not self.ctx.server:
self.connect_button_action(sender)
self.server_connect_bar.bind(on_text_validate=connect_bar_validate)
self.server_connect_bar.bind(on_text_validate=self.connect_button_action)
self.connect_layout.add_widget(self.server_connect_bar)
self.server_connect_button = Button(text="Connect", size=(100, 30), size_hint_y=None, size_hint_x=None)
self.server_connect_button.bind(on_press=self.connect_button_action)
@@ -366,10 +358,7 @@ class GameManager(App):
self.log_panels[display_name] = panel.content = UILog(bridge_logger)
self.tabs.add_widget(panel)
self.main_area_container = GridLayout(size_hint_y=1, rows=1)
self.main_area_container.add_widget(self.tabs)
self.grid.add_widget(self.main_area_container)
self.grid.add_widget(self.tabs)
if len(self.logging_pairs) == 1:
# Hide Tab selection if only one tab
@@ -385,19 +374,17 @@ class GameManager(App):
bottom_layout.add_widget(info_button)
self.textinput = TextInput(size_hint_y=None, height=30, multiline=False, write_tab=False)
self.textinput.bind(on_text_validate=self.on_message)
self.textinput.text_validate_unfocus = False
def text_focus(event):
"""Needs to be set via delay, as unfocusing happens after on_message"""
self.textinput.focus = True
self.textinput.text_focus = text_focus
bottom_layout.add_widget(self.textinput)
self.grid.add_widget(bottom_layout)
self.commandprocessor("/help")
Clock.schedule_interval(self.update_texts, 1 / 30)
self.container.add_widget(self.grid)
self.server_connect_bar.focus = True
self.server_connect_bar.select_text(
self.server_connect_bar.text.find(":") + 1,
len(self.server_connect_bar.text)
)
return self.container
def update_texts(self, dt):
@@ -408,12 +395,10 @@ class GameManager(App):
f" | Connected to: {self.ctx.server_address} " \
f"{'.'.join(str(e) for e in self.ctx.server_version)}"
self.server_connect_button.text = "Disconnect"
self.server_connect_bar.readonly = True
self.progressbar.max = len(self.ctx.checked_locations) + len(self.ctx.missing_locations)
self.progressbar.value = len(self.ctx.checked_locations)
else:
self.server_connect_button.text = "Connect"
self.server_connect_bar.readonly = False
self.title = self.base_title + " " + Utils.__version__
self.progressbar.value = 0
@@ -426,10 +411,11 @@ class GameManager(App):
def connect_button_action(self, button):
if self.ctx.server:
self.ctx.server_address = None
self.ctx.username = None
async_start(self.ctx.disconnect())
asyncio.create_task(self.ctx.disconnect())
else:
async_start(self.ctx.connect(self.server_connect_bar.text.replace("/connect ", "")))
asyncio.create_task(self.ctx.connect(self.server_connect_bar.text.replace("/connect ", "")))
def on_stop(self):
# "kill" input tasks
@@ -450,6 +436,8 @@ class GameManager(App):
elif input_text:
self.commandprocessor(input_text)
Clock.schedule_once(textinput.text_focus)
except Exception as e:
logging.getLogger("Client").exception(e)
@@ -458,10 +446,6 @@ class GameManager(App):
self.log_panels["Archipelago"].on_message_markup(text)
self.log_panels["All"].on_message_markup(text)
def focus_textinput(self):
if hasattr(self, "textinput"):
self.textinput.focus = True
def update_address_bar(self, text: str):
if hasattr(self, "server_connect_bar"):
self.server_connect_bar.text = text

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