Compare commits

...

33 Commits
0.1.0 ... 0.1.1

Author SHA1 Message Date
Fabian Dill
b43e4fae86 update websockets 2021-05-16 23:10:45 +02:00
Fabian Dill
1f17aa394e allow uploading of Factorio mods 2021-05-16 22:59:45 +02:00
Fabian Dill
a1d7bc558c preconfigure and sign qusb2snes 2021-05-16 18:30:13 +02:00
Fabian Dill
de31fc320c allow webhost handling of APMC files 2021-05-16 01:16:51 +02:00
espeon65536
685de847c4 Minecraft updates (#13)
* Minecraft locations, items, and generation without logic

* added id lookup for minecraft

* typing import fix in minecraft/Items.py

* fix 2

* implementing Minecraft options and hard/postgame advancement exclusion

* first logic pass (75/80)

* logic pass 2 and proper completion conditions

* added insane difficulty pool, modified method of excluding item pools for easier extension

* bump network_data_package version

* minecraft testing framework

* switch Ancient Debris to Netherite Scrap to avoid advancement triggering on receiving that item

* Testing now functions, split tests up by advancement pane, added some story tests

* Newer testing framework: every advancement gets its own function, for ease of testing

* fixed logic for The End... Again...

* changed option names to "include_hard_advancements" etc.

* village/pillager-related advancements now require can_adventure: weapon + food

* a few minecraft tests

* rename "Flint & Steel" to "Flint and Steel" for parity with in-game name

* additional MC tests

* more tests, mostly nether-related tests

* more tests, removed anvil path for Two Birds One Arrow

* include Minecraft slot data, and a world seed for each Minecraft player slot

* Added new items: ender pearls, lapis, porkchops

* All remaining Minecraft tests

* formatting of Minecraft tests and logic for better readability

* require Wither kill for Monsters Hunted

* properly removed 8 Emeralds item from item pool

* enchanting required for wither; fishing rod required for water breathing; water breathing required for elder guardian kill

* Added 12 new advancements (ported from old achievement system)

* renamed "On a Rail" for consistency with modern advancements

* tests for the new advancements

* moved slot_data generation for minecraft into worlds/minecraft/__init__.py, added logic_version to slot_data

* output minecraft options in the spoiler log

* modified advancement goal values for new advancements

* make non-native Minecraft items appear as Shovel in ALttP, and unknown-game items as Power Stars

* fixed glowstone block logic for Not Quite Nine Lives

* setup for shuffling MC structures: building ER world and shuffling regions/entrances

* ensured Nether Fortresses can't be placed in the End

* finished logic for structure randomization

* fixed nonnative items always showing up as Hammers in ALttP shops

* output minecraft structure info in the spoiler

* generate .apmc file for communication with MC client

* fixed structure rando always using the same seed

* move stuff to worlds/minecraft/Regions.py

* make output apmc file have consistent name with other files

* added minecraft bottle macro; fixed tests imports

* generalizing MC region generation

* restructured structure shuffling in preparation for structure plando

* only output structure rando info in spoiler if they are shuffled

* Force structure rando to always be off, for the stable release

* added Minecraft options to player settings

* formally added combat_difficulty as an option

* Added Ender Dragon into playthrough, cleaned up goal map

* Added new difficulties: Easy, Normal, Hard combat

* moved .apmc generation time to prevent outputs on failed generation

* updated tests for new combat logic

* Fixed bug causing generation to fail; removed Nether Fortress event since it should no longer be needed with the fix

* moved all MC-specific functions into gen_minecraft

* renamed "logic_version" to "client_version"

* bug fixes
properly flagged event locations/items with id None
moved generation back to Main.py to fix mysterious generation failures

* moved link_minecraft_regions into minecraft init, left create_regions in Main for caching

* added seed_name, player_name, client_version to apmc file

* reenabled structure shuffle

* added entrance tests for minecraft

* Minecraft logic updates
Wither kill now considers nether fortresses as a valid source of soul sand
A Furious Cocktail now requires beacons for resistance and village access for carrots
Uneasy Alliance now requires fishing rod to pull the ghast through the portal
On a Rail now requires iron pickaxe to make powered rails
Overkill now may require strength II without stone axe, which needs nether access

* embed all apmc info into slot_data

* updated MC tests for logic changes

* put apmc into zipfile

Co-authored-by: achuang <alexander.w.chuang@gmail.com>
2021-05-16 00:49:58 +02:00
Kono Tyran
40751f267b removed reference to playersettings yaml as full descriptions are now in the provided example. 2021-05-15 22:46:21 +00:00
Fabian Dill
3e1941a561 allow Factorio Client to recognize if it's trying to connect to the wrong multiworld. 2021-05-16 00:21:00 +02:00
Fabian Dill
8e27ad3547 include full websockets module due to dynamic imports not being identifiable by cx_freeze 2021-05-15 23:01:52 +02:00
Fabian Dill
c4f5db9c84 pass through sys args to factorio server 2021-05-15 22:11:20 +02:00
Fabian Dill
19896e1fae prepare webhost for multi-game per-slot downloads 2021-05-14 15:25:57 +02:00
Fabian Dill
23678b814d specify get_id as being alttp only 2021-05-14 14:38:23 +02:00
Fabian Dill
13fe1f2ea2 /api/generate send back error message 2021-05-14 14:12:21 +02:00
Chris Wilson
c24d6a0785 Add error message to player-settings and weighted-settings pages if the call to /api/generate returns a non-2xx response code. 2021-05-13 21:33:56 -04:00
Fabian Dill
b2f3fd56f4 bunch of fixes after testing round 2021-05-14 01:25:41 +02:00
Fabian Dill
b82d6cec31 regain basic WebHost functionality 2021-05-13 21:57:11 +02:00
Fabian Dill
c5ff962ea1 document start_hints 2021-05-13 02:53:59 +02:00
Fabian Dill
4aa56c1a7f don't default to active start_hints 2021-05-13 02:39:20 +02:00
Fabian Dill
681279cb2b Implement "start_hints" option 2021-05-13 02:35:50 +02:00
Fabian Dill
c4ea879651 "precollect" visible Factorio tech tree as hints, so points are never spent on what was visible. 2021-05-13 02:10:37 +02:00
Fabian Dill
8cdf9d2ddc faster .apsave loading and saving 2021-05-13 01:58:53 +02:00
Fabian Dill
daa959e353 remove suppress rom argument 2021-05-13 01:40:36 +02:00
Fabian Dill
d5cdff5ec9 filter hints to whom they concern 2021-05-13 01:37:50 +02:00
Fabian Dill
fb192b989d update jinja templates to use base static files 2021-05-13 00:41:49 +02:00
Fabian Dill
d35adc5868 Update Flask and Jinja
Flask Autoversion is now integrated
Flask config.from_file is now integrated
2021-05-13 00:28:53 +02:00
Fabian Dill
c0bf4f58ad extend gitignore 2021-05-11 23:57:42 +02:00
Kono Tyran
f24a81fdaf fix !remaining command to look beyond ALTTP 2021-05-11 21:38:44 +00:00
Kono Tyran
40ff0e867c fixed abbreviation ap to full name. 2021-05-11 21:38:34 +00:00
Fabian Dill
a231850911 Make hint costs relative 2021-05-11 23:08:50 +02:00
Fabian Dill
1b2283b173 Factorio: correctly cache control_template to allow multiple Factorio worlds 2021-05-11 13:28:58 +02:00
Fabian Dill
729088fd85 Fix generation failure if aga tower door was placed on HC ledge in inverted dungeonsfull 2021-05-11 01:26:59 +02:00
Fabian Dill
88d75a41ae Factorio setup tutorial 2021-05-10 22:42:11 +02:00
Fabian Dill
237b44ca66 Update Documentation to match compatibility variable 2021-05-10 22:04:19 +02:00
Fabian Dill
6fef30d9b3 remove german tutorial video 2021-05-10 13:06:51 +02:00
107 changed files with 842 additions and 16320 deletions

111
.gitignore vendored
View File

@@ -12,6 +12,8 @@
*.db3
*multidata
*multisave
*.archipelago
*.apsave
build
bundle/components.wxs
@@ -19,7 +21,6 @@ dist
README.html
.vs/
EnemizerCLI/
.mypy_cache/
RaceRom.py
weights/
/MultiMystery/
@@ -35,4 +36,110 @@ mystery_result_*.yaml
success.txt
output/
Output Logs/
/factorio/
/factorio/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
*.dll
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/

View File

@@ -44,6 +44,7 @@ class MultiWorld():
self.shops = []
self.itempool = []
self.seed = None
self.seed_name: str = "Unavailable"
self.precollected_items = []
self.state = CollectionState(self)
self._cached_entrances = None
@@ -879,13 +880,12 @@ class CollectionState(object):
self.has('Progressive Armor', player) and self.has('Shield', player)
def can_kill_wither(self, player: int):
build_wither = self.fortress_loot(player) and (self.can_reach('The Nether', 'Region', player) or self.can_piglin_trade(player))
normal_kill = self.has("Progressive Weapons", player, 3) and self.has("Progressive Armor", player, 2) and self.can_brew_potions(player) and self.can_enchant(player)
if self.combat_difficulty(player) == 'easy':
return build_wither and normal_kill and self.has('Archery', player)
return self.fortress_loot(player) and normal_kill and self.has('Archery', player)
elif self.combat_difficulty(player) == 'hard': # cheese kill using bedrock ceilings
return build_wither and (normal_kill or self.can_reach('The Nether', 'Region', player) or self.can_reach('The End', 'Region', player))
return build_wither and normal_kill
return self.fortress_loot(player) and (normal_kill or self.can_reach('The Nether', 'Region', player) or self.can_reach('The End', 'Region', player))
return self.fortress_loot(player) and normal_kill
def can_kill_ender_dragon(self, player: int):
if self.combat_difficulty(player) == 'easy':

View File

@@ -49,10 +49,6 @@ class ClientCommandProcessor(CommandProcessor):
"""List all received items"""
logger.info('Received items:')
for index, item in enumerate(self.ctx.items_received, 1):
self.ctx.ui_node.notify_item_received(self.ctx.player_names[item.player], self.ctx.item_name_getter(item.item),
self.ctx.location_name_getter(item.location), index,
len(self.ctx.items_received),
self.ctx.item_name_getter(item.item) in Items.progression_items)
logging.info('%s from %s (%s) (%d/%d in list)' % (
color(self.ctx.item_name_getter(item.item), 'red', 'bold'),
color(self.ctx.player_names[item.player], 'yellow'),
@@ -116,7 +112,7 @@ class CommonContext():
self.team = None
self.slot = None
self.auth = None
self.ui_node = None
self.seed_name = None
self.locations_checked: typing.Set[int] = set()
self.locations_scouted: typing.Set[int] = set()
@@ -129,7 +125,7 @@ class CommonContext():
self.input_requests = 0
# game state
self.player_names: typing.Dict[int: str] = {0: "Server"}
self.player_names: typing.Dict[int: str] = {0: "Archipelago"}
self.exit_event = asyncio.Event()
self.watcher_event = asyncio.Event()
@@ -194,7 +190,7 @@ class CommonContext():
def consume_players_package(self, package: typing.List[tuple]):
self.player_names = {slot: name for team, slot, name, orig_name in package if self.team == team}
self.player_names[0] = "Server"
self.player_names[0] = "Archipelago"
def event_invalid_slot(self):
raise Exception('Invalid Slot; please verify that you have connected to the correct world.')
@@ -223,9 +219,6 @@ class CommonContext():
async def server_loop(ctx: CommonContext, address=None):
ui_node = getattr(ctx, "ui_node", None)
if ui_node:
ui_node.send_connection_status(ctx)
cached_address = None
if ctx.server and ctx.server.socket:
logger.error('Already connected')
@@ -237,8 +230,6 @@ async def server_loop(ctx: CommonContext, address=None):
# Wait for the user to provide a multiworld server address
if not address:
logger.info('Please connect to an Archipelago server.')
if ui_node:
ui_node.poll_for_server_ip()
return
address = f"ws://{address}" if "://" not in address else address
@@ -250,8 +241,6 @@ async def server_loop(ctx: CommonContext, address=None):
ctx.server = Endpoint(socket)
logger.info('Connected')
ctx.server_address = address
if ui_node:
ui_node.send_connection_status(ctx)
ctx.current_reconnect_delay = ctx.starting_reconnect_delay
async for data in ctx.server.socket:
for msg in decode(data):
@@ -273,8 +262,6 @@ async def server_loop(ctx: CommonContext, address=None):
await ctx.connection_closed()
if ctx.server_address:
logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s")
if ui_node:
ui_node.send_connection_status(ctx)
asyncio.create_task(server_autoreconnect(ctx))
ctx.current_reconnect_delay *= 2
@@ -292,41 +279,42 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
logger.exception(f"Could not get command from {args}")
raise
if cmd == 'RoomInfo':
logger.info('--------------------------------')
logger.info('Room Information:')
logger.info('--------------------------------')
version = args["version"]
ctx.server_version = tuple(version)
version = ".".join(str(item) for item in version)
logger.info(f'Server protocol version: {version}')
logger.info("Server protocol tags: " + ", ".join(args["tags"]))
if args['password']:
logger.info('Password required')
logger.info(f"Forfeit setting: {args['forfeit_mode']}")
logger.info(f"Remaining setting: {args['remaining_mode']}")
logger.info(f"A !hint costs {args['hint_cost']} points and you get {args['location_check_points']}"
f" for each location checked.")
ctx.hint_cost = int(args['hint_cost'])
ctx.check_points = int(args['location_check_points'])
ctx.forfeit_mode = args['forfeit_mode']
ctx.remaining_mode = args['remaining_mode']
if ctx.ui_node:
ctx.ui_node.send_game_info(ctx)
if len(args['players']) < 1:
logger.info('No player connected')
if ctx.seed_name and ctx.seed_name != args["seed_name"]:
logger.info("The server is running a different multiworld than your client is. (invalid seed_name)")
else:
args['players'].sort()
current_team = -1
logger.info('Players:')
for network_player in args['players']:
if network_player.team != current_team:
logger.info(f' Team #{network_player.team + 1}')
current_team = network_player.team
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
if args["datapackage_version"] > network_data_package["version"] or args["datapackage_version"] == 0:
await ctx.send_msgs([{"cmd": "GetDataPackage"}])
await ctx.server_auth(args['password'])
logger.info('--------------------------------')
logger.info('Room Information:')
logger.info('--------------------------------')
version = args["version"]
ctx.server_version = tuple(version)
version = ".".join(str(item) for item in version)
logger.info(f'Server protocol version: {version}')
logger.info("Server protocol tags: " + ", ".join(args["tags"]))
if args['password']:
logger.info('Password required')
logger.info(f"Forfeit setting: {args['forfeit_mode']}")
logger.info(f"Remaining setting: {args['remaining_mode']}")
logger.info(f"A !hint costs {args['hint_cost']}% of checks points and you get {args['location_check_points']}"
f" for each location checked. Use !hint for more information.")
ctx.hint_cost = int(args['hint_cost'])
ctx.check_points = int(args['location_check_points'])
ctx.forfeit_mode = args['forfeit_mode']
ctx.remaining_mode = args['remaining_mode']
if len(args['players']) < 1:
logger.info('No player connected')
else:
args['players'].sort()
current_team = -1
logger.info('Players:')
for network_player in args['players']:
if network_player.team != current_team:
logger.info(f' Team #{network_player.team + 1}')
current_team = network_player.team
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
if args["datapackage_version"] > network_data_package["version"] or args["datapackage_version"] == 0:
await ctx.send_msgs([{"cmd": "GetDataPackage"}])
await ctx.server_auth(args['password'])
elif cmd == 'DataPackage':
logger.info("Got new ID/Name Datapackage")

View File

@@ -7,7 +7,7 @@ from concurrent.futures import ThreadPoolExecutor
import colorama
import asyncio
from queue import Queue, Empty
from queue import Queue
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger
from MultiServer import mark_raw
@@ -21,7 +21,6 @@ rcon_port = 24242
rcon_password = ''.join(random.choice(string.ascii_letters) for x in range(32))
save_name = "Archipelago"
server_args = (save_name, "--rcon-port", rcon_port, "--rcon-password", rcon_password)
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO)
options = Utils.get_options()
@@ -35,6 +34,9 @@ if not os.path.exists(executable):
else:
raise FileNotFoundError(executable)
import sys
server_args = (save_name, "--rcon-port", rcon_port, "--rcon-password", rcon_password, *sys.argv[1:])
threadpool = ThreadPoolExecutor(10)
@@ -105,6 +107,7 @@ async def game_watcher(ctx: FactorioContext, bridge_file: str):
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
victory = data["victory"]
ctx.auth = data["slot_name"]
ctx.seed_name = data["seed_name"]
if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])

4
Gui.py
View File

@@ -428,7 +428,6 @@ def guiMain(args=None):
guiargs.fastmenu = rom_vars.fastMenuVar.get()
guiargs.create_spoiler = bool(createSpoilerVar.get())
guiargs.skip_playthrough = not bool(createSpoilerVar.get())
guiargs.suppress_rom = bool(suppressRomVar.get())
guiargs.open_pyramid = openpyramidVar.get()
guiargs.mapshuffle = bool(mapshuffleVar.get())
guiargs.compassshuffle = bool(compassshuffleVar.get())
@@ -513,7 +512,7 @@ def guiMain(args=None):
elif type(v) is dict: # use same settings for every player
setattr(guiargs, k, {player: getattr(guiargs, k) for player in range(1, guiargs.multi + 1)})
try:
if not guiargs.suppress_rom and not os.path.exists(guiargs.rom):
if not os.path.exists(guiargs.rom):
raise FileNotFoundError(f"Could not find specified rom file {guiargs.rom}")
if guiargs.count is not None:
seed = guiargs.seed
@@ -1204,7 +1203,6 @@ def guiMain(args=None):
setattr(args, k, v[1]) # only get values for player 1 for now
# load values from commandline args
createSpoilerVar.set(int(args.create_spoiler))
suppressRomVar.set(int(args.suppress_rom))
mapshuffleVar.set(args.mapshuffle)
compassshuffleVar.set(args.compassshuffle)
keyshuffleVar.set(args.keyshuffle)

View File

@@ -24,7 +24,6 @@ ModuleUpdate.update()
import colorama
from NetUtils import *
import WebUI
from worlds.alttp import Regions, Shops
from worlds.alttp import Items
@@ -45,12 +44,6 @@ class LttPCommandProcessor(ClientCommandProcessor):
self.output(f"Setting slow mode to {self.ctx.slow_mode}")
def _cmd_web(self):
if self.ctx.webui_socket_port:
webbrowser.open(f'http://localhost:5050?port={self.ctx.webui_socket_port}')
else:
self.output("Web UI was never started.")
@mark_raw
def _cmd_snes(self, snes_address: str = "") -> bool:
"""Connect to a snes. Optionally include network address of a snes to connect to, otherwise show available devices"""
@@ -69,21 +62,9 @@ class LttPCommandProcessor(ClientCommandProcessor):
class Context(CommonContext):
command_processor = LttPCommandProcessor
def __init__(self, snes_address, server_address, password, found_items, port: int):
def __init__(self, snes_address, server_address, password, found_items):
super(Context, self).__init__(server_address, password, found_items)
# WebUI Stuff
self.ui_node = WebUI.WebUiClient()
logger.addHandler(self.ui_node)
self.webui_socket_port: typing.Optional[int] = port
self.hint_cost = 0
self.check_points = 0
self.forfeit_mode = ''
self.remaining_mode = ''
self.hint_points = 0
# End of WebUI Stuff
# snes stuff
self.snes_address = snes_address
self.snes_socket = None
@@ -495,7 +476,7 @@ async def get_snes_devices(ctx: Context):
reply = loads(await socket.recv())
devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else None
ctx.ui_node.send_device_list(devices)
await socket.close()
return devices
@@ -517,8 +498,6 @@ async def snes_connect(ctx: Context, address):
if len(devices) == 1:
device = devices[0]
elif ctx.ui_node.manual_snes and ctx.ui_node.manual_snes in devices:
device = ctx.ui_node.manual_snes
elif ctx.snes_reconnect_address:
if ctx.snes_attached_device[1] in devices:
device = ctx.snes_attached_device[1]
@@ -538,7 +517,6 @@ async def snes_connect(ctx: Context, address):
await ctx.snes_socket.send(dumps(Attach_Request))
ctx.snes_state = SNESState.SNES_ATTACHED
ctx.snes_attached_device = (devices.index(device), device)
ctx.ui_node.send_connection_status(ctx)
if 'sd2snes' in device.lower() or 'COM' in device:
logger.info("SD2SNES/FXPAK Detected")
@@ -607,7 +585,6 @@ async def snes_recv_loop(ctx: Context):
ctx.snes_state = SNESState.SNES_DISCONNECTED
ctx.snes_recv_queue = asyncio.Queue()
ctx.hud_message_queue = []
ctx.ui_node.send_connection_status(ctx)
ctx.rom = None
@@ -743,8 +720,6 @@ async def track_locations(ctx: Context, roomid, roomdata):
ctx.locations_checked.add(location_id)
location = ctx.location_name_getter(location_id)
logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
ctx.ui_node.send_location_check(ctx, location)
try:
if roomid in location_shop_ids:
@@ -887,10 +862,6 @@ async def game_watcher(ctx: Context):
if recv_index < len(ctx.items_received) and recv_item == 0:
item = ctx.items_received[recv_index]
ctx.ui_node.notify_item_received(ctx.player_names[item.player], ctx.item_name_getter(item.item),
ctx.location_name_getter(item.location), recv_index + 1,
len(ctx.items_received),
ctx.item_name_getter(item.item) in Items.progression_items)
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
color(ctx.item_name_getter(item.item), 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'),
ctx.location_name_getter(item.location), recv_index + 1, len(ctx.items_received)))
@@ -920,57 +891,6 @@ async def run_game(romfile):
subprocess.Popen([auto_start, romfile],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
async def websocket_server(websocket: websockets.WebSocketServerProtocol, path, ctx: Context):
endpoint = Endpoint(websocket)
ctx.ui_node.endpoints.append(endpoint)
process_command = LttPCommandProcessor(ctx)
try:
async for incoming_data in websocket:
data = loads(incoming_data)
logging.debug(f"WebUIData:{data}")
if ('type' not in data) or ('content' not in data):
raise Exception('Invalid data received in websocket')
elif data['type'] == 'webStatus':
if data['content'] == 'connections':
ctx.ui_node.send_connection_status(ctx)
elif data['content'] == 'devices':
await get_snes_devices(ctx)
elif data['content'] == 'gameInfo':
ctx.ui_node.send_game_info(ctx)
elif data['content'] == 'checkData':
ctx.ui_node.send_location_check(ctx, 'Waiting for check...')
elif data['type'] == 'webConfig':
if 'serverAddress' in data['content']:
ctx.server_address = data['content']['serverAddress']
await ctx.connect(data['content']['serverAddress'])
elif 'deviceId' in data['content']:
# Allow a SNES disconnect via UI sending -1 as new device
if data['content']['deviceId'] == "-1":
ctx.ui_node.manual_snes = None
ctx.snes_reconnect_address = None
await snes_disconnect(ctx)
else:
await snes_disconnect(ctx)
ctx.ui_node.manual_snes = data['content']['deviceId']
await snes_connect(ctx, ctx.snes_address)
elif data['type'] == 'webControl':
if 'disconnect' in data['content']:
await ctx.disconnect()
elif data['type'] == 'webCommand':
process_command(data['content'])
except Exception as e:
if not isinstance(e, websockets.WebSocketException):
logging.exception(e)
finally:
await ctx.ui_node.disconnect(endpoint)
async def main():
multiprocessing.freeze_support()
parser = argparse.ArgumentParser()
@@ -982,8 +902,6 @@ async def main():
parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical'])
parser.add_argument('--founditems', default=False, action='store_true',
help='Show items found by other players for themselves.')
parser.add_argument('--web_ui', default=False, action='store_true',
help="Emit a webserver for the webbrowser based user interface.")
args = parser.parse_args()
logging.basicConfig(format='%(message)s', level=getattr(logging, args.loglevel.upper(), logging.INFO))
if args.diff_file:
@@ -1002,23 +920,9 @@ async def main():
asyncio.create_task(run_game(adjustedromfile if adjusted else romfile))
port = None
if args.web_ui:
# Find an available port on the host system to use for hosting the websocket server
while True:
port = randrange(49152, 65535)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
if not sock.connect_ex(('localhost', port)) == 0:
break
import threading
WebUI.start_server(
port, on_start=threading.Timer(1, webbrowser.open, (f'http://localhost:5050?port={port}',)).start)
ctx = Context(args.snes, args.connect, args.password, args.founditems, port)
ctx = Context(args.snes, args.connect, args.password, args.founditems)
input_task = asyncio.create_task(console_loop(ctx), name="Input")
if args.web_ui:
ui_socket = websockets.serve(functools.partial(websocket_server, ctx=ctx),
'localhost', port, ping_timeout=None, ping_interval=None)
await ui_socket
if ctx.server_task is None:
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")

260
Main.py
View File

@@ -7,7 +7,7 @@ import time
import zlib
import concurrent.futures
import pickle
from typing import Dict
from typing import Dict, Tuple
from BaseClasses import MultiWorld, CollectionState, Region, Item
from worlds.alttp.Items import ItemFactory, item_name_groups
@@ -67,6 +67,7 @@ def main(args, seed=None):
world.secure()
else:
world.random.seed(world.seed)
world.seed_name = str(args.outputname if args.outputname else world.seed)
world.shuffle = args.shuffle.copy()
world.logic = args.logic.copy()
@@ -314,7 +315,7 @@ def main(args, seed=None):
balance_multiworld_progression(world)
logger.info('Generating output files.')
outfilebase = 'AP_%s' % (args.outputname if args.outputname else world.seed)
outfilebase = 'AP_' + world.seed_name
rom_names = []
def _gen_rom(team: int, player: int):
@@ -366,8 +367,7 @@ def main(args, seed=None):
'U' if world.keyshuffle[player] == "universal" else 'S' if world.keyshuffle[player] else '',
'B' if world.bigkeyshuffle[player] else '')
outfilepname = f'_T{team + 1}' if world.teams > 1 else ''
outfilepname += f'_P{player}'
outfilepname = f'_P{player}'
outfilepname += f"_{world.player_names[player][team].replace(' ', '_')}" \
if world.player_names[player][team] != 'Player%d' % player else ''
outfilestuffs = {
@@ -410,145 +410,165 @@ def main(args, seed=None):
rompath = output_path(f'{outfilebase}{outfilepname}{outfilesuffix}.sfc')
rom.write_to_file(rompath, hide_enemizer=True)
if args.create_diff:
Patch.create_patch_file(rompath)
Patch.create_patch_file(rompath, player=player, player_name = world.player_names[player][team])
return player, team, bytes(rom.name)
pool = concurrent.futures.ThreadPoolExecutor()
multidata_task = None
check_accessibility_task = pool.submit(world.fulfills_accessibility)
if not args.suppress_rom:
rom_futures = []
mod_futures = []
for team in range(world.teams):
for player in world.alttp_player_ids:
rom_futures.append(pool.submit(_gen_rom, team, player))
for player in world.factorio_player_ids:
mod_futures.append(pool.submit(generate_mod, world, player,
str(args.outputname if args.outputname else world.seed)))
rom_futures = []
mod_futures = []
for team in range(world.teams):
for player in world.alttp_player_ids:
rom_futures.append(pool.submit(_gen_rom, team, player))
for player in world.factorio_player_ids:
mod_futures.append(pool.submit(generate_mod, world, player))
def get_entrance_to_region(region: Region):
for entrance in region.entrances:
if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld):
return entrance
for entrance in region.entrances: # BFS might be better here, trying DFS for now.
return get_entrance_to_region(entrance.parent_region)
def get_entrance_to_region(region: Region):
for entrance in region.entrances:
if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld):
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 = {player: {} for player in range(1, world.players + 1) if world.shuffle[player] != "vanilla" or world.retro[player]}
from worlds.alttp.Regions import RegionType
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
# collect ER hint info
er_hint_data = {player: {} for player in range(1, world.players + 1) if world.shuffle[player] != "vanilla" or world.retro[player]}
from worlds.alttp.Regions import RegionType
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
ordered_areas = ('Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace',
'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace',
'Misery Mire', 'Turtle Rock', 'Ganons Tower', "Total")
ordered_areas = ('Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace',
'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace',
'Misery Mire', 'Turtle Rock', 'Ganons Tower', "Total")
checks_in_area = {player: {area: list() for area in ordered_areas}
for player in range(1, world.players + 1)}
checks_in_area = {player: {area: list() for area in ordered_areas}
for player in range(1, world.players + 1)}
for player in range(1, world.players + 1):
checks_in_area[player]["Total"] = 0
for player in range(1, world.players + 1):
checks_in_area[player]["Total"] = 0
for location in [loc for loc in world.get_filled_locations() if type(loc.address) is int]:
main_entrance = get_entrance_to_region(location.parent_region)
if location.game != Games.LTTP:
checks_in_area[location.player]["Light 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 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
for location in [loc for loc in world.get_filled_locations() if type(loc.address) is int]:
main_entrance = get_entrance_to_region(location.parent_region)
if location.game != Games.LTTP:
checks_in_area[location.player]["Light 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 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 = []
takeanyregions = ["Old Man Sword Cave", "Take-Any #1", "Take-Any #2", "Take-Any #3", "Take-Any #4"]
for index, take_any in enumerate(takeanyregions):
for region in [world.get_region(take_any, player) for player in range(1, world.players + 1) if world.retro[player]]:
item = ItemFactory(region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'], region.player)
player = region.player
location_id = SHOP_ID_START + total_shop_slots + index
oldmancaves = []
takeanyregions = ["Old Man Sword Cave", "Take-Any #1", "Take-Any #2", "Take-Any #3", "Take-Any #4"]
for index, take_any in enumerate(takeanyregions):
for region in [world.get_region(take_any, player) for player in range(1, world.players + 1) if world.retro[player]]:
item = ItemFactory(region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'], region.player)
player = region.player
location_id = SHOP_ID_START + total_shop_slots + index
main_entrance = get_entrance_to_region(region)
if main_entrance.parent_region.type == RegionType.LightWorld:
checks_in_area[player]["Light World"].append(location_id)
else:
checks_in_area[player]["Dark World"].append(location_id)
checks_in_area[player]["Total"] += 1
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:
checks_in_area[player]["Dark World"].append(location_id)
checks_in_area[player]["Total"] += 1
er_hint_data[player][location_id] = main_entrance.name
oldmancaves.append(((location_id, player), (item.code, player)))
er_hint_data[player][location_id] = main_entrance.name
oldmancaves.append(((location_id, player), (item.code, player)))
FillDisabledShopSlots(world)
def write_multidata(roms, mods):
import base64
import NetUtils
for future in roms:
rom_name = future.result()
rom_names.append(rom_name)
slot_data = {}
client_versions = {}
minimum_versions = {"server": (0, 1, 1), "clients": client_versions}
games = {}
for slot in world.player_ids:
client_versions[slot] = (0, 0, 3)
games[slot] = world.game[slot]
connect_names = {base64.b64encode(rom_name).decode(): (team, slot) for
slot, team, rom_name in rom_names}
precollected_items = {player: [] for player in range(1, world.players+1)}
for item in world.precollected_items:
precollected_items[item.player].append(item.code)
precollected_hints = {player: set() for player in range(1, world.players+1)}
# for now special case Factorio visibility
sending_visible_players = set()
for player in world.factorio_player_ids:
if world.visibility[player]:
sending_visible_players.add(player)
FillDisabledShopSlots(world)
for i, team in enumerate(parsed_names):
for player, name in enumerate(team, 1):
if player not in world.alttp_player_ids:
connect_names[name] = (i, player)
for slot in world.hk_player_ids:
slots_data = slot_data[slot] = {}
for option_name in Options.hollow_knight_options:
option = getattr(world, option_name)[slot]
slots_data[option_name] = int(option.value)
for slot in world.minecraft_player_ids:
slot_data[slot] = fill_minecraft_slot_data(world, slot)
def write_multidata(roms, mods):
import base64
for future in roms:
rom_name = future.result()
rom_names.append(rom_name)
slot_data = {}
client_versions = {}
minimum_versions = {"server": (0, 0, 4), "clients": client_versions}
games = {}
for slot in world.player_ids:
client_versions[slot] = (0, 0, 3)
games[slot] = world.game[slot]
connect_names = {base64.b64encode(rom_name).decode(): (team, slot) for
slot, team, rom_name in rom_names}
locations_data: Dict[int, Dict[int, Tuple[int, int]]] = {player: {} for player in world.player_ids}
for location in world.get_filled_locations():
if type(location.address) == int:
locations_data[location.player][location.address] = (location.item.code, location.item.player)
if location.player in sending_visible_players and location.item.player != location.player:
hint = NetUtils.Hint(location.item.player, location.player, location.address,
location.item.code, False)
precollected_hints[location.player].add(hint)
precollected_hints[location.item.player].add(hint)
elif location.item.name in args.start_hints[location.item.player]:
hint = NetUtils.Hint(location.item.player, location.player, location.address,
location.item.code, False,
er_hint_data.get(location.player, {}).get(location.address, ""))
precollected_hints[location.player].add(hint)
precollected_hints[location.item.player].add(hint)
for i, team in enumerate(parsed_names):
for player, name in enumerate(team, 1):
if player not in world.alttp_player_ids:
connect_names[name] = (i, player)
for slot in world.hk_player_ids:
slots_data = slot_data[slot] = {}
for option_name in Options.hollow_knight_options:
option = getattr(world, option_name)[slot]
slots_data[option_name] = int(option.value)
for slot in world.minecraft_player_ids:
slot_data[slot] = fill_minecraft_slot_data(world, slot)
multidata = zlib.compress(pickle.dumps({
"slot_data" : slot_data,
"games": games,
"names": parsed_names,
"connect_names": connect_names,
"remote_items": {player for player in range(1, world.players + 1) if
world.remote_items[player]},
"locations": {
(location.address, location.player):
(location.item.code, location.item.player)
for location in world.get_filled_locations() if
type(location.address) is int},
"checks_in_area": checks_in_area,
"server_options": get_options()["server_options"],
"er_hint_data": er_hint_data,
"precollected_items": precollected_items,
"version": tuple(_version_tuple),
"tags": ["AP"],
"minimum_versions": minimum_versions,
"seed_name": str(args.outputname if args.outputname else world.seed)
}), 9)
multidata = zlib.compress(pickle.dumps({
"slot_data" : slot_data,
"games": games,
"names": parsed_names,
"connect_names": connect_names,
"remote_items": {player for player in range(1, world.players + 1) if
world.remote_items[player]},
"locations": locations_data,
"checks_in_area": checks_in_area,
"server_options": get_options()["server_options"],
"er_hint_data": er_hint_data,
"precollected_items": precollected_items,
"precollected_hints": precollected_hints,
"version": tuple(_version_tuple),
"tags": ["AP"],
"minimum_versions": minimum_versions,
"seed_name": world.seed_name
}), 9)
with open(output_path('%s.archipelago' % outfilebase), 'wb') as f:
f.write(bytes([1])) # version of format
f.write(multidata)
for future in mods:
future.result() # collect errors if they occured
with open(output_path('%s.archipelago' % outfilebase), 'wb') as f:
f.write(bytes([1])) # version of format
f.write(multidata)
for future in mods:
future.result() # collect errors if they occured
multidata_task = pool.submit(write_multidata, rom_futures, mod_futures)
multidata_task = pool.submit(write_multidata, rom_futures, mod_futures)
if not check_accessibility_task.result():
if not world.can_beat_game():
raise Exception("Game appears as unbeatable. Aborting.")
@@ -558,7 +578,7 @@ def main(args, seed=None):
multidata_task.result() # retrieve exception if one exists
pool.shutdown() # wait for all queued tasks to complete
for player in world.minecraft_player_ids: # Doing this after shutdown prevents the .apmc from being generated if there's an error
generate_mc_data(world, player, str(args.outputname if args.outputname else world.seed))
generate_mc_data(world, player)
if not args.skip_playthrough:
logger.info('Calculating playthrough.')
create_playthrough(world)

View File

@@ -26,7 +26,6 @@ if __name__ == "__main__":
from Utils import get_public_ipv4, get_options
from Mystery import get_seed_name
from Patch import create_patch_file
options = get_options()
@@ -41,6 +40,7 @@ if __name__ == "__main__":
create_spoiler = multi_mystery_options["create_spoiler"]
zip_roms = multi_mystery_options["zip_roms"]
zip_diffs = multi_mystery_options["zip_diffs"]
zip_apmcs = multi_mystery_options["zip_apmcs"]
zip_spoiler = multi_mystery_options["zip_spoiler"]
zip_multidata = multi_mystery_options["zip_multidata"]
zip_format = multi_mystery_options["zip_format"]
@@ -133,7 +133,7 @@ if __name__ == "__main__":
asyncio.run(MultiClient.run_game(os.path.join(output_path, file)))
break
if any((zip_roms, zip_multidata, zip_spoiler, zip_diffs)):
if any((zip_roms, zip_multidata, zip_spoiler, zip_diffs, zip_apmcs)):
import zipfile
compression = {1: zipfile.ZIP_DEFLATED,
@@ -178,15 +178,28 @@ if __name__ == "__main__":
remove_zipped_file(file)
def _handle_apmc_file(file: str):
if zip_apmcs:
pack_file(file)
if zip_apmcs == 2:
remove_zipped_file(file)
with concurrent.futures.ThreadPoolExecutor() as pool:
futures = []
files = os.listdir(output_path)
with zipfile.ZipFile(zipname, "w", compression=compression, compresslevel=9) as zf:
for file in os.listdir(output_path):
for file in files:
if seed_name in file:
if file.endswith(".sfc"):
futures.append(pool.submit(_handle_sfc_file, file))
elif file.endswith(".apbp"):
futures.append(pool.submit(_handle_diff_file, file))
elif file.endswith(".apmc"):
futures.append(pool.submit(_handle_apmc_file, file))
# just handle like a diff file for now
elif file.endswith(".zip"):
futures.append(pool.submit(_handle_diff_file, file))
if zip_multidata and os.path.exists(os.path.join(output_path, multidataname)):
pack_file(multidataname)

View File

@@ -43,7 +43,7 @@ class Client(Endpoint):
version = Version(0, 0, 0)
tags: typing.List[str] = []
def __init__(self, socket: websockets.server.WebSocketServerProtocol, ctx: Context):
def __init__(self, socket: websockets.WebSocketServerProtocol, ctx: Context):
super().__init__(socket)
self.auth = False
self.name = None
@@ -78,7 +78,7 @@ class Context(Node):
self.connect_names = {} # names of slots clients can connect to
self.allow_forfeits = {}
self.remote_items = set()
self.locations = {}
self.locations:typing.Dict[int, typing.Dict[int, typing.Tuple[int, int]]] = {}
self.host = host
self.port = port
self.server_password = server_password
@@ -114,6 +114,11 @@ class Context(Node):
self.minimum_client_versions: typing.Dict[int, Utils.Version] = {}
self.seed_name = ""
def get_hint_cost(self, slot):
if self.hint_cost:
return max(0, int(self.hint_cost * 0.01 * len(self.locations[slot])))
return 0
def load(self, multidatapath: str, use_embedded_server_options: bool = False):
with open(multidatapath, 'rb') as f:
data = f.read()
@@ -132,7 +137,7 @@ class Context(Node):
mdata_ver = decoded_obj["minimum_versions"]["server"]
if mdata_ver > Utils._version_tuple:
raise RuntimeError(f"Supplied Multidata requires a server of at least version {mdata_ver},"
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
f"however this server is of version {Utils._version_tuple}")
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
self.minimum_client_versions = {}
@@ -155,7 +160,8 @@ class Context(Node):
for slot, item_codes in decoded_obj["precollected_items"].items():
if slot in self.remote_items:
self.received_items[team, slot] = [NetworkItem(item_code, -2, 0) for item_code in item_codes]
for slot, hints in decoded_obj["precollected_hints"].items():
self.hints[team, slot].update(hints)
if use_embedded_server_options:
server_options = decoded_obj.get("server_options", {})
self._set_options(server_options)
@@ -240,42 +246,40 @@ class Context(Node):
def get_save(self) -> dict:
d = {
"rom_names": list(self.connect_names.items()),
"received_items": tuple((k, v) for k, v in self.received_items.items()),
"hints_used": tuple((key, value) for key, value in self.hints_used.items()),
"hints": tuple(
(key, list(hint.re_check(self, key[0]) for hint in value)) for key, value in self.hints.items()),
"location_checks": tuple((key, tuple(value)) for key, value in self.location_checks.items()),
"name_aliases": tuple((key, value) for key, value in self.name_aliases.items()),
"client_game_state": tuple((key, value) for key, value in self.client_game_state.items()),
"connect_names": self.connect_names,
"received_items": self.received_items,
"hints_used": dict(self.hints_used),
"hints": dict(self.hints),
"location_checks": dict(self.location_checks),
"name_aliases": self.name_aliases,
"client_game_state": dict(self.client_game_state),
"client_activity_timers": tuple(
(key, value.timestamp()) for key, value in self.client_activity_timers.items()),
"client_connection_timers": tuple(
(key, value.timestamp()) for key, value in self.client_connection_timers.items()),
}
return d
def set_save(self, savedata: dict):
if self.connect_names != savedata["connect_names"]:
raise Exception("This savegame does not appear to match the loaded multiworld.")
self.received_items = savedata["received_items"]
self.hints_used.update(savedata["hints_used"])
self.hints.update(savedata["hints"])
received_items = {tuple(k): [NetworkItem(*i) for i in v] for k, v in savedata["received_items"]}
self.received_items = received_items
self.hints_used.update({tuple(key): value for key, value in savedata["hints_used"]})
self.hints.update(
{tuple(key): set(NetUtils.Hint(*hint) for hint in value) for key, value in savedata["hints"]})
self.name_aliases.update({tuple(key): value for key, value in savedata["name_aliases"]})
self.client_game_state.update({tuple(key): value for key, value in savedata["client_game_state"]})
self.name_aliases.update(savedata["name_aliases"])
self.client_game_state.update(savedata["client_game_state"])
self.client_connection_timers.update(
{tuple(key): datetime.datetime.fromtimestamp(value, datetime.timezone.utc) for key, value
in savedata["client_connection_timers"]})
self.client_activity_timers.update(
{tuple(key): datetime.datetime.fromtimestamp(value, datetime.timezone.utc) for key, value
in savedata["client_activity_timers"]})
self.location_checks.update({tuple(key): set(value) for key, value in savedata["location_checks"]})
self.location_checks.update(savedata["location_checks"])
logging.info(f'Loaded save file with {sum([len(p) for p in received_items.values()])} received items '
f'for {len(received_items)} players')
logging.info(f'Loaded save file with {sum([len(p) for p in self.received_items.values()])} received items '
f'for {len(self.received_items)} players')
def get_aliased_name(self, team: int, slot: int):
if (team, slot) in self.name_aliases:
@@ -316,16 +320,20 @@ class Context(Node):
def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint]):
cmd = ctx.dumper([{"cmd": "Hint", "hints" : hints}])
commands = ctx.dumper([hint.as_network_message() for hint in hints])
concerns = collections.defaultdict(list)
for hint in hints:
net_msg = hint.as_network_message()
concerns[hint.receiving_player].append(net_msg)
if not hint.local:
concerns[hint.finding_player].append(net_msg)
for text in (format_hint(ctx, team, hint) for hint in hints):
logging.info("Notice (Team #%d): %s" % (team + 1, text))
for client in ctx.endpoints:
if client.auth and client.team == team:
asyncio.create_task(ctx.send_encoded_msgs(client, cmd))
asyncio.create_task(ctx.send_encoded_msgs(client, commands))
client_hints = concerns[client.slot]
if client_hints:
asyncio.create_task(ctx.send_msgs(client, client_hints))
def update_aliases(ctx: Context, team: int, client: typing.Optional[Client] = None):
@@ -437,10 +445,6 @@ def get_received_items(ctx: Context, team: int, player: int) -> typing.List[Netw
return ctx.received_items.setdefault((team, player), [])
def tuplize_received_items(items):
return [NetworkItem(item.item, item.location, item.player) for item in items]
def send_new_items(ctx: Context):
for client in ctx.endpoints:
if client.auth: # can't send to disconnected client
@@ -449,22 +453,22 @@ def send_new_items(ctx: Context):
asyncio.create_task(ctx.send_msgs(client, [{
"cmd": "ReceivedItems",
"index": client.send_index,
"items": tuplize_received_items(items)[client.send_index:]}]))
"items": items[client.send_index:]}]))
client.send_index = len(items)
def forfeit_player(ctx: Context, team: int, slot: int):
# register any locations that are in the multidata
all_locations = {location_id for location_id, location_slot in ctx.locations if location_slot == slot}
all_locations = set(ctx.locations[slot])
ctx.notify_all("%s (Team #%d) has forfeited" % (ctx.player_names[(team, slot)], team + 1))
register_location_checks(ctx, team, slot, all_locations)
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
items = []
for (location, location_slot) in ctx.locations:
if location_slot == slot and location not in ctx.location_checks[team, slot]:
items.append(ctx.locations[location, slot][0]) # item ID
for location_id in ctx.locations[slot]:
if location_id not in ctx.location_checks[team, slot]:
items.append(ctx.locations[slot][location_id][0]) # item ID
return sorted(items)
@@ -473,8 +477,8 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
if new_locations:
ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc)
for location in new_locations:
if (location, slot) in ctx.locations:
item_id, target_player = ctx.locations[(location, slot)]
if location in ctx.locations[slot]:
item_id, target_player = ctx.locations[slot][location]
new_item = NetworkItem(item_id, location, slot)
if target_player != slot or slot in ctx.remote_items:
get_received_items(ctx, team, target_player).append(new_item)
@@ -504,29 +508,26 @@ def notify_team(ctx: Context, team: int, text: str):
def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[NetUtils.Hint]:
hints = []
seeked_item_id = lookup_any_item_name_to_id[item]
for check, result in ctx.locations.items():
item_id, receiving_player = result
if receiving_player == slot and item_id == seeked_item_id:
location_id, finding_player = check
found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance))
for finding_player, check_data in ctx.locations.items():
for location_id, result in check_data.items():
item_id, receiving_player = result
if receiving_player == slot and item_id == seeked_item_id:
found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance))
return hints
def collect_hints_location(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]:
hints = []
seeked_location = Regions.lookup_name_to_id[location]
for check, result in ctx.locations.items():
location_id, finding_player = check
if finding_player == slot and location_id == seeked_location:
item_id, receiving_player = result
found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance))
break # each location has 1 item
return hints
seeked_location: int = Regions.lookup_name_to_id[location]
item_id, receiving_player = ctx.locations[slot].get(seeked_location, (None, None))
if item_id:
found = seeked_location in ctx.location_checks[team, slot]
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
return [NetUtils.Hint(receiving_player, slot, seeked_location, item_id, found, entrance)]
return []
def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
@@ -786,7 +787,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
if self.ctx.remaining_mode == "enabled":
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if remaining_item_ids:
self.output("Remaining items: " + ", ".join(Items.lookup_id_to_name.get(item_id, "unknown item")
self.output("Remaining items: " + ", ".join(lookup_any_item_id_to_name.get(item_id, "unknown item")
for item_id in remaining_item_ids))
else:
self.output("No remaining items found.")
@@ -799,7 +800,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if remaining_item_ids:
self.output("Remaining items: " + ", ".join(Items.lookup_id_to_name.get(item_id, "unknown item")
self.output("Remaining items: " + ", ".join(lookup_any_item_id_to_name.get(item_id, "unknown item")
for item_id in remaining_item_ids))
else:
self.output("No remaining items found.")
@@ -864,7 +865,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
"""Use !hint {item_name/location_name}, for example !hint Lamp or !hint Link's House. """
points_available = get_client_points(self.ctx, self.client)
if not item_or_location:
self.output(f"A hint costs {self.ctx.hint_cost} points. "
self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. "
f"You have {points_available} points.")
hints = {hint.re_check(self.ctx, self.client.team) for hint in
self.ctx.hints[self.client.team, self.client.slot]}
@@ -885,7 +886,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
hints = collect_hints(self.ctx, self.client.team, self.client.slot, item_name)
else: # location name
hints = collect_hints_location(self.ctx, self.client.team, self.client.slot, item_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
@@ -899,8 +900,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
if not not_found_hints: # everything's been found, no need to pay
can_pay = 1000
elif self.ctx.hint_cost:
can_pay = points_available // self.ctx.hint_cost
elif cost:
can_pay = points_available // cost
else:
can_pay = 1000
@@ -926,7 +927,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
else:
self.output(f"You can't afford the hint. "
f"You have {points_available} points and need at least "
f"{self.ctx.hint_cost}")
f"{self.ctx.get_hint_cost(self.client.slot)}")
notify_hints(self.ctx, self.client.team, hints)
self.ctx.save()
return True
@@ -941,21 +942,19 @@ class ClientMessageProcessor(CommonCommandProcessor):
def get_checked_checks(ctx: Context, client: Client) -> typing.List[int]:
return [location_id for
location_id, slot in ctx.locations if
slot == client.slot and
location_id in ctx.locations[client.slot] if
location_id in ctx.location_checks[client.team, client.slot]]
def get_missing_checks(ctx: Context, client: Client) -> typing.List[int]:
return [location_id for
location_id, slot in ctx.locations if
slot == client.slot and
location_id in ctx.locations[client.slot] if
location_id not in ctx.location_checks[client.team, client.slot]]
def get_client_points(ctx: Context, client: Client) -> int:
return (ctx.location_check_points * len(ctx.location_checks[client.team, client.slot]) -
ctx.hint_cost * ctx.hints_used[client.team, client.slot])
ctx.get_hint_cost(client.slot) * ctx.hints_used[client.team, client.slot])
async def process_client_cmd(ctx: Context, client: Client, args: dict):
@@ -1032,7 +1031,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
}]
items = get_received_items(ctx, client.team, client.slot)
if items:
reply.append({"cmd": 'ReceivedItems', "index": 0, "items": tuplize_received_items(items)})
reply.append({"cmd": 'ReceivedItems', "index": 0, "items": items})
client.send_index = len(items)
await ctx.send_msgs(client, reply)
@@ -1047,7 +1046,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
if items:
client.send_index = len(items)
await ctx.send_msgs(client, [{"cmd": "ReceivedItems","index": 0,
"items": tuplize_received_items(items)}])
"items": items}])
elif cmd == 'LocationChecks':
register_location_checks(ctx, client.team, client.slot, args["locations"])
@@ -1058,7 +1057,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
if type(location) is not int or location not in lookup_any_location_id_to_name:
await ctx.send_msgs(client, [{"cmd": "InvalidArguments", "text": 'LocationScouts'}])
return
target_item, target_player = ctx.locations[location, client.slot]
target_item, target_player = ctx.locations[client.slot][location]
locs.append(NetworkItem(target_item, location, target_player))
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
@@ -1206,7 +1205,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
if usable:
for client in self.ctx.endpoints:
if client.name == seeked_player:
new_item = NetworkItem(lookup_any_item_name_to_id[item], -1, client.slot)
new_item = NetworkItem(lookup_any_item_name_to_id[item], -1, 0)
get_received_items(self.ctx, client.team, client.slot).append(new_item)
self.ctx.notify_all('Cheat console: sending "' + item + '" to ' +
self.ctx.get_aliased_name(client.team, client.slot))

View File

@@ -303,7 +303,10 @@ def handle_name(name: str, player: int, name_counter: Counter):
name] > 1 else ''),
player=player,
PLAYER=(player if player > 1 else '')))
return new_name.strip().replace(' ', '_')[:16]
new_name = new_name.strip().replace(' ', '_')[:16]
if new_name == "Archipelago":
raise Exception(f"You cannot name yourself \"{new_name}\"")
return new_name
def prefer_int(input_data: str) -> typing.Union[str, int]:
@@ -538,6 +541,8 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
elif itemvalue:
startitems.append(item)
ret.startinventory = startitems
ret.start_hints = set(weights.get('start_hints', []))
if ret.game == "A Link to the Past":
roll_alttp_settings(ret, weights, plando_options)

View File

@@ -308,3 +308,7 @@ class Hint(typing.NamedTuple):
add_json_text(parts, ".")
return {"cmd": "PrintJSON", "data": parts, "type": "hint"}
@property
def local(self):
return self.receiving_player == self.finding_player

View File

@@ -12,7 +12,7 @@ from typing import Tuple, Optional
import Utils
from worlds.alttp.Rom import JAP10HASH
current_patch_version = 1
current_patch_version = 2
def get_base_rom_path(file_name: str = "") -> str:
@@ -43,9 +43,9 @@ def get_base_rom_bytes(file_name: str = "") -> bytes:
def generate_yaml(patch: bytes, metadata: Optional[dict] = None) -> bytes:
patch = yaml.dump({"meta": metadata,
"patch": patch,
"game": "alttp",
"compatible_version": 1,
"game": "A Link to the Past",
# minimum version of patch system expected for patching to be successful
"compatible_version": 1,
"version": current_patch_version,
"base_checksum": JAP10HASH})
return patch.encode(encoding="utf-8-sig")
@@ -58,10 +58,13 @@ def generate_patch(rom: bytes, metadata: Optional[dict] = None) -> bytes:
return generate_yaml(patch, metadata)
def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str = None) -> str:
def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str = None,
player: int = 0, player_name: str = "") -> 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),
{
"server": server}) # allow immediate connection to server in multiworld. Empty string otherwise
meta)
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + ".apbp"
write_lzma(bytes, target)
return target

View File

@@ -12,7 +12,7 @@ class Version(typing.NamedTuple):
minor: int
build: int
__version__ = "0.1.0"
__version__ = "0.1.1"
_version_tuple = tuplize_version(__version__)
import builtins
@@ -392,6 +392,11 @@ class RestrictedUnpickler(pickle.Unpickler):
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint"}:
import NetUtils
return getattr(NetUtils, name)
if module == "Options":
import Options
obj = getattr(Options, name)
if issubclass(obj, Options.Option):
return obj
# Forbid everything else.
raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
(module, name))

View File

@@ -8,16 +8,14 @@ from waitress import serve
from WebHostLib.models import db
from WebHostLib.autolauncher import autohost
configpath = "config.yaml"
configpath = os.path.abspath("config.yaml")
def get_app():
app = raw_app
if os.path.exists(configpath):
import yaml
with open(configpath) as c:
app.config.update(yaml.safe_load(c))
app.config.from_file(configpath, yaml.safe_load)
logging.info(f"Updated config from {configpath}")
db.bind(**app.config["PONY"])
db.generate_mapping(create_tables=True)

View File

@@ -6,7 +6,6 @@ import socket
from pony.flask import Pony
from flask import Flask, request, redirect, url_for, render_template, Response, session, abort, send_from_directory
from flask_caching import Cache
from flaskext.autoversion import Autoversion
from flask_compress import Compress
from .models import *
@@ -48,9 +47,6 @@ app.config["CACHE_TYPE"] = "simple"
app.config["JSON_AS_ASCII"] = False
app.config["PATCH_TARGET"] = "archipelago.gg"
app.autoversion = True
av = Autoversion(app)
cache = Cache(app)
Compress(app)

View File

@@ -1,4 +1,6 @@
import json
import pickle
from uuid import UUID
from . import api_endpoints
@@ -46,7 +48,7 @@ def generate_api():
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=pickle.dumps({"race": race}), state=STATE_QUEUED,
meta=json.dumps({"race": race}), state=STATE_QUEUED,
owner=session["_id"])
commit()
return {"text": f"Generation of seed {gen.id} started successfully.",
@@ -58,6 +60,7 @@ def generate_api():
return {"text": "Uncaught Exception:" + str(e)}, 500
@api_endpoints.route('/status/<suuid:seed>')
def wait_seed_api(seed: UUID):
seed_id = seed

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import logging
import json
import multiprocessing
from datetime import timedelta, datetime
import concurrent.futures
@@ -9,6 +10,8 @@ import time
from pony.orm import db_session, select, commit
from Utils import restricted_loads
class CommonLocker():
"""Uses a file lock to signal that something is already running"""
@@ -25,6 +28,7 @@ class AlreadyRunningException(Exception):
if sys.platform == 'win32':
import os
class Locker(CommonLocker):
def __enter__(self):
try:
@@ -43,6 +47,7 @@ if sys.platform == 'win32':
else: # unix
import fcntl
class Locker(CommonLocker):
def __enter__(self):
try:
@@ -78,14 +83,21 @@ def handle_generation_failure(result: BaseException):
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
options = generation.options
logging.info(f"Generating {generation.id} for {len(options)} players")
meta = generation.meta
pool.apply_async(gen_game, (options,),
{"race": meta["race"], "sid": generation.id, "owner": generation.owner},
handle_generation_success, handle_generation_failure)
generation.state = STATE_STARTED
try:
meta = json.loads(generation.meta)
options = restricted_loads(generation.options)
logging.info(f"Generating {generation.id} for {len(options)} players")
pool.apply_async(gen_game, (options,),
{"race": meta["race"],
"sid": generation.id,
"owner": generation.owner},
handle_generation_success, handle_generation_failure)
except:
generation.state = STATE_ERROR
commit()
raise
else:
generation.state = STATE_STARTED
def init_db(pony_config: dict):
@@ -138,6 +150,7 @@ multiworlds = {}
guardians = concurrent.futures.ThreadPoolExecutor(2, thread_name_prefix="Guardian")
class MultiworldInstance():
def __init__(self, room: Room, config: dict):
self.room_id = room.id
@@ -162,7 +175,7 @@ class MultiworldInstance():
self.process = None
def _collect(self):
self.process.join() # wait for process to finish
self.process.join() # wait for process to finish
self.process = None
self.guardian = None

View File

@@ -2,12 +2,12 @@ from flask import send_file, Response
from pony.orm import select
from Patch import update_patch_data
from WebHostLib import app, Patch, Room, Seed
from WebHostLib import app, Slot, Room, Seed
import zipfile
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
def download_patch(room_id, patch_id):
patch = Patch.get(id=patch_id)
patch = Slot.get(id=patch_id)
if not patch:
return "Patch not found"
else:
@@ -30,8 +30,9 @@ def download_spoiler(seed_id):
@app.route("/dl_raw_patch/<suuid:seed_id>/<int:player_id>")
def download_raw_patch(seed_id, player_id: int):
patch = select(patch for patch in Patch if
patch.player_id == player_id and patch.seed.id == seed_id).first()
seed = Seed.get(id=seed_id)
patch = select(patch for patch in seed.slots if
patch.player_id == player_id).first()
if not patch:
return "Patch not found"
@@ -43,3 +44,25 @@ def download_raw_patch(seed_id, player_id: int):
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](seed_id)}.apbp"
return send_file(patch_data, as_attachment=True, attachment_filename=fname)
@app.route("/slot_file/<suuid:seed_id>/<int:player_id>")
def download_slot_file(seed_id, player_id: int):
seed = Seed.get(id=seed_id)
slot_data: Slot = select(patch for patch in seed.slots if
patch.player_id == player_id).first()
if not slot_data:
return "Slot Data not found"
else:
import io
if slot_data.game == "Minecraft":
fname = f"AP_{app.jinja_env.filters['suuid'](seed_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
elif slot_data.game == "Factorio":
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
for name in zf.namelist():
if name.endswith("info.json"):
fname = name.rsplit("/", 1)[0]+".zip"
else:
return "Game download not supported."
return send_file(io.BytesIO(slot_data.data), as_attachment=True, attachment_filename=fname)

View File

@@ -1,6 +1,7 @@
import os
import tempfile
import random
import json
from collections import Counter
from flask import request, flash, redirect, url_for, session, render_template
@@ -39,7 +40,7 @@ def generate(race=False):
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=pickle.dumps({"race": race}), state=STATE_QUEUED,
meta=json.dumps({"race": race}), state=STATE_QUEUED,
owner=session["_id"])
commit()
@@ -79,7 +80,6 @@ def gen_game(gen_options, race=False, owner=None, sid=None):
erargs.outputname = seedname
erargs.outputpath = target.name
erargs.teams = 1
erargs.progression_balancing = {}
erargs.create_diff = True
name_counter = Counter()
@@ -94,10 +94,6 @@ def gen_game(gen_options, race=False, owner=None, sid=None):
erargs.names = ",".join(erargs.name[i] for i in range(1, playercount + 1))
del (erargs.name)
erargs.skip_progression_balancing = {player: not balanced for player, balanced in
erargs.progression_balancing.items()}
del (erargs.progression_balancing)
ERmain(erargs, seed)
return upload_to_db(target.name, owner, sid, race)
@@ -107,7 +103,11 @@ def gen_game(gen_options, race=False, owner=None, sid=None):
gen = Generation.get(id=sid)
if gen is not None:
gen.state = STATE_ERROR
gen.meta = (e.__class__.__name__ + ": "+ str(e)).encode()
meta = json.loads(gen.meta)
meta["error"] = (e.__class__.__name__ + ": "+ str(e))
gen.meta = json.dumps(meta)
commit()
raise
@@ -122,12 +122,12 @@ def wait_seed(seed: UUID):
if not generation:
return "Generation not found."
elif generation.state == STATE_ERROR:
return render_template("seedError.html", seed_error=generation.meta.decode())
return render_template("seedError.html", seed_error=generation.meta)
return render_template("waitSeed.html", seed_id=seed_id)
def upload_to_db(folder, owner, sid, race:bool):
patches = set()
slots = set()
spoiler = ""
multidata = None
@@ -137,8 +137,8 @@ def upload_to_db(folder, owner, sid, race:bool):
player_text = file.split("_P", 1)[1]
player_name = player_text.split("_", 1)[1].split(".", 1)[0]
player_id = int(player_text.split(".", 1)[0].split("_", 1)[0])
patches.add(Patch(data=open(file, "rb").read(),
player_id=player_id, player_name = player_name))
slots.add(Slot(data=open(file, "rb").read(),
player_id=player_id, player_name = player_name, game = "A Link to the Past"))
elif file.endswith(".txt"):
spoiler = open(file, "rt", encoding="utf-8-sig").read()
elif file.endswith(".archipelago"):
@@ -146,12 +146,12 @@ def upload_to_db(folder, owner, sid, race:bool):
if multidata:
with db_session:
if sid:
seed = Seed(multidata=multidata, spoiler=spoiler, patches=patches, owner=owner,
id=sid, meta={"tags": ["generated"]})
seed = Seed(multidata=multidata, spoiler=spoiler, slots=slots, owner=owner,
id=sid, meta=json.dumps({"race": race, "tags": ["generated"]}))
else:
seed = Seed(multidata=multidata, spoiler=spoiler, patches=patches, owner=owner,
meta={"tags": ["generated"]})
for patch in patches:
seed = Seed(multidata=multidata, spoiler=spoiler, slots=slots, owner=owner,
meta=json.dumps({"race": race, "tags": ["generated"]}))
for patch in slots:
patch.seed = seed
if sid:
gen = Generation.get(id=sid)

View File

@@ -9,12 +9,13 @@ STATE_STARTED = 1
STATE_ERROR = -1
class Patch(db.Entity):
class Slot(db.Entity):
id = PrimaryKey(int, auto=True)
player_id = Required(int)
player_name = Required(str, 16)
data = Required(bytes, lazy=True)
data = Optional(bytes, lazy=True)
seed = Optional('Seed')
game = Required(str)
class Room(db.Entity):
@@ -37,9 +38,9 @@ class Seed(db.Entity):
multidata = Required(bytes, lazy=True)
owner = Required(UUID, index=True)
creation_time = Required(datetime, default=lambda: datetime.utcnow())
patches = Set(Patch)
slots = Set(Slot)
spoiler = Optional(LongStr, lazy=True)
meta = Required(Json, lazy=True, default=lambda: {}) # additional meta information/tags
meta = Required(str, default=lambda: "{\"race\": false}") # additional meta information/tags
class Command(db.Entity):
@@ -51,6 +52,6 @@ class Command(db.Entity):
class Generation(db.Entity):
id = PrimaryKey(UUID, default=uuid4)
owner = Required(UUID)
options = Required(Json, lazy=True)
meta = Required(Json, lazy=True)
options = Required(buffer, lazy=True)
meta = Required(str, default=lambda: "{\"race\": false}")
state = Required(int, default=0, index=True)

View File

@@ -1,7 +1,6 @@
flask>=1.1.2
flask>=2.0.0
pony>=0.7.14
waitress>=2.0.0
flask-caching>=1.10.1
Flask-Autoversion>=0.2.0
Flask-Compress>=1.9.0
Flask-Limiter>=1.4

View File

@@ -170,6 +170,12 @@ const generateGame = (raceMode = false) => {
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;
}).catch((error) => {
const userMessage = document.getElementById('user-message');
userMessage.innerText = 'Something went wrong and your game could not be generated.';
userMessage.classList.add('visible');
window.scrollTo(0, 0);
console.error(error);
});
};

View File

@@ -0,0 +1,51 @@
# Factorio Randomizer Setup Guide
## Required Software
### Server Host
- [Factorio](https://factorio.com)
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
### Players
- [Factorio](https://factorio.com)
## General Concept
One Server Host exists per Factorio World in an Archipelago Multiworld, any number of modded Factorio players can then connect to that world. From the view of Archipelago, this Factorio host is a client.
## Installation Procedures
### Dedicated Server Setup
You need a dedicated isolated Factorio installation that the FactorioClient can take control over, if you intend to both emit a world and play, you need to follow both this setup and the player setup.
This requires two Factorio installations. The easiest and cheapest way to do so is to either buy or register a Factorio on factorio.com, which allows you to download as many Factorio games as you want.
1. Download the latest Factorio from https://factorio.com/download for your system, for Windows the recommendation is "win64-manual".
2. Make sure the Factorio you play and the Factorio you use for hosting do not share paths. If you downloaded the "manual" version, this is already the case, otherwise, go into the hosting Factorio's folder and put the following text into its `config-path.cfg`:
```ini
config-path=__PATH__executable__/../../config
use-system-read-write-data-directories=false
```
3. Navigate to where you installed Archipelago and open the host.yaml file as text. Find the entry `executable` under `factorio_options` and set it to point to your Factorio. If you put Factorio into your Archipelago folder, this would already match.
### Player Setup
- Manually install the AP mod for the correct world you want to join, then use Factorio's built-in multiplayer.
## Joining a MultiWorld Game
1. Make a fresh world. Using any Factorio, create a savegame with options of your choice and save that world as Archipelago.
2. Take that savegame and put it into your Archipelago folder
3. Install the generated Factorio AP Mod
4. Run FactorioClient, it should launch a Factorio server, which you can control with `/factorio <original factorio commands>`,
* It should say it loaded the Archipelago mod and found a bridge file. If not, the most likely case is that the mod is not correctly installed or activated.
5. In FactorioClient, do `/connect <Archipelago Server Address>` to join that multiworld. You can find further commands with `/help` as well as `!help` once connected.
* / commands are run on your local client, ! commands are requests for the AP server
* Players should be able to connect to your Factorio Server and begin playing.

View File

@@ -68,7 +68,7 @@ game: Minecraft
# Shared Options supported by all games:
accessibility: locations
progression_balancing: off
progression_balancing: on
# Minecraft Specific Options
# Number of advancements required (out of 92 total) to spawn the
@@ -108,7 +108,6 @@ shuffle_structures:
off: 0
```
For more detail on what each setting does check the default `PlayerSettings.yaml` that comes with the Archipelago install.
## Joining a MultiWorld Game
@@ -126,8 +125,8 @@ previously.
After having placed your data file in the `APData` folder, start the Forge server and make sure you have OP
status by typing `/op YourMinecraftUsername` in the forge server console then connecting in your Minecraft client.
Once in game type `/connect <AP-Address> (<Password>)` where `<AP-Address>` is the address of the
Archipelago server. `(<Password>)`
Once in game type `/connect <AP-Address> (Port) (Password)` where `<AP-Address>` is the address of the
Archipelago server. `(Port)` is only required if the Archipelago server is not using the default port of 38281. `(Password)`
is only required if the Archipleago server you are using has a password set.
### Play the game

View File

@@ -86,6 +86,25 @@
}
]
},
{
"gameTitle": "Factorio",
"tutorials": [
{
"name": "Multiworld Setup Tutorial",
"description": "A guide to setting up the Archipelago Factorio software on your computer.",
"files": [
{
"language": "English",
"filename": "factorio/setup_en.md",
"link": "factorio/setup/en",
"authors": [
"Berserker"
]
}
]
}
]
},
{
"gameTitle": "Minecraft",
"tutorials": [

View File

@@ -1,11 +1,5 @@
# A Link to the Past Randomizer Setup Guide
<div id="tutorial-video-container">
<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/icWPmse0Z3E" frameborder="0"
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen>
</iframe>
</div>
## Benötigte Software
- [MultiWorld Utilities](https://github.com/Berserker66/MultiWorld-Utilities/releases)
- [QUsb2Snes](https://github.com/Skarsnik/QUsb2snes/releases) (Included in the above Utilities)

View File

@@ -473,5 +473,11 @@ const generateGame = (raceMode = false) => {
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;
}).catch((error) => {
const userMessage = document.getElementById('user-message');
userMessage.innerText = 'Something went wrong and your game could not be generated.';
userMessage.classList.add('visible');
window.scrollTo(0, 0);
console.error(error);
});
};

View File

@@ -29,6 +29,20 @@ html{
color: #000000;
}
#player-settings #user-message{
display: none;
width: calc(100% - 8px);
background-color: #ffe86b;
border-radius: 4px;
color: #000000;
padding: 4px;
text-align: center;
}
#player-settings #user-message.visible{
display: block;
}
#player-settings h1{
font-size: 2.5rem;
font-weight: normal;

View File

@@ -14,7 +14,7 @@ html{
color: #eeffeb;
}
#user-warning{
#user-warning, #weighted-settings #user-message{
display: none;
width: calc(100% - 8px);
background-color: #ffe86b;
@@ -25,6 +25,10 @@ html{
cursor: pointer;
}
#weighted-settings #user-message.visible{
display: block;
}
#weighted-settings code{
background-color: #d9cd8e;
border-radius: 4px;

View File

@@ -1,5 +1,5 @@
{% extends "tablepage.html" %}
{% block head %}
{{ super() }}
<script type="application/ecmascript" src="{{ static_autoversion("assets/autodatatable.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/autodatatable.js") }}"></script>
{% endblock %}

View File

@@ -3,8 +3,8 @@
{% block head %}
{{ super() }}
<title>Mystery Check Result</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/check.css") }}" />
<script type="application/ecmascript" src="{{ static_autoversion("assets/check.js") }}"></script>
<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>
{% endblock %}
{% block body %}

View File

@@ -3,7 +3,7 @@
{% block head %}
{{ super() }}
<title>Mystery YAML Test Roll Results</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/checkResult.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/checkResult.css") }}" />
{% endblock %}
{% block body %}

View File

@@ -3,8 +3,8 @@
{% block head %}
{{ super() }}
<title>Generate Game</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/generate.css") }}" />
<script type="application/ecmascript" src="{{ static_autoversion("assets/generate.js") }}"></script>
<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>
{% endblock %}
{% block body %}

View File

@@ -1,5 +1,5 @@
{% block head %}
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/header/baseHeader.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/header/baseHeader.css") }}" />
{% endblock %}
{% block header %}

View File

@@ -1,5 +1,5 @@
{% block head %}
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/header/dirtHeader.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/header/dirtHeader.css") }}" />
{% endblock %}
{% include 'header/baseHeader.html' %}

View File

@@ -1,5 +1,5 @@
{% block head %}
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/header/grassHeader.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/header/grassHeader.css") }}" />
{% endblock %}
{% include 'header/baseHeader.html' %}

View File

@@ -1,5 +1,5 @@
{% block head %}
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/header/oceanHeader.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/header/oceanHeader.css") }}" />
{% endblock %}
{% include 'header/baseHeader.html' %}

View File

@@ -3,8 +3,8 @@
{% block head %}
{{ super() }}
<title>Upload Multidata</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/hostGame.css") }}" />
<script type="application/ecmascript" src="{{ static_autoversion("assets/hostGame.js") }}"></script>
<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>
{% endblock %}
{% block body %}

View File

@@ -2,7 +2,7 @@
{% import "macros.html" as macros %}
{% block head %}
<title>Multiworld {{ room.id|suuid }}</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/hostRoom.css") }}"/>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostRoom.css") }}"/>
{% endblock %}
{% block body %}
@@ -21,7 +21,7 @@
you can simply refresh this page and the server will be started again.<br>
{% if room.last_port %}
You can connect to this room by using '/connect archipelago.gg:{{ room.last_port }}'
in the <a href="https://github.com/Berserker66/MultiWorld-Utilities/releases">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

@@ -14,5 +14,5 @@
{% endblock %}
{% block head %}
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/islandFooter.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/islandFooter.css") }}" />
{% endblock %}

View File

@@ -2,7 +2,7 @@
{% block head %}
<title>MultiWorld</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/landing.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/landing.css") }}" />
{% endblock %}
{% block body %}

View File

@@ -7,11 +7,19 @@
</ul>
{%- endmacro %}
{% macro list_patches_room(room) %}
{% if room.seed.patches %}
{% if room.seed.slots %}
<ul>
{% for patch in room.seed.patches|list|sort(attribute="player_id") %}
{% for patch in room.seed.slots|list|sort(attribute="player_id") %}
{% if patch.game == "Minecraft" %}
<li><a href="{{ url_for("download_slot_file", seed_id=room.seed.id, player_id=patch.player_id) }}">
APMC for player {{ patch.player_id }} - {{ patch.player_name }}</a></li>
{% elif patch.game == "Factorio" %}
<li><a href="{{ url_for("download_slot_file", seed_id=room.seed.id, player_id=patch.player_id) }}">
Mod for player {{ patch.player_id }} - {{ patch.player_name }}</a></li>
{% else %}
<li><a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}">
Patch for player {{ patch.player_id }} - {{ patch.player_name }}</a></li>
{% endif %}
{% endfor %}
</ul>
{% endif %}

View File

@@ -4,11 +4,11 @@
<meta charset="UTF-8">
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css2?family=Jost:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/tooltip.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/cookieNotice.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/globalStyles.css") }}" />
<script type="application/ecmascript" src="{{ static_autoversion("assets/styleController.js") }}"></script>
<script type="application/ecmascript" src="{{ static_autoversion("assets/cookieNotice.js") }}"></script>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tooltip.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/cookieNotice.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/globalStyles.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/styleController.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/cookieNotice.js") }}"></script>
{% block head %}
<title>MultiWorld</title>
{% endblock %}

View File

@@ -2,15 +2,16 @@
{% block head %}
<title>Player Settings</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/playerSettings.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/playerSettings.css") }}" />
<script type="application/ecmascript" src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="application/ecmascript" src="{{ static_autoversion("assets/js-yaml.min.js") }}"></script>
<script type="application/ecmascript" src="{{ static_autoversion("assets/playerSettings.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/js-yaml.min.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/playerSettings.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/grassHeader.html' %}
<div id="player-settings">
<div id="user-message"></div>
<h1>Start Game</h1>
<p>Choose the options you would like to play with! You may generate a single-player game from this page,
or download a settings file you can use to participate in a MultiWorld. If you would like to make

View File

@@ -2,8 +2,8 @@
<html lang="en">
<head>
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/playerTracker.css") }}"/>
<script type="application/ecmascript" src="{{ static_autoversion("assets/playerTracker.js") }}"></script>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/playerTracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/playerTracker.js") }}"></script>
</head>
<body>
@@ -39,7 +39,7 @@
</tr>
<tr>
<td><img src="{{ icons["Pegasus Boots"] }}" class="{{ 'acquired' if "Pegasus Boots" in acquired_items }}" /></td>
<td><img src="{{ gloves_url }}" class="{{ 'acquired' if gloves_acquired }}" /></td>
<td><img src="{{ glove_url }}" class="{{ 'acquired' if glove_acquired }}" /></td>
<td><img src="{{ icons["Flippers"] }}" class="{{ 'acquired' if "Flippers" in acquired_items }}" /></td>
<td><img src="{{ icons["Moon Pearl"] }}" class="{{ 'acquired' if "Moon Pearl" in acquired_items }}" /></td>
<td><img src="{{ icons["Mushroom"] }}" class="{{ 'acquired' if "Mushroom" in acquired_items }}" /></td>

View File

@@ -3,7 +3,7 @@
{% block head %}
<title>Generation failed, please retry.</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/waitSeed.css") }}"/>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/waitSeed.css") }}"/>
{% endblock %}
{% block body %}

View File

@@ -7,5 +7,5 @@
<script type="text/javascript"
src="https://cdn.datatables.net/v/dt/jq-3.3.1/dt-1.10.21/sc-2.0.2/sp-1.1.1/datatables.min.js"
></script>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/tablepage.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tablepage.css") }}" />
{% endblock %}

View File

@@ -2,9 +2,9 @@
{% block head %}
{{ super() }}
<title>Multiworld Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/tracker.css") }}"/>
<script type="application/ecmascript" src="{{ static_autoversion("assets/jquery.scrollsync.js") }}"></script>
<script type="application/ecmascript" src="{{ static_autoversion("assets/tracker.js") }}"></script>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tracker.js") }}"></script>
{% endblock %}
{% block body %}

View File

@@ -3,11 +3,11 @@
{% block head %}
{% include 'header/grassHeader.html' %}
<title>Archipelago</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/tutorial.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tutorial.css") }}" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
crossorigin="anonymous"></script>
<script type="application/ecmascript" src="{{ static_autoversion("assets/tutorial.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tutorial.js") }}"></script>
{% endblock %}
{% block body %}

View File

@@ -3,8 +3,8 @@
{% block head %}
{% include 'header/grassHeader.html' %}
<title>Archipelago Guides</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/tutorialLanding.css") }}" />
<script type="application/ecmascript" src="{{ static_autoversion("assets/tutorialLanding.js") }}"></script>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tutorialLanding.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tutorialLanding.js") }}"></script>
{% endblock %}
{% block body %}

View File

@@ -3,8 +3,8 @@
{% block head %}
{{ super() }}
<title>Generate Game</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/userContent.css") }}" />
<script type="application/ecmascript" src="{{ static_autoversion("assets/userContent.js") }}"></script>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/userContent.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/userContent.js") }}"></script>
{% endblock %}
{% block body %}
@@ -31,10 +31,7 @@
<tr>
<td><a href="{{ url_for("viewSeed", seed=room.seed.id) }}">{{ room.seed.id|suuid }}</a></td>
<td><a href="{{ url_for("hostRoom", room=room.id) }}">{{ room.id|suuid }}</a></td>
<td
class="center"
data-tooltip="{{ room.seed.multidata.names[0]|join(", ")|truncate(256, False, " ...") }}"
>{{ room.seed.multidata.names[0]|length }}</td>
<td>>={{ room.seed.slots|length }}</td>
<td>{{ room.creation_time.strftime("%Y-%m-%d %H:%M") }}</td>
<td>{{ room.last_activity.strftime("%Y-%m-%d %H:%M") }}</td>
</tr>
@@ -59,11 +56,7 @@
{% for seed in seeds %}
<tr>
<td><a href="{{ url_for("viewSeed", seed=seed.id) }}">{{ seed.id|suuid }}</a></td>
<td class="center"
{% if seed.multidata %}
data-tooltip="{{ seed.multidata.names[0]|join(", ")|truncate(256, False, " ...") }}"
{% endif %}
>{% if seed.multidata %}{{ seed.multidata.names[0]|length }}{% else %}1{% endif %}
<td>{% if seed.multidata %}>={{ seed.slots|length }}{% else %}1{% endif %}
</td>
<td>{{ seed.creation_time.strftime("%Y-%m-%d %H:%M") }}</td>
</tr>

View File

@@ -3,8 +3,8 @@
{% block head %}
<title>View Seed {{ seed.id|suuid }}</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/viewSeed.css") }}"/>
<script type="application/ecmascript" src="{{ static_autoversion("assets/viewSeed.js") }}"></script>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/viewSeed.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/viewSeed.js") }}"></script>
{% endblock %}
{% block body %}
@@ -37,17 +37,10 @@
<td>Players:&nbsp;</td>
<td>
<ul>
{% for team in seed.multidata["names"] %}
{% set outer_loop = loop %}
<li>Team #{{ loop.index }} - {{ team | length }}
<ul>
{% for player in team %}
<li>
<a href="{{ url_for("download_raw_patch", seed_id=seed.id, player_id=loop.index, team_id=outer_loop.index0) }}">{{ player }}</a>
</li>
{% endfor %}
</ul>
</li>
{% for patch in seed.slots|sort(attribute='player_id') %}
<li>
<a href="{{ url_for("download_raw_patch", seed_id=seed.id, player_id=patch.player_id) }}">{{ patch.player_name }}</a>
</li>
{% endfor %}
</ul>
</td>
@@ -64,13 +57,13 @@
</tr>
{% else %}
<tr>
<td>Patches:&nbsp;</td>
<td>Files:&nbsp;</td>
<td>
<ul>
{% for patch in seed.patches %}
{% for slot in seed.slots %}
<li>
<a href="{{ url_for("download_raw_patch", seed_id=seed.id, player_id=patch.player, team_id=0) }}">Player {{ patch.player }}</a>
<a href="{{ url_for("download_raw_patch", seed_id=seed.id, player_id=slot.player_id) }}">Player {{ slot.player_name }}</a>
</li>

View File

@@ -4,7 +4,7 @@
{% block head %}
<title>Generation in Progress</title>
<meta http-equiv="refresh" content="1">
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/waitSeed.css") }}"/>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/waitSeed.css") }}"/>
{% endblock %}
{% block body %}

View File

@@ -2,16 +2,17 @@
{% block head %}
<title>Player Settings</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/weightedSettings.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/weightedSettings.css") }}" />
<script type="application/ecmascript" src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="application/ecmascript" src="{{ static_autoversion("assets/js-yaml.min.js") }}"></script>
<script type="application/ecmascript" src="{{ static_autoversion("assets/weightedSettings.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/js-yaml.min.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/weightedSettings.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/grassHeader.html' %}
<div id="weighted-settings">
<header id="user-warning"></header>
<div id="user-message"></div>
<h1>Weighted Settings</h1>
<div id="instructions">
This page is used to configure your weighted settings. You have three presets you can control, which

View File

@@ -7,16 +7,15 @@ from uuid import UUID
from worlds.alttp import Items, Regions
from WebHostLib import app, cache, Room
from NetUtils import Hint
from Utils import restricted_loads
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name
def get_id(item_name):
def get_alttp_id(item_name):
return Items.item_table[item_name][2]
app.jinja_env.filters["location_name"] = lambda location: Regions.lookup_id_to_name.get(location, location)
app.jinja_env.filters['item_name'] = lambda id: Items.lookup_id_to_name.get(id, id)
app.jinja_env.filters["location_name"] = lambda location: lookup_any_location_id_to_name.get(location, location)
app.jinja_env.filters['item_name'] = lambda id: lookup_any_item_id_to_name.get(id, id)
icons = {
"Blue Shield": r"https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png",
@@ -155,9 +154,9 @@ levels = {"Fighter Sword": 1,
"Bow": 1,
"Silver Bow": 2}
multi_items = {get_id(name) for name in ("Progressive Sword", "Progressive Bow", "Bottle", "Progressive Glove")}
links = {get_id(key): get_id(value) for key, value in links.items()}
levels = {get_id(key): value for key, value in levels.items()}
multi_items = {get_alttp_id(name) for name in ("Progressive Sword", "Progressive Bow", "Bottle", "Progressive Glove")}
links = {get_alttp_id(key): get_alttp_id(value) for key, value in links.items()}
levels = {get_alttp_id(key): value for key, value in levels.items()}
tracking_names = ["Progressive Sword", "Progressive Bow", "Book of Mudora", "Hammer",
"Hookshot", "Magic Mirror", "Flute",
@@ -237,7 +236,7 @@ ordered_areas = ('Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower',
tracking_ids = []
for item in tracking_names:
tracking_ids.append(get_id(item))
tracking_ids.append(get_alttp_id(item))
small_key_ids = {}
big_key_ids = {}
@@ -266,6 +265,7 @@ def attribute_item(inventory, team, recipient, item):
def attribute_item_solo(inventory, item):
"""Adds item to inventory counter, converts everything to progressive."""
target_item = links.get(item, item)
if item in levels: # non-progressive
inventory[target_item] = max(inventory[target_item], levels[item])
@@ -319,11 +319,12 @@ def get_static_room_data(room: Room):
player_big_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1)}
player_small_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1)}
for _, (item_id, item_player) in locations.items():
if item_id in ids_big_key:
player_big_key_locations[item_player].add(ids_big_key[item_id])
if item_id in ids_small_key:
player_small_key_locations[item_player].add(ids_small_key[item_id])
for loc_data in locations.values():
for item_id, item_player in loc_data.values():
if item_id in ids_big_key:
player_big_key_locations[item_player].add(ids_big_key[item_id])
elif item_id in ids_small_key:
player_small_key_locations[item_player].add(ids_small_key[item_id])
result = locations, names, use_door_tracker, player_checks_in_area, player_location_to_area, \
player_big_key_locations, player_small_key_locations, multidata["precollected_items"]
@@ -332,7 +333,7 @@ def get_static_room_data(room: Room):
@app.route('/tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>')
@cache.memoize(timeout=15)
@cache.memoize(timeout=60) # multisave is currently created at most every minute
def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int):
# Team and player must be positive and greater than zero
if tracked_team < 0 or tracked_player < 1:
@@ -362,35 +363,25 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int):
multisave = {}
# Add items to player inventory
for (ms_team, ms_player), locations_checked in multisave.get("location_checks", {}):
for (ms_team, ms_player), locations_checked in multisave.get("location_checks", {}).items():
# logging.info(f"{ms_team}, {ms_player}, {locations_checked}")
# Skip teams and players not matching the request
player_locations = locations[ms_player]
if ms_team == tracked_team:
# If the player does not have the item, do nothing
for location in locations_checked:
if (location, ms_player) not in locations:
continue
item, recipient = locations[location, ms_player]
if recipient == tracked_player: # a check done for the tracked player
attribute_item_solo(inventory, item)
if ms_player == tracked_player: # a check done by the tracked player
checks_done[location_to_area[location]] += 1
checks_done["Total"] += 1
if location in player_locations:
item, recipient = player_locations[location]
if recipient == tracked_player: # a check done for the tracked player
attribute_item_solo(inventory, item)
if ms_player == tracked_player: # a check done by the tracked player
checks_done[location_to_area[location]] += 1
checks_done["Total"] += 1
# Note the presence of the triforce item
for (ms_team, ms_player), game_state in multisave.get("client_game_state", []):
# Skip teams and players not matching the request
if ms_team != tracked_team or ms_player != tracked_player:
continue
if game_state:
inventory[106] = 1 # Triforce
acquired_items = []
for itm in inventory:
acquired_items.append(get_item_name_from_id(itm))
game_state = multisave.get("client_game_state", {}).get((tracked_team, tracked_player), 0)
if game_state == 30:
inventory[106] = 1 # Triforce
# Progressive items need special handling for icons and class
progressive_items = {
@@ -400,86 +391,42 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int):
"Progressive Mail": 96,
"Progressive Shield": 95,
}
progressive_names = {
"Progressive Sword": [None, 'Fighter Sword', 'Master Sword', 'Tempered Sword', 'Golden Sword'],
"Progressive Glove": [None, 'Power Glove', 'Titan Mitts'],
"Progressive Bow": [None, "Bow", "Silver Bow"],
"Progressive Mail": ["Green Mail", "Blue Mail", "Red Mail"],
"Progressive Shield": [None, "Blue Shield", "Red Shield", "Mirror Shield"]
}
# Determine which icon to use for the sword
sword_url = icons["Fighter Sword"]
sword_acquired = False
sword_names = ['Fighter Sword', 'Master Sword', 'Tempered Sword', 'Golden Sword']
if "Progressive Sword" in acquired_items:
sword_url = icons[sword_names[min(inventory[progressive_items["Progressive Sword"]], 4) - 1]]
sword_acquired = True
else:
for sword in reversed(sword_names):
if sword in acquired_items:
sword_url = icons[sword]
sword_acquired = True
break
# Determine which icon to use
display_data = {}
for item_name, item_id in progressive_items.items():
level = min(inventory[item_id], len(progressive_names[item_name]))
display_name = progressive_names[item_name][level]
acquired = True
if not display_name:
acquired = False
display_name = progressive_names[item_name][level+1]
base_name = item_name.split(maxsplit=1)[1].lower()
display_data[base_name+"_acquired"] = acquired
display_data[base_name+"_url"] = icons[display_name]
gloves_url = icons["Power Glove"]
gloves_acquired = False
glove_names = ["Power Glove", "Titan Mitts"]
if "Progressive Glove" in acquired_items:
gloves_url = icons[glove_names[min(inventory[progressive_items["Progressive Glove"]], 2) - 1]]
gloves_acquired = True
else:
for glove in reversed(glove_names):
if glove in acquired_items:
gloves_url = icons[glove]
gloves_acquired = True
break
bow_url = icons["Bow"]
bow_acquired = False
bow_names = ["Bow", "Silver Bow"]
if "Progressive Bow" in acquired_items:
bow_url = icons[bow_names[min(inventory[progressive_items["Progressive Bow"]], 2) - 1]]
bow_acquired = True
else:
for bow in reversed(bow_names):
if bow in acquired_items:
bow_url = icons[bow]
bow_acquired = True
break
mail_url = icons["Green Mail"]
mail_names = ["Blue Mail", "Red Mail"]
if "Progressive Mail" in acquired_items:
mail_url = icons[mail_names[min(inventory[progressive_items["Progressive Mail"]], 2) - 1]]
else:
for mail in reversed(mail_names):
if mail in acquired_items:
mail_url = icons[mail]
break
shield_url = icons["Blue Shield"]
shield_acquired = False
shield_names = ["Blue Shield", "Red Shield", "Mirror Shield"]
if "Progressive Shield" in acquired_items:
shield_url = icons[shield_names[min(inventory[progressive_items["Progressive Shield"]], 3) - 1]]
shield_acquired = True
else:
for shield in reversed(shield_names):
if shield in acquired_items:
shield_url = icons[shield]
shield_acquired = True
break
# The single player tracker doesn't care about overworld, underworld, and total checks. Maybe it should?
sp_areas = ordered_areas[2:15]
return render_template("playerTracker.html", inventory=inventory, get_item_name_from_id=get_item_name_from_id,
player_name=player_name, room=room, icons=icons, checks_done=checks_done,
checks_in_area=seed_checks_in_area, acquired_items=acquired_items,
sword_url=sword_url, sword_acquired=sword_acquired, gloves_url=gloves_url,
gloves_acquired=gloves_acquired, bow_url=bow_url, bow_acquired=bow_acquired,
checks_in_area=seed_checks_in_area, acquired_items={lookup_any_item_id_to_name[id] for id in inventory},
small_key_ids=small_key_ids, big_key_ids=big_key_ids, sp_areas=sp_areas,
key_locations=player_small_key_locations[tracked_player],
big_key_locations=player_big_key_locations[tracked_player],
mail_url=mail_url, shield_url=shield_url, shield_acquired=shield_acquired)
**display_data)
@app.route('/tracker/<suuid:tracker>')
@cache.memoize(timeout=30) # update every 30 seconds
@cache.memoize(timeout=60) # multisave is currently created at most every minute
def getTracker(tracker: UUID):
room = Room.get(tracker=tracker)
if not room:
@@ -500,26 +447,27 @@ def getTracker(tracker: UUID):
else:
multisave = {}
if "hints" in multisave:
for key, hintdata in multisave["hints"]:
for hint in hintdata:
hints[key[0]].add(Hint(*hint))
for (team, player), locations_checked in multisave.get("location_checks", {}):
for (team, slot), slot_hints in multisave["hints"].items():
hints[team] |= set(slot_hints)
for (team, player), locations_checked in multisave.get("location_checks", {}).items():
player_locations = locations[player]
if precollected_items:
precollected = precollected_items[player]
for item_id in precollected:
attribute_item(inventory, team, player, item_id)
for location in locations_checked:
if (location, player) not in locations or location not in player_location_to_area[player]:
if location not in player_locations or location not in player_location_to_area[player]:
continue
item, recipient = locations[location, player]
item, recipient = player_locations[location]
attribute_item(inventory, team, recipient, item)
checks_done[team][player][player_location_to_area[player][location]] += 1
checks_done[team][player]["Total"] += 1
for (team, player), game_state in multisave.get("client_game_state", []):
if game_state:
for (team, player), game_state in multisave.get("client_game_state", {}).items():
if game_state == 30:
inventory[team][player][106] = 1 # Triforce
group_big_key_locations = set()

View File

@@ -1,13 +1,14 @@
import json
import zlib
import zipfile
import logging
import lzma
import json
import base64
import MultiServer
from flask import request, flash, redirect, url_for, session, render_template
from pony.orm import commit, select
from pony.orm import flush, select
from WebHostLib import app, Seed, Room, Patch
from WebHostLib import app, Seed, Room, Slot
from Utils import parse_yaml
accepted_zip_contents = {"patches": ".apbp",
"spoiler": ".txt",
@@ -30,7 +31,7 @@ def uploads():
flash('No selected file')
elif file and allowed_file(file.filename):
if file.filename.endswith(".zip"):
patches = set()
slots = set()
spoiler = ""
multidata = None
with zipfile.ZipFile(file, 'r') as zfile:
@@ -40,9 +41,28 @@ def uploads():
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."
elif file.filename.endswith(".apbp"):
splitted = file.filename.split("/")[-1][3:].split("P", 1)
player_id, player_name = splitted[1].split(".")[0].split("_")
patches.add(Patch(data=zfile.open(file, "r").read(), player_name=player_name, player_id=player_id))
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)", 500
metadata = yaml_data["meta"]
slots.add(Slot(data=data, player_name=metadata["player_name"],
player_id=metadata["player_id"],
game="A Link to the Past"))
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(".zip"):
# Factorio mods needs a specific name or they do no function
_, seed_name, slot_id, slot_name = file.filename.rsplit("_", 1)[0].split("-")
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(".txt"):
spoiler = zfile.open(file, "r").read().decode("utf-8-sig")
elif file.filename.endswith(".archipelago"):
@@ -54,11 +74,11 @@ def uploads():
else:
multidata = zfile.open(file).read()
if multidata:
commit() # commit patches
seed = Seed(multidata=multidata, spoiler=spoiler, patches=patches, owner=session["_id"])
commit() # create seed
for patch in patches:
patch.seed = seed
flush() # commit slots
seed = Seed(multidata=multidata, spoiler=spoiler, slots=slots, owner=session["_id"])
flush() # create seed
for slot in slots:
slot.seed = seed
return redirect(url_for("viewSeed", seed=seed.id))
else:
@@ -72,7 +92,7 @@ def uploads():
raise
else:
seed = Seed(multidata=multidata, owner=session["_id"])
commit() # place into DB and generate ids
flush() # place into DB and generate ids
return redirect(url_for("viewSeed", seed=seed.id))
else:
flash("Not recognized file format. Awaiting a .multidata file.")

159
WebUI.py
View File

@@ -1,159 +0,0 @@
import http.server
import logging
import json
import typing
import socket
import socketserver
import threading
import webbrowser
import asyncio
from functools import partial
from NetUtils import Node
from LttPClient import Context
import Utils
class WebUiClient(Node, logging.Handler):
loader = staticmethod(json.loads)
dumper = staticmethod(json.dumps)
def __init__(self):
super(WebUiClient, self).__init__()
self.manual_snes = None
@staticmethod
def build_message(msg_type: str, content: typing.Union[str, dict]) -> dict:
return {'type': msg_type, 'content': content}
def emit(self, record: logging.LogRecord) -> None:
self.broadcast_all({"type": record.levelname.lower(), "content": str(record.msg)})
def send_chat_message(self, message):
self.broadcast_all(self.build_message('chat', message))
def send_connection_status(self, ctx: Context):
asyncio.create_task(self._send_connection_status(ctx))
async def _send_connection_status(self, ctx: Context):
cache = Utils.persistent_load()
cached_address = cache.get("servers", {}).get("default", None)
server_address = ctx.server_address if ctx.server_address else cached_address if cached_address else None
self.broadcast_all(self.build_message('connections', {
'snesDevice': ctx.snes_attached_device[1] if ctx.snes_attached_device else None,
'snes': ctx.snes_state,
'serverAddress': server_address,
'server': 1 if ctx.server is not None and not ctx.server.socket.closed else 0,
}))
def send_device_list(self, devices):
self.broadcast_all(self.build_message('availableDevices', {
'devices': devices,
}))
def poll_for_server_ip(self):
self.broadcast_all(self.build_message('serverAddress', {}))
def notify_item_sent(self, finder, recipient, item, location, i_am_finder: bool, i_am_recipient: bool,
item_is_unique: bool = False):
self.broadcast_all(self.build_message('itemSent', {
'finder': finder,
'recipient': recipient,
'item': item,
'location': location,
'iAmFinder': int(i_am_finder),
'iAmRecipient': int(i_am_recipient),
'itemIsUnique': int(item_is_unique),
}))
def notify_item_found(self, finder: str, item: str, location: str, i_am_finder: bool, item_is_unique: bool = False):
self.broadcast_all(self.build_message('itemFound', {
'finder': finder,
'item': item,
'location': location,
'iAmFinder': int(i_am_finder),
'itemIsUnique': int(item_is_unique),
}))
def notify_item_received(self, finder: str, item: str, location: str, item_index: int, queue_length: int,
item_is_unique: bool = False):
self.broadcast_all(self.build_message('itemReceived', {
'finder': finder,
'item': item,
'location': location,
'itemIndex': item_index,
'queueLength': queue_length,
'itemIsUnique': int(item_is_unique),
}))
def send_hint(self, finder, recipient, item, location, found, i_am_finder: bool, i_am_recipient: bool,
entrance_location: str = None):
self.broadcast_all(self.build_message('hint', {
'finder': finder,
'recipient': recipient,
'item': item,
'location': location,
'found': int(found),
'iAmFinder': int(i_am_finder),
'iAmRecipient': int(i_am_recipient),
'entranceLocation': entrance_location,
}))
def send_game_info(self, ctx: Context):
self.broadcast_all(self.build_message('gameInfo', {
'clientVersion': Utils.__version__,
'hintCost': ctx.hint_cost,
'checkPoints': ctx.check_points,
'forfeitMode': ctx.forfeit_mode,
'remainingMode': ctx.remaining_mode,
}))
def send_location_check(self, ctx: Context, last_check: str):
self.broadcast_all(self.build_message('locationCheck', {
'totalChecks': len(ctx.locations_checked),
'hintPoints': ctx.hint_points,
'lastCheck': last_check,
}))
web_thread = None
PORT = 5050
class RequestHandler(http.server.SimpleHTTPRequestHandler):
def log_request(self, code='-', size='-'):
pass
def log_message(self, format, *args):
pass
def log_date_time_string(self):
pass
Handler = partial(RequestHandler,
directory=Utils.local_path("data", "web", "public"))
def start_server(socket_port: int, on_start=lambda: None):
global web_thread
try:
server = socketserver.TCPServer(("", PORT), Handler)
except OSError:
# In most cases "Only one usage of each socket address (protocol/network address/port) is normally permitted"
import logging
# If the exception is caused by our desired port being unavailable, assume the web server is already running
# from another client instance
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
if sock.connect_ex(('localhost', PORT)) == 0:
logging.info("Web server is already running in another client window.")
webbrowser.open(f'http://localhost:{PORT}?port={socket_port}')
return
# If the exception is caused by something else, report on it
logging.exception("Unable to bind port for local web server. The CLI client should work in all cases.")
else:
print("serving at port", PORT)
on_start()
web_thread = threading.Thread(target=server.serve_forever).start()

View File

@@ -10,6 +10,7 @@ require "util"
FREE_SAMPLES = {{ free_samples }}
SLOT_NAME = "{{ slot_name }}"
SEED_NAME = "{{ seed_name }}"
--SUPPRESS_INVENTORY_EVENTS = false
-- Initialize force data, either from it being created or already being part of the game when the mod was added.
@@ -180,7 +181,8 @@ function dumpInfo(force)
local data_collection = {
["research_done"] = research_done,
["victory"] = chain_lookup(global, "forcedata", force.name, "victory"),
["slot_name"] = SLOT_NAME
["slot_name"] = SLOT_NAME,
["seed_name"] = SEED_NAME
}
for tech_name, tech in pairs(force.technologies) do

View File

@@ -1,4 +0,0 @@
{
"presets": ["@babel/preset-react", "@babel/preset-env"],
"plugins": ["@babel/plugin-proposal-class-properties"]
}

View File

@@ -1,40 +0,0 @@
module.exports = {
env: {
browser: true,
es6: true,
},
extends: [
'plugin:react/recommended',
'airbnb',
],
parser: 'babel-eslint',
globals: {
Atomics: 'readonly',
SharedArrayBuffer: 'readonly',
},
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 2018,
sourceType: 'module',
},
plugins: [
'react',
],
rules: {
"react/jsx-filename-extension": 0,
"react/jsx-one-expression-per-line": 0,
"react/destructuring-assignment": 0,
"react/jsx-curly-spacing": [2, { "when": "always" }],
"react/prop-types": 0,
"react/no-access-state-in-setstate": 0,
"react/button-has-type": 0,
"max-len": [2, { code: 120 }],
"operator-linebreak": [2, "after"],
"no-console": [2, { allow: ["error", "warn"] }],
"linebreak-style": 0,
"jsx-a11y/no-static-element-interactions": 0,
"jsx-a11y/click-events-have-key-events": 0,
},
};

2
data/web/.gitignore vendored
View File

@@ -1,2 +0,0 @@
node_modules
*.map

14170
data/web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,50 +0,0 @@
{
"name": "web-ui",
"version": "1.0.0",
"description": "",
"main": "index.jsx",
"scripts": {
"build": "webpack --config webpack.config.js",
"dev": "webpack --config webpack.dev.js"
},
"author": "LegendaryLinux",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.35",
"@fortawesome/free-solid-svg-icons": "^5.15.3",
"@fortawesome/react-fontawesome": "^0.1.14",
"crypto-browserify": "^3.12.0",
"crypto-js": "^4.0.0",
"css-loader": "^5.1.3",
"lodash-es": "^4.17.21",
"prop-types": "^15.7.2",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-redux": "^7.2.2",
"react-router-dom": "^5.2.0",
"redux": "^4.0.5",
"redux-devtools-extension": "^2.13.9",
"sass-loader": "^10.1.1",
"style-loader": "^2.0.0",
"webpack-cli": "^4.5.0"
},
"devDependencies": {
"@babel/core": "^7.13.10",
"@babel/plugin-proposal-class-properties": "^7.13.0",
"@babel/preset-env": "^7.13.10",
"@babel/preset-react": "^7.12.13",
"babel-eslint": "^10.1.0",
"babel-loader": "^8.2.2",
"buffer": "^6.0.3",
"eslint": "^7.22.0",
"eslint-config-airbnb": "^18.2.1",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-react": "^7.22.0",
"eslint-plugin-react-hooks": "^4.2.0",
"file-loader": "^6.2.0",
"node-sass": "^5.0.0",
"stream-browserify": "^3.0.0",
"webpack": "^5.27.1"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Berserker Multiworld Web GUI</title>
<script type="application/ecmascript" src="assets/index.bundle.js"></script>
</head>
<body>
<div id="app">
<!-- Populated by React/JSX -->
</div>
</body>
</html>

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

View File

@@ -1,10 +0,0 @@
import React from 'react';
import '../../../styles/HeaderBar/components/HeaderBar.scss';
const HeaderBar = () => (
<div id="header-bar">
Multiworld WebUI
</div>
);
export default HeaderBar;

View File

@@ -1,8 +0,0 @@
const APPEND_MESSAGE = 'APPEND_MESSAGE';
const appendMessage = (content) => ({
type: APPEND_MESSAGE,
content,
});
export default appendMessage;

View File

@@ -1,8 +0,0 @@
const SET_MONITOR_FONT_SIZE = 'SET_MONITOR_FONT_SIZE';
const setMonitorFontSize = (fontSize) => ({
type: SET_MONITOR_FONT_SIZE,
fontSize,
});
export default setMonitorFontSize;

View File

@@ -1,8 +0,0 @@
const SET_SHOW_RELEVANT = 'SET_SHOW_RELEVANT';
const setShowRelevant = (showRelevant) => ({
type: SET_SHOW_RELEVANT,
showRelevant,
});
export default setShowRelevant;

View File

@@ -1,8 +0,0 @@
const SET_SIMPLE_FONT = 'SET_SIMPLE_FONT';
const setSimpleFont = (simpleFont) => ({
type: SET_SIMPLE_FONT,
simpleFont,
});
export default setSimpleFont;

View File

@@ -1,42 +0,0 @@
import _assign from 'lodash-es/assign';
const initialState = {
fontSize: 18,
simpleFont: false,
showRelevantOnly: false,
messageLog: [],
};
const appendToLog = (log, item) => {
const trimmedLog = log.slice(-349);
trimmedLog.push(item);
return trimmedLog;
};
const monitorReducer = (state = initialState, action) => {
switch (action.type) {
case 'SET_MONITOR_FONT_SIZE':
return _assign({}, state, {
fontSize: action.fontSize,
});
case 'SET_SIMPLE_FONT':
return _assign({}, state, {
simpleFont: action.simpleFont,
});
case 'SET_SHOW_RELEVANT':
return _assign({}, state, {
showRelevantOnly: action.showRelevant,
});
case 'APPEND_MESSAGE':
return _assign({}, state, {
messageLog: appendToLog(state.messageLog, action.content),
});
default:
return state;
}
};
export default monitorReducer;

View File

@@ -1,13 +0,0 @@
import React from 'react';
import '../../../styles/Monitor/components/Monitor.scss';
import MonitorControls from '../containers/MonitorControls';
import MonitorWindow from '../containers/MonitorWindow';
const Monitor = () => (
<div id="monitor">
<MonitorControls />
<MonitorWindow />
</div>
);
export default Monitor;

View File

@@ -1,218 +0,0 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import _forEach from 'lodash-es/forEach';
import WebSocketUtils from '../../global/WebSocketUtils';
import '../../../styles/Monitor/containers/MonitorControls.scss';
// Redux actions
import setMonitorFontSize from '../Redux/actions/setMonitorFontSize';
import setShowRelevant from '../Redux/actions/setShowRelevant';
import setSimpleFont from '../Redux/actions/setSimpleFont';
const mapReduxStateToProps = (reduxState) => ({
fontSize: reduxState.monitor.fontSize,
webSocket: reduxState.webUI.webSocket,
availableDevices: reduxState.webUI.availableDevices,
snesDevice: reduxState.gameState.connections.snesDevice,
snesConnected: reduxState.gameState.connections.snesConnected,
serverAddress: reduxState.gameState.connections.serverAddress,
serverConnected: reduxState.gameState.connections.serverConnected,
simpleFont: reduxState.monitor.simpleFont,
});
const mapDispatchToProps = (dispatch) => ({
updateFontSize: (fontSize) => {
dispatch(setMonitorFontSize(fontSize));
},
doToggleRelevance: (showRelevantOnly) => {
dispatch(setShowRelevant(showRelevantOnly));
},
doSetSimpleFont: (simpleFont) => {
dispatch(setSimpleFont(simpleFont));
},
});
class MonitorControls extends Component {
constructor(props) {
super(props);
this.state = {
deviceId: null,
serverAddress: this.props.serverAddress,
};
}
componentDidMount() {
setTimeout(() => {
if (this.props.webSocket) {
// Poll for available devices
this.pollSnesDevices();
}
}, 500);
}
componentDidUpdate(prevProps) {
// If there is only one SNES device available, connect to it automatically
if (
prevProps.availableDevices.length !== this.props.availableDevices.length &&
this.props.availableDevices.length === 1
) {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({ deviceId: this.props.availableDevices[0] }, () => {
if (!this.props.snesConnected) {
this.connectToSnes();
}
});
}
// If we have moved from a disconnected state (default) into a connected state, request the game information
if (
(
(prevProps.snesConnected !== this.props.snesConnected) || // SNES status changed
(prevProps.serverConnected !== this.props.serverConnected) // OR server status changed
) && ((this.props.serverConnected) && (this.props.snesConnected)) // AND both are connected
) {
this.props.webSocket.send(WebSocketUtils.formatSocketData('webStatus', 'gameInfo'));
this.props.webSocket.send(WebSocketUtils.formatSocketData('webStatus', 'checkData'));
}
}
increaseTextSize = () => {
if (this.props.fontSize >= 25) return;
this.props.updateFontSize(this.props.fontSize + 1);
};
decreaseTextSize = () => {
if (this.props.fontSize <= 10) return;
this.props.updateFontSize(this.props.fontSize - 1);
};
generateSnesOptions = () => {
const options = [];
// No available devices, show waiting for devices
if (this.props.availableDevices.length === 0) {
options.push(<option key="0" value="-1">Waiting for devices...</option>);
return options;
}
// More than one available device, list all options
options.push(<option key="-1" value="-1">Select a device</option>);
_forEach(this.props.availableDevices, (device) => {
options.push(<option key={ device } value={ device }>{device}</option>);
});
return options;
}
updateDeviceId = (event) => this.setState({ deviceId: event.target.value }, this.connectToSnes);
pollSnesDevices = () => {
if (!this.props.webSocket) { return; }
this.props.webSocket.send(WebSocketUtils.formatSocketData('webStatus', 'devices'));
}
connectToSnes = () => {
if (!this.props.webSocket) { return; }
this.props.webSocket.send(WebSocketUtils.formatSocketData('webConfig', { deviceId: this.state.deviceId }));
}
updateServerAddress = (event) => this.setState({ serverAddress: event.target.value ? event.target.value : null });
connectToServer = (event) => {
if (event.key !== 'Enter') { return; }
// If the user presses enter on an empty textbox, disconnect from the server
if (!event.target.value) {
this.props.webSocket.send(WebSocketUtils.formatSocketData('webControl', 'disconnect'));
return;
}
this.props.webSocket.send(
WebSocketUtils.formatSocketData('webConfig', { serverAddress: this.state.serverAddress }),
);
}
toggleRelevance = (event) => {
this.props.doToggleRelevance(event.target.checked);
};
setSimpleFont = (event) => this.props.doSetSimpleFont(event.target.checked);
render() {
return (
<div id="monitor-controls">
<div id="connection-status">
<div id="snes-connection">
<table>
<tbody>
<tr>
<td>SNES Device:</td>
<td>
<select
onChange={ this.updateDeviceId }
disabled={ this.props.availableDevices.length === 0 }
value={ this.state.deviceId }
>
{this.generateSnesOptions()}
</select>
</td>
</tr>
<tr>
<td>Status:</td>
<td>
<span className={ this.props.snesConnected ? 'connected' : 'not-connected' }>
{this.props.snesConnected ? 'Connected' : 'Not Connected'}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div id="server-connection">
<table>
<tbody>
<tr>
<td>Server:</td>
<td>
<input
defaultValue={ this.props.serverAddress }
onKeyUp={ this.updateServerAddress }
onKeyDown={ this.connectToServer }
/>
</td>
</tr>
<tr>
<td>Status:</td>
<td>
<span className={ this.props.serverConnected ? 'connected' : 'not-connected' }>
{this.props.serverConnected ? 'Connected' : 'Not Connected'}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div id="accessibility">
<div>
Text Size:
<button disabled={ this.props.fontSize <= 10 } onClick={ this.decreaseTextSize }>-</button>
{ this.props.fontSize }
<button disabled={ this.props.fontSize >= 25 } onClick={ this.increaseTextSize }>+</button>
</div>
<div>
Only show my items <input type="checkbox" onChange={ this.toggleRelevance } />
</div>
<div>
Use alternate font
<input
type="checkbox"
onChange={ this.setSimpleFont }
defaultChecked={ this.props.simpleFont }
/>
</div>
</div>
</div>
);
}
}
export default connect(mapReduxStateToProps, mapDispatchToProps)(MonitorControls);

View File

@@ -1,96 +0,0 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import md5 from 'crypto-js/md5';
import WebSocketUtils from '../../global/WebSocketUtils';
import '../../../styles/Monitor/containers/MonitorWindow.scss';
// Redux actions
import appendMessage from '../Redux/actions/appendMessage';
const mapReduxStateToProps = (reduxState) => ({
fontSize: reduxState.monitor.fontSize,
webSocket: reduxState.webUI.webSocket,
messageLog: reduxState.monitor.messageLog,
showRelevantOnly: reduxState.monitor.showRelevantOnly,
});
const mapDispatchToProps = (dispatch) => ({
doAppendMessage: (message) => dispatch(appendMessage(
<div
key={ `${md5(message)}${Math.floor((Math.random() * 1000000))}` }
className="user-command relevant"
>
{message}
</div>,
)),
});
class MonitorWindow extends Component {
constructor(props) {
super(props);
this.monitorRef = React.createRef();
this.commandRef = React.createRef();
this.commandInputRef = React.createRef();
}
componentDidMount() {
// Adjust the monitor height to match user's viewport
this.adjustMonitorHeight();
// Resize the monitor as the user adjusts the window size
window.addEventListener('resize', this.adjustMonitorHeight);
}
componentDidUpdate() {
this.monitorRef.current.style.fontSize = `${this.props.fontSize}px`;
this.adjustMonitorHeight();
}
componentWillUnmount() {
// If one day we have different components occupying the main viewport, let us not attempt to
// perform actions on an unmounted component
window.removeEventListener('resize', this.adjustMonitorHeight);
}
adjustMonitorHeight = () => {
const monitorDimensions = this.monitorRef.current.getBoundingClientRect();
const commandDimensions = this.commandRef.current.getBoundingClientRect();
// Set monitor height
const newMonitorHeight = window.innerHeight - monitorDimensions.top - commandDimensions.height - 30;
this.monitorRef.current.style.height = `${newMonitorHeight}px`;
this.scrollToBottom();
};
scrollToBottom = () => {
this.monitorRef.current.scrollTo(0, this.monitorRef.current.scrollHeight);
};
sendCommand = (event) => {
// If the user didn't press enter, or the command is empty, do nothing
if (event.key !== 'Enter' || !event.target.value) return;
this.props.doAppendMessage(event.target.value);
this.scrollToBottom();
this.props.webSocket.send(WebSocketUtils.formatSocketData('webCommand', event.target.value));
this.commandInputRef.current.value = '';
};
render() {
return (
<div id="monitor-window-wrapper">
<div
id="monitor-window"
ref={ this.monitorRef }
className={ `${this.props.showRelevantOnly ? 'relevant-only' : null}` }
>
{ this.props.messageLog }
</div>
<div id="command-wrapper" ref={ this.commandRef }>
Command: <input onKeyDown={ this.sendCommand } ref={ this.commandInputRef } />
</div>
</div>
);
}
}
export default connect(mapReduxStateToProps, mapDispatchToProps)(MonitorWindow);

View File

@@ -1,8 +0,0 @@
const SET_AVAILABLE_DEVICES = 'SET_AVAILABLE_DEVICES';
const setAvailableDevices = (devices) => ({
type: SET_AVAILABLE_DEVICES,
devices,
});
export default setAvailableDevices;

View File

@@ -1,8 +0,0 @@
const SET_WEBSOCKET = 'SET_WEBSOCKET';
const setWebSocket = (webSocket) => ({
type: SET_WEBSOCKET,
webSocket,
});
export default setWebSocket;

View File

@@ -1,25 +0,0 @@
import _assign from 'lodash-es/assign';
const initialState = {
webSocket: null,
availableDevices: [],
};
const webUIReducer = (state = initialState, action) => {
switch (action.type) {
case 'SET_WEBSOCKET':
return _assign({}, state, {
webSocket: action.webSocket,
});
case 'SET_AVAILABLE_DEVICES':
return _assign({}, state, {
availableDevices: action.devices,
});
default:
return state;
}
};
export default webUIReducer;

View File

@@ -1,109 +0,0 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import HeaderBar from '../../HeaderBar/components/HeaderBar';
import Monitor from '../../Monitor/components/Monitor';
import WidgetArea from '../../WidgetArea/containers/WidgetArea';
import MonitorTools from '../../global/MonitorTools';
import '../../../styles/WebUI/containers/WebUI.scss';
// Redux actions
import setWebSocket from '../Redux/actions/setWebSocket';
import WebSocketUtils from '../../global/WebSocketUtils';
import updateGameState from '../../global/Redux/actions/updateGameState';
import appendMessage from '../../Monitor/Redux/actions/appendMessage';
const mapReduxStateToProps = (reduxState) => ({
connections: reduxState.gameState.connections,
simpleFont: reduxState.monitor.simpleFont,
});
const mapDispatchToProps = (dispatch) => ({
doSetWebSocket: (webSocket) => dispatch(setWebSocket(webSocket)),
handleIncomingMessage: (message) => dispatch(WebSocketUtils.handleIncomingMessage(message)),
doUpdateGameState: (gameState) => dispatch(updateGameState(gameState)),
appendMonitorMessage: (message) => dispatch(appendMessage(message)),
});
class WebUI extends Component {
constructor(props) {
super(props);
this.webSocket = null;
this.maxConnectionAttempts = 20;
this.webUiRef = React.createRef();
this.state = {
connectionAttempts: 0,
};
}
componentDidMount() {
this.webSocketConnect();
}
webSocketConnect = () => {
this.props.appendMonitorMessage(MonitorTools.createTextDiv(
`Attempting to connect to MultiClient (attempt ${this.state.connectionAttempts + 1})...`,
));
this.setState({ connectionAttempts: this.state.connectionAttempts + 1 }, () => {
if (this.state.connectionAttempts >= 20) {
this.props.appendMonitorMessage(MonitorTools.createTextDiv(
'Unable to connect to MultiClient. Maximum of 20 attempts exceeded.',
));
return;
}
const getParams = new URLSearchParams(document.location.search.substring(1));
const port = getParams.get('port');
if (!port) { throw new Error('Unable to determine socket port from GET parameters'); }
const webSocketAddress = `ws://localhost:${port}`;
try {
this.props.webSocket.close();
this.props.doSetWebSocket(null);
} catch (error) {
// Ignore errors caused by attempting to close an invalid WebSocket object
}
const webSocket = new WebSocket(webSocketAddress);
webSocket.onerror = () => {
this.props.doUpdateGameState({
connections: {
snesDevice: this.props.connections.snesDevice,
snesConnected: false,
serverAddress: this.props.connections.serverAddress,
serverConnected: false,
},
});
if (this.state.connectionAttempts < this.maxConnectionAttempts) {
setTimeout(this.webSocketConnect, 5000);
}
};
// Dispatch a custom event when websocket messages are received
webSocket.onmessage = (message) => {
this.props.handleIncomingMessage(message);
};
// Store the webSocket object in the Redux store so other components can access it
webSocket.onopen = () => {
this.props.doSetWebSocket(webSocket);
webSocket.send(WebSocketUtils.formatSocketData('webStatus', 'connections'));
this.props.appendMonitorMessage(MonitorTools.createTextDiv('Connected to MultiClient.'));
this.setState({ connectionAttempts: 0 });
};
});
};
render() {
return (
<div id="web-ui" ref={ this.webUiRef } className={ this.props.simpleFont ? 'simple-font' : null }>
<HeaderBar />
<div id="content-middle">
<Monitor />
<WidgetArea />
</div>
</div>
);
}
}
export default connect(mapReduxStateToProps, mapDispatchToProps)(WebUI);

View File

@@ -1,117 +0,0 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import '../../../styles/WidgetArea/containers/WidgetArea.scss';
const mapReduxStateToProps = (reduxState) => ({
clientVersion: reduxState.gameState.clientVersion,
forfeitMode: reduxState.gameState.forfeitMode,
remainingMode: reduxState.gameState.remainingMode,
hintCost: reduxState.gameState.hintCost,
checkPoints: reduxState.gameState.checkPoints,
hintPoints: reduxState.gameState.hintPoints,
totalChecks: reduxState.gameState.totalChecks,
lastCheck: reduxState.gameState.lastCheck,
});
class WidgetArea extends Component {
constructor(props) {
super(props);
this.state = {
collapsed: false,
};
}
saveNotes = (event) => {
localStorage.setItem('notes', event.target.value);
};
// eslint-disable-next-line react/no-access-state-in-setstate
toggleCollapse = () => this.setState({ collapsed: !this.state.collapsed });
render() {
return (
<div id="widget-area" className={ `${this.state.collapsed ? 'collapsed' : null}` }>
{
this.state.collapsed ? (
<div id="widget-button-row">
<button className="collapse-button" onClick={ this.toggleCollapse }></button>
</div>
) : null
}
{
this.state.collapsed ? null : (
<div id="widget-area-contents">
<div id="game-info">
<div id="game-info-title">
Game Info:
<button className="collapse-button" onClick={ this.toggleCollapse }></button>
</div>
<table>
<tbody>
<tr>
<th>Client Version:</th>
<td>{this.props.clientVersion}</td>
</tr>
<tr>
<th>Forfeit Mode:</th>
<td>{this.props.forfeitMode}</td>
</tr>
<tr>
<th>Remaining Mode:</th>
<td>{this.props.remainingMode}</td>
</tr>
</tbody>
</table>
</div>
<div id="check-data">
<div id="check-data-title">Checks:</div>
<table>
<tbody>
<tr>
<th>Total Checks:</th>
<td>{this.props.totalChecks}</td>
</tr>
<tr>
<th>Last Check:</th>
<td>{this.props.lastCheck}</td>
</tr>
</tbody>
</table>
</div>
<div id="hint-data">
<div id="hint-data-title">
Hint Data:
</div>
<table>
<tbody>
<tr>
<th>Hint Cost:</th>
<td>{this.props.hintCost}</td>
</tr>
<tr>
<th>Check Points:</th>
<td>{this.props.checkPoints}</td>
</tr>
<tr>
<th>Current Points:</th>
<td>{this.props.hintPoints}</td>
</tr>
</tbody>
</table>
</div>
<div id="notes">
<div id="notes-title">
<div>Notes:</div>
</div>
<textarea defaultValue={ localStorage.getItem('notes') } onKeyUp={ this.saveNotes } />
</div>
More tools Coming Soon
</div>
)
}
</div>
);
}
}
export default connect(mapReduxStateToProps)(WidgetArea);

View File

@@ -1,69 +0,0 @@
import React from 'react';
import md5 from 'crypto-js/md5';
const finderSpan = (finder, possessive = false, ownItem = false) => (
<span className={ `finder-span ${ownItem ? 'mine' : null}` }>{finder}{possessive ? "'s" : null}</span>
);
const recipientSpan = (recipient, possessive = false, ownItem = false) => (
<span className={ `recipient-span ${ownItem ? 'mine' : null}` }>{recipient}{possessive ? "'s" : null}</span>
);
const itemSpan = (item, unique) => <span className={ `item-span ${unique ? 'unique' : ''}` }>{item}</span>;
const locationSpan = (location) => <span className="location-span">{location}</span>;
const entranceSpan = (entrance) => <span className="entrance-span">{entrance}</span>;
class MonitorTools {
/** Convert plaintext into a React-friendly div */
static createTextDiv = (text) => (
<div key={ `${md5(text)}${Math.floor((Math.random() * 1000000))}` }>
{text}
</div>
);
/** Sent an item to another player */
static sentItem = (finder, recipient, item, location, iAmFinder = false, iAmRecipient = false, unique = false) => (
<div
key={ `${md5(finder + recipient + item + location)}${Math.floor((Math.random() * 1000000))}` }
className={ (iAmFinder || iAmRecipient) ? 'relevant' : null }
>
{finderSpan(finder, false, iAmFinder)} found {recipientSpan(recipient, true, iAmRecipient)}&nbsp;
{itemSpan(item, unique)} at {locationSpan(location)}
</div>
)
/** Received item from another player */
static receivedItem = (finder, item, location, itemIndex, queueLength, unique = false) => (
<div
key={ `${md5(finder + item + location)}${Math.floor((Math.random() * 1000000))}` }
className="relevant"
>
({itemIndex}/{queueLength}) {finderSpan(finder, false)} found your&nbsp;
{itemSpan(item, unique)} at {locationSpan(location)}
</div>
)
/** Player found their own item (local or remote player) */
static foundItem = (finder, item, location, iAmFinder = false, unique = false) => (
<div
key={ `${md5(finder + item + location)}${Math.floor((Math.random() * 1000000))}` }
className={ iAmFinder ? 'relevant' : null }
>
{finderSpan(finder, false, iAmFinder)} found their own {itemSpan(item, unique)} at {locationSpan(location)}
</div>
)
/** Hint message */
static hintMessage = (finder, recipient, item, location, found, iAmFinder = false, iAmRecipient = false,
entranceLocation = null) => (
<div
key={ `${md5(finder + recipient + item + location)}${Math.floor((Math.random() * 1000000))}` }
className={ (iAmFinder || iAmRecipient) ? 'relevant' : null }
>
{recipientSpan(recipient, true, iAmRecipient)} {itemSpan(item)} can be found in&nbsp;
{finderSpan(finder, true, iAmFinder)} world at {locationSpan(location)}
{ entranceLocation ? [', which is at ', entranceSpan(entranceLocation)] : null }&nbsp;
({found ? '✔' : '❌'})
</div>
)
}
export default MonitorTools;

View File

@@ -1,8 +0,0 @@
const UPDATE_GAME_STATE = 'UPDATE_GAME_STATE';
const updateGameState = (gameState) => ({
type: UPDATE_GAME_STATE,
gameState,
});
export default updateGameState;

View File

@@ -1,30 +0,0 @@
import _assign from 'lodash-es/assign';
const initialState = {
clientVersion: null,
forfeitMode: null,
remainingMode: null,
connections: {
snesDevice: '',
snesConnected: false,
serverAddress: null,
serverConnected: false,
},
totalChecks: 0,
lastCheck: null,
hintCost: null,
checkPoints: null,
hintPoints: 0,
};
const gameStateReducer = (state = initialState, action) => {
switch (action.type) {
case 'UPDATE_GAME_STATE':
return _assign({}, state, action.gameState);
default:
return state;
}
};
export default gameStateReducer;

View File

@@ -1,106 +0,0 @@
import MonitorTools from './MonitorTools';
// Redux actions
import appendMessage from '../Monitor/Redux/actions/appendMessage';
import updateGameState from './Redux/actions/updateGameState';
import setAvailableDevices from '../WebUI/Redux/actions/setAvailableDevices';
class WebSocketUtils {
static formatSocketData = (eventType, content) => JSON.stringify({
type: eventType,
content,
});
/**
* Handle incoming websocket data and return appropriate data for dispatch
* @param message
* @returns Object
*/
static handleIncomingMessage = (message) => {
try {
const data = JSON.parse(message.data);
switch (data.type) {
// Client sent snes and server connection statuses
case 'connections':
return updateGameState({
connections: {
snesDevice: data.content.snesDevice ? data.content.snesDevice : '',
snesConnected: parseInt(data.content.snes, 10) === 3,
serverAddress: data.content.serverAddress ? data.content.serverAddress.replace(/^.*\/\//, '') : null,
serverConnected: parseInt(data.content.server, 10) === 1,
},
});
case 'availableDevices':
return setAvailableDevices(data.content.devices);
// Client unable to automatically connect to multiworld server
case 'serverAddress':
return appendMessage(MonitorTools.createTextDiv(
'Unable to automatically connect to multiworld server. Please enter an address manually.',
));
case 'itemSent':
return appendMessage(MonitorTools.sentItem(data.content.finder, data.content.recipient,
data.content.item, data.content.location, parseInt(data.content.iAmFinder, 10) === 1,
parseInt(data.content.iAmRecipient, 10) === 1, parseInt(data.content.itemIsUnique, 10) === 1));
case 'itemReceived':
return appendMessage(MonitorTools.receivedItem(data.content.finder, data.content.item,
data.content.location, data.content.itemIndex, data.content.queueLength,
parseInt(data.content.itemIsUnique, 10) === 1));
case 'itemFound':
return appendMessage(MonitorTools.foundItem(data.content.finder, data.content.item, data.content.location,
parseInt(data.content.iAmFinder, 10) === 1, parseInt(data.content.itemIsUnique, 10) === 1));
case 'hint':
return appendMessage(MonitorTools.hintMessage(data.content.finder, data.content.recipient,
data.content.item, data.content.location, parseInt(data.content.found, 10) === 1,
parseInt(data.content.iAmFinder, 10) === 1, parseInt(data.content.iAmRecipient, 10) === 1,
data.content.entranceLocation));
case 'gameInfo':
return updateGameState({
clientVersion: data.content.clientVersion,
forfeitMode: data.content.forfeitMode,
remainingMode: data.content.remainingMode,
hintCost: parseInt(data.content.hintCost, 10),
checkPoints: parseInt(data.content.checkPoints, 10),
});
case 'locationCheck':
return updateGameState({
totalChecks: parseInt(data.content.totalChecks, 10),
lastCheck: data.content.lastCheck,
hintPoints: parseInt(data.content.hintPoints, 10),
});
// The client prints several types of messages to the console
case 'critical':
case 'error':
case 'warning':
case 'info':
case 'chat':
return appendMessage(MonitorTools.createTextDiv(
(typeof (data.content) === 'string') ? data.content : JSON.stringify(data.content),
));
default:
console.warn(`Unknown message type received: ${data.type}`);
console.warn(data.content);
return { type: 'NO_OP' };
}
} catch (error) {
console.error(message);
console.error(error);
// The returned value from this function will be dispatched to Redux. If an error occurs,
// Redux and the SPA in general should live on. Dispatching something with the correct format
// but that matches no known Redux action will cause the state to update to itself, which is
// treated as a no-op.
return { type: 'NO_OP' };
}
};
}
export default WebSocketUtils;

View File

@@ -1,28 +0,0 @@
import React from 'react';
import ReactDom from 'react-dom';
import { Provider } from 'react-redux';
import { createStore, combineReducers } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly';
import WebUI from './WebUI/containers/WebUI';
import '../styles/index.scss';
// Redux reducers
import webUI from './WebUI/Redux/reducers/webUIReducer';
import gameState from './global/Redux/reducers/gameStateReducer';
import monitor from './Monitor/Redux/reducers/monitorReducer';
const store = createStore(combineReducers({
webUI,
gameState,
monitor,
}), composeWithDevTools());
const App = () => (
<Provider store={ store }>
<WebUI />
</Provider>
);
window.onload = () => {
ReactDom.render(<App />, document.getElementById('app'));
};

View File

@@ -1,4 +0,0 @@
#header-bar{
font-size: 3.4em;
min-width: 1036px;
}

View File

@@ -1,4 +0,0 @@
#monitor{
flex-grow: 1;
min-width: 800px;
}

View File

@@ -1,43 +0,0 @@
#monitor-controls{
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: 0.5em;
padding-bottom: 0.5em;
#connection-status{
display: flex;
flex-direction: row;
#snes-connection, #server-connection{
margin-right: 1em;
table{
td{
padding-right: 0.5em;
}
}
}
.connected{
color: #008000;
}
.not-connected{
color: #ff0000;
}
button {
border-radius: 3px;
}
}
#accessibility{
display: flex;
flex-direction: column;
button{
border-radius: 4px;
margin: 0.5em;
}
}
}

View File

@@ -1,55 +0,0 @@
#monitor-window-wrapper{
#monitor-window{
display: flex;
flex-direction: column;
justify-content: flex-start;
background-color: #414042;
color: #dce7df;
overflow-y: auto;
margin-bottom: 10px;
div{
width: calc(100% - 14px);
padding: 7px;
border-bottom: 1px solid #000000;
&.user-command{
color: #ffffff;
background-color: #575757;
}
}
&.relevant-only{
div:not(.relevant){
visibility: collapse;
}
}
.item-span{
color: #67e9ff;
&.unique{
color: #ff884e;
text-shadow: #000000 2px 2px;
}
}
.location-span{ color: #f5e63c; }
.entrance-span{ color: #73ae38; }
.finder-span{ color: #f96cb8; }
.recipient-span{ color: #9b8aff; }
.mine{ color: #ffa500; }
}
#command-wrapper{
display: flex;
flex-direction: row;
justify-content: flex-start;
height: 25px;
line-height: 25px;
input{
margin-left: 0.5em;
flex-grow: 1;
}
}
}

View File

@@ -1,19 +0,0 @@
@font-face{
font-family: HyliaSerif;
src: local('HyliaSerif'), url('../../../assets/HyliaSerif.otf')
}
#web-ui{
width: calc(100% - 1.5em);
padding: 0.75em;
font-family: HyliaSerif, sans-serif;
&.simple-font{
font-family: sans-serif;
}
#content-middle{
display: flex;
flex-direction: row;
}
}

View File

@@ -1,60 +0,0 @@
#widget-area{
margin-left: 0.5em;
margin-right: 0.5em;
padding: 0.25em;
border: 2px solid #6a6a6a;
&:not(.collapsed){
width: calc(20% - 1.5em - 4px);
}
#widget-button-row{
width: 100%;
text-align: right;
.collapse-button{
width: 35px;
}
}
#widget-area-contents{
display: flex;
flex-direction: column;
table{
th{
text-align: left;
}
td{
padding-left: 1em;
}
}
#game-info{
margin-bottom: 1em;
#game-info-title{
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
#check-data{
margin-bottom: 1em;
}
#hint-data{
margin-bottom: 1em;
}
#notes{
display: flex;
flex-direction: column;
textarea{
height: 10em;
}
}
}
}

View File

@@ -1,6 +0,0 @@
body {
background-color: #131313;
color: #eae703;
letter-spacing: 2px;
margin: 0;
}

View File

@@ -1,54 +0,0 @@
const path = require('path');
module.exports = {
mode: 'production',
watch: false,
resolve: {
fallback: {
crypto: require.resolve('crypto-browserify'),
buffer: require.resolve('buffer/'),
stream: require.resolve('stream-browserify'),
},
},
entry: {
index: './src/js/index.js',
},
module: {
rules: [
{
test: /\.(js|jsx|es6)$/,
loader: 'babel-loader',
options: {
compact: true,
minified: true,
},
},
{
test: /\.((css)|(s[a|c]ss))$/,
use: [
{ loader: 'style-loader' },
{ loader: 'css-loader' },
{ loader: 'sass-loader' },
],
},
{
test: /\.(otf)$/,
use: [
{
loader: 'file-loader',
options: {
name: '[name].[ext]',
outputPath: 'fonts/',
publicPath: 'assets/fonts/',
},
},
],
},
],
},
output: {
path: `${path.resolve(__dirname)}/public/assets`,
publicPath: '/',
filename: '[name].bundle.js',
},
};

View File

@@ -1,52 +0,0 @@
const path = require('path');
module.exports = {
mode: 'development',
watch: true,
resolve: {
fallback: {
crypto: require.resolve('crypto-browserify'),
},
},
entry: {
index: './src/js/index.js',
},
module: {
rules: [
{
test: /\.(js|jsx|es6)$/,
loader: 'babel-loader',
options: {
compact: false,
minified: false,
},
},
{
test: /\.((css)|(s[a|c]ss))$/,
use: [
{ loader: 'style-loader' },
{ loader: 'css-loader' },
{ loader: 'sass-loader' },
],
},
{
test: /\.(otf)$/,
use: [
{
loader: 'file-loader',
options: {
name: '[name].[ext]',
outputPath: 'fonts/',
publicPath: 'assets/fonts/',
},
},
],
},
],
},
output: {
path: `${path.resolve(__dirname)}/public/assets`,
publicPath: '/',
filename: '[name].bundle.js',
},
};

View File

@@ -19,7 +19,8 @@ server_options:
# Client hint system
# Points given to a player for each acquired item in their world
location_check_points: 1
# Point cost to receive a hint via !hint for players
# Relative point cost to receive a hint via !hint for players
# so for example hint_cost: 20 would mean that for every 20% of available checks, you get the ability to hint, for a total of 5
hint_cost: 1000 # Set to 0 if you want free hints
# Forfeit modes
# "disabled" -> clients can't forfeit,
@@ -37,7 +38,7 @@ server_options:
auto_shutdown: 0
# Compatibility handling
# 2 -> Recommended for casual/cooperative play, attempt to be compatible with everything across all versions
# 1 -> Recommended for friendly racing, only allow Berserker's Multiworld, to disallow old /getitem for example
# 1 -> No longer in use, kept reserved in case of future use
# 0 -> Recommended for tournaments to force a level playing field, only allow an exact version match
compatibility: 2
# log all server traffic, mostly for dev use
@@ -80,6 +81,11 @@ multi_mystery_options:
# -1 -> Create them without zipping
# 2 -> Delete the non-zipped one.
zip_diffs: 2
# Zip apmc files for Minecraft
# 0 -> Don't zip
# 1 -> Create a zip
# 2 -> Create a zip and delete apmc files inside of it
zip_apmcs: 1
# Zip spoiler log
# 1 -> Include the spoiler log in the zip
# 2 -> Delete the non-zipped one

View File

@@ -42,6 +42,8 @@ progression_balancing:
# Pegasus Boots: on
# Bomb Upgrade (+10): 4
# Arrow Upgrade (+10): 4
# start_hints: # Begin the game with these items' locations revealed to you at the start of the game. Get the info via !hint in your client.
# - Moon Pearl
# Factorio options:
tech_tree_layout:
single: 1

View File

@@ -1,5 +1,5 @@
colorama>=0.4.4
websockets>=8.1
websockets>=9.0.2
PyYAML>=5.4.1
fuzzywuzzy>=0.18.0
bsdiff4>=1.2.1
@@ -8,4 +8,4 @@ appdirs>=1.4.4
maseya-z3pr>=1.0.0rc1
xxtea>=2.0.0.post0
factorio-rcon-py>=1.2.1
jinja2>=2.11.3
jinja2>=3.0.0

View File

@@ -83,6 +83,7 @@ cx_Freeze.setup(
executables=exes,
options={
"build_exe": {
"packages": ["websockets"],
"includes": [],
"excludes": ["numpy", "Cython", "PySide2", "PIL",
"pandas"],
@@ -133,17 +134,31 @@ else:
qusb2sneslog = buildfolder / "QUsb2Snes" / "log.txt"
if os.path.exists(qusb2sneslog):
os.remove(qusb2sneslog)
qusb2snesconfig = buildfolder / "QUsb2Snes" / "config.ini"
if os.path.exists(qusb2snesconfig):
os.remove(qusb2snesconfig)
alttpr_sprites_folder = buildfolder / "data" / "sprites" / "alttpr"
for file in os.listdir(alttpr_sprites_folder):
if file != ".gitignore":
os.remove(alttpr_sprites_folder / file)
# turns on all bridges, disables auto update
with open(qusb2snesconfig, "w") as f:
f.write("""[General]
SendToSet=true
checkUpdateCounter=20
luabridge=true
LuaBridgeRNGSeed=79120361805329566567327599
FirstTime=true
sd2snessupport=true
retroarchdevice=true
snesclassic=true""")
if signtool:
for exe in exes:
print(f"Signing {exe.target_name}")
os.system(signtool + os.path.join(buildfolder, exe.target_name))
print(f"Signing QUsb2Snes")
os.system(signtool + os.path.join(buildfolder, "Qusb2Snes", "QUsb2Snes.exe"))
alttpr_sprites_folder = buildfolder / "data" / "sprites" / "alttpr"
for file in os.listdir(alttpr_sprites_folder):
if file != ".gitignore":
os.remove(alttpr_sprites_folder / file)
manifest_creation()

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