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
world: Union[bool, str] = False # False -> own world, True -> not own world
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):
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}')
else:
logging.debug(f'{warning}')
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)
else:
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)}
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):
sphere_state.sweep_for_events(key_only=True, locations=locations)
return [loc for loc in locations if sphere_state.can_reach(loc)]
@ -279,9 +284,7 @@ def balance_multiworld_progression(world):
candidate_items = []
while True:
for location in balancing_sphere:
if location.event and (
world.keyshuffle[location.item.player] or not location.item.smallkey) and (
world.bigkeyshuffle[location.item.player] or not location.item.bigkey):
if event_key(location):
balancing_state.collect(location.item, True, location)
if location.item.player in balancing_players and not location.locked:
candidate_items.append(location)
@ -342,8 +345,7 @@ def balance_multiworld_progression(world):
sphere_locations.append(location)
for location in sphere_locations:
if location.event and (world.keyshuffle[location.item.player] or not location.item.smallkey) and (
world.bigkeyshuffle[location.item.player] or not location.item.bigkey):
if event_key(location):
state.collect(location.item, True, location)
checked_locations.extend(sphere_locations)

View File

@ -36,6 +36,10 @@ import WebUI
from worlds.alttp import Regions
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):
if not name:
@ -53,6 +57,7 @@ class Context():
# WebUI Stuff
self.ui_node = WebUI.WebUiClient()
logger.addHandler(self.ui_node)
self.custom_address = None
self.webui_socket_port: typing.Optional[int] = port
self.hint_cost = 0
@ -119,6 +124,7 @@ class Context():
return
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,
'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}
@ -423,11 +429,11 @@ def launch_qusb2snes(ctx: Context):
qusb2snes_path = Utils.local_path(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
subprocess.Popen(qusb2snes_path, cwd=os.path.dirname(qusb2snes_path))
else:
ctx.ui_node.log_info(
logger.info(
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")
@ -435,7 +441,7 @@ def launch_qusb2snes(ctx: Context):
async def _snes_connect(ctx: Context, address: str):
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()
succesful = False
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
if problem not in seen_problems:
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:
# 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
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:
await asyncio.sleep(1)
await socket.send(dumps(DeviceList_Request))
@ -485,7 +491,7 @@ async def get_snes_devices(ctx: Context):
async def snes_connect(ctx: Context, address):
global SNES_RECONNECT_DELAY
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
recv_task = None
@ -511,7 +517,7 @@ async def snes_connect(ctx: Context, address):
return
ctx.ui_node.log_info("Attaching to " + device)
logger.info("Attaching to " + device)
Attach_Request = {
"Opcode": "Attach",
@ -524,12 +530,12 @@ async def snes_connect(ctx: Context, address):
ctx.ui_node.send_connection_status(ctx)
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
await ctx.snes_socket.send(dumps({"Opcode" : "Info", "Space" : "SNES"}))
reply = loads(await ctx.snes_socket.recv())
if reply and 'Results' in reply:
ctx.ui_node.log_info(reply['Results'])
logger.info(reply['Results'])
else:
ctx.is_sd2snes = False
@ -548,9 +554,9 @@ async def snes_connect(ctx: Context, address):
ctx.snes_socket = None
ctx.snes_state = SNES_DISCONNECTED
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:
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))
SNES_RECONNECT_DELAY *= 2
@ -577,11 +583,11 @@ async def snes_recv_loop(ctx: Context):
try:
async for msg in ctx.snes_socket:
ctx.snes_recv_queue.put_nowait(msg)
ctx.ui_node.log_warning("Snes disconnected")
logger.warning("Snes disconnected")
except Exception as e:
if not isinstance(e, websockets.WebSocketException):
logging.exception(e)
ctx.ui_node.log_error("Lost connection to the snes, type /snes to reconnect")
logger.exception(e)
logger.error("Lost connection to the snes, type /snes to reconnect")
finally:
socket, ctx.snes_socket = ctx.snes_socket, None
if socket is not None and not socket.closed:
@ -595,7 +601,7 @@ async def snes_recv_loop(ctx: Context):
ctx.rom = None
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))
@ -624,10 +630,10 @@ async def snes_read(ctx : Context, address, size):
break
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):
ctx.ui_node.log_error(str(data))
ctx.ui_node.log_warning('Unable to connect to SNES Device because QUsb2Snes broke temporarily.'
logger.error(str(data))
logger.warning('Unable to connect to SNES Device because QUsb2Snes broke temporarily.'
'Try un-selecting and re-selecting the SNES Device.')
if ctx.snes_socket is not None and not ctx.snes_socket.closed:
await ctx.snes_socket.close()
@ -652,7 +658,7 @@ async def snes_write(ctx : Context, write_list):
for address, data in write_list:
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
for ptr, byte in enumerate(data, address + 0x7E0000 - WRAM_START):
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(cmd)
else:
logging.warning(f"Could not send data to SNES: {cmd}")
logger.warning(f"Could not send data to SNES: {cmd}")
except websockets.ConnectionClosed:
return False
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(data)
else:
logging.warning(f"Could not send data to SNES: {data}")
logger.warning(f"Could not send data to SNES: {data}")
except websockets.ConnectionClosed:
return False
@ -719,7 +725,7 @@ async def server_loop(ctx: Context, address=None):
ctx.ui_node.send_connection_status(ctx)
cached_address = None
if ctx.server and ctx.server.socket:
ctx.ui_node.log_error('Already connected')
logger.error('Already connected')
return
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
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()
return
address = f"ws://{address}" if "://" not in address else address
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:
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
ctx.server = Endpoint(socket)
ctx.ui_node.log_info('Connected')
logger.info('Connected')
ctx.server_address = address
ctx.ui_node.send_connection_status(ctx)
SERVER_RECONNECT_DELAY = START_RECONNECT_DELAY
@ -754,21 +760,21 @@ async def server_loop(ctx: Context, address=None):
for msg in loads(data):
cmd, args = (msg[0], msg[1]) if len(msg) > 1 else (msg, None)
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:
pass
except ConnectionRefusedError:
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.')
else:
ctx.ui_node.log_error('Connection refused by the multiworld server')
logger.error('Connection refused by the multiworld server')
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:
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):
logging.exception(e)
logger.exception(e)
finally:
ctx.awaiting_rom = False
ctx.auth = None
@ -780,7 +786,7 @@ async def server_loop(ctx: Context, address=None):
ctx.server = None
ctx.server_task = None
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)
asyncio.create_task(server_autoreconnect(ctx))
SERVER_RECONNECT_DELAY *= 2
@ -797,18 +803,18 @@ async def server_autoreconnect(ctx: Context):
async def process_server_cmd(ctx: Context, cmd, args):
if cmd == 'RoomInfo':
ctx.ui_node.log_info('--------------------------------')
ctx.ui_node.log_info('Room Information:')
ctx.ui_node.log_info('--------------------------------')
logger.info('--------------------------------')
logger.info('Room Information:')
logger.info('--------------------------------')
version = args["version"]
ctx.server_version = tuple(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:
ctx.ui_node.log_info("Server protocol tags: " + ", ".join(args["tags"]))
logger.info("Server protocol tags: " + ", ".join(args["tags"]))
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
logging.info(f"Forfeit setting: {args['forfeit_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.ui_node.send_game_info(ctx)
if len(args['players']) < 1:
ctx.ui_node.log_info('No player connected')
logger.info('No player connected')
else:
args['players'].sort()
current_team = -1
ctx.ui_node.log_info('Connected players:')
logger.info('Connected players:')
for team, slot, name in args['players']:
if team != current_team:
ctx.ui_node.log_info(f' Team #{team + 1}')
logger.info(f' Team #{team + 1}')
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'])
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')
#last to check, recoverable problem
elif 'InvalidPassword' in args:
ctx.ui_node.log_error('Invalid password')
logger.error('Invalid password')
ctx.password = None
await server_auth(ctx, True)
else:
@ -855,7 +861,7 @@ async def process_server_cmd(ctx: Context, cmd, args):
elif cmd == 'Connected':
if ctx.send_unsafe:
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)
ctx.team, ctx.slot = args[0]
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:
replacements = {0xA2: 'Small Key', 0x9D: 'Big Key', 0x8D: 'Compass', 0x7D: 'Map'}
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]}")
ctx.locations_info[location] = (item, player)
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}
elif cmd == 'Print':
ctx.ui_node.log_info(args)
logger.info(args)
elif cmd == 'HintPointUpdate':
ctx.hint_points = args[0]
else:
logging.debug(f"unknown command {args}")
logger.debug(f"unknown command {args}")
def get_tags(ctx: Context):
@ -963,11 +969,11 @@ def get_tags(ctx: Context):
async def server_auth(ctx: Context, password_requested):
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)
if ctx.rom is None:
ctx.awaiting_rom = True
ctx.ui_node.log_info(
logger.info(
'No ROM detected, awaiting snes connection to authenticate to the multiworld server (/snes)')
return
ctx.awaiting_rom = False
@ -997,7 +1003,7 @@ class ClientCommandProcessor(CommandProcessor):
self.ctx = ctx
def output(self, text: str):
self.ctx.ui_node.log_info(text)
logger.info(text)
def _cmd_exit(self) -> bool:
"""Close connections and client"""
@ -1034,7 +1040,7 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_received(self) -> bool:
"""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):
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,
@ -1086,7 +1092,7 @@ class ClientCommandProcessor(CommandProcessor):
self.ctx.found_items = toggle.lower() in {"1", "true", "on"}
else:
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)]]))
return True
@ -1097,7 +1103,7 @@ class ClientCommandProcessor(CommandProcessor):
else:
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):
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."""
if toggle:
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:
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
def default(self, raw: str):
@ -1144,7 +1150,7 @@ async def track_locations(ctx : Context, roomid, roomdata):
def new_check(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)
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:
new_check(location)
except Exception as e:
ctx.ui_node.log_info(f"Exception: {e}")
logger.exception(f"Exception: {e}")
uw_begin = 0x129
uw_end = 0
@ -1215,7 +1221,7 @@ async def send_finished_game(ctx: Context):
await ctx.send_msgs([['GameFinished', '']])
ctx.finished_game = True
except Exception as ex:
logging.exception(ex)
logger.exception(ex)
async def game_watcher(ctx : Context):
@ -1245,7 +1251,7 @@ async def game_watcher(ctx : Context):
await server_auth(ctx, False)
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()
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:
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 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 = []
if "items" in plando_options:
default_placement = PlandoItem(item="", location="")
def add_plando_item(item: str, location: str):
if item not in item_table:
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", [])
for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)):
from_pool = get_choice("from_pool", placement, default_placement.from_pool)
location_world = get_choice("world", placement, default_placement.world)
force = get_choice("force", placement, default_placement.force)
from_pool = get_choice("from_pool", placement, PlandoItem._field_defaults["from_pool"])
location_world = get_choice("world", placement, PlandoItem._field_defaults["world"])
force = str(get_choice("force", placement, PlandoItem._field_defaults["force"])).lower()
if "items" in placement and "locations" in placement:
items = placement["items"]
locations = placement["locations"]

View File

@ -13,6 +13,7 @@ class Node:
def __init__(self):
self.endpoints = []
super(Node, self).__init__()
def broadcast_all(self, 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
- Instructions are separated by a semicolon
- Available Instructions:
- Direct Placement:
- Example: "Eastern Palace-Trinexx"
- 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"
- Boss Placement:
- Example: "Trinexx"
- 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.
- Boss Shuffle:
- Example: "simple"
- Runs a particular boss shuffle mode to finish construction instead of vanilla placement, typically used as a last instruction.
- Direct Placement:
- Example: "Eastern Palace-Trinexx"
- 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"
- Boss Placement:
- Example: "Trinexx"
- 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.
- Boss Shuffle:
- Example: "simple"
- 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 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
### 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.
- Has the options "text", "at" and "percentage"
- percentage is the percentage chance for this text to be placed, can be omitted entirely for 100%
- text is the text to be placed.
- can be weighted.
- \n is a newline.
- @ is the entered player's name.
- Warning: Text Mapper does not support full unicode.
- [Alphabet](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Text.py#L756)
- text is the text to be placed.
- can be weighted.
- \n is a newline.
- @ is the entered player's name.
- Warning: Text Mapper does not support full unicode.
- [Alphabet](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Text.py#L756)
- at is the location within the game to attach the text to.
- can be weighted.
- [List of targets](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Text.py#L1498)
- can be weighted.
- [List of targets](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Text.py#L1498)
#### Example
```yaml
@ -75,3 +138,32 @@ plando_texts:
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
"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 logging
import json
import typing
import socket
import socketserver
import threading
@ -12,36 +13,20 @@ from NetUtils import Node
from MultiClient import Context
import Utils
logger = logging.getLogger("WebUIRelay")
class WebUiClient(Node):
class WebUiClient(Node, logging.Handler):
loader = staticmethod(json.loads)
dumper = staticmethod(json.dumps)
def __init__(self):
super().__init__()
super(WebUiClient, self).__init__()
self.manual_snes = None
@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}
def log_info(self, message, *args, **kwargs):
self.broadcast_all(self.build_message('info', message))
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 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))

View File

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

View File

@ -2251,7 +2251,10 @@ def plando_connect(world, player: int):
if world.plando_connections[player]:
for connection in world.plando_connections[player]:
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)',

View File

@ -452,6 +452,7 @@ def copy_world(world):
ret.timer = world.timer.copy()
ret.shufflepots = world.shufflepots.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.restrict_dungeon_item_on_boss = world.restrict_dungeon_item_on_boss.copy()