Merge branch 'main' into breaking_changes

# Conflicts:
#	MultiClient.py
#	WebUI.py
This commit is contained in:
Fabian Dill 2021-01-21 05:36:16 +01:00
commit 670b8b4b11
11 changed files with 220 additions and 116 deletions

View File

@ -1429,16 +1429,16 @@ class PlandoItem(NamedTuple):
location: str location: str
world: Union[bool, str] = False # False -> own world, True -> not own world world: Union[bool, str] = False # False -> own world, True -> not own world
from_pool: bool = True # if item should be removed from item pool from_pool: bool = True # if item should be removed from item pool
force: Union[bool, str] = 'silent' # False -> warns if item not successfully placed. True -> errors out on failure to place item. force: str = 'silent' # false -> warns if item not successfully placed. true -> errors out on failure to place item.
def warn(self, warning: str): def warn(self, warning: str):
if str(self.force).lower() in ['true', 'fail', 'failure', 'none', 'false', 'warn', 'warning']: if self.force in ['true', 'fail', 'failure', 'none', 'false', 'warn', 'warning']:
logging.warning(f'{warning}') logging.warning(f'{warning}')
else: else:
logging.debug(f'{warning}') logging.debug(f'{warning}')
def failed(self, warning: str, exception=Exception): def failed(self, warning: str, exception=Exception):
if str(self.force).lower() in ['true', 'fail', 'failure']: if self.force in ['true', 'fail', 'failure']:
raise exception(warning) raise exception(warning)
else: else:
self.warn(warning) self.warn(warning)

12
Fill.py
View File

@ -257,6 +257,11 @@ def balance_multiworld_progression(world):
reachable_locations_count = {player: 0 for player in range(1, world.players + 1)} reachable_locations_count = {player: 0 for player in range(1, world.players + 1)}
def event_key(location):
return location.event and (
world.keyshuffle[location.item.player] or not location.item.smallkey) and (
world.bigkeyshuffle[location.item.player] or not location.item.bigkey)
def get_sphere_locations(sphere_state, locations): def get_sphere_locations(sphere_state, locations):
sphere_state.sweep_for_events(key_only=True, locations=locations) sphere_state.sweep_for_events(key_only=True, locations=locations)
return [loc for loc in locations if sphere_state.can_reach(loc)] return [loc for loc in locations if sphere_state.can_reach(loc)]
@ -279,9 +284,7 @@ def balance_multiworld_progression(world):
candidate_items = [] candidate_items = []
while True: while True:
for location in balancing_sphere: for location in balancing_sphere:
if location.event and ( if event_key(location):
world.keyshuffle[location.item.player] or not location.item.smallkey) and (
world.bigkeyshuffle[location.item.player] or not location.item.bigkey):
balancing_state.collect(location.item, True, location) balancing_state.collect(location.item, True, location)
if location.item.player in balancing_players and not location.locked: if location.item.player in balancing_players and not location.locked:
candidate_items.append(location) candidate_items.append(location)
@ -342,8 +345,7 @@ def balance_multiworld_progression(world):
sphere_locations.append(location) sphere_locations.append(location)
for location in sphere_locations: for location in sphere_locations:
if location.event and (world.keyshuffle[location.item.player] or not location.item.smallkey) and ( if event_key(location):
world.bigkeyshuffle[location.item.player] or not location.item.bigkey):
state.collect(location.item, True, location) state.collect(location.item, True, location)
checked_locations.extend(sphere_locations) checked_locations.extend(sphere_locations)

View File

@ -36,6 +36,10 @@ import WebUI
from worlds.alttp import Regions from worlds.alttp import Regions
import Utils import Utils
# logging note:
# logging.* gets send to only the text console, logger.* gets send to the WebUI as well, if it's initialized.
logger = logging.getLogger("Client")
def create_named_task(coro, *args, name=None): def create_named_task(coro, *args, name=None):
if not name: if not name:
@ -53,6 +57,7 @@ class Context():
# WebUI Stuff # WebUI Stuff
self.ui_node = WebUI.WebUiClient() self.ui_node = WebUI.WebUiClient()
logger.addHandler(self.ui_node)
self.custom_address = None self.custom_address = None
self.webui_socket_port: typing.Optional[int] = port self.webui_socket_port: typing.Optional[int] = port
self.hint_cost = 0 self.hint_cost = 0
@ -119,6 +124,7 @@ class Context():
return return
await self.server.socket.send(dumps(msgs)) await self.server.socket.send(dumps(msgs))
color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34, color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34,
'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43, 'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43,
'blue_bg': 44, 'purple_bg': 45, 'cyan_bg': 46, 'white_bg': 47} 'blue_bg': 44, 'purple_bg': 45, 'cyan_bg': 46, 'white_bg': 47}
@ -423,11 +429,11 @@ def launch_qusb2snes(ctx: Context):
qusb2snes_path = Utils.local_path(qusb2snes_path) qusb2snes_path = Utils.local_path(qusb2snes_path)
if os.path.isfile(qusb2snes_path): if os.path.isfile(qusb2snes_path):
ctx.ui_node.log_info(f"Attempting to start {qusb2snes_path}") logger.info(f"Attempting to start {qusb2snes_path}")
import subprocess import subprocess
subprocess.Popen(qusb2snes_path, cwd=os.path.dirname(qusb2snes_path)) subprocess.Popen(qusb2snes_path, cwd=os.path.dirname(qusb2snes_path))
else: else:
ctx.ui_node.log_info( logger.info(
f"Attempt to start (Q)Usb2Snes was aborted as path {qusb2snes_path} was not found, " f"Attempt to start (Q)Usb2Snes was aborted as path {qusb2snes_path} was not found, "
f"please start it yourself if it is not running") f"please start it yourself if it is not running")
@ -435,7 +441,7 @@ def launch_qusb2snes(ctx: Context):
async def _snes_connect(ctx: Context, address: str): async def _snes_connect(ctx: Context, address: str):
address = f"ws://{address}" if "://" not in address else address address = f"ws://{address}" if "://" not in address else address
ctx.ui_node.log_info("Connecting to QUsb2snes at %s ..." % address) logger.info("Connecting to QUsb2snes at %s ..." % address)
seen_problems = set() seen_problems = set()
succesful = False succesful = False
while not succesful: while not succesful:
@ -447,7 +453,7 @@ async def _snes_connect(ctx: Context, address: str):
# only tell the user about new problems, otherwise silently lay in wait for a working connection # only tell the user about new problems, otherwise silently lay in wait for a working connection
if problem not in seen_problems: if problem not in seen_problems:
seen_problems.add(problem) seen_problems.add(problem)
ctx.ui_node.log_error(f"Error connecting to QUsb2snes ({problem})") logger.error(f"Error connecting to QUsb2snes ({problem})")
if len(seen_problems) == 1: if len(seen_problems) == 1:
# this is the first problem. Let's try launching QUsb2snes if it isn't already running # this is the first problem. Let's try launching QUsb2snes if it isn't already running
@ -470,7 +476,7 @@ async def get_snes_devices(ctx: Context):
devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else None devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else None
if not devices: if not devices:
ctx.ui_node.log_info('No SNES device found. Ensure QUsb2Snes is running and connect it to the multibridge.') logger.info('No SNES device found. Ensure QUsb2Snes is running and connect it to the multibridge.')
while not devices: while not devices:
await asyncio.sleep(1) await asyncio.sleep(1)
await socket.send(dumps(DeviceList_Request)) await socket.send(dumps(DeviceList_Request))
@ -485,7 +491,7 @@ async def get_snes_devices(ctx: Context):
async def snes_connect(ctx: Context, address): async def snes_connect(ctx: Context, address):
global SNES_RECONNECT_DELAY global SNES_RECONNECT_DELAY
if ctx.snes_socket is not None and ctx.snes_state == SNES_CONNECTED: if ctx.snes_socket is not None and ctx.snes_state == SNES_CONNECTED:
ctx.ui_node.log_error('Already connected to snes') logger.error('Already connected to snes')
return return
recv_task = None recv_task = None
@ -511,7 +517,7 @@ async def snes_connect(ctx: Context, address):
return return
ctx.ui_node.log_info("Attaching to " + device) logger.info("Attaching to " + device)
Attach_Request = { Attach_Request = {
"Opcode": "Attach", "Opcode": "Attach",
@ -524,12 +530,12 @@ async def snes_connect(ctx: Context, address):
ctx.ui_node.send_connection_status(ctx) ctx.ui_node.send_connection_status(ctx)
if 'sd2snes' in device.lower() or 'COM' in device: if 'sd2snes' in device.lower() or 'COM' in device:
ctx.ui_node.log_info("SD2SNES/FXPAK Detected") logger.info("SD2SNES/FXPAK Detected")
ctx.is_sd2snes = True ctx.is_sd2snes = True
await ctx.snes_socket.send(dumps({"Opcode" : "Info", "Space" : "SNES"})) await ctx.snes_socket.send(dumps({"Opcode" : "Info", "Space" : "SNES"}))
reply = loads(await ctx.snes_socket.recv()) reply = loads(await ctx.snes_socket.recv())
if reply and 'Results' in reply: if reply and 'Results' in reply:
ctx.ui_node.log_info(reply['Results']) logger.info(reply['Results'])
else: else:
ctx.is_sd2snes = False ctx.is_sd2snes = False
@ -548,9 +554,9 @@ async def snes_connect(ctx: Context, address):
ctx.snes_socket = None ctx.snes_socket = None
ctx.snes_state = SNES_DISCONNECTED ctx.snes_state = SNES_DISCONNECTED
if not ctx.snes_reconnect_address: if not ctx.snes_reconnect_address:
ctx.ui_node.log_error("Error connecting to snes (%s)" % e) logger.error("Error connecting to snes (%s)" % e)
else: else:
ctx.ui_node.log_error(f"Error connecting to snes, attempt again in {SNES_RECONNECT_DELAY}s") logger.error(f"Error connecting to snes, attempt again in {SNES_RECONNECT_DELAY}s")
asyncio.create_task(snes_autoreconnect(ctx)) asyncio.create_task(snes_autoreconnect(ctx))
SNES_RECONNECT_DELAY *= 2 SNES_RECONNECT_DELAY *= 2
@ -577,11 +583,11 @@ async def snes_recv_loop(ctx: Context):
try: try:
async for msg in ctx.snes_socket: async for msg in ctx.snes_socket:
ctx.snes_recv_queue.put_nowait(msg) ctx.snes_recv_queue.put_nowait(msg)
ctx.ui_node.log_warning("Snes disconnected") logger.warning("Snes disconnected")
except Exception as e: except Exception as e:
if not isinstance(e, websockets.WebSocketException): if not isinstance(e, websockets.WebSocketException):
logging.exception(e) logger.exception(e)
ctx.ui_node.log_error("Lost connection to the snes, type /snes to reconnect") logger.error("Lost connection to the snes, type /snes to reconnect")
finally: finally:
socket, ctx.snes_socket = ctx.snes_socket, None socket, ctx.snes_socket = ctx.snes_socket, None
if socket is not None and not socket.closed: if socket is not None and not socket.closed:
@ -595,7 +601,7 @@ async def snes_recv_loop(ctx: Context):
ctx.rom = None ctx.rom = None
if ctx.snes_reconnect_address: if ctx.snes_reconnect_address:
ctx.ui_node.log_info(f"...reconnecting in {SNES_RECONNECT_DELAY}s") logger.info(f"...reconnecting in {SNES_RECONNECT_DELAY}s")
asyncio.create_task(snes_autoreconnect(ctx)) asyncio.create_task(snes_autoreconnect(ctx))
@ -624,10 +630,10 @@ async def snes_read(ctx : Context, address, size):
break break
if len(data) != size: if len(data) != size:
logging.error('Error reading %s, requested %d bytes, received %d' % (hex(address), size, len(data))) logger.error('Error reading %s, requested %d bytes, received %d' % (hex(address), size, len(data)))
if len(data): if len(data):
ctx.ui_node.log_error(str(data)) logger.error(str(data))
ctx.ui_node.log_warning('Unable to connect to SNES Device because QUsb2Snes broke temporarily.' logger.warning('Unable to connect to SNES Device because QUsb2Snes broke temporarily.'
'Try un-selecting and re-selecting the SNES Device.') 'Try un-selecting and re-selecting the SNES Device.')
if ctx.snes_socket is not None and not ctx.snes_socket.closed: if ctx.snes_socket is not None and not ctx.snes_socket.closed:
await ctx.snes_socket.close() await ctx.snes_socket.close()
@ -652,7 +658,7 @@ async def snes_write(ctx : Context, write_list):
for address, data in write_list: for address, data in write_list:
if (address < WRAM_START) or ((address + len(data)) > (WRAM_START + WRAM_SIZE)): if (address < WRAM_START) or ((address + len(data)) > (WRAM_START + WRAM_SIZE)):
ctx.ui_node.log_error("SD2SNES: Write out of range %s (%d)" % (hex(address), len(data))) logger.error("SD2SNES: Write out of range %s (%d)" % (hex(address), len(data)))
return False return False
for ptr, byte in enumerate(data, address + 0x7E0000 - WRAM_START): for ptr, byte in enumerate(data, address + 0x7E0000 - WRAM_START):
cmd += b'\xA9' # LDA cmd += b'\xA9' # LDA
@ -669,7 +675,7 @@ async def snes_write(ctx : Context, write_list):
await ctx.snes_socket.send(dumps(PutAddress_Request)) await ctx.snes_socket.send(dumps(PutAddress_Request))
await ctx.snes_socket.send(cmd) await ctx.snes_socket.send(cmd)
else: else:
logging.warning(f"Could not send data to SNES: {cmd}") logger.warning(f"Could not send data to SNES: {cmd}")
except websockets.ConnectionClosed: except websockets.ConnectionClosed:
return False return False
else: else:
@ -682,7 +688,7 @@ async def snes_write(ctx : Context, write_list):
await ctx.snes_socket.send(dumps(PutAddress_Request)) await ctx.snes_socket.send(dumps(PutAddress_Request))
await ctx.snes_socket.send(data) await ctx.snes_socket.send(data)
else: else:
logging.warning(f"Could not send data to SNES: {data}") logger.warning(f"Could not send data to SNES: {data}")
except websockets.ConnectionClosed: except websockets.ConnectionClosed:
return False return False
@ -719,7 +725,7 @@ async def server_loop(ctx: Context, address=None):
ctx.ui_node.send_connection_status(ctx) ctx.ui_node.send_connection_status(ctx)
cached_address = None cached_address = None
if ctx.server and ctx.server.socket: if ctx.server and ctx.server.socket:
ctx.ui_node.log_error('Already connected') logger.error('Already connected')
return return
if address is None: # set through CLI or APBP if address is None: # set through CLI or APBP
@ -735,18 +741,18 @@ async def server_loop(ctx: Context, address=None):
# Wait for the user to provide a multiworld server address # Wait for the user to provide a multiworld server address
if not address: if not address:
logging.info('Please connect to a multiworld server.') logger.info('Please connect to a multiworld server.')
ctx.ui_node.poll_for_server_ip() ctx.ui_node.poll_for_server_ip()
return return
address = f"ws://{address}" if "://" not in address else address address = f"ws://{address}" if "://" not in address else address
port = urllib.parse.urlparse(address).port or 38281 port = urllib.parse.urlparse(address).port or 38281
ctx.ui_node.log_info('Connecting to multiworld server at %s' % address) logger.info('Connecting to multiworld server at %s' % address)
try: try:
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None) socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
ctx.server = Endpoint(socket) ctx.server = Endpoint(socket)
ctx.ui_node.log_info('Connected') logger.info('Connected')
ctx.server_address = address ctx.server_address = address
ctx.ui_node.send_connection_status(ctx) ctx.ui_node.send_connection_status(ctx)
SERVER_RECONNECT_DELAY = START_RECONNECT_DELAY SERVER_RECONNECT_DELAY = START_RECONNECT_DELAY
@ -754,21 +760,21 @@ async def server_loop(ctx: Context, address=None):
for msg in loads(data): for msg in loads(data):
cmd, args = (msg[0], msg[1]) if len(msg) > 1 else (msg, None) cmd, args = (msg[0], msg[1]) if len(msg) > 1 else (msg, None)
await process_server_cmd(ctx, cmd, args) await process_server_cmd(ctx, cmd, args)
ctx.ui_node.log_warning('Disconnected from multiworld server, type /connect to reconnect') logger.warning('Disconnected from multiworld server, type /connect to reconnect')
except WebUI.WaitingForUiException: except WebUI.WaitingForUiException:
pass pass
except ConnectionRefusedError: except ConnectionRefusedError:
if cached_address: if cached_address:
ctx.ui_node.log_error('Unable to connect to multiworld server at cached address. ' logger.error('Unable to connect to multiworld server at cached address. '
'Please use the connect button above.') 'Please use the connect button above.')
else: else:
ctx.ui_node.log_error('Connection refused by the multiworld server') logger.error('Connection refused by the multiworld server')
except (OSError, websockets.InvalidURI): except (OSError, websockets.InvalidURI):
ctx.ui_node.log_error('Failed to connect to the multiworld server') logger.error('Failed to connect to the multiworld server')
except Exception as e: except Exception as e:
ctx.ui_node.log_error('Lost connection to the multiworld server, type /connect to reconnect') logger.error('Lost connection to the multiworld server, type /connect to reconnect')
if not isinstance(e, websockets.WebSocketException): if not isinstance(e, websockets.WebSocketException):
logging.exception(e) logger.exception(e)
finally: finally:
ctx.awaiting_rom = False ctx.awaiting_rom = False
ctx.auth = None ctx.auth = None
@ -780,7 +786,7 @@ async def server_loop(ctx: Context, address=None):
ctx.server = None ctx.server = None
ctx.server_task = None ctx.server_task = None
if ctx.server_address: if ctx.server_address:
ctx.ui_node.log_info(f"... reconnecting in {SERVER_RECONNECT_DELAY}s") logger.info(f"... reconnecting in {SERVER_RECONNECT_DELAY}s")
ctx.ui_node.send_connection_status(ctx) ctx.ui_node.send_connection_status(ctx)
asyncio.create_task(server_autoreconnect(ctx)) asyncio.create_task(server_autoreconnect(ctx))
SERVER_RECONNECT_DELAY *= 2 SERVER_RECONNECT_DELAY *= 2
@ -797,18 +803,18 @@ async def server_autoreconnect(ctx: Context):
async def process_server_cmd(ctx: Context, cmd, args): async def process_server_cmd(ctx: Context, cmd, args):
if cmd == 'RoomInfo': if cmd == 'RoomInfo':
ctx.ui_node.log_info('--------------------------------') logger.info('--------------------------------')
ctx.ui_node.log_info('Room Information:') logger.info('Room Information:')
ctx.ui_node.log_info('--------------------------------') logger.info('--------------------------------')
version = args["version"] version = args["version"]
ctx.server_version = tuple(version) ctx.server_version = tuple(version)
version = ".".join(str(item) for item in version) version = ".".join(str(item) for item in version)
ctx.ui_node.log_info(f'Server protocol version: {version}') logger.info(f'Server protocol version: {version}')
if "tags" in args: if "tags" in args:
ctx.ui_node.log_info("Server protocol tags: " + ", ".join(args["tags"])) logger.info("Server protocol tags: " + ", ".join(args["tags"]))
if args['password']: if args['password']:
ctx.ui_node.log_info('Password required') logger.info('Password required')
if "forfeit_mode" in args: # could also be version > 2.2.1, but going with implicit content here if "forfeit_mode" in args: # could also be version > 2.2.1, but going with implicit content here
logging.info(f"Forfeit setting: {args['forfeit_mode']}") logging.info(f"Forfeit setting: {args['forfeit_mode']}")
logging.info(f"Remaining setting: {args['remaining_mode']}") logging.info(f"Remaining setting: {args['remaining_mode']}")
@ -820,16 +826,16 @@ async def process_server_cmd(ctx: Context, cmd, args):
ctx.remaining_mode = args['remaining_mode'] ctx.remaining_mode = args['remaining_mode']
ctx.ui_node.send_game_info(ctx) ctx.ui_node.send_game_info(ctx)
if len(args['players']) < 1: if len(args['players']) < 1:
ctx.ui_node.log_info('No player connected') logger.info('No player connected')
else: else:
args['players'].sort() args['players'].sort()
current_team = -1 current_team = -1
ctx.ui_node.log_info('Connected players:') logger.info('Connected players:')
for team, slot, name in args['players']: for team, slot, name in args['players']:
if team != current_team: if team != current_team:
ctx.ui_node.log_info(f' Team #{team + 1}') logger.info(f' Team #{team + 1}')
current_team = team current_team = team
ctx.ui_node.log_info(' %s (Player %d)' % (name, slot)) logger.info(' %s (Player %d)' % (name, slot))
await server_auth(ctx, args['password']) await server_auth(ctx, args['password'])
elif cmd == 'ConnectionRefused': elif cmd == 'ConnectionRefused':
@ -845,7 +851,7 @@ async def process_server_cmd(ctx: Context, cmd, args):
raise Exception('Server reported your client version as incompatible') raise Exception('Server reported your client version as incompatible')
#last to check, recoverable problem #last to check, recoverable problem
elif 'InvalidPassword' in args: elif 'InvalidPassword' in args:
ctx.ui_node.log_error('Invalid password') logger.error('Invalid password')
ctx.password = None ctx.password = None
await server_auth(ctx, True) await server_auth(ctx, True)
else: else:
@ -855,7 +861,7 @@ async def process_server_cmd(ctx: Context, cmd, args):
elif cmd == 'Connected': elif cmd == 'Connected':
if ctx.send_unsafe: if ctx.send_unsafe:
ctx.send_unsafe = False ctx.send_unsafe = False
ctx.ui_node.log_info(f'Turning off sending of ALL location checks not declared as missing. If you want it on, please use /send_unsafe true') logger.info(f'Turning off sending of ALL location checks not declared as missing. If you want it on, please use /send_unsafe true')
Utils.persistent_store("servers", ctx.rom, ctx.server_address) Utils.persistent_store("servers", ctx.rom, ctx.server_address)
ctx.team, ctx.slot = args[0] ctx.team, ctx.slot = args[0]
ctx.player_names = {p: n for p, n in args[1]} ctx.player_names = {p: n for p, n in args[1]}
@ -894,7 +900,7 @@ async def process_server_cmd(ctx: Context, cmd, args):
if location not in ctx.locations_info: if location not in ctx.locations_info:
replacements = {0xA2: 'Small Key', 0x9D: 'Big Key', 0x8D: 'Compass', 0x7D: 'Map'} replacements = {0xA2: 'Small Key', 0x9D: 'Big Key', 0x8D: 'Compass', 0x7D: 'Map'}
item_name = replacements.get(item, get_item_name_from_id(item)) item_name = replacements.get(item, get_item_name_from_id(item))
ctx.ui_node.log_info( logger.info(
f"Saw {color(item_name, 'red', 'bold')} at {list(Regions.location_table.keys())[location - 1]}") f"Saw {color(item_name, 'red', 'bold')} at {list(Regions.location_table.keys())[location - 1]}")
ctx.locations_info[location] = (item, player) ctx.locations_info[location] = (item, player)
ctx.watcher_event.set() ctx.watcher_event.set()
@ -945,13 +951,13 @@ async def process_server_cmd(ctx: Context, cmd, args):
ctx.player_names = {p: n for p, n in args} ctx.player_names = {p: n for p, n in args}
elif cmd == 'Print': elif cmd == 'Print':
ctx.ui_node.log_info(args) logger.info(args)
elif cmd == 'HintPointUpdate': elif cmd == 'HintPointUpdate':
ctx.hint_points = args[0] ctx.hint_points = args[0]
else: else:
logging.debug(f"unknown command {args}") logger.debug(f"unknown command {args}")
def get_tags(ctx: Context): def get_tags(ctx: Context):
@ -963,11 +969,11 @@ def get_tags(ctx: Context):
async def server_auth(ctx: Context, password_requested): async def server_auth(ctx: Context, password_requested):
if password_requested and not ctx.password: if password_requested and not ctx.password:
ctx.ui_node.log_info('Enter the password required to join this game:') logger.info('Enter the password required to join this game:')
ctx.password = await console_input(ctx) ctx.password = await console_input(ctx)
if ctx.rom is None: if ctx.rom is None:
ctx.awaiting_rom = True ctx.awaiting_rom = True
ctx.ui_node.log_info( logger.info(
'No ROM detected, awaiting snes connection to authenticate to the multiworld server (/snes)') 'No ROM detected, awaiting snes connection to authenticate to the multiworld server (/snes)')
return return
ctx.awaiting_rom = False ctx.awaiting_rom = False
@ -997,7 +1003,7 @@ class ClientCommandProcessor(CommandProcessor):
self.ctx = ctx self.ctx = ctx
def output(self, text: str): def output(self, text: str):
self.ctx.ui_node.log_info(text) logger.info(text)
def _cmd_exit(self) -> bool: def _cmd_exit(self) -> bool:
"""Close connections and client""" """Close connections and client"""
@ -1034,7 +1040,7 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_received(self) -> bool: def _cmd_received(self) -> bool:
"""List all received items""" """List all received items"""
self.ctx.ui_node.log_info('Received items:') logger.info('Received items:')
for index, item in enumerate(self.ctx.items_received, 1): for index, item in enumerate(self.ctx.items_received, 1):
self.ctx.ui_node.notify_item_received(self.ctx.player_names[item.player], get_item_name_from_id(item.item), self.ctx.ui_node.notify_item_received(self.ctx.player_names[item.player], get_item_name_from_id(item.item),
get_location_name_from_address(item.location), index, get_location_name_from_address(item.location), index,
@ -1086,7 +1092,7 @@ class ClientCommandProcessor(CommandProcessor):
self.ctx.found_items = toggle.lower() in {"1", "true", "on"} self.ctx.found_items = toggle.lower() in {"1", "true", "on"}
else: else:
self.ctx.found_items = not self.ctx.found_items self.ctx.found_items = not self.ctx.found_items
self.ctx.ui_node.log_info(f"Set showing team items to {self.ctx.found_items}") logger.info(f"Set showing team items to {self.ctx.found_items}")
asyncio.create_task(self.ctx.send_msgs([['UpdateTags', get_tags(self.ctx)]])) asyncio.create_task(self.ctx.send_msgs([['UpdateTags', get_tags(self.ctx)]]))
return True return True
@ -1097,7 +1103,7 @@ class ClientCommandProcessor(CommandProcessor):
else: else:
self.ctx.slow_mode = not self.ctx.slow_mode self.ctx.slow_mode = not self.ctx.slow_mode
self.ctx.ui_node.log_info(f"Setting slow mode to {self.ctx.slow_mode}") logger.info(f"Setting slow mode to {self.ctx.slow_mode}")
def _cmd_web(self): def _cmd_web(self):
if self.ctx.webui_socket_port: if self.ctx.webui_socket_port:
@ -1109,9 +1115,9 @@ class ClientCommandProcessor(CommandProcessor):
"""Force sending of locations the server did not specify was actually missing. WARNING: This may brick online trackers. Turned off on reconnect.""" """Force sending of locations the server did not specify was actually missing. WARNING: This may brick online trackers. Turned off on reconnect."""
if toggle: if toggle:
self.ctx.send_unsafe = toggle.lower() in {"1", "true", "on"} self.ctx.send_unsafe = toggle.lower() in {"1", "true", "on"}
self.ctx.ui_node.log_info(f'Turning {("on" if self.ctx.send_unsafe else "off")} the option to send ALL location checks to the multiserver.') logger.info(f'Turning {("on" if self.ctx.send_unsafe else "off")} the option to send ALL location checks to the multiserver.')
else: else:
self.ctx.ui_node.log_info("You must specify /send_unsafe true explicitly.") logger.info("You must specify /send_unsafe true explicitly.")
self.ctx.send_unsafe = False self.ctx.send_unsafe = False
def default(self, raw: str): def default(self, raw: str):
@ -1144,7 +1150,7 @@ async def track_locations(ctx : Context, roomid, roomdata):
def new_check(location): def new_check(location):
ctx.unsafe_locations_checked.add(location) ctx.unsafe_locations_checked.add(location)
ctx.ui_node.log_info("New check: %s (%d/216)" % (location, len(ctx.unsafe_locations_checked))) logging.info("New check: %s (%d/216)" % (location, len(ctx.unsafe_locations_checked)))
ctx.ui_node.send_location_check(ctx, location) ctx.ui_node.send_location_check(ctx, location)
for location, (loc_roomid, loc_mask) in location_table_uw.items(): for location, (loc_roomid, loc_mask) in location_table_uw.items():
@ -1152,7 +1158,7 @@ async def track_locations(ctx : Context, roomid, roomdata):
if location not in ctx.unsafe_locations_checked and loc_roomid == roomid and (roomdata << 4) & loc_mask != 0: if location not in ctx.unsafe_locations_checked and loc_roomid == roomid and (roomdata << 4) & loc_mask != 0:
new_check(location) new_check(location)
except Exception as e: except Exception as e:
ctx.ui_node.log_info(f"Exception: {e}") logger.exception(f"Exception: {e}")
uw_begin = 0x129 uw_begin = 0x129
uw_end = 0 uw_end = 0
@ -1215,7 +1221,7 @@ async def send_finished_game(ctx: Context):
await ctx.send_msgs([['GameFinished', '']]) await ctx.send_msgs([['GameFinished', '']])
ctx.finished_game = True ctx.finished_game = True
except Exception as ex: except Exception as ex:
logging.exception(ex) logger.exception(ex)
async def game_watcher(ctx : Context): async def game_watcher(ctx : Context):
@ -1245,7 +1251,7 @@ async def game_watcher(ctx : Context):
await server_auth(ctx, False) await server_auth(ctx, False)
if ctx.auth and ctx.auth != ctx.rom: if ctx.auth and ctx.auth != ctx.rom:
ctx.ui_node.log_warning("ROM change detected, please reconnect to the multiworld server") logger.warning("ROM change detected, please reconnect to the multiworld server")
await ctx.disconnect() await ctx.disconnect()
gamemode = await snes_read(ctx, WRAM_START + 0x10, 1) gamemode = await snes_read(ctx, WRAM_START + 0x10, 1)
@ -1309,7 +1315,7 @@ async def game_watcher(ctx : Context):
if scout_location > 0 and scout_location not in ctx.locations_scouted: if scout_location > 0 and scout_location not in ctx.locations_scouted:
ctx.locations_scouted.add(scout_location) ctx.locations_scouted.add(scout_location)
ctx.ui_node.log_info(f'Scouting item at {list(Regions.location_table.keys())[scout_location - 1]}') logger.info(f'Scouting item at {list(Regions.location_table.keys())[scout_location - 1]}')
await ctx.send_msgs([['LocationScouts', [scout_location]]]) await ctx.send_msgs([['LocationScouts', [scout_location]]])
await track_locations(ctx, roomid, roomdata) await track_locations(ctx, roomid, roomdata)

View File

@ -560,7 +560,7 @@ def roll_settings(weights, plando_options: typing.Set[str] = frozenset(("bosses"
ret.plando_items = [] ret.plando_items = []
if "items" in plando_options: if "items" in plando_options:
default_placement = PlandoItem(item="", location="")
def add_plando_item(item: str, location: str): def add_plando_item(item: str, location: str):
if item not in item_table: if item not in item_table:
raise Exception(f"Could not plando item {item} as the item was not recognized") raise Exception(f"Could not plando item {item} as the item was not recognized")
@ -571,9 +571,9 @@ def roll_settings(weights, plando_options: typing.Set[str] = frozenset(("bosses"
options = weights.get("plando_items", []) options = weights.get("plando_items", [])
for placement in options: for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)): if roll_percentage(get_choice("percentage", placement, 100)):
from_pool = get_choice("from_pool", placement, default_placement.from_pool) from_pool = get_choice("from_pool", placement, PlandoItem._field_defaults["from_pool"])
location_world = get_choice("world", placement, default_placement.world) location_world = get_choice("world", placement, PlandoItem._field_defaults["world"])
force = get_choice("force", placement, default_placement.force) force = str(get_choice("force", placement, PlandoItem._field_defaults["force"])).lower()
if "items" in placement and "locations" in placement: if "items" in placement and "locations" in placement:
items = placement["items"] items = placement["items"]
locations = placement["locations"] locations = placement["locations"]

View File

@ -13,6 +13,7 @@ class Node:
def __init__(self): def __init__(self):
self.endpoints = [] self.endpoints = []
super(Node, self).__init__()
def broadcast_all(self, msgs): def broadcast_all(self, msgs):
msgs = self.dumper(msgs) msgs = self.dumper(msgs)

View File

@ -69,6 +69,20 @@
] ]
} }
] ]
},
{
"name": "Plando Tutorial",
"description": "A guide to creating Multiworld Plandos",
"files": [
{
"language": "English",
"filename": "zelda3/plando_en.md",
"link": "zelda3/plando/en",
"authors": [
"Berserker"
]
}
]
} }
] ]
} }

View File

@ -18,17 +18,17 @@
it defaults to vanilla it defaults to vanilla
- Instructions are separated by a semicolon - Instructions are separated by a semicolon
- Available Instructions: - Available Instructions:
- Direct Placement: - Direct Placement:
- Example: "Eastern Palace-Trinexx" - Example: "Eastern Palace-Trinexx"
- Takes a particular Arena and particular boss, then places that boss into that arena - Takes a particular Arena and particular boss, then places that boss into that arena
- Ganons Tower has 3 placements, "Ganons Tower Top", "Ganons Tower Middle" and "Ganons Tower Bottom" - Ganons Tower has 3 placements, "Ganons Tower Top", "Ganons Tower Middle" and "Ganons Tower Bottom"
- Boss Placement: - Boss Placement:
- Example: "Trinexx" - Example: "Trinexx"
- Takes a particular boss and places that boss in any remaining slots in which this boss can function. - Takes a particular boss and places that boss in any remaining slots in which this boss can function.
- In this example, it would fill Desert Palace, but not Tower of Hera. - In this example, it would fill Desert Palace, but not Tower of Hera.
- Boss Shuffle: - Boss Shuffle:
- Example: "simple" - Example: "simple"
- Runs a particular boss shuffle mode to finish construction instead of vanilla placement, typically used as a last instruction. - Runs a particular boss shuffle mode to finish construction instead of vanilla placement, typically used as a last instruction.
- [Available Bosses](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Bosses.py#L135) - [Available Bosses](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Bosses.py#L135)
- [Available Arenas](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Bosses.py#L186) - [Available Arenas](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Bosses.py#L186)
@ -47,20 +47,83 @@ boss_shuffle:
4. A Trinexx -> Kholdstare Singularity that prevents ice Trinexx in GT 4. A Trinexx -> Kholdstare Singularity that prevents ice Trinexx in GT
### Items
- This module is disabled by default.
- Has the options from_pool, world, percentage, force and either item and location or items and locations
- All of these options support subweights
- percentage is the percentage chance for this block to trigger
- is a number in the range [0, 100], can be omitted entirely for 100%
- from_pool denotes if the item should be taken from the item pool, or be an additional item entirely.
- can be true or false, defaults to true when omitted
- world is the target world to place the item
- ignored if only one world is generated
- can be a number, to target that slot in the multiworld
- can be a name, to target that player's world
- can be true, to target any other player's world
- can be false, to target own world and is the default
- can be null, to target a random world
- force is either "silent", "true" or "false".
- "true" means the item has to be placed, or the generator aborts with an exception.
- "false" means the generator logs a warning if the placement can't be done.
- "silent" means that this entry is entirely ignored if the placement fails and is the default.
- Single Placement
- place a single item at a single location
- item denotes the Item to place
- location denotes the Location to place it into
- Multi Placement
- place multiple items into multiple locations, until either list is exhausted.
- items denotes the items to use, can be given a number to have multiple of that item
- locations lists the possible locations those items can be placed in
- placements are picked randomly, not sorted in any way
- [Available Items](https://github.com/Berserker66/MultiWorld-Utilities/blob/3b5ba161dea223b96e9b1fc890e03469d9c6eb59/Items.py#L26)
- [Available Locations](https://github.com/Berserker66/MultiWorld-Utilities/blob/3b5ba161dea223b96e9b1fc890e03469d9c6eb59/Regions.py#L418)
### Text #### Examples
```yaml
plando_items:
- item:
Lamp: 1
Fire Rod: 1
location: Link's House
from_pool: true
world: true
percentage: 50
- items:
Progressive Sword: 4
Progressive Bow: 1
Progressive Bow (Alt): 1
locations:
- Desert Palace - Big Chest
- Eastern Palace - Big Chest
- Tower of Hera - Big Chest
- Swamp Palace - Big Chest
- Thieves' Town - Big Chest
- Skull Woods - Big Chest
- Ice Palace - Big Chest
- Misery Mire - Big Chest
- Turtle Rock - Big Chest
- Palace of Darkness - Big Chest
world: false
```
The first example has a 50% chance to occur, which if it does places either the Lamp or Fire Rod in one's own
Link's House and removes the picked item from the item pool.
The second example always triggers and places the Swords and Bows into one's own Big Chests
### Texts
- This module is disabled by default. - This module is disabled by default.
- Has the options "text", "at" and "percentage" - Has the options "text", "at" and "percentage"
- percentage is the percentage chance for this text to be placed, can be omitted entirely for 100% - percentage is the percentage chance for this text to be placed, can be omitted entirely for 100%
- text is the text to be placed. - text is the text to be placed.
- can be weighted. - can be weighted.
- \n is a newline. - \n is a newline.
- @ is the entered player's name. - @ is the entered player's name.
- Warning: Text Mapper does not support full unicode. - Warning: Text Mapper does not support full unicode.
- [Alphabet](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Text.py#L756) - [Alphabet](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Text.py#L756)
- at is the location within the game to attach the text to. - at is the location within the game to attach the text to.
- can be weighted. - can be weighted.
- [List of targets](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Text.py#L1498) - [List of targets](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Text.py#L1498)
#### Example #### Example
```yaml ```yaml
@ -75,3 +138,32 @@ plando_texts:
This has a 50% chance to trigger at all, if it does, This has a 50% chance to trigger at all, if it does,
it throws a coin between "uncle_leaving_text" and "uncle_dying_sewer", then places the text it throws a coin between "uncle_leaving_text" and "uncle_dying_sewer", then places the text
"This is a plando.\nYou've been warned." at that location. "This is a plando.\nYou've been warned." at that location.
### Connections
- This module is disabled by default.
- Has the options "percentage", "entrance", "exit" and "direction".
- All options support subweights
- percentage is the percentage chance for this to be connected, can be omitted entirely for 100%
- Any Door has 4 total directions, as a door can be unlinked like in insanity ER
- entrance is the overworld door
- exit is the underworld exit
- direction can be "both", "entrance" or "exit"
- doors can be found in [this file](https://github.com/Berserker66/MultiWorld-Utilities/blob/main/EntranceShuffle.py)
#### Example
```yaml
plando_connections:
- entrance: Links House
exit: Hyrule Castle Exit (West)
direction: both
- entrance: Hyrule Castle Entrance (West)
exit: Links House Exit
direction: both
```
The first block connects the overworld entrance that normally leads to Link's House
to put you into the HC West Wing instead, exiting from within there will put you at the Overworld exiting Link's House.
Without the second block, you'd still exit from within Link's House to outside Link's House and the left side
Balcony Entrance would still lead into HC West Wing

View File

@ -1,6 +1,7 @@
import http.server import http.server
import logging import logging
import json import json
import typing
import socket import socket
import socketserver import socketserver
import threading import threading
@ -12,36 +13,20 @@ from NetUtils import Node
from MultiClient import Context from MultiClient import Context
import Utils import Utils
logger = logging.getLogger("WebUIRelay")
class WebUiClient(Node, logging.Handler):
class WebUiClient(Node):
loader = staticmethod(json.loads) loader = staticmethod(json.loads)
dumper = staticmethod(json.dumps) dumper = staticmethod(json.dumps)
def __init__(self): def __init__(self):
super().__init__() super(WebUiClient, self).__init__()
self.manual_snes = None self.manual_snes = None
@staticmethod @staticmethod
def build_message(msg_type: str, content: dict) -> dict: def build_message(msg_type: str, content: typing.Union[str, dict]) -> dict:
return {'type': msg_type, 'content': content} return {'type': msg_type, 'content': content}
def log_info(self, message, *args, **kwargs): def emit(self, record: logging.LogRecord) -> None:
self.broadcast_all(self.build_message('info', message)) self.broadcast_all({"type": record.levelname.lower(), "content": str(record.msg)})
logger.info(message, *args, **kwargs)
def log_warning(self, message, *args, **kwargs):
self.broadcast_all(self.build_message('warning', message))
logger.warning(message, *args, **kwargs)
def log_error(self, message, *args, **kwargs):
self.broadcast_all(self.build_message('error', message))
logger.error(message, *args, **kwargs)
def log_critical(self, message, *args, **kwargs):
self.broadcast_all(self.build_message('critical', message))
logger.critical(message, *args, **kwargs)
def send_chat_message(self, message): def send_chat_message(self, message):
self.broadcast_all(self.build_message('chat', message)) self.broadcast_all(self.build_message('chat', message))

View File

@ -1,9 +1,9 @@
colorama>=0.4.4 colorama>=0.4.4
websockets>=8.1 websockets>=8.1
PyYAML>=5.3.1 PyYAML>=5.4
fuzzywuzzy>=0.18.0 fuzzywuzzy>=0.18.0
bsdiff4>=1.2.0 bsdiff4>=1.2.0
prompt_toolkit>=3.0.8 prompt_toolkit>=3.0.10
appdirs>=1.4.4 appdirs>=1.4.4
maseya-z3pr>=1.0.0rc1 maseya-z3pr>=1.0.0rc1
xxtea>=2.0.0.post0 xxtea>=2.0.0.post0

View File

@ -2251,7 +2251,10 @@ def plando_connect(world, player: int):
if world.plando_connections[player]: if world.plando_connections[player]:
for connection in world.plando_connections[player]: for connection in world.plando_connections[player]:
func = lookup[connection.direction] func = lookup[connection.direction]
func(world, connection.entrance, connection.exit, player) try:
func(world, connection.entrance, connection.exit, player)
except Exception as e:
raise Exception(f"Could not connect using {connection}") from e
LW_Dungeon_Entrances = ['Desert Palace Entrance (South)', LW_Dungeon_Entrances = ['Desert Palace Entrance (South)',

View File

@ -452,6 +452,7 @@ def copy_world(world):
ret.timer = world.timer.copy() ret.timer = world.timer.copy()
ret.shufflepots = world.shufflepots.copy() ret.shufflepots = world.shufflepots.copy()
ret.shuffle_prizes = world.shuffle_prizes.copy() ret.shuffle_prizes = world.shuffle_prizes.copy()
ret.shop_shuffle = world.shop_shuffle.copy()
ret.dark_room_logic = world.dark_room_logic.copy() ret.dark_room_logic = world.dark_room_logic.copy()
ret.restrict_dungeon_item_on_boss = world.restrict_dungeon_item_on_boss.copy() ret.restrict_dungeon_item_on_boss = world.restrict_dungeon_item_on_boss.copy()