From cc61f16e577a6dc297ecd269813de93de53c43d4 Mon Sep 17 00:00:00 2001 From: recklesscoder <57289227+recklesscoder@users.noreply.github.com> Date: Mon, 13 Feb 2023 03:17:25 +0100 Subject: [PATCH] Protocol: Improve machine-readability of prints (#1388) * Protocol: Improve machine-readability of prints * Factorio: Make use of new PrintJSON fields for echo detection. * Protocol: Add message field to chat prints. --- CommonClient.py | 5 ++++ FactorioClient.py | 5 ++-- MultiServer.py | 65 +++++++++++++++++++++++----------------- docs/network protocol.md | 51 ++++++++++++++++++++----------- 4 files changed, 79 insertions(+), 47 deletions(-) diff --git a/CommonClient.py b/CommonClient.py index 2fb0ef8e..92f8d76a 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -341,6 +341,11 @@ class CommonContext: return self.slot in self.slot_info[slot].group_members return False + def is_echoed_chat(self, print_json_packet: dict) -> bool: + return print_json_packet.get("type", "") == "Chat" \ + and print_json_packet.get("team", None) == self.team \ + and print_json_packet.get("slot", None) == self.slot + def is_uninteresting_item_send(self, print_json_packet: dict) -> bool: """Helper function for filtering out ItemSend prints that do not concern the local player.""" return print_json_packet.get("type", "") == "ItemSend" \ diff --git a/FactorioClient.py b/FactorioClient.py index 5abce654..9c294c10 100644 --- a/FactorioClient.py +++ b/FactorioClient.py @@ -109,9 +109,10 @@ class FactorioContext(CommonContext): def on_print_json(self, args: dict): if self.rcon_client: - if not self.filter_item_sends or not self.is_uninteresting_item_send(args): + if (not self.filter_item_sends or not self.is_uninteresting_item_send(args)) \ + and not self.is_echoed_chat(args): text = self.factorio_json_text_parser(copy.deepcopy(args["data"])) - if not text.startswith(self.player_names[self.slot] + ":"): + if not text.startswith(self.player_names[self.slot] + ":"): # TODO: Remove string heuristic in the future. self.print_to_game(text) super(FactorioContext, self).on_print_json(args) diff --git a/MultiServer.py b/MultiServer.py index 90572ec1..faeb1b22 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -329,18 +329,18 @@ class Context: self.clients[endpoint.team][endpoint.slot].remove(endpoint) await on_client_disconnected(self, endpoint) - - def notify_client(self, client: Client, text: str): + def notify_client(self, client: Client, text: str, additional_arguments: dict = {}): if not client.auth: return logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text)) - async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }]}])) + async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}])) - - def notify_client_multiple(self, client: Client, texts: typing.List[str]): + def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}): if not client.auth: return - async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }]} for text in texts])) + async_start(self.send_msgs(client, + [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments} + for text in texts])) # loading @@ -666,7 +666,7 @@ class Context: def on_goal_achieved(self, client: Client): finished_msg = f'{self.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1})' \ f' has completed their goal.' - self.broadcast_text_all(finished_msg) + self.broadcast_text_all(finished_msg, {"type": "Goal", "team": client.team, "slot": client.slot}) if "auto" in self.collect_mode: collect_player(self, client.team, client.slot) if "auto" in self.release_mode: @@ -762,18 +762,21 @@ async def on_client_joined(ctx: Context, client: Client): ctx.broadcast_text_all( f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) " f"{verb} {ctx.games[client.slot]} has joined. " - f"Client({version_str}), {client.tags}).") + f"Client({version_str}), {client.tags}).", + {"type": "Join", "team": client.team, "slot": client.slot, "tags": client.tags}) ctx.notify_client(client, "Now that you are connected, " "you can use !help to list commands to run via the server. " "If your client supports it, " - "you may have additional local commands you can list with /help.") + "you may have additional local commands you can list with /help.", + {"type": "Tutorial"}) ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc) async def on_client_left(ctx: Context, client: Client): update_client_status(ctx, client, ClientStatus.CLIENT_UNKNOWN) ctx.broadcast_text_all( - "%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1)) + "%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1), + {"type": "Part", "team": client.team, "slot": client.slot}) ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc) @@ -859,9 +862,9 @@ def update_checked_locations(ctx: Context, team: int, slot: int): def release_player(ctx: Context, team: int, slot: int): """register any locations that are in the multidata""" all_locations = set(ctx.locations[slot]) - ctx.broadcast_text_all( - "%s (Team #%d) has released all remaining items from their world." % - (ctx.player_names[(team, slot)], team + 1)) + ctx.broadcast_text_all("%s (Team #%d) has released all remaining items from their world." + % (ctx.player_names[(team, slot)], team + 1), + {"type": "Release", "team": team, "slot": slot}) register_location_checks(ctx, team, slot, all_locations) update_checked_locations(ctx, team, slot) @@ -874,8 +877,9 @@ def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False): if values[1] == slot: all_locations[source_slot].add(location_id) - ctx.broadcast_text_all( - "%s (Team #%d) has collected their items from other worlds." % (ctx.player_names[(team, slot)], team + 1)) + ctx.broadcast_text_all("%s (Team #%d) has collected their items from other worlds." + % (ctx.player_names[(team, slot)], team + 1), + {"type": "Collect", "team": team, "slot": slot}) for source_player, location_ids in all_locations.items(): register_location_checks(ctx, team, source_player, location_ids, count_activity=False) update_checked_locations(ctx, team, source_player) @@ -1145,11 +1149,15 @@ class ClientMessageProcessor(CommonCommandProcessor): def __call__(self, raw: str) -> typing.Optional[bool]: if not raw.startswith("!admin"): - self.ctx.broadcast_text_all(self.ctx.get_aliased_name(self.client.team, self.client.slot) + ': ' + raw) + self.ctx.broadcast_text_all(self.ctx.get_aliased_name(self.client.team, self.client.slot) + ': ' + raw, + {"type": "Chat", "team": self.client.team, "slot": self.client.slot, "message": raw}) return super(ClientMessageProcessor, self).__call__(raw) - def output(self, text): - self.ctx.notify_client(self.client, text) + def output(self, text: str): + self.ctx.notify_client(self.client, text, {"type": "CommandResult"}) + + def output_multiple(self, texts: typing.List[str]): + self.ctx.notify_client_multiple(self.client, texts, {"type": "CommandResult"}) def default(self, raw: str): pass # default is client sending just text @@ -1172,9 +1180,8 @@ class ClientMessageProcessor(CommonCommandProcessor): # disallow others from knowing what the new remote administration password is. "!admin /option server_password"): output = f"!admin /option server_password {('*' * random.randint(4, 16))}" - # Otherwise notify the others what is happening. - self.ctx.broadcast_text_all( - self.ctx.get_aliased_name(self.client.team, self.client.slot) + ': ' + output) + self.ctx.broadcast_text_all(self.ctx.get_aliased_name(self.client.team, self.client.slot) + ': ' + output, + {"type": "Chat", "team": self.client.team, "slot": self.client.slot, "message": output}) if not self.ctx.server_password: self.output("Sorry, Remote administration is disabled") @@ -1211,7 +1218,7 @@ class ClientMessageProcessor(CommonCommandProcessor): def _cmd_players(self) -> bool: """Get information about connected and missing players.""" if len(self.ctx.player_names) < 10: - self.ctx.broadcast_text_all(get_players_string(self.ctx)) + self.ctx.broadcast_text_all(get_players_string(self.ctx), {"type": "CommandResult"}) else: self.output(get_players_string(self.ctx)) return True @@ -1300,7 +1307,7 @@ class ClientMessageProcessor(CommonCommandProcessor): if locations: texts = [f'Missing: {self.ctx.location_names[location]}' for location in locations] texts.append(f"Found {len(locations)} missing location checks") - self.ctx.notify_client_multiple(self.client, texts) + self.output_multiple(texts) else: self.output("No missing location checks found.") return True @@ -1313,7 +1320,7 @@ class ClientMessageProcessor(CommonCommandProcessor): if locations: texts = [f'Checked: {self.ctx.location_names[location]}' for location in locations] texts.append(f"Found {len(locations)} done location checks") - self.ctx.notify_client_multiple(self.client, texts) + self.output_multiple(texts) else: self.output("No done location checks found.") return True @@ -1351,7 +1358,8 @@ class ClientMessageProcessor(CommonCommandProcessor): get_received_items(self.ctx, self.client.team, self.client.slot, True).append(new_item) self.ctx.broadcast_text_all( 'Cheat console: sending "' + item_name + '" to ' + self.ctx.get_aliased_name(self.client.team, - self.client.slot)) + self.client.slot), + {"type": "ItemCheat", "team": self.client.team, "receiving": self.client.slot, "item": new_item}) send_new_items(self.ctx) return True else: @@ -1652,7 +1660,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags ctx.broadcast_text_all( f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags " - f"from {old_tags} to {client.tags}.") + f"from {old_tags} to {client.tags}.", + {"type": "TagsChanged", "team": client.team, "slot": client.slot, "tags": client.tags}) elif cmd == 'Sync': start_inventory = get_start_inventory(ctx, client.slot, client.remote_start_inventory) @@ -1770,11 +1779,11 @@ class ServerCommandProcessor(CommonCommandProcessor): def output(self, text: str): if self.client: - self.ctx.notify_client(self.client, text) + self.ctx.notify_client(self.client, text, {"type": "AdminCommandResult"}) super(ServerCommandProcessor, self).output(text) def default(self, raw: str): - self.ctx.broadcast_text_all('[Server]: ' + raw) + self.ctx.broadcast_text_all('[Server]: ' + raw, {"type": "ServerChat", "message": raw}) def _cmd_save(self) -> bool: """Save current state to multidata""" diff --git a/docs/network protocol.md b/docs/network protocol.md index f743e474..bfffcc58 100644 --- a/docs/network protocol.md +++ b/docs/network protocol.md @@ -9,7 +9,7 @@ These steps should be followed in order to establish a gameplay connection with 5. Client sends [Connect](#Connect) packet in order to authenticate with the server. 6. Server validates the client's packet and responds with [Connected](#Connected) or [ConnectionRefused](#ConnectionRefused). 7. Server may send [ReceivedItems](#ReceivedItems) to the client, in the case that the client is missing items that are queued up for it. -8. Server sends [PrintJSON](#PrintJSON) to all players to notify them of updates. +8. Server sends [PrintJSON](#PrintJSON) to all players to notify them of the new client connection. In the case that the client does not authenticate properly and receives a [ConnectionRefused](#ConnectionRefused) then the server will maintain the connection and allow for follow-up [Connect](#Connect) packet. @@ -160,26 +160,43 @@ The arguments for RoomUpdate are identical to [RoomInfo](#RoomInfo) barring: All arguments for this packet are optional, only changes are sent. ### PrintJSON -Sent to clients purely to display a message to the player. The data being sent with this packet allows for configurability or specific messaging. +Sent to clients purely to display a message to the player. While various message types provide additional arguments, clients only need to evaluate the `data` argument to construct the human-readable message text. All other arguments may be ignored safely. #### Arguments -| Name | Type | Notes | -| ---- | ---- | ----- | -| data | list\[[JSONMessagePart](#JSONMessagePart)\] | Type of this part of the message. | -| type | str | May be present to indicate the [PrintJsonType](#PrintJsonType) of this message. | -| receiving | int | Is present if type is Hint or ItemSend and marks the destination player's ID. | -| item | [NetworkItem](#NetworkItem) | Is present if type is Hint or ItemSend and marks the source player id, location id, item id and item flags. | -| found | bool | Is present if type is Hint, denotes whether the location hinted for was checked. | -| countdown | int | Is present if type is `Countdown`, denotes the amount of seconds remaining on the countdown. | +| Name | Type | Message Types | Contents | +| ---- | ---- | ------------- | -------- | +| data | list\[[JSONMessagePart](#JSONMessagePart)\] | (all) | Textual content of this message | +| type | str | (any) | [PrintJsonType](#PrintJsonType) of this message (optional) | +| receiving | int | ItemSend, ItemCheat, Hint | Destination player's ID | +| item | [NetworkItem](#NetworkItem) | ItemSend, ItemCheat, Hint | Source player's ID, location ID, item ID and item flags | +| found | bool | Hint | Whether the location hinted for was checked | +| team | int | Join, Part, Chat, TagsChanged, Goal, Release, Collect, ItemCheat | Team of the triggering player | +| slot | int | Join, Part, Chat, TagsChanged, Goal, Release, Collect | Slot of the triggering player | +| message | str | Chat, ServerChat | Original chat message without sender prefix | +| tags | list\[str\] | Join, TagsChanged | Tags of the triggering player | +| countdown | int | Countdown | Amount of seconds remaining on the countdown | -##### PrintJsonType -PrintJsonType indicates the type of [PrintJson](#PrintJson) packet, different types can be handled differently by the client and can also contain additional arguments. When receiving an unknown or missing type the data's list\[[JSONMessagePart](#JSONMessagePart)\] should still be displayed to the player as normal text. +#### PrintJsonType +PrintJsonType indicates the type of a [PrintJSON](#PrintJSON) packet. Different types can be handled differently by the client and can also contain additional arguments. When receiving an unknown or missing type, the `data`'s list\[[JSONMessagePart](#JSONMessagePart)\] should still be displayed to the player as normal text. Currently defined types are: -| Type | Notes | -| ---- | ----- | -| ItemSend | The message is in response to a player receiving an item. | -| Hint | The message is in response to a player hinting. | -| Countdown | The message contains information about the current server Countdown. | + +| Type | Subject | +| ---- | ------- | +| ItemSend | A player received an item. | +| ItemCheat | A player used the `!getitem` command. | +| Hint | A player hinted. | +| Join | A player connected. | +| Part | A player disconnected. | +| Chat | A player sent a chat message. | +| ServerChat | The server broadcasted a message. | +| Tutorial | The client has triggered a tutorial message, such as when first connecting. | +| TagsChanged | A player changed their tags. | +| CommandResult | Someone (usually the client) entered an `!` command. | +| AdminCommandResult | The client entered an `!admin` command. | +| Goal | A player reached their goal. | +| Release | A player released the remaining items in their world. | +| Collect | A player collected the remaining items for their world. | +| Countdown | The current server countdown has progressed. | ### DataPackage Sent to clients to provide what is known as a 'data package' which contains information to enable a client to most easily communicate with the Archipelago server. Contents include things like location id to name mappings, among others; see [Data Package Contents](#Data-Package-Contents) for more info.