Archipelago/CommonClient.py

738 lines
28 KiB
Python
Raw Normal View History

2021-04-01 09:40:58 +00:00
from __future__ import annotations
import logging
import asyncio
import urllib.parse
2021-08-20 20:31:17 +00:00
import sys
2021-10-25 07:58:08 +00:00
import typing
2021-11-01 18:37:47 +00:00
import time
2021-04-01 09:40:58 +00:00
import ModuleUpdate
ModuleUpdate.update()
2021-04-01 09:40:58 +00:00
import websockets
import Utils
2021-11-10 14:35:43 +00:00
if __name__ == "__main__":
2021-11-17 21:46:32 +00:00
Utils.init_logging("TextClient", exception_logger="Client")
2021-11-10 14:35:43 +00:00
2021-04-01 09:40:58 +00:00
from MultiServer import CommandProcessor
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot
2021-11-28 03:06:30 +00:00
from Utils import Version, stream_input
from worlds import network_data_package, AutoWorldRegister
import os
2021-04-01 09:40:58 +00:00
logger = logging.getLogger("Client")
2022-05-21 20:24:49 +00:00
# without terminal, we have to use gui mode
2021-11-09 11:53:05 +00:00
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
2021-08-04 16:38:49 +00:00
2021-04-01 09:40:58 +00:00
class ClientCommandProcessor(CommandProcessor):
def __init__(self, ctx: CommonContext):
self.ctx = ctx
def output(self, text: str):
logger.info(text)
def _cmd_exit(self) -> bool:
"""Close connections and client"""
self.ctx.exit_event.set()
return True
def _cmd_connect(self, address: str = "") -> bool:
"""Connect to a MultiWorld Server"""
self.ctx.server_address = None
asyncio.create_task(self.ctx.connect(address if address else None), name="connecting")
2021-04-01 09:40:58 +00:00
return True
def _cmd_disconnect(self) -> bool:
"""Disconnect from a MultiWorld Server"""
self.ctx.server_address = None
asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
2021-04-01 09:40:58 +00:00
return True
def _cmd_received(self) -> bool:
"""List all received items"""
2021-06-21 00:14:25 +00:00
logger.info(f'{len(self.ctx.items_received)} received items:')
2021-04-01 09:40:58 +00:00
for index, item in enumerate(self.ctx.items_received, 1):
2021-06-21 00:14:25 +00:00
self.output(f"{self.ctx.item_name_getter(item.item)} from {self.ctx.player_names[item.player]}")
2021-04-01 09:40:58 +00:00
return True
def _cmd_missing(self) -> bool:
"""List all missing location checks, from your local game state"""
if not self.ctx.game:
self.output("No game set, cannot determine missing checks.")
2021-11-09 11:53:05 +00:00
return False
2021-04-01 09:40:58 +00:00
count = 0
checked_count = 0
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
2021-04-01 09:40:58 +00:00
if location_id < 0:
continue
if location_id not in self.ctx.locations_checked:
if location_id in self.ctx.missing_locations:
self.output('Missing: ' + location)
count += 1
elif location_id in self.ctx.checked_locations:
self.output('Checked: ' + location)
count += 1
checked_count += 1
if count:
self.output(
f"Found {count} missing location checks{f'. {checked_count} location checks previously visited.' if checked_count else ''}")
else:
self.output("No missing location checks found.")
return True
def _cmd_items(self):
"""List all item names for the currently running game."""
self.output(f"Item Names for {self.ctx.game}")
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
self.output(item_name)
def _cmd_locations(self):
"""List all location names for the currently running game."""
self.output(f"Location Names for {self.ctx.game}")
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
self.output(location_name)
2021-04-01 09:40:58 +00:00
def _cmd_ready(self):
2021-12-03 06:04:17 +00:00
"""Send ready status to server."""
2021-04-01 09:40:58 +00:00
self.ctx.ready = not self.ctx.ready
if self.ctx.ready:
state = ClientStatus.CLIENT_READY
self.output("Readied up.")
else:
state = ClientStatus.CLIENT_CONNECTED
self.output("Unreadied.")
asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
2021-04-01 09:40:58 +00:00
def default(self, raw: str):
2021-11-29 20:35:06 +00:00
raw = self.ctx.on_user_say(raw)
if raw:
asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
2021-04-01 09:40:58 +00:00
2021-08-04 16:38:49 +00:00
class CommonContext:
# Should be adjusted as needed in subclasses
tags: typing.Set[str] = {"AP"}
game: typing.Optional[str] = None
items_handling: typing.Optional[int] = None
# defaults
starting_reconnect_delay: int = 5
current_reconnect_delay: int = starting_reconnect_delay
command_processor: type(CommandProcessor) = ClientCommandProcessor
ui = None
ui_task: typing.Optional[asyncio.Task] = None
input_task: typing.Optional[asyncio.Task] = None
keep_alive_task: typing.Optional[asyncio.Task] = None
server_task: typing.Optional[asyncio.Task] = None
server: typing.Optional[Endpoint] = None
server_version: Version = Version(0, 0, 0)
2022-05-21 20:24:49 +00:00
current_energy_link_value: int = 0 # to display in UI, gets set by server
last_death_link: float = time.time() # last send/received death link on AP layer
# remaining type info
slot_info: typing.Dict[int, NetworkSlot]
server_address: str
password: typing.Optional[str]
hint_cost: typing.Optional[int]
games: typing.Dict[int, str]
player_names: typing.Dict[int, str]
# locations
locations_checked: typing.Set[int] # local state
locations_scouted: typing.Set[int]
missing_locations: typing.Set[int]
checked_locations: typing.Set[int] # server state
locations_info: typing.Dict[int, NetworkItem]
# current message box through kvui
2022-06-06 22:15:08 +00:00
_messagebox = None
def __init__(self, server_address, password):
2021-04-01 09:40:58 +00:00
# server state
self.server_address = server_address
self.password = password
self.hint_cost = None
self.games = {}
self.slot_info = {}
self.permissions = {
"forfeit": "disabled",
"collect": "disabled",
"remaining": "disabled",
}
2021-04-01 09:40:58 +00:00
# own state
self.finished_game = False
self.ready = False
self.team = None
self.slot = None
self.auth = None
self.seed_name = None
2021-04-01 09:40:58 +00:00
self.locations_checked = set() # local state
self.locations_scouted = set()
2021-04-01 09:40:58 +00:00
self.items_received = []
self.missing_locations = set()
self.checked_locations = set() # server state
self.locations_info = {}
2021-04-01 09:40:58 +00:00
self.input_queue = asyncio.Queue()
self.input_requests = 0
# game state
self.player_names = {0: "Archipelago"}
2021-04-01 09:40:58 +00:00
self.exit_event = asyncio.Event()
self.watcher_event = asyncio.Event()
self.jsontotextparser = JSONtoTextParser(self)
self.set_getters(network_data_package)
# execution
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
@property
def total_locations(self) -> typing.Optional[int]:
"""Will return None until connected."""
if self.checked_locations or self.missing_locations:
return len(self.checked_locations | self.missing_locations)
2021-04-01 09:40:58 +00:00
async def connection_closed(self):
2022-05-21 20:24:49 +00:00
self.reset_server_state()
if self.server and self.server.socket is not None:
await self.server.socket.close()
def reset_server_state(self):
2021-04-01 09:40:58 +00:00
self.auth = None
self.slot = None
self.team = None
2021-04-01 09:40:58 +00:00
self.items_received = []
self.locations_info = {}
self.server_version = Version(0, 0, 0)
self.server = None
self.server_task = None
2022-05-21 20:24:49 +00:00
self.games = {}
self.hint_cost = None
self.permissions = {
"forfeit": "disabled",
"collect": "disabled",
"remaining": "disabled",
}
2021-04-01 09:40:58 +00:00
2021-11-04 07:57:27 +00:00
# noinspection PyAttributeOutsideInit
2021-04-01 09:40:58 +00:00
def set_getters(self, data_package: dict, network=False):
if not network: # local data; check if newer data was already downloaded
local_package = Utils.persistent_load().get("datapackage", {}).get("latest", {})
if local_package and local_package["version"] > network_data_package["version"]:
data_package: dict = local_package
elif network: # check if data from server is newer
if data_package["version"] > network_data_package["version"]:
Utils.persistent_store("datapackage", "latest", network_data_package)
item_lookup: dict = {}
locations_lookup: dict = {}
for game, gamedata in data_package["games"].items():
for item_name, item_id in gamedata["item_name_to_id"].items():
item_lookup[item_id] = item_name
for location_name, location_id in gamedata["location_name_to_id"].items():
locations_lookup[location_id] = location_name
2021-04-01 09:40:58 +00:00
def get_item_name_from_id(code: int) -> str:
2021-04-01 09:40:58 +00:00
return item_lookup.get(code, f'Unknown item (ID:{code})')
self.item_name_getter = get_item_name_from_id
def get_location_name_from_address(address: int) -> str:
2021-04-01 09:40:58 +00:00
return locations_lookup.get(address, f'Unknown location (ID:{address})')
self.location_name_getter = get_location_name_from_address
async def disconnect(self):
if self.server and not self.server.socket.closed:
await self.server.socket.close()
if self.server_task is not None:
await self.server_task
async def send_msgs(self, msgs):
if not self.server or not self.server.socket.open or self.server.socket.closed:
return
await self.server.socket.send(encode(msgs))
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}
2021-05-11 21:08:50 +00:00
self.player_names[0] = "Archipelago"
2021-04-01 09:40:58 +00:00
def event_invalid_slot(self):
raise Exception('Invalid Slot; please verify that you have connected to the correct world.')
def event_invalid_game(self):
raise Exception('Invalid Game; please verify that you connected with the right game to the correct world.')
async def server_auth(self, password_requested: bool = False):
2021-04-01 09:40:58 +00:00
if password_requested and not self.password:
logger.info('Enter the password required to join this game:')
self.password = await self.console_input()
return self.password
async def send_connect(self, **kwargs):
payload = {
'cmd': 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
'tags': self.tags, 'items_handling': self.items_handling,
'uuid': Utils.get_unique_identifier(), 'game': self.game
}
if kwargs:
payload.update(kwargs)
await self.send_msgs([payload])
2021-04-01 09:40:58 +00:00
async def console_input(self):
self.input_requests += 1
return await self.input_queue.get()
2021-08-04 16:38:49 +00:00
async def connect(self, address=None):
2021-04-01 09:40:58 +00:00
await self.disconnect()
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
2021-04-01 09:40:58 +00:00
def slot_concerns_self(self, slot) -> bool:
if slot == self.slot:
return True
if slot in self.slot_info:
return self.slot in self.slot_info[slot].group_members
return False
2021-04-13 12:49:32 +00:00
def on_print(self, args: dict):
logger.info(args["text"])
def on_print_json(self, args: dict):
2021-07-30 23:53:06 +00:00
if self.ui:
self.ui.print_json(args["data"])
else:
text = self.jsontotextparser(args["data"])
logger.info(text)
2021-04-13 12:49:32 +00:00
def on_package(self, cmd: str, args: dict):
"""For custom package handling in subclasses."""
pass
2021-11-29 20:35:06 +00:00
def on_user_say(self, text: str) -> typing.Optional[str]:
"""Gets called before sending a Say to the server from the user.
Returned text is sent, or sending is aborted if None is returned."""
return text
def update_permissions(self, permissions: typing.Dict[str, int]):
for permission_name, permission_flag in permissions.items():
try:
flag = Permission(permission_flag)
logger.info(f"{permission_name.capitalize()} permission: {flag.name}")
self.permissions[permission_name] = flag.name
except Exception as e: # safeguard against permissions that may be implemented in the future
logger.exception(e)
2021-11-29 20:35:06 +00:00
async def shutdown(self):
self.server_address = None
if self.server and not self.server.socket.closed:
await self.server.socket.close()
if self.server_task:
await self.server_task
while self.input_requests > 0:
self.input_queue.put_nowait(None)
self.input_requests -= 1
self.keep_alive_task.cancel()
if self.ui_task:
await self.ui_task
if self.input_task:
self.input_task.cancel()
2021-11-29 20:35:06 +00:00
# DeathLink hooks
2021-11-01 18:37:47 +00:00
def on_deathlink(self, data: dict):
"""Gets dispatched when a new DeathLink is triggered by another linked player."""
self.last_death_link = max(data["time"], self.last_death_link)
text = data.get("cause", "")
if text:
logger.info(f"DeathLink: {text}")
else:
logger.info(f"DeathLink: Received from {data['source']}")
2021-11-01 18:37:47 +00:00
async def send_death(self, death_text: str = ""):
2022-02-24 05:17:39 +00:00
if self.server and self.server.socket:
logger.info("DeathLink: Sending death to your friends...")
self.last_death_link = time.time()
await self.send_msgs([{
"cmd": "Bounce", "tags": ["DeathLink"],
"data": {
"time": self.last_death_link,
"source": self.player_names[self.slot],
"cause": death_text
}
}])
2021-11-01 18:37:47 +00:00
async def update_death_link(self, death_link: bool):
old_tags = self.tags.copy()
if death_link:
self.tags.add("DeathLink")
else:
self.tags -= {"DeathLink"}
if old_tags != self.tags and self.server and not self.server.socket.closed:
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
2022-06-06 22:15:08 +00:00
def gui_error(self, title: str, text: typing.Union[Exception, str]):
"""Displays an error messagebox"""
if not self.ui:
return
title = title or "Error"
from kvui import MessageBox
if self._messagebox:
self._messagebox.dismiss()
# make "Multiple exceptions" look nice
text = str(text).replace('[Errno', '\n[Errno').strip()
# split long messages into title and text
parts = title.split('. ', 1)
if len(parts) == 1:
parts = title.split(', ', 1)
if len(parts) > 1:
text = parts[1] + '\n\n' + text
title = parts[0]
# display error
self._messagebox = MessageBox(title, text, error=True)
self._messagebox.open()
def run_gui(self):
"""Import kivy UI system and start running it as self.ui_task."""
from kvui import GameManager
class TextManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Text Client"
self.ui = TextManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def run_cli(self):
if sys.stdin:
# steam overlay breaks when starting console_loop
if 'gameoverlayrenderer' in os.environ.get('LD_PRELOAD', ''):
logger.info("Skipping terminal input, due to conflicting Steam Overlay detected. Please use GUI only.")
else:
self.input_task = asyncio.create_task(console_loop(self), name="Input")
2021-04-01 09:40:58 +00:00
async def keep_alive(ctx: CommonContext, seconds_between_checks=100):
"""some ISPs/network configurations drop TCP connections if no payload is sent (ignore TCP-keep-alive)
so we send a payload to prevent drop and if we were dropped anyway this will cause an auto-reconnect."""
seconds_elapsed = 0
while not ctx.exit_event.is_set():
await asyncio.sleep(1) # short sleep to not block program shutdown
if ctx.server and ctx.slot:
seconds_elapsed += 1
if seconds_elapsed > seconds_between_checks:
await ctx.send_msgs([{"cmd": "Bounce", "slots": [ctx.slot]}])
seconds_elapsed = 0
2021-04-01 09:40:58 +00:00
async def server_loop(ctx: CommonContext, address=None):
if ctx.server and ctx.server.socket:
logger.error('Already connected')
return
if address is None: # set through CLI or APBP
address = ctx.server_address
# Wait for the user to provide a multiworld server address
if not address:
logger.info('Please connect to an Archipelago server.')
return
address = f"ws://{address}" if "://" not in address else address
port = urllib.parse.urlparse(address).port or 38281
logger.info(f'Connecting to Archipelago server at {address}')
try:
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
ctx.server = Endpoint(socket)
logger.info('Connected')
ctx.server_address = address
ctx.current_reconnect_delay = ctx.starting_reconnect_delay
async for data in ctx.server.socket:
for msg in decode(data):
await process_server_cmd(ctx, msg)
logger.warning('Disconnected from multiworld server, type /connect to reconnect')
2022-06-06 22:15:08 +00:00
except ConnectionRefusedError as e:
msg = 'Connection refused by the server. May not be running Archipelago on that address or port.'
logger.exception(msg, extra={'compact_gui': True})
ctx.gui_error(msg, e)
except websockets.InvalidURI as e:
msg = 'Failed to connect to the multiworld server (invalid URI)'
logger.exception(msg, extra={'compact_gui': True})
ctx.gui_error(msg, e)
except OSError as e:
msg = 'Failed to connect to the multiworld server'
logger.exception(msg, extra={'compact_gui': True})
ctx.gui_error(msg, e)
except Exception as e:
msg = 'Lost connection to the multiworld server, type /connect to reconnect'
logger.exception(msg, extra={'compact_gui': True})
ctx.gui_error(msg, e)
2021-04-01 09:40:58 +00:00
finally:
await ctx.connection_closed()
if ctx.server_address:
logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s")
asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
2021-04-01 09:40:58 +00:00
ctx.current_reconnect_delay *= 2
async def server_autoreconnect(ctx: CommonContext):
await asyncio.sleep(ctx.current_reconnect_delay)
if ctx.server_address and ctx.server_task is None:
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
2021-04-01 09:40:58 +00:00
async def process_server_cmd(ctx: CommonContext, args: dict):
try:
cmd = args["cmd"]
except:
logger.exception(f"Could not get command from {args}")
raise
if cmd == 'RoomInfo':
if ctx.seed_name and ctx.seed_name != args["seed_name"]:
2022-06-06 22:15:08 +00:00
msg = "The server is running a different multiworld than your client is. (invalid seed_name)"
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
2021-04-01 09:40:58 +00:00
else:
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')
ctx.update_permissions(args.get("permissions", {}))
if "games" in args:
ctx.games = {x: game for x, game in enumerate(args["games"], start=1)}
2021-08-04 16:38:49 +00:00
logger.info(
f"A !hint costs {args['hint_cost']}% of your total location count as points"
f" 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'])
if len(args['players']) < 1:
logger.info('No player connected')
else:
args['players'].sort()
current_team = -1
2022-02-23 23:51:31 +00:00
logger.info('Connected 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'])
2021-04-01 09:40:58 +00:00
elif cmd == 'DataPackage':
logger.info("Got new ID/Name Datapackage")
ctx.set_getters(args['data'], network=True)
elif cmd == 'ConnectionRefused':
errors = args["errors"]
if 'InvalidSlot' in errors:
ctx.event_invalid_slot()
elif 'InvalidGame' in errors:
ctx.event_invalid_game()
2021-04-01 09:40:58 +00:00
elif 'IncompatibleVersion' in errors:
raise Exception('Server reported your client version as incompatible')
elif 'InvalidItemsHandling' in errors:
raise Exception('The item handling flags requested by the client are not supported')
2021-04-01 09:40:58 +00:00
# last to check, recoverable problem
elif 'InvalidPassword' in errors:
logger.error('Invalid password')
ctx.password = None
await ctx.server_auth(True)
2021-06-25 05:25:03 +00:00
elif errors:
2021-04-01 09:40:58 +00:00
raise Exception("Unknown connection errors: " + str(errors))
2021-06-25 05:25:03 +00:00
else:
raise Exception('Connection refused by the multiworld host, no reason provided')
2021-04-01 09:40:58 +00:00
elif cmd == 'Connected':
ctx.team = args["team"]
ctx.slot = args["slot"]
# int keys get lost in JSON transfer
ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
2021-04-01 09:40:58 +00:00
ctx.consume_players_package(args["players"])
msgs = []
if ctx.locations_checked:
msgs.append({"cmd": "LocationChecks",
"locations": list(ctx.locations_checked)})
if ctx.locations_scouted:
msgs.append({"cmd": "LocationScouts",
"locations": list(ctx.locations_scouted)})
if msgs:
await ctx.send_msgs(msgs)
if ctx.finished_game:
2021-04-03 18:02:15 +00:00
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
2021-04-01 09:40:58 +00:00
# Get the server side view of missing as of time of connecting.
# This list is used to only send to the server what is reported as ACTUALLY Missing.
# This also serves to allow an easy visual of what locations were already checked previously
# when /missing is used for the client side view of what is missing.
ctx.missing_locations = set(args["missing_locations"])
ctx.checked_locations = set(args["checked_locations"])
2021-04-01 09:40:58 +00:00
elif cmd == 'ReceivedItems':
start_index = args["index"]
if start_index == 0:
ctx.items_received = []
elif start_index != len(ctx.items_received):
sync_msg = [{'cmd': 'Sync'}]
if ctx.locations_checked:
sync_msg.append({"cmd": "LocationChecks",
"locations": list(ctx.locations_checked)})
await ctx.send_msgs(sync_msg)
if start_index == len(ctx.items_received):
for item in args['items']:
ctx.items_received.append(NetworkItem(*item))
ctx.watcher_event.set()
elif cmd == 'LocationInfo':
2022-03-07 19:21:29 +00:00
for item in [NetworkItem(*item) for item in args['locations']]:
ctx.locations_info[item.location] = item
2021-04-01 09:40:58 +00:00
ctx.watcher_event.set()
elif cmd == "RoomUpdate":
if "players" in args:
ctx.consume_players_package(args["players"])
if "hint_points" in args:
ctx.hint_points = args['hint_points']
if "checked_locations" in args:
checked = set(args["checked_locations"])
ctx.checked_locations |= checked
ctx.missing_locations -= checked
if "permissions" in args:
ctx.update_permissions(args["permissions"])
2021-04-01 09:40:58 +00:00
elif cmd == 'Print':
2021-04-13 12:49:32 +00:00
ctx.on_print(args)
2021-04-01 09:40:58 +00:00
elif cmd == 'PrintJSON':
2021-04-13 12:49:32 +00:00
ctx.on_print_json(args)
2021-04-01 09:40:58 +00:00
elif cmd == 'InvalidPacket':
logger.warning(f"Invalid Packet of {args['type']}: {args['text']}")
2021-04-01 09:40:58 +00:00
2021-08-01 23:35:24 +00:00
elif cmd == "Bounced":
2021-11-01 18:37:47 +00:00
tags = args.get("tags", [])
2021-11-02 10:11:57 +00:00
# we can skip checking "DeathLink" in ctx.tags, as otherwise we wouldn't have been send this
2021-11-01 18:37:47 +00:00
if "DeathLink" in tags and ctx.last_death_link != args["data"]["time"]:
ctx.on_deathlink(args["data"])
2022-02-24 03:47:01 +00:00
elif cmd == "SetReply":
if args["key"] == "EnergyLink":
ctx.current_energy_link_value = args["value"]
if ctx.ui:
ctx.ui.set_new_energy_link_value()
2021-04-01 09:40:58 +00:00
else:
logger.debug(f"unknown command {cmd}")
ctx.on_package(cmd, args)
2021-04-01 09:40:58 +00:00
async def console_loop(ctx: CommonContext):
commandprocessor = ctx.command_processor(ctx)
queue = asyncio.Queue()
stream_input(sys.stdin, queue)
2021-04-01 09:40:58 +00:00
while not ctx.exit_event.is_set():
try:
input_text = await queue.get()
2021-11-28 03:06:30 +00:00
queue.task_done()
2021-04-01 09:40:58 +00:00
if ctx.input_requests > 0:
ctx.input_requests -= 1
ctx.input_queue.put_nowait(input_text)
continue
if input_text:
commandprocessor(input_text)
except Exception as e:
logger.exception(e)
2021-11-09 11:53:05 +00:00
def get_base_parser(description=None):
import argparse
parser = argparse.ArgumentParser(description=description)
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
parser.add_argument('--password', default=None, help='Password of the multiworld host.')
if sys.stdout: # If terminal output exists, offer gui-less mode
parser.add_argument('--nogui', default=False, action='store_true', help="Turns off Client GUI.")
return parser
if __name__ == '__main__':
# Text Mode to use !hint and such with games that have no text entry
class TextContext(CommonContext):
tags = {"AP", "IgnoreGame", "TextOnly"}
game = "" # empty matches any game since 0.3.2
items_handling = 0 # don't receive any NetworkItems
2021-10-25 07:58:08 +00:00
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(TextContext, self).server_auth(password_requested)
if not self.auth:
logger.info('Enter slot name:')
self.auth = await self.console_input()
await self.send_connect()
def on_package(self, cmd: str, args: dict):
if cmd == "Connected":
self.game = self.games.get(self.slot, None)
2021-10-25 07:58:08 +00:00
async def main(args):
ctx = TextContext(args.connect, args.password)
ctx.auth = args.name
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
await ctx.exit_event.wait()
await ctx.shutdown()
import colorama
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
parser.add_argument('--name', default=None, help="Slot Name to connect as.")
parser.add_argument("url", nargs="?", help="Archipelago connection url")
args = parser.parse_args()
if args.url:
url = urllib.parse.urlparse(args.url)
args.connect = url.netloc
if url.username:
args.name = urllib.parse.unquote(url.username)
if url.password:
args.password = urllib.parse.unquote(url.password)
colorama.init()
asyncio.run(main(args))
colorama.deinit()