@@ -45,10 +45,21 @@ def get_ssl_context():
45
45
46
46
47
47
class ClientCommandProcessor (CommandProcessor ):
48
+ """
49
+ The Command Processor will parse every method of the class that starts with "_cmd_" as a command to be called
50
+ when parsing user input, i.e. _cmd_exit will be called when the user sends the command "/exit".
51
+
52
+ The decorator @mark_raw can be imported from MultiServer and tells the parser to only split on the first
53
+ space after the command i.e. "/exit one two three" will be passed in as method("one two three") with mark_raw
54
+ and method("one", "two", "three") without.
55
+
56
+ In addition all docstrings for command methods will be displayed to the user on launch and when using "/help"
57
+ """
48
58
def __init__ (self , ctx : CommonContext ):
49
59
self .ctx = ctx
50
60
51
61
def output (self , text : str ):
62
+ """Helper function to abstract logging to the CommonClient UI"""
52
63
logger .info (text )
53
64
54
65
def _cmd_exit (self ) -> bool :
@@ -61,6 +72,7 @@ def _cmd_connect(self, address: str = "") -> bool:
61
72
if address :
62
73
self .ctx .server_address = None
63
74
self .ctx .username = None
75
+ self .ctx .password = None
64
76
elif not self .ctx .server_address :
65
77
self .output ("Please specify an address." )
66
78
return False
@@ -163,13 +175,14 @@ def _cmd_ready(self):
163
175
async_start (self .ctx .send_msgs ([{"cmd" : "StatusUpdate" , "status" : state }]), name = "send StatusUpdate" )
164
176
165
177
def default (self , raw : str ):
178
+ """The default message parser to be used when parsing any messages that do not match a command"""
166
179
raw = self .ctx .on_user_say (raw )
167
180
if raw :
168
181
async_start (self .ctx .send_msgs ([{"cmd" : "Say" , "text" : raw }]), name = "send Say" )
169
182
170
183
171
184
class CommonContext :
172
- # Should be adjusted as needed in subclasses
185
+ # The following attributes are used to Connect and should be adjusted as needed in subclasses
173
186
tags : typing .Set [str ] = {"AP" }
174
187
game : typing .Optional [str ] = None
175
188
items_handling : typing .Optional [int ] = None
@@ -251,7 +264,7 @@ def update_game(self, game: str, name_to_id_lookup_table: typing.Dict[str, int])
251
264
starting_reconnect_delay : int = 5
252
265
current_reconnect_delay : int = starting_reconnect_delay
253
266
command_processor : typing .Type [CommandProcessor ] = ClientCommandProcessor
254
- ui = None
267
+ ui : typing . Optional [ "kvui.GameManager" ] = None
255
268
ui_task : typing .Optional ["asyncio.Task[None]" ] = None
256
269
input_task : typing .Optional ["asyncio.Task[None]" ] = None
257
270
keep_alive_task : typing .Optional ["asyncio.Task[None]" ] = None
@@ -342,6 +355,8 @@ def __init__(self, server_address: typing.Optional[str] = None, password: typing
342
355
343
356
self .item_names = self .NameLookupDict (self , "item" )
344
357
self .location_names = self .NameLookupDict (self , "location" )
358
+ self .versions = {}
359
+ self .checksums = {}
345
360
346
361
self .jsontotextparser = JSONtoTextParser (self )
347
362
self .rawjsontotextparser = RawJSONtoTextParser (self )
@@ -428,7 +443,10 @@ async def get_username(self):
428
443
self .auth = await self .console_input ()
429
444
430
445
async def send_connect (self , ** kwargs : typing .Any ) -> None :
431
- """ send `Connect` packet to log in to server """
446
+ """
447
+ Send a `Connect` packet to log in to the server,
448
+ additional keyword args can override any value in the connection packet
449
+ """
432
450
payload = {
433
451
'cmd' : 'Connect' ,
434
452
'password' : self .password , 'name' : self .auth , 'version' : Utils .version_tuple ,
@@ -438,6 +456,7 @@ async def send_connect(self, **kwargs: typing.Any) -> None:
438
456
if kwargs :
439
457
payload .update (kwargs )
440
458
await self .send_msgs ([payload ])
459
+ await self .send_msgs ([{"cmd" : "Get" , "keys" : ["_read_race_mode" ]}])
441
460
442
461
async def console_input (self ) -> str :
443
462
if self .ui :
@@ -458,13 +477,15 @@ def cancel_autoreconnect(self) -> bool:
458
477
return False
459
478
460
479
def slot_concerns_self (self , slot ) -> bool :
480
+ """Helper function to abstract player groups, should be used instead of checking slot == self.slot directly."""
461
481
if slot == self .slot :
462
482
return True
463
483
if slot in self .slot_info :
464
484
return self .slot in self .slot_info [slot ].group_members
465
485
return False
466
486
467
487
def is_echoed_chat (self , print_json_packet : dict ) -> bool :
488
+ """Helper function for filtering out messages sent by self."""
468
489
return print_json_packet .get ("type" , "" ) == "Chat" \
469
490
and print_json_packet .get ("team" , None ) == self .team \
470
491
and print_json_packet .get ("slot" , None ) == self .slot
@@ -496,13 +517,14 @@ def on_user_say(self, text: str) -> typing.Optional[str]:
496
517
"""Gets called before sending a Say to the server from the user.
497
518
Returned text is sent, or sending is aborted if None is returned."""
498
519
return text
499
-
520
+
500
521
def on_ui_command (self , text : str ) -> None :
501
522
"""Gets called by kivy when the user executes a command starting with `/` or `!`.
502
523
The command processor is still called; this is just intended for command echoing."""
503
524
self .ui .print_json ([{"text" : text , "type" : "color" , "color" : "orange" }])
504
525
505
526
def update_permissions (self , permissions : typing .Dict [str , int ]):
527
+ """Internal method to parse and save server permissions from RoomInfo"""
506
528
for permission_name , permission_flag in permissions .items ():
507
529
try :
508
530
flag = Permission (permission_flag )
@@ -514,6 +536,7 @@ def update_permissions(self, permissions: typing.Dict[str, int]):
514
536
async def shutdown (self ):
515
537
self .server_address = ""
516
538
self .username = None
539
+ self .password = None
517
540
self .cancel_autoreconnect ()
518
541
if self .server and not self .server .socket .closed :
519
542
await self .server .socket .close ()
@@ -550,26 +573,34 @@ async def prepare_data_package(self, relevant_games: typing.Set[str],
550
573
needed_updates .add (game )
551
574
continue
552
575
553
- local_version : int = network_data_package ["games" ].get (game , {}).get ("version" , 0 )
554
- local_checksum : typing .Optional [str ] = network_data_package ["games" ].get (game , {}).get ("checksum" )
555
- # no action required if local version is new enough
556
- if (not remote_checksum and (remote_version > local_version or remote_version == 0 )) \
557
- or remote_checksum != local_checksum :
558
- cached_game = Utils .load_data_package_for_checksum (game , remote_checksum )
559
- cache_version : int = cached_game .get ("version" , 0 )
560
- cache_checksum : typing .Optional [str ] = cached_game .get ("checksum" )
561
- # download remote version if cache is not new enough
562
- if (not remote_checksum and (remote_version > cache_version or remote_version == 0 )) \
563
- or remote_checksum != cache_checksum :
564
- needed_updates .add (game )
576
+ cached_version : int = self .versions .get (game , 0 )
577
+ cached_checksum : typing .Optional [str ] = self .checksums .get (game )
578
+ # no action required if cached version is new enough
579
+ if (not remote_checksum and (remote_version > cached_version or remote_version == 0 )) \
580
+ or remote_checksum != cached_checksum :
581
+ local_version : int = network_data_package ["games" ].get (game , {}).get ("version" , 0 )
582
+ local_checksum : typing .Optional [str ] = network_data_package ["games" ].get (game , {}).get ("checksum" )
583
+ if ((remote_checksum or remote_version <= local_version and remote_version != 0 )
584
+ and remote_checksum == local_checksum ):
585
+ self .update_game (network_data_package ["games" ][game ], game )
565
586
else :
566
- self .update_game (cached_game , game )
587
+ cached_game = Utils .load_data_package_for_checksum (game , remote_checksum )
588
+ cache_version : int = cached_game .get ("version" , 0 )
589
+ cache_checksum : typing .Optional [str ] = cached_game .get ("checksum" )
590
+ # download remote version if cache is not new enough
591
+ if (not remote_checksum and (remote_version > cache_version or remote_version == 0 )) \
592
+ or remote_checksum != cache_checksum :
593
+ needed_updates .add (game )
594
+ else :
595
+ self .update_game (cached_game , game )
567
596
if needed_updates :
568
597
await self .send_msgs ([{"cmd" : "GetDataPackage" , "games" : [game_name ]} for game_name in needed_updates ])
569
598
570
599
def update_game (self , game_package : dict , game : str ):
571
600
self .item_names .update_game (game , game_package ["item_name_to_id" ])
572
601
self .location_names .update_game (game , game_package ["location_name_to_id" ])
602
+ self .versions [game ] = game_package .get ("version" , 0 )
603
+ self .checksums [game ] = game_package .get ("checksum" )
573
604
574
605
def update_data_package (self , data_package : dict ):
575
606
for game , game_data in data_package ["games" ].items ():
@@ -611,6 +642,7 @@ def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
611
642
logger .info (f"DeathLink: Received from { data ['source' ]} " )
612
643
613
644
async def send_death (self , death_text : str = "" ):
645
+ """Helper function to send a deathlink using death_text as the unique death cause string."""
614
646
if self .server and self .server .socket :
615
647
logger .info ("DeathLink: Sending death to your friends..." )
616
648
self .last_death_link = time .time ()
@@ -624,6 +656,7 @@ async def send_death(self, death_text: str = ""):
624
656
}])
625
657
626
658
async def update_death_link (self , death_link : bool ):
659
+ """Helper function to set Death Link connection tag on/off and update the connection if already connected."""
627
660
old_tags = self .tags .copy ()
628
661
if death_link :
629
662
self .tags .add ("DeathLink" )
@@ -633,7 +666,7 @@ async def update_death_link(self, death_link: bool):
633
666
await self .send_msgs ([{"cmd" : "ConnectUpdate" , "tags" : self .tags }])
634
667
635
668
def gui_error (self , title : str , text : typing .Union [Exception , str ]) -> typing .Optional ["kvui.MessageBox" ]:
636
- """Displays an error messagebox"""
669
+ """Displays an error messagebox in the loaded Kivy UI. Override if using a different UI framework """
637
670
if not self .ui :
638
671
return None
639
672
title = title or "Error"
@@ -660,17 +693,19 @@ def handle_connection_loss(self, msg: str) -> None:
660
693
logger .exception (msg , exc_info = exc_info , extra = {'compact_gui' : True })
661
694
self ._messagebox_connection_loss = self .gui_error (msg , exc_info [1 ])
662
695
663
- def run_gui (self ):
664
- """Import kivy UI system and start running it as self.ui_task. """
696
+ def make_gui (self ) -> typing . Type [ "kvui.GameManager" ] :
697
+ """To return the Kivy App class needed for run_gui so it can be overridden before being built """
665
698
from kvui import GameManager
666
699
667
700
class TextManager (GameManager ):
668
- logging_pairs = [
669
- ("Client" , "Archipelago" )
670
- ]
671
701
base_title = "Archipelago Text Client"
672
702
673
- self .ui = TextManager (self )
703
+ return TextManager
704
+
705
+ def run_gui (self ):
706
+ """Import kivy UI system from make_gui() and start running it as self.ui_task."""
707
+ ui_class = self .make_gui ()
708
+ self .ui = ui_class (self )
674
709
self .ui_task = asyncio .create_task (self .ui .async_run (), name = "UI" )
675
710
676
711
def run_cli (self ):
@@ -983,6 +1018,7 @@ async def console_loop(ctx: CommonContext):
983
1018
984
1019
985
1020
def get_base_parser (description : typing .Optional [str ] = None ):
1021
+ """Base argument parser to be reused for components subclassing off of CommonClient"""
986
1022
import argparse
987
1023
parser = argparse .ArgumentParser (description = description )
988
1024
parser .add_argument ('--connect' , default = None , help = 'Address of the multiworld host.' )
@@ -992,7 +1028,7 @@ def get_base_parser(description: typing.Optional[str] = None):
992
1028
return parser
993
1029
994
1030
995
- def run_as_textclient ():
1031
+ def run_as_textclient (* args ):
996
1032
class TextContext (CommonContext ):
997
1033
# Text Mode to use !hint and such with games that have no text entry
998
1034
tags = CommonContext .tags | {"TextOnly" }
@@ -1031,16 +1067,21 @@ async def main(args):
1031
1067
parser = get_base_parser (description = "Gameless Archipelago Client, for text interfacing." )
1032
1068
parser .add_argument ('--name' , default = None , help = "Slot Name to connect as." )
1033
1069
parser .add_argument ("url" , nargs = "?" , help = "Archipelago connection url" )
1034
- args = parser .parse_args ()
1070
+ args = parser .parse_args (args )
1035
1071
1072
+ # handle if text client is launched using the "archipelago://name:pass@host:port" url from webhost
1036
1073
if args .url :
1037
1074
url = urllib .parse .urlparse (args .url )
1038
- args .connect = url .netloc
1039
- if url .username :
1040
- args .name = urllib .parse .unquote (url .username )
1041
- if url .password :
1042
- args .password = urllib .parse .unquote (url .password )
1075
+ if url .scheme == "archipelago" :
1076
+ args .connect = url .netloc
1077
+ if url .username :
1078
+ args .name = urllib .parse .unquote (url .username )
1079
+ if url .password :
1080
+ args .password = urllib .parse .unquote (url .password )
1081
+ else :
1082
+ parser .error (f"bad url, found { args .url } , expected url in form of archipelago://archipelago.gg:38281" )
1043
1083
1084
+ # use colorama to display colored text highlighting on windows
1044
1085
colorama .init ()
1045
1086
1046
1087
asyncio .run (main (args ))
@@ -1049,4 +1090,4 @@ async def main(args):
1049
1090
1050
1091
if __name__ == '__main__' :
1051
1092
logging .getLogger ().setLevel (logging .INFO ) # force log-level to work around log level resetting to WARNING
1052
- run_as_textclient ()
1093
+ run_as_textclient (* sys . argv [ 1 :]) # default value for parse_args
0 commit comments