diff --git a/CHANGELOG.md b/CHANGELOG.md index e677fad42..c628b31c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -779,3 +779,17 @@ ### 220723a (4.3.3-post2) * Fixed /zone_ambient not allowing tracks with spaces + +### 220821a (4.3.4) +* Added /sneakself, which sneaks all of your active multiclients that are not currently sneaked but can be sneaked +* GMs and zone watchers now see thoughts in IC if the thinker is in the same area +* Added /mindreader, which allows GMs to let mind readers to see players using /think just like GMs now do +* GMs are no longer subject to the duplicate showname checks +* Players using Danganronpa Online 1.2.2 now properly adjust to new character lists if the server is refreshed and the character list is changed +* Added an extra parameter to /clock_period, allowing GMs to set the hour length of all hours within a period +* Added an explicit /clock_period_end to delete a clock period, rather than relying on an undocumented 1-parameter call to /clock_period +* Ding sound effects are no longer included with "Something catches your attention" IC messages, except those that are triggered because an area was marked as noteworthy +* Fixed area validator not properly handling areas with empty names or backgrounds +* Fixed typo in area_templates.yaml field +* Fixed /think messages not showing the last sent sprite +* Fixed IC-via-OOC commands sending an empty position if the sender never spoke IC before sending the command diff --git a/README.md b/README.md index 92b7c64d9..857fe8ac8 100644 --- a/README.md +++ b/README.md @@ -52,22 +52,22 @@ It is highly recommended you read through all the installation steps first befor * If everything was set up correctly, you will see something like this appear: ``` -[2022-07-17T10:20:20]: Starting... -[2022-07-17T10:20:20]: Launching TsuserverDR 4.3.3 (220717a)... -[2022-07-17T10:20:20]: Loading server configurations... -[2022-07-17T10:20:20]: Server configurations loaded successfully! -[2022-07-17T10:20:20]: Starting a nonlocal server... -[2022-07-17T10:20:20]: Server started successfully! -[2022-07-17T10:20:21]: Server should be now accessible from 192.0.2.0:50000:My First DR Server +[2022-08-21T10:20:20]: Starting... +[2022-08-21T10:20:20]: Launching TsuserverDR 4.3.4 (220821a)... +[2022-08-21T10:20:20]: Loading server configurations... +[2022-08-21T10:20:20]: Server configurations loaded successfully! +[2022-08-21T10:20:20]: Starting a nonlocal server... +[2022-08-21T10:20:20]: Server started successfully! +[2022-08-21T10:20:21]: Server should be now accessible from 192.0.2.0:50000:My First DR Server ``` * If you are listing your server in the Attorney Online master server, make sure its details are set up correctly. In particular, make sure that your server name and description are correct, as that is how players will find your server. If everything was set up correctly, you will see something like this appear: ``` -[2022-07-17T10:20:21]: Attempting to connect to the master server at https://servers.aceattorneyonline.com/servers with the following details: -[2022-07-17T10:20:21]: *Server name: My First DR Server -[2022-07-17T10:20:21]: *Server description: This is my flashy new DR server -[2022-07-17T10:20:22]: Connected to the master server. +[2022-08-21T10:20:21]: Attempting to connect to the master server at https://servers.aceattorneyonline.com/servers with the following details: +[2022-08-21T10:20:21]: *Server name: My First DR Server +[2022-08-21T10:20:21]: *Server description: This is my flashy new DR server +[2022-08-21T10:20:22]: Connected to the master server. ``` - The server will make a single ping to [ipify](https://api.ipify.org) in order to obtain its public IP address. If it fails to do that, it will let you know that, as it means there is probably something wrong with your internet connection and that other players may not be able to connect to your server. @@ -76,9 +76,9 @@ It is highly recommended you read through all the installation steps first befor * To stop the server, press Ctrl+C once from your terminal. This will initiate a shutdown sequence and notify you when it is done. If the shutdown finished successfully, you will see something like this appear: ``` -[2022-07-17T22:23:04]: You have initiated a server shut down. -[2022-07-17T22:23:04]: Kicking 12 remaining clients. -[2022-07-17T22:23:04]: Server has successfully shut down. +[2022-08-21T22:23:04]: You have initiated a server shut down. +[2022-08-21T22:23:04]: Kicking 12 remaining clients. +[2022-08-21T22:23:04]: Server has successfully shut down. ``` * If you do not see anything after a few seconds of starting a shutdown, you can try spamming Ctrl+C to try and force a shutdown or directly close out your terminal. This is not recommended due to the cleanup process not finishing correctly but it is doable. @@ -369,16 +369,19 @@ GMs can: * **charlog** "ID" - Lists all character changes (including iniswaps and character name changes) a target has gone through since connecting, including the time they were changed. * **clock** "area range start" "area range end" "hour length" "hour start" "hours in a day" - - Sets up a day cycle that, starting from the given hour, will tick one hour every given number of seconds and provide a time announcement to a given range of areas. + - Sets up a day cycle that, starting from the given hour, will tick one hour every given number of seconds (main hour length) and provide a time announcement to a given range of areas. - Hours go from 0 inclusive to the number of hours in a day given exclusive, or 0 to 23 inclusive if not given a number of hours in a day. * **clock_end** "ID" - Ends the day cycle initiated by the target or yourself if not given a target. * **clock_pause** "ID" - Pauses the day cycle initiated by the target or yourself if not given a target. -* **clock_period** "name" "hour start" +* **clock_period** "name" "hour length" "hour start" - Initializes a clock period that starts at the given hour for your day cycle. - Whenever the clock ticks into the period, players in the clock range will be ordered to switch to that time of day's version of their theme. + - If only two arguments are given, the second argument is assumed to be hour start, and hour length is the main hour length. - Clock period names are automatically made all lowercase. +* **clock_period_end** "name" + - Deletes a clock period for your day cycle. * **clock_set** "hour length" "hour" - Modifies the hour length and current hour of your day cycle without restarting it. This is the way to move the day cycle out of unknown time if needed as well. - Acts just like doing /clock again, but does not erase already set periods. @@ -446,6 +449,8 @@ GMs can: - Ends the area's lurk callout timer if there is one active. * **make_gm** "ID" - Makes the target a GM, provided the target is a multiclient of the player. +* **mindreader** "ID" + - Changes a player's ability to see thoughts of other players made with /think. By default it is off. * **multiclients** "ID" - Lists all the clients opened by a target and the areas they are in. * **notecard_check** @@ -541,6 +546,8 @@ GMs can: - Sets a target to be sneaking if they were visible. - If the target was subject to a handicap shorter than the server's automatic sneak handicap length, they will be imposed this handicap. - If no ID is given, target is yourself. +* **sneakself** + - Sneaks all opened multiclients that can be sneaked. * **st** "message" - Sends a message to all active staff members. * **status_set_other** "ID/char name/edited-to character/showname/char showname/OOC name" "status" diff --git a/config_sample/area_templates.yaml b/config_sample/area_templates.yaml index ac8719af9..2b54fb2d0 100644 --- a/config_sample/area_templates.yaml +++ b/config_sample/area_templates.yaml @@ -17,7 +17,7 @@ locking_allowed: true iniswap_allowed: true rp_getarea_allowed: false -- template_name: AO_Courtroom +- template_name: AO_Courtroom area: Courtroom No. background: gs4 bglock: true @@ -28,5 +28,5 @@ - template_name: OOC_Stuff area: OOC background: Principal's Room_HD - eivdence_mod: HiddenCM - iniswap_allowed: true \ No newline at end of file + evidence_mod: HiddenCM + iniswap_allowed: true diff --git a/server/area_manager.py b/server/area_manager.py index 4203209ce..27a49b525 100644 --- a/server/area_manager.py +++ b/server/area_manager.py @@ -242,7 +242,8 @@ def broadcast_ooc(self, msg: str): for client in self.clients: client.send_ooc(msg) - def broadcast_ic_attention(self, cond: Callable[[ClientManager.Client], bool] = None): + def broadcast_ic_attention(self, cond: Callable[[ClientManager.Client], bool] = None, + ding: bool = True): """ Send an IC message with a ding to everyone in the area indicating something catches their attention, *except* if the player is blind or deaf, or if the area is a lobby @@ -253,6 +254,8 @@ def broadcast_ic_attention(self, cond: Callable[[ClientManager.Client], bool] = cond : types.LambdaType: ClientManager.Client -> bool, optional Custom condition each player in the area must also satisfy to receive the attention message. + ding : bool, optional + If the accompanying IC message should also include the "ding" effect. Returns ------- @@ -264,13 +267,13 @@ def broadcast_ic_attention(self, cond: Callable[[ClientManager.Client], bool] = return if cond is None: - cond = lambda client: True + cond = lambda _: True for player in self.clients: if player.is_deaf and player.is_blind: continue if cond(player): - player.send_ic_attention() + player.send_ic_attention(ding=ding) def get_background_tod(self) -> Dict[str, str]: if not self.lights: @@ -643,10 +646,10 @@ def change_lights(self, new_lights: bool, initiator: ClientManager.Client = None party.check_lights() for c in self.clients: - found_something = c.area_changer.notify_me_rp(self, changed_visibility=True, - changed_hearing=False) + found_something, ding_something = c.area_changer.notify_me_rp( + self, changed_visibility=True, changed_hearing=False) if found_something and new_lights: - c.send_ic_attention() + c.send_ic_attention(ding=ding_something) def set_next_msg_delay(self, msg_length: int): """ diff --git a/server/client_changearea.py b/server/client_changearea.py index 7772e4484..203972a83 100644 --- a/server/client_changearea.py +++ b/server/client_changearea.py @@ -111,7 +111,7 @@ def check_change_area(self, area: AreaManager.Area, def notify_change_area(self, area: AreaManager.Area, old_dname: str, ignore_bleeding: bool = False, ignore_autopass: bool = False, - just_me: bool = False) -> bool: + just_me: bool = False) -> Tuple[bool, bool]: """ Send all OOC notifications that come from switching areas. Right now there is @@ -133,20 +133,23 @@ def notify_change_area(self, area: AreaManager.Area, old_dname: str, If just_me is True, no notifications are sent to other players in the area. - Returns True if any RP related notifications are sent to the player who changed areas, - False otherwise. - + Returns a tuple of two bool arguments: + 0. True if any RP related notifications are sent to the player who changed areas, False + otherwise. + 1. True if such RP related notifications to be sent should include a "ding" effect, False + otherwise. """ - found_something = self.notify_me(area, old_dname, ignore_bleeding=ignore_bleeding) + found_something, ding_something = self.notify_me(area, old_dname, + ignore_bleeding=ignore_bleeding) if not just_me: self.notify_others(area, old_dname, ignore_bleeding=ignore_bleeding, ignore_autopass=ignore_autopass) - return found_something + return found_something, ding_something def notify_me(self, area: AreaManager.Area, old_dname: str, - ignore_bleeding: bool = False) -> bool: + ignore_bleeding: bool = False) -> Tuple[bool, bool]: client = self.client # Code here assumes successful area change, so it will be sending client notifications @@ -217,11 +220,11 @@ def notify_me(self, area: AreaManager.Area, old_dname: str, area.bleeds_to.add(old_area.name) client.send_ooc('You are bleeding.') - found_something = self.notify_me_rp(area) - return found_something + found_something, ding_something = self.notify_me_rp(area) + return found_something, ding_something def notify_me_rp(self, area: AreaManager.Area, changed_visibility: bool = True, - changed_hearing: bool = True) -> bool: + changed_hearing: bool = True) -> Tuple[bool, bool]: ########### # Check bleeding status blood = self.notify_me_blood(area, changed_visibility=changed_visibility, @@ -238,7 +241,7 @@ def notify_me_rp(self, area: AreaManager.Area, changed_visibility: bool = True, changed_visibility=changed_visibility, changed_hearing=changed_hearing) - return blood or statuses or area_noteworthy + return blood or statuses or area_noteworthy, area_noteworthy def notify_me_blood(self, area: AreaManager.Area, changed_visibility: bool = True, changed_hearing: bool = True) -> bool: @@ -485,7 +488,7 @@ def notify_others(self, area: AreaManager.Area, old_dname: str, status_refreshed_clients = list() # Do not IC ping if client has no status area.broadcast_ic_attention(cond=lambda c: (ic_attention_others or - c in status_refreshed_clients)) + c in status_refreshed_clients), ding=False) def notify_others_moving(self, client: ClientManager.Client, area: AreaManager.Area, autopass_mes: str, blind_mes: str): @@ -777,9 +780,9 @@ def change_area(self, area: AreaManager.Area, override_all: bool = False, # .format(client.get_char_name(), old_area.name, old_area.id, # old_area.name, old_area.id), client) - found_something = client.notify_change_area(area, old_dname, - ignore_bleeding=ignore_bleeding, - ignore_autopass=ignore_autopass) + found_something, ding_something = client.notify_change_area( + area, old_dname, ignore_bleeding=ignore_bleeding, + ignore_autopass=ignore_autopass) old_area.publisher.publish('area_client_left', { 'client': client, @@ -799,7 +802,9 @@ def change_area(self, area: AreaManager.Area, override_all: bool = False, client.area = area client.new_area = area # Update again, as the above if may not have run area.new_client(client) - self.post_area_changed(old_area, area, found_something=found_something, + self.post_area_changed(old_area, area, + found_something=found_something, + ding_something=ding_something, old_dname=old_dname, override_all=override_all, override_passages=override_passages, override_effects=override_effects, @@ -813,7 +818,9 @@ def change_area(self, area: AreaManager.Area, override_all: bool = False, from_party=from_party) def post_area_changed(self, old_area: Union[None, AreaManager.Area], area: AreaManager.Area, - found_something: bool = False, old_dname: str = '', + found_something: bool = False, + ding_something: bool = False, + old_dname: str = '', override_all: bool = False, override_passages: bool = False, override_effects: bool = False, ignore_bleeding: bool = False, ignore_followers: bool = False, @@ -834,11 +841,13 @@ def post_area_changed(self, old_area: Union[None, AreaManager.Area], area: AreaM client.send_health(side=1, health=client.area.hp_def) client.send_health(side=2, health=client.area.hp_pro) + new_area_clock_period = area.get_clock_period() if old_area: old_area_clock_period = old_area.get_clock_period() - new_area_clock_period = area.get_clock_period() if old_area_clock_period != new_area_clock_period: client.send_time_of_day(name=new_area_clock_period) + else: + client.send_time_of_day(name=new_area_clock_period) if client.is_blind: client.send_background(name=client.server.config['blackout_background']) @@ -852,7 +861,7 @@ def post_area_changed(self, old_area: Union[None, AreaManager.Area], area: AreaM client.send_ic_blankpost() if found_something: - client.send_ic_attention() + client.send_ic_attention(ding=ding_something) client.reload_music_list() # Update music list to include new area's reachable areas # If new area has lurk callout timer, reset it to that, provided it makes sense diff --git a/server/client_manager.py b/server/client_manager.py index 9b18d9a26..b11359045 100644 --- a/server/client_manager.py +++ b/server/client_manager.py @@ -19,7 +19,7 @@ from __future__ import annotations import typing -from typing import Any, Callable, List, Optional, Set, Tuple, Dict +from typing import Any, Callable, List, Optional, Set, Tuple, Dict, Union if typing.TYPE_CHECKING: # Avoid circular referencing from server.area_manager import AreaManager @@ -51,7 +51,7 @@ def __init__(self, server: TsuserverDR, transport, user_id: int, ipid: int, self.required_packets_received = set() # Needs to have length 2 to actually connect self.can_askchaa = True # Needs to be true to process an askchaa packet self.version = ('Undefined', 'Undefined') # AO version used established through ID pack - self.packet_handler = clients.ClientDRO1d2d0() + self.packet_handler = clients.ClientDRO1d2d2() self.bad_version = False self.publisher = Publisher(self) @@ -63,12 +63,15 @@ def __init__(self, server: TsuserverDR, transport, user_id: int, ipid: int, self.name = '' self.char_folder = '' self.char_showname = '' - self.pos = '' + self.pos = 'wit' self.showname = '' - self.ever_chose_character = False self.joined = time.time() self.last_active = Constants.get_time() + self.ever_chose_character = False + self.ever_outbounded_gamemode = False + self.ever_outbounded_time_of_day = False + self.area = server.area_manager.default_area() self.new_area = self.area # It is different from self.area in transition to a new area self.party = None @@ -128,6 +131,7 @@ def __init__(self, server: TsuserverDR, transport, user_id: int, ipid: int, self.ignored_players = set() self.paranoia = 2 self.notecard = '' + self.is_mindreader = False # Pairing stuff self.charid_pair = -1 @@ -252,14 +256,31 @@ def send_ooc_others(self, msg: str, username: str = None, allow_empty: bool = Fa self.server.make_all_clients_do("send_ooc", msg, pred=cond, allow_empty=allow_empty, username=username) - def send_ic(self, params: List = None, sender: ClientManager.Client = None, + def send_ic(self, + params: List = None, + sender: ClientManager.Client = None, + bypass_text_replace: bool = False, + bypass_deafened_starters: bool = False, + use_last_received_sprites: bool = False, + gag_replaced: bool = False, pred: Callable[[ClientManager.Client], bool] = None, - not_to: ClientManager.Client = None, gag_replaced=False, - is_staff=None, in_area=None, to_blind=None, to_deaf=None, - bypass_text_replace=False, bypass_deafened_starters=False, - use_last_received_sprites=False, - msg=None, folder=None, pos=None, char_id=None, ding=None, color=None, - showname=None, hide_character=0): + not_to: Set[ClientManager.Client] = None, + part_of: Set[ClientManager.Client] = None, + is_staff: bool = None, + is_officer: bool = None, + is_zstaff: bool = None, + is_zstaff_flex: bool = None, + in_area: bool = None, + to_blind: bool = None, + to_deaf: bool = None, + msg=None, + folder=None, + pos=None, + char_id=None, + ding=None, + color=None, + showname=None, + hide_character=0): # sender is the client who sent the IC message # self is who is receiving the IC message at this particular moment @@ -268,6 +289,9 @@ def send_ic(self, params: List = None, sender: ClientManager.Client = None, if params is None and msg is None: raise ValueError('Expected message.') + if not_to is None: + not_to = set() + # Fill in defaults # Expected behavior is as follows: # If params is None, then the sent IC message will only include custom details @@ -295,8 +319,11 @@ def send_ic(self, params: List = None, sender: ClientManager.Client = None, # Check if receiver is actually meant to receive the message. Bail out early if not. # FIXME: First argument should be sender, not self. Using in_area=True fails otherwise - cond = Constants.build_cond(self, is_staff=is_staff, in_area=in_area, not_to=not_to, - to_blind=to_blind, to_deaf=to_deaf, pred=pred) + cond = Constants.build_cond(self, is_staff=is_staff, is_officer=is_officer, + in_area=in_area, not_to=not_to, + part_of=part_of, to_blind=to_blind, to_deaf=to_deaf, + is_zstaff=is_zstaff, is_zstaff_flex=is_zstaff_flex, + pred=pred) if not cond(self): return # If self is ignoring sender, now is the moment to discard @@ -400,6 +427,7 @@ def pop_if_there(dictionary, argument): pargs['pos'] = last_args['pos'] pargs['anim_type'] = last_args['anim_type'] pargs['flip'] = last_args['flip'] + pargs['hide_character'] = last_args['hide_character'] # Regardless of anything, pairing is visually canceled while in first person # so set them to default values @@ -516,29 +544,61 @@ def pop_if_there(dictionary, argument): self.send_command_dict('MS', final_pargs) - def send_ic_others(self, params: List = None, sender: ClientManager.Client=None, + def send_ic_others(self, + params: List = None, + sender: ClientManager.Client = None, bypass_text_replace: bool = False, bypass_deafened_starters: bool = False, - pred: Callable[[ClientManager.Client], bool] = None, not_to=None, - gag_replaced=False, is_staff=None, in_area=None, to_blind=None, - to_deaf=None, msg=None, folder=None, pos=None, char_id=None, ding=None, - color=None, showname=None, hide_character=0): + use_last_received_sprites: bool = False, + gag_replaced: bool = False, + pred: Callable[[ClientManager.Client], bool] = None, + not_to: Set[ClientManager.Client] = None, + part_of: Set[ClientManager.Client] = None, + is_staff: bool = None, + is_officer: bool = None, + is_zstaff: bool = None, + is_zstaff_flex: bool = None, + in_area: bool = None, + to_blind: bool = None, + to_deaf: bool = None, + msg=None, + folder=None, + pos=None, + char_id=None, + ding=None, + color=None, + showname=None, + hide_character=0): if not_to is None: not_to = {self} else: not_to = not_to.union({self}) - for c in self.server.get_clients(): - c.send_ic(params=None, sender=sender, bypass_text_replace=bypass_text_replace, - bypass_deafened_starters=bypass_deafened_starters, - pred=pred, not_to=not_to, gag_replaced=gag_replaced, is_staff=is_staff, - in_area=in_area, to_blind=to_blind, to_deaf=to_deaf, - msg=msg, folder=folder, pos=pos, char_id=char_id, ding=ding, color=color, - showname=showname, hide_character=hide_character) - - def send_ic_attention(self): - self.send_ic(msg='(Something catches your attention)', ding=1, hide_character=1) + cond = Constants.build_cond(self, is_staff=is_staff, is_officer=is_officer, + in_area=in_area, not_to=not_to.union({self}), + part_of=part_of, to_blind=to_blind, to_deaf=to_deaf, + is_zstaff=is_zstaff, is_zstaff_flex=is_zstaff_flex, + pred=pred) + self.server.make_all_clients_do("send_ic", pred=cond, + params=params, + sender=sender, + bypass_text_replace=bypass_text_replace, + bypass_deafened_starters=bypass_deafened_starters, + use_last_received_sprites=use_last_received_sprites, + gag_replaced=gag_replaced, + msg=msg, + folder=folder, + pos=pos, + char_id=char_id, + ding=ding, + color=color, + showname=showname, + hide_character=hide_character) + + def send_ic_attention(self, ding: bool = True): + int_ding = 1 if ding else 0 + self.send_ic(msg='(Something catches your attention)', ding=int_ding, hide_character=1) def send_ic_blankpost(self): if self.packet_handler.ALLOWS_INVISIBLE_BLANKPOSTS: @@ -623,11 +683,13 @@ def send_clock(self, client_id=None, hour=None): }) def send_gamemode(self, name=None): + self.ever_outbounded_gamemode = True self.send_command_dict('GM', { 'name': name, }) def send_time_of_day(self, name=None): + self.ever_outbounded_time_of_day = True self.send_command_dict('TOD', { 'name': name, }) @@ -777,7 +839,7 @@ def change_character(self, char_id: int, force: bool = False, # Assumes players are not iniswapped initially, waiting for chrini packet self.char_folder = self.get_char_name() self.char_showname = '' - self.pos = '' + self.pos = 'wit' if announce_zwatch: self.send_ooc_others('(X) Client {} has changed from character `{}` to `{}` in ' @@ -890,7 +952,7 @@ def check_change_area(self, area: AreaManager.Area, def notify_change_area(self, area: AreaManager.Area, old_char: str, ignore_bleeding: bool = False, ignore_autopass: bool = False, - just_me: bool = False) -> bool: + just_me: bool = False) -> Tuple[bool, bool]: return self.area_changer.notify_change_area( area, old_char, ignore_bleeding=ignore_bleeding, ignore_autopass=ignore_autopass, just_me=just_me) @@ -920,16 +982,20 @@ def change_area(self, area: AreaManager.Area, override_all: bool = False, more_unavail_chars=more_unavail_chars, from_party=from_party) def post_area_changed(self, old_area: Union[None, AreaManager.Area], area: AreaManager.Area, - found_something: bool = False, old_dname: str = '', - override_all: bool = False, - override_passages: bool = False, override_effects: bool = False, - ignore_bleeding: bool = False, ignore_followers: bool = False, - ignore_autopass: bool = False, - ignore_checks: bool = False, ignore_notifications: bool = False, - more_unavail_chars: Set[int] = None, change_to: int = None, - from_party: bool = False): + found_something: bool = False, + ding_something: bool = False, + old_dname: str = '', + override_all: bool = False, + override_passages: bool = False, override_effects: bool = False, + ignore_bleeding: bool = False, ignore_followers: bool = False, + ignore_autopass: bool = False, + ignore_checks: bool = False, ignore_notifications: bool = False, + more_unavail_chars: Set[int] = None, change_to: int = None, + from_party: bool = False): self.area_changer.post_area_changed( - old_area, area, found_something=found_something, + old_area, area, + found_something=found_something, + ding_something=ding_something, old_dname=old_dname, override_all=override_all, override_passages=override_passages, override_effects=override_effects, @@ -952,22 +1018,21 @@ def change_blindness(self, blind: bool): self.send_background(name=self.area.background, tod_backgrounds=self.area.get_background_tod()) - found_something = self.area_changer.notify_me_rp(self.area, changed_visibility=changed, - changed_hearing=False) + found_something, ding_something = self.area_changer.notify_me_rp( + self.area, changed_visibility=changed, changed_hearing=False) if found_something and not blind: - self.send_ic_attention() + self.send_ic_attention(ding=ding_something) def change_deafened(self, deaf: bool): changed = (self.is_deaf != deaf) self.is_deaf = deaf - found_something = self.area_changer.notify_me_rp(self.area, changed_visibility=False, - changed_hearing=changed) + found_something, ding_something = self.area_changer.notify_me_rp( + self.area, changed_visibility=False, changed_hearing=changed) if found_something and not deaf: - self.send_ic_attention() + self.send_ic_attention(ding=ding_something) - def change_gagged(self, gagged: bool ): - # changed = (self.is_gagged != gagged) + def change_gagged(self, gagged: bool): self.is_gagged = gagged def check_change_showname(self, showname: str, target_area: AreaManager.Area = None): @@ -985,14 +1050,15 @@ def check_change_showname(self, showname: str, target_area: AreaManager.Area = N raise ClientError("Showname `{}` exceeds the server's character limit of {}." .format(showname, self.server.config['showname_max_length'])) - # Check if showname is already used within area - for c in target_area.clients: - if c == self: - continue - if c.showname == showname or c.char_showname == showname: - raise ValueError("Showname `{}` is already in use in this area." - .format(showname)) - # This ValueError must be recaught, otherwise the client will crash. + # Check if showname is already used within area, and not GM + if not self.is_staff(): + for c in target_area.clients: + if c == self: + continue + if c.showname == showname or c.char_showname == showname: + raise ValueError("Showname `{}` is already in use in this area." + .format(showname)) + # This ValueError must be recaught, otherwise the client will crash. def change_character_ini_details(self, char_folder: str, char_showname: str): self.char_folder = char_folder @@ -1562,30 +1628,7 @@ def refresh_visible_char_list(self): def send_done(self): self.refresh_visible_char_list() self.post_area_changed(None, self.area) - """ - self.send_command_dict('HP', { - 'side': 1, - 'health': self.area.hp_def - }) - self.send_command_dict('HP', { - 'side': 2, - 'health': self.area.hp_pro - }) - if self.is_blind: - self.send_background(name=self.server.config['blackout_background']) - else: - self.send_background(name=self.area.background, - tod_backgrounds=self.area.get_background_tod()) - self.send_command_dict('LE', { - 'evidence_ao2_list': self.area.get_evidence_list(self), - }) - self.send_command_dict('MM', { - 'unknown': 1, - }) - self.send_command_dict('OPPASS', { - 'guard_pass': '', - }) - """ + if self.char_id is None: self.char_id = -1 # Set to a valid ID if still needed self.send_command_dict('DONE', dict()) @@ -1594,10 +1637,6 @@ def send_done(self): self.send_ooc(f'Unknown client detected {self.version}. ' f'Assuming standard DRO client protocol.') - if self.bad_version: - self.send_ooc(f'Unknown client detected {self.version}. ' - f'Assuming standard DRO client protocol.') - def char_select(self): # By running the change_character code, all checks and actions for switching to # spectator are made diff --git a/server/clients.py b/server/clients.py index e0507e47c..b365e7426 100644 --- a/server/clients.py +++ b/server/clients.py @@ -38,6 +38,7 @@ def __eq__(self, other): ALLOWS_CLEARING_MODIFIED_MESSAGE_FROM_SELF = True ALLOWS_INVISIBLE_BLANKPOSTS = True REPLACES_BASE_OPUS_FOR_MP3 = False + ALLOWS_CHAR_LIST_RELOAD = True DECRYPTOR_OUTBOUND = [ ('key', 34), # 0 @@ -336,12 +337,18 @@ def __eq__(self, other): JOINED_AREA_OUTBOUND = [ ] + +class ClientDRO1d2d2(DefaultDROProtocol): + VERSION_TO_SEND = [1, 2, 2] + class ClientDRO1d2d0(DefaultDROProtocol): VERSION_TO_SEND = [1, 2, 0] + ALLOWS_CHAR_LIST_RELOAD = False class ClientDRO1d1d0(DefaultDROProtocol): VERSION_TO_SEND = [1, 1, 0] HAS_JOINED_AREA = False + ALLOWS_CHAR_LIST_RELOAD = False class ClientDRO1d0d0(DefaultDROProtocol): VERSION_TO_SEND = [1, 0, 0] @@ -350,6 +357,7 @@ class ClientDRO1d0d0(DefaultDROProtocol): HAS_ACKMS = True HAS_JOINED_AREA = False REPLACES_BASE_OPUS_FOR_MP3 = True + ALLOWS_CHAR_LIST_RELOAD = False MS_INBOUND = [ ('msg_type', ArgType.STR), # 0 @@ -403,6 +411,7 @@ class ClientDROLegacy(DefaultDROProtocol): ALLOWS_CLEARING_MODIFIED_MESSAGE_FROM_SELF = False ALLOWS_INVISIBLE_BLANKPOSTS = False REPLACES_BASE_OPUS_FOR_MP3 = True + ALLOWS_CHAR_LIST_RELOAD = False MS_INBOUND = [ ('msg_type', ArgType.STR), # 0 @@ -464,6 +473,7 @@ class ClientAO2d6(DefaultDROProtocol): ALLOWS_CLEARING_MODIFIED_MESSAGE_FROM_SELF = False ALLOWS_INVISIBLE_BLANKPOSTS = False REPLACES_BASE_OPUS_FOR_MP3 = True + ALLOWS_CHAR_LIST_RELOAD = False MS_INBOUND = [ ('msg_type', ArgType.STR), # 0 @@ -538,6 +548,7 @@ class ClientAO2d7(DefaultDROProtocol): ALLOWS_CLEARING_MODIFIED_MESSAGE_FROM_SELF = False ALLOWS_INVISIBLE_BLANKPOSTS = False REPLACES_BASE_OPUS_FOR_MP3 = True + ALLOWS_CHAR_LIST_RELOAD = False MS_INBOUND = [ ('msg_type', ArgType.STR), # 0 @@ -624,6 +635,7 @@ class ClientAO2d8d4(DefaultDROProtocol): ALLOWS_CLEARING_MODIFIED_MESSAGE_FROM_SELF = False ALLOWS_INVISIBLE_BLANKPOSTS = False REPLACES_BASE_OPUS_FOR_MP3 = True + ALLOWS_CHAR_LIST_RELOAD = False MS_INBOUND = [ ('msg_type', ArgType.STR), # 0 @@ -715,6 +727,7 @@ class ClientAO2d9d0(DefaultDROProtocol): ALLOWS_CLEARING_MODIFIED_MESSAGE_FROM_SELF = False ALLOWS_INVISIBLE_BLANKPOSTS = False REPLACES_BASE_OPUS_FOR_MP3 = True + ALLOWS_CHAR_LIST_RELOAD = False ASKCHAA_INBOUND = [ ('ao290doesnotsupportpacketswithnoarguments', ArgType.STR_OR_EMPTY), # 0 @@ -830,6 +843,7 @@ class ClientAO2d10(ClientAO2d9d0): ALLOWS_CLEARING_MODIFIED_MESSAGE_FROM_SELF = False ALLOWS_INVISIBLE_BLANKPOSTS = False REPLACES_BASE_OPUS_FOR_MP3 = True + ALLOWS_CHAR_LIST_RELOAD = False ASKCHAA_INBOUND = [] diff --git a/server/commands.py b/server/commands.py index b4189cffd..1b6a0fc7c 100644 --- a/server/commands.py +++ b/server/commands.py @@ -1537,15 +1537,15 @@ def ooc_cmd_clock(client: ClientManager.Client, arg: str): Requires /clock_end to undo. Returns an error if the given hour start is not a nonnegative number or beyond the indicated number of hours in a day, if the number of hours in a day is not a positive integer, or if the - hour length is not a positive number. + hour length is not a positive integer. SYNTAX - /clock {hours_in_day} + /clock {hours_in_day} PARAMETERS : Send notifications from this area onwards up to... : Send notifications up to (and including) this area. - : Length of each ingame hour (in seconds) + : Main length of each ingame hour (in seconds) : Starting hour (integer from 0 to 23) OPTIONAL PARAMETERS @@ -1699,61 +1699,111 @@ def ooc_cmd_clock_pause(client: ClientManager.Client, arg: str): def ooc_cmd_clock_period(client: ClientManager.Client, arg: str): """ (STAFF ONLY) Adds a period to the day cycle you established. Whenever the day cycle clock ticks - into a time part of the period, all clients in the affected areas will be ordered to change - to that time of day's version of their theme. Time of day periods go from their given hour - start all the way until the next period. + into a time part of the period after a given hour length of seconds, all clients in the + affected areas will be ordered to change to that time of day's version of their theme. + Time of day periods go from their given hour start all the way until the next period. If the period name already exists, its hour start will be overwritten. + If the hour length is not given, it will use the main hour length of the day cycle. If some period already starts at the given hour start, its name will be overwritten. Returns an error if you have not started a day cycle, or if the hour start is not an integer - from 0 inclusive to the number of hours in a day set up for the day cycle exclusive. + from 0 inclusive to the number of hours in a day set up for the day cycle exclusive, or if given + an hour length and it is not a positive integer. SYNTAX /clock_period + /clock_period {hour_length} PARAMETERS : Name of the period. : Start time of the period. + OPTIONAL PARAMETERS + {hour_length}: Length of each ingame hour (in seconds). Defaults to the main hour length. + EXAMPLE Assuming the commands are run in order... >>> /clock_period day 8 Sets up a period that goes from 8 AM to 8 AM. - >>> /clock_period night 22 - Sets up a night period that goes from 10 PM to 8 AM. Day period now goes from 8 AM to 10 PM. + >>> /clock_period night 150 22 + Sets up a night period that goes from 10 PM to 8 AM, each hour in the period ticking every 150 + seconds. Day period now goes from 8 AM to 10 PM. """ - Constants.assert_command(client, arg, is_staff=True, parameters='&1-2') + Constants.assert_command(client, arg, is_staff=True, parameters='&2-3') try: task = client.server.tasker.get_task(client, ['as_day_cycle']) except KeyError: raise ClientError('You have not initiated any day cycles.') + args = arg.split() + hour_length = client.server.tasker.get_task_attr(client, ['as_day_cycle'], 'main_hour_length') + hours_in_day = client.server.tasker.get_task_attr(client, ['as_day_cycle'], 'hours_in_day') + + name = args[0].lower() + pre_hour_start = args[2] if len(args) == 3 else args[1] + pre_hour_length = args[1] if len(args) == 3 else str(hour_length) + try: - args = arg.split() - if len(args) == 1: - name, pre_start, start = args[0].lower(), "-1", -1 - else: - name, pre_start = args[0].lower(), args[1] - start = int(pre_start) # Do it separately so ValueError exception may read args[1] - hours_in_day = client.server.tasker.get_task_attr(client, ['as_day_cycle'], - 'hours_in_day') - if not 0 <= start < hours_in_day: - start = args[1] - raise ValueError + hour_start = int(pre_hour_start) + if not 0 <= hour_start < hours_in_day: + raise ValueError except ValueError: - raise ArgumentError('Invalid period start hour {}.'.format(pre_start)) + raise ArgumentError(f'Invalid period start hour {hour_start}.') - client.server.tasker.set_task_attr(client, ['as_day_cycle'], 'new_period_start', (start, name)) + try: + hour_length = int(pre_hour_length) + if hour_length <= 0: + raise ValueError + except ValueError: + raise ArgumentError(f'Invalid period hour length {hour_length}.') + + client.server.tasker.set_task_attr(client, ['as_day_cycle'], 'new_period_start', + (hour_start, name, hour_length)) + client.server.tasker.set_task_attr(client, ['as_day_cycle'], 'refresh_reason', 'period') + client.server.tasker.cancel_task(task) + + +def ooc_cmd_clock_period_end(client: ClientManager.Client, arg: str): + """ (STAFF ONLY) + Removes a previously created period to the day cycle you established. + If the removed period is the one currently active, the period becomes whatever the new period + should be using the remaining periods if there are any, or fully deactivated if there are no + other periods left. + Returns an error if you have not started a day cycle, or if the period does not exist. + + SYNTAX + /clock_period_end + + PARAMETERS + : Name of the period. + + EXAMPLE + Assuming the commands are run in order... + >>> /clock_period_end day + Removes the period called day. + """ + + Constants.assert_command(client, arg, is_staff=True, parameters='=1') + + try: + task = client.server.tasker.get_task(client, ['as_day_cycle']) + except KeyError: + raise ClientError('You have not initiated any day cycles.') + + client.server.tasker.set_task_attr(client, ['as_day_cycle'], 'new_period_start', + (-1, arg, 0)) client.server.tasker.set_task_attr(client, ['as_day_cycle'], 'refresh_reason', 'period') client.server.tasker.cancel_task(task) def ooc_cmd_clock_set(client: ClientManager.Client, arg: str): """ (STAFF ONLY) - Updates the hour length and current hour of the client's day cycle without restarting it, + Updates the main hour length and current hour of the client's day cycle without restarting it, changing its area range or notifying normal players. If the day cycle time was unknown, the time is updated in the same manner (effectively taking it out of unknown mode). + The hour length of any active periods are not modified and have priority over the main hour + length. Returns an error if you have not started a day cycle, or if the hour is not an integer from 0 inclusive to the number of hours in a day set up for the day cycle exclusive. @@ -2894,8 +2944,9 @@ def ooc_cmd_gmlock(client: ClientManager.Client, arg: str): def ooc_cmd_gmself(client: ClientManager.Client, arg: str): - """ (STAFF ONLY): + """ (STAFF ONLY) Makes all opened multiclients login as game master without them needing to put in a GM password. + Opened multiclients that are already logged in as game master are unaffected. Returns an error if all opened multiclients are already game masters. SYNTAX @@ -4760,7 +4811,7 @@ def ooc_cmd_noteworthy(client: ClientManager.Client, arg: str): if client.area.noteworthy: client.send_ooc_others('Something catches your attention.', is_zstaff_flex=False, in_area=True, pred=lambda c: not (c.is_deaf and c.is_blind)) - client.area.broadcast_ic_attention() + client.area.broadcast_ic_attention(ding=True) logger.log_server('[{}][{}]Set noteworthy status to {}' .format(client.area.id, client.get_char_name(), client.area.noteworthy), @@ -7876,7 +7927,7 @@ def ooc_cmd_status_set(client: ClientManager.Client, arg: str): c.send_ooc(f'You note something different about {client.displayname}.', is_zstaff_flex=False) - client.area.broadcast_ic_attention(cond=lambda c: c in refreshed_clients) + client.area.broadcast_ic_attention(cond=lambda c: c in refreshed_clients, ding=False) else: client.status = '' @@ -7939,7 +7990,7 @@ def ooc_cmd_status_set_other(client: ClientManager.Client, arg: str): c.send_ooc(f'You now note something about {target.displayname}.', is_zstaff_flex=False) - target.area.broadcast_ic_attention() + target.area.broadcast_ic_attention(ding=False) else: # By previous if, player must have had a status before @@ -7985,8 +8036,11 @@ def ooc_cmd_switch(client: ClientManager.Client, arg: str): def ooc_cmd_think(client: ClientManager.Client, arg: str): """ - Sends an IC message that only you (and zone watchers or GMs+ in OOC) can see. - You get to see the IC message with the last sprite you saw. + Sends an IC message that only you can see in IC (a thought), as well as any other players in + your area that are either zone watchers, GMs+ or mind readers. + Zone watchers, GMs+ or mind readers receive a copy of the thought in OOC, regardless of them + having seen the thought because they were in the area or not. + Players who see the thought in IC get to see it with the last sprite they saw. SYNTAX /think @@ -8004,12 +8058,27 @@ def ooc_cmd_think(client: ClientManager.Client, arg: str): msg = arg[:256] client.send_ic(msg=msg, pos=client.pos, folder=client.char_folder, char_id=client.char_id, - showname='[T] ' + client.showname_else_char_showname, hide_character=1, + showname='[T] ' + client.showname_else_char_showname, bypass_text_replace=True, use_last_received_sprites=True) client.send_ooc(f'You thought `{arg}`.') - client.send_ooc_others(f'{client.displayname} [{client.id}] thought `{arg}` ' + + client.send_ic_others(msg=msg, pos=client.pos, folder=client.char_folder, + char_id=client.char_id, + showname='[T] ' + client.showname_else_char_showname, + bypass_text_replace=True, use_last_received_sprites=True, + is_zstaff_flex=True, in_area=True) + client.send_ooc_others(f'(X) {client.displayname} [{client.id}] thought `{arg}` ' f'({client.area.id}).', is_zstaff_flex=True) + client.send_ic_others(msg=msg, pos=client.pos, folder=client.char_folder, + char_id=client.char_id, + showname='[T] ' + client.showname_else_char_showname, + bypass_text_replace=True, use_last_received_sprites=True, + is_zstaff_flex=False, in_area=True, pred=lambda c: c.is_mindreader) + client.send_ooc_others(f'(X) {client.displayname} [{client.id}] thought `{arg}` ' + f'({client.area.id}).', + is_zstaff_flex=False, pred=lambda c: c.is_mindreader) + def ooc_cmd_time(client: ClientManager.Client, arg: str): """ @@ -11226,6 +11295,93 @@ def ooc_cmd_zone_ambient_end(client: ClientManager.Client, arg: str): a.ambient = '' +def ooc_cmd_sneakself(client: ClientManager.Client, arg: str): + """ (STAFF ONLY+VARYING REQUIREMENTS) + Makes all opened multiclients be sneaked without having to manually sneak them. + Opened multiclients that are already sneaked are unaffected. + If a multiclient is in a private area, or in a lobby area and you are not an officer, or is + already sneaked, the sneak will fail for that multiclient. + Returns an error if no opened multiclients can successfully be sneaked. + + SYNTAX + /sneakself + + EXAMPLES + If user with client ID 0 is GM has multiclients with ID 1 and 3, neither sneaked, and runs... + >>> /sneakself + Sneaks clients 0, 1 and 3. + """ + + Constants.assert_command(client, arg, is_staff=True) + + targets = [c for c in client.get_multiclients() if c.is_visible] + targets = [c for c in targets if not c.area.private_area] + if not client.is_officer(): + targets = [c for c in targets if c.area.lobby_area] + if not targets: + raise ClientError('No opened clients can be sneaked.') + + # Sneak matching targets + for c in targets: + c.change_visibility(False) + + client.send_ooc("You sneaked all of your valid multiclients.") + client.send_ooc_others(f'(X) {client.displayname} [{client.id}] sneaked all their valid ' + f'multiclients [{client.id}] ({client.area.id}).', + not_to=set(targets), is_zstaff=True) + + non_targets = [c for c in client.get_multiclients() if c not in targets] + if non_targets: + s_non_targets = Constants.cjoin([f'{c.displayname} [{c.id}]' for c in non_targets]) + client.send_ooc(f'The following clients could not be sneaked: {s_non_targets}') + + +def ooc_cmd_mindreader(client: ClientManager.Client, arg: str): + """ (STAFF ONLY) + Toggles a client by ID being a mind reader or not (i.e. can read all thoughts caused by /think, + not just those initiated by the player), or yourself if not given an argument. + Returns an error if the given identifier does not correspond to a user. + + SYNTAX + /mindreader + /mindreader + + OPTIONAL PARAMETERS + {client_id}: Client identifier (number in brackets in /getarea) + + EXAMPLE + Assuming a user with client ID 0 starts as not being a mind reader... + >>> /mindreader 0 + This user can now read all thoughts. + >>> /mindreader 0 + This user can no longer read thoughts not initiated by the user. + """ + + Constants.assert_command(client, arg, is_staff=True, parameters='<2') + + # Invert current mindreader status of matching targets + if not arg: + target = client + else: + target = Constants.parse_id(client, arg) + target.is_mindreader = not target.is_mindreader + + status = {False: 'no longer', True: 'now'} + status2 = {False: 'no longer a', True: 'a'} + if client != target: + client.send_ooc(f'{target.displayname} ({target.id}) is {status[target.is_mindreader]} a ' + f'mind reader.') + client.send_ooc_others(f'(X) {client.displayname} ({client.id}) made {target.displayname} ' + f'({target.id}) be {status2[target.is_mindreader]} mind reader ' + f'({client.area.id}).', is_zstaff_flex=True) + target.send_ooc(f'You are {status[target.is_transient]} a mind reader.') + else: + client.send_ooc(f'You made yourself be {status2[target.is_mindreader]} mind reader.') + client.send_ooc_others(f'(X) {client.displayname} ({client.id}) made themselves be ' + f'{status2[target.is_mindreader]} mind reader ' + f'({client.area.id}).', is_zstaff_flex=True) + + def ooc_cmd_exec(client: ClientManager.Client, arg: str): """ VERY DANGEROUS. SHOULD ONLY BE ENABLED FOR DEBUGGING. diff --git a/server/network/ao_commands.py b/server/network/ao_commands.py index 945a4d3a4..e3c74120e 100644 --- a/server/network/ao_commands.py +++ b/server/network/ao_commands.py @@ -138,7 +138,10 @@ def check_client_version(): if software == 'DRO': if major >= 2: - client.packet_handler = clients.ClientDRO1d2d0() + if minor >= 2: + client.packet_handler = clients.ClientDRO1d2d2() + else: + client.packet_handler = clients.ClientDRO1d2d0() elif major >= 1: client.packet_handler = clients.ClientDRO1d1d0() else: @@ -320,12 +323,14 @@ def net_cmd_cc(client: ClientManager.Client, pargs: Dict[str, Any]): client.last_active = Constants.get_time() if not ever_chose_character_before: - client.send_command_dict('GM', { - 'name': '' - }) - client.send_command_dict('TOD', { - 'name': '' - }) + if not client.ever_outbounded_gamemode: + client.send_command_dict('GM', { + 'name': '' + }) + if not client.ever_outbounded_time_of_day: + client.send_command_dict('TOD', { + 'name': '' + }) try: client.area.play_current_track(only_for={client}, force_same_restart=1) except AreaError: diff --git a/server/tasker.py b/server/tasker.py index 0d11daeba..5931809e2 100644 --- a/server/tasker.py +++ b/server/tasker.py @@ -25,17 +25,18 @@ import time import typing -from typing import Any, List +from typing import Any, List, Tuple from server.constants import Constants from server.exceptions import ServerError if typing.TYPE_CHECKING: from server.client_manager import ClientManager + from server.tsuserver import TsuserverDR class Tasker: - def __init__(self, server): + def __init__(self, server: TsuserverDR): """ Parameters ---------- @@ -256,11 +257,12 @@ async def as_day_cycle(self, client: ClientManager.Client, args: List): _, area_1, area_2, hour_length, hour_start, hours_in_day, send_first_hour = args hour = hour_start minute_at_interruption = 0 + main_hour_length = hour_length time_started_at = time.time() time_refreshed_at = time.time() # Doesnt need init, but PyLint complains otherwise periods = list() force_period_refresh = False - current_period = (-1, '') + current_period = (-1, '', main_hour_length) notify_normies = False # Initialize task attributes @@ -269,6 +271,7 @@ async def as_day_cycle(self, client: ClientManager.Client, args: List): self.set_task_attr(client, ['as_day_cycle'], 'refresh_reason', '') self.set_task_attr(client, ['as_day_cycle'], 'period', '') self.set_task_attr(client, ['as_day_cycle'], 'hours_in_day', hours_in_day) + self.set_task_attr(client, ['as_day_cycle'], 'main_hour_length', main_hour_length) # Manually notify for the very first hour (if needed) targets = [c for c in self.server.get_clients() if c == client or @@ -277,14 +280,14 @@ async def as_day_cycle(self, client: ClientManager.Client, args: List): c.send_ooc('It is now {}:00.'.format('{0:02d}'.format(hour))) c.send_clock(client_id=client.id, hour=hour) - def find_period_of_hour(hour): + def find_period_of_hour(hour) -> Tuple[int, str, int]: if not periods: - return (-1, '') + return (-1, '', main_hour_length) if hour < periods[0][0]: return periods[-1] output = None for period_tuple in periods: - period_start, _ = period_tuple + period_start, _, _ = period_tuple if hour >= period_start: output = period_tuple return output @@ -341,10 +344,11 @@ def find_period_of_hour(hour): c.send_time_of_day(name='') c.send_ooc(f'It is no longer some particular period of day.') current_period = find_period_of_hour(hour) - new_period_start, new_period_name = current_period + hour_length = main_hour_length else: current_period = find_period_of_hour(hour) - new_period_start, new_period_name = current_period + new_period_start, new_period_name, new_period_length = current_period + hour_length = new_period_length if new_period_start == hour or force_period_refresh: for c in targets: self.set_task_attr(client, ['as_day_cycle'], 'period', new_period_name) @@ -396,6 +400,10 @@ def find_period_of_hour(hour): old_hour = hour hour_length, hour = self.get_task_attr(client, ['as_day_cycle'], 'new_day_cycle_args') + main_hour_length = hour_length + self.set_task_attr(client, ['as_day_cycle'], 'main_hour_length', + main_hour_length) + # Do not notify of clock set to normies if only hour length changed notify_normies = (old_hour != hour) minute_at_interruption = 0 @@ -419,6 +427,14 @@ def find_period_of_hour(hour): # So preemptively -1 if not self.get_task_attr(client, ['as_day_cycle'], 'is_paused'): hour -= 1 # Take one hour away, because an hour would be added anyway + + # This does not modify the hour length of active periods, so if there are any + # periods, warn clock master + if periods: + client.send_ooc('(X) Warning: A period is currently active, so the day ' + 'cycle is using its hour length. Modify its hour length ' + 'using /clock_period.') + elif refresh_reason == 'set_hours': old_hour = hour self.set_task_attr(client, ['as_day_cycle'], 'refresh_reason', @@ -504,66 +520,75 @@ def find_period_of_hour(hour): if not self.get_task_attr(client, ['as_day_cycle'], 'is_paused'): minute_at_interruption += (time_refreshed_at-time_started_at)/hour_length*60 time_started_at = time.time() - start, name = self.get_task_attr(client, ['as_day_cycle'], 'new_period_start') + start, name, length = self.get_task_attr(client, ['as_day_cycle'], 'new_period_start') # Pop entries with same start or name if needed (duplicated entries) - for (entry_start, entry_name) in periods.copy(): + found = False + for (entry_start, entry_name, entry_length) in periods.copy(): if entry_start == start or entry_name == name: - periods.remove((entry_start, entry_name)) - - if start >= 0: - periods.append((start, name)) - - # start=-1 is used to indicate *please erase this period name*. By the previous - # for loop, any matching period names are removed, and by the if statement - # -1 is not added. - - periods.sort() - # Decide which period the current hour belongs to - # Note it could be possible the current hour is smaller than than the first - # period start. By wrapping around 24 hours logic, that means the current - # period is the one given by the latest period. - - # Also note this is only relevant if the time is not unknown. If it is, - # then no updates should be sent - changed_current_period = False - if not self.get_task_attr(client, ['as_day_cycle'], 'is_unknown'): - new_period_start, new_period_name = find_period_of_hour(hour) - changed_current_period = (current_period[1] != new_period_name) - current_period = new_period_start, new_period_name - if periods and new_period_start == hour: - changed_current_period = True - if changed_current_period: - targets = [c for c in self.server.get_clients() - if c == client or area_1 <= c.area.id <= area_2] - self.set_task_attr(client, ['as_day_cycle'], 'period', new_period_name) - if new_period_name: - for c in targets: - c.send_time_of_day(name=new_period_name) - c.send_ooc(f'It is now {new_period_name}.') - else: - for c in targets: - c.send_time_of_day(name='') - c.send_ooc(f'It is no longer some particular period of day.') - - # Send notifications appropriately - if start >= 0: - # Case added a period - formatted_time = '{}:00'.format('{0:02d}'.format(start)) - client.send_ooc(f'(X) You have added period `{name}` that starts at ' - f'{formatted_time}.') - client.send_ooc_others(f'(X) {client.displayname} [{client.id}] has ' - f'added period `{name}` to their day cycle that ' - f'starts at {formatted_time} ' - f'({client.area.id}).', - is_zstaff_flex=True) + periods.remove((entry_start, entry_name, entry_length)) + found = True + + if not found and start < 0: + # Check if attempted to remove a non-existing period + client.send_ooc(f'Period `{name}` not found.') else: - # Case removed a period - client.send_ooc(f'(X) You have removed period `{name}`.') - client.send_ooc_others(f'(X) {client.displayname} [{client.id}] has ' - f'removed period `{name}` off their day cycle ' - f'({client.area.id}).', - is_zstaff_flex=True) + if start >= 0: + periods.append((start, name, length)) + + # start=-1 is used to indicate *please erase this period name*. By the previous + # for loop, any matching period names are removed, and by the if statement + # -1 is not added. + + periods.sort() + # Decide which period the current hour belongs to + # Note it could be possible the current hour is smaller than than the first + # period start. By wrapping around 24 hours logic, that means the current + # period is the one given by the latest period. + + # Also note this is only relevant if the time is not unknown. If it is, + # then no updates should be sent + changed_current_period = False + if not self.get_task_attr(client, ['as_day_cycle'], 'is_unknown'): + new_period_start, new_period_name, new_period_length = find_period_of_hour(hour) + changed_current_period = (current_period[1] != new_period_name) + current_period = new_period_start, new_period_name, new_period_length + if periods and new_period_start == hour: + changed_current_period = True + + if changed_current_period: + targets = [c for c in self.server.get_clients() + if c == client or area_1 <= c.area.id <= area_2] + self.set_task_attr(client, ['as_day_cycle'], 'period', new_period_name) + if new_period_name: + for c in targets: + c.send_time_of_day(name=new_period_name) + c.send_ooc(f'It is now {new_period_name}.') + else: + for c in targets: + c.send_time_of_day(name='') + c.send_ooc(f'It is no longer some particular period of day.') + + # Send notifications appropriately + if start >= 0: + # Case added a period + formatted_time = '{}:00'.format('{0:02d}'.format(start)) + client.send_ooc(f'(X) You have added period `{name}`. ' + f'Period hour length: {new_period_length} seconds. ' + f'Period hour start: {formatted_time}.') + client.send_ooc_others(f'(X) {client.displayname} [{client.id}] has ' + f'added period `{name}` to their day cycle. ' + f'Period hour length: {new_period_length} seconds. ' + f'Period hour start: {formatted_time} ' + f'({client.area.id}).', + is_zstaff_flex=True) + else: + # Case removed a period + client.send_ooc(f'(X) You have removed period `{name}`.') + client.send_ooc_others(f'(X) {client.displayname} [{client.id}] has ' + f'removed period `{name}` off their day cycle ' + f'({client.area.id}).', + is_zstaff_flex=True) elif refresh_reason == 'unpause': self.set_task_attr(client, ['as_day_cycle'], 'is_paused', False) diff --git a/server/tsuserver.py b/server/tsuserver.py index aad441de3..8edf936fd 100644 --- a/server/tsuserver.py +++ b/server/tsuserver.py @@ -68,9 +68,9 @@ def __init__(self, protocol: AOProtocol = None, self.release = 4 self.major_version = 3 - self.minor_version = 3 - self.segment_version = 'post2' - self.internal_version = '220723a' + self.minor_version = 4 + self.segment_version = '' + self.internal_version = '220821a' version_string = self.get_version_string() self.software = 'TsuserverDR {}'.format(version_string) self.version = 'TsuserverDR {} ({})'.format(version_string, self.internal_version) @@ -398,12 +398,32 @@ def load_config(self) -> Dict[str, Any]: def load_characters(self) -> List[str]: characters = ValidateCharacters().validate('config/characters.yaml') - if self.char_list != characters: - # Inconsistent character list, so change everyone to spectator - for client in self.get_clients(): - if client.char_id != -1: - # Except those that are already spectators - client.change_character(-1) + if self.char_list == characters: + return characters.copy() + + # Inconsistent character list, so change everyone to spectator + new_chars = {char: num for (num, char) in enumerate(characters)} + + for client in self.get_clients(): + target_char_id = -1 + old_char_name = client.get_char_name() + + if client.char_id < 0: + # Do nothing for spectators + pass + elif old_char_name not in new_chars: + # Character no longer exists, so switch to spectator + client.send_ooc('Your character is no longer available. Switching to spectator.') + pass + else: + target_char_id = new_chars[old_char_name] + + if client.packet_handler.ALLOWS_CHAR_LIST_RELOAD: + client.send_command_dict('SC', { + 'chars_ao2_list': characters, + }) + client.change_character(target_char_id, force=True) + else: client.send_ooc('The server character list was changed and no longer reflects your ' 'client character list. Please rejoin the server.') diff --git a/server/validate/areas.py b/server/validate/areas.py index da6fa26db..c9d0a1d60 100644 --- a/server/validate/areas.py +++ b/server/validate/areas.py @@ -69,10 +69,10 @@ def validate_contents(self, contents, extra_parameters=None): # Create the areas for item in contents: # Check required parameters - if 'area' not in item: + if 'area' not in item or not item['area']: info = 'Area {} has no name.'.format(current_area_id) raise AreaError(info) - if 'background' not in item: + if 'background' not in item or not item['background']: info = 'Area {} has no background.'.format(item['area']) raise AreaError(info) diff --git a/tests/structures.py b/tests/structures.py index eb122ba74..ec3f7467a 100644 --- a/tests/structures.py +++ b/tests/structures.py @@ -654,7 +654,7 @@ def sic(self, message, msg_type=0, pre='-', folder=None, anim=None, pos=None, sf if anim is None: anim = 'happy' if pos is None: - pos = self.pos if self.pos else 'def' + pos = self.pos if self.pos else 'wit' if char_id is None: char_id = self.char_id if evi is None: @@ -782,7 +782,7 @@ def receive_command_stc(self, command_type, *args): if command_type == 'decryptor': # Hi buffer = 'HI#FAKEHDID#%' elif command_type == 'ID': # Server ID - buffer = "ID#DRO#1.2.0#%" + buffer = "ID#DRO#1.2.2#%" err = ('Wrong client ID for {}.\nExpected {}\nGot {}' .format(self, args[0], self.id)) assert args[0] == str(self.id), err diff --git a/tests/test_client_connection.py b/tests/test_client_connection.py index 22816caa7..8fccf2885 100644 --- a/tests/test_client_connection.py +++ b/tests/test_client_connection.py @@ -3,7 +3,7 @@ _standard_FL = ('yellowtext', 'customobjections', 'flipping', 'fastloading', 'noencryption', 'deskmod', 'evidence', 'cccc_ic_support', 'looping_sfx', 'additive', 'effects', 'y_offset', 'ackMS', 'showname', 'chrini', 'charscheck', 'v110') -_standard_client_version = ('1', '2', '0') +_standard_client_version = ('1', '2', '2') class TestClientConnection(_Unittest): @@ -92,6 +92,7 @@ def test_04_client0_joinserver(self): c.assert_packet('CharsCheck', None) c.assert_packet('HP', (1, 10)) c.assert_packet('HP', (2, 10)) + c.assert_packet('TOD', None) c.assert_packet('BN', None) c.assert_packet('LE', tuple()) c.assert_packet('joined_area', None) @@ -141,6 +142,7 @@ def test_05_client1_joinandpickchar(self): c.assert_packet('CharsCheck', None) c.assert_packet('HP', (1, 10)) c.assert_packet('HP', (2, 10)) + c.assert_packet('TOD', None) c.assert_packet('BN', None) c.assert_packet('LE', tuple()) c.assert_packet('joined_area', None) @@ -160,8 +162,7 @@ def test_05_client1_joinandpickchar(self): # Only now pick char c.send_command_cts("CC#1#0#FAKEHDID#%") # Pick char 0 c.assert_packet('PV', (1, 'CID', 0)) # 1 because second client online - c.assert_packet('GM', '') - c.assert_packet('TOD', '', over=True) + c.assert_packet('GM', '', over=True) assert(c.get_char_name() == self.server.char_list[0]) # Check number of clients @@ -198,6 +199,7 @@ def test_06_client2_joinandpicksamechar(self): c.assert_packet('CharsCheck', None) c.assert_packet('HP', (1, 10)) c.assert_packet('HP', (2, 10)) + c.assert_packet('TOD', None) c.assert_packet('BN', None) c.assert_packet('LE', tuple()) c.assert_packet('joined_area', None) @@ -219,8 +221,7 @@ def test_06_client2_joinandpicksamechar(self): c.assert_no_packets() # Should not happen as client 1 has char 0 c.send_command_cts("CC#2#1#FAKEHDID#%") # Attempt to pick char 1 c.assert_packet('PV', (2, 'CID', 1)) # 2 because third client online - c.assert_packet('GM', '') - c.assert_packet('TOD', '', over=True) + c.assert_packet('GM', '', over=True) assert(c.get_char_name() == self.server.char_list[1]) # Check number of clients @@ -244,8 +245,7 @@ def test_07_client0_pickchar(self): c.assert_no_packets() # Should not happen as there is no char 4 c.send_command_cts("CC#0#3#FAKEHDID#%") # Attempt to pick char 3 c.assert_packet('PV', (0, 'CID', 3)) # 0 because first client online - c.assert_packet('GM', '') - c.assert_packet('TOD', '', over=True) + c.assert_packet('GM', '', over=True) assert(c.get_char_name() == self.server.char_list[3]) self.assertEqual(len(self.server.client_manager.clients), 3)