1use crate::llm::actions::{
2 client_trait::{Client, ClientActionResult},
3 protocol_trait::Protocol,
4 ActionDefinition,
5};
6use crate::protocol::{ConnectContext, EventType};
7use crate::server::a2s::{
8 actions::{action, parameter},
9 wire::{self, Kind},
10};
11use crate::state::app_state::AppState;
12use anyhow::{bail, Result};
13use serde_json::{json, Value};
14use std::sync::LazyLock;
15
16#[derive(Default)]
17pub struct A2sClientProtocol;
18impl A2sClientProtocol {
19 pub fn new() -> Self {
20 Self
21 }
22}
23
24fn query_action() -> ActionDefinition {
25 action(
26 "a2s_query",
27 "Query the game server; the decoded answer arrives as a2s_response. Challenges and split responses are handled for you.",
28 vec![parameter("query", "string", "info, players or rules", true)],
29 json!({"type":"a2s_query","query":"info"}),
30 )
31}
32
33fn disconnect_action() -> ActionDefinition {
34 action(
35 "disconnect",
36 "Stop this A2S client",
37 vec![],
38 json!({"type":"disconnect"}),
39 )
40}
41
42fn actions() -> Vec<ActionDefinition> {
43 vec![query_action(), disconnect_action()]
44}
45
46pub static READY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
47 EventType::new(
48 "a2s_ready",
49 "Ready to query the game server over UDP.",
50 query_action().example.clone(),
51 )
52 .with_parameters(vec![parameter(
53 "remote_addr",
54 "string",
55 "Address of the game server's query port",
56 true,
57 )])
58 .with_actions(actions())
59});
60
61pub static RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
62 EventType::new("a2s_response", "The server's decoded answer to one query.", query_action().example.clone())
63 .with_parameters(vec![
64 parameter("query", "string", "info, players or rules", true),
65 parameter("info", "object", "For info: name, map, folder, game, app_id, players, max_players, bots, server_type, environment, private, vac, version and any extra data", false),
66 parameter("players", "array", "For players: [{index, name, score, duration_secs}]", false),
67 parameter("rules", "object", "For rules: rule names to values", false),
68 parameter("packets", "number", "Datagrams the answer arrived in", true),
69 ])
70 .with_actions(actions())
71});
72
73pub fn kind(v: &Value) -> Result<Kind> {
74 Kind::parse(
75 v["query"]
76 .as_str()
77 .ok_or_else(|| anyhow::anyhow!("query must be a string"))?,
78 )
79}
80
81impl Protocol for A2sClientProtocol {
82 fn protocol_name(&self) -> &'static str {
83 "A2S"
84 }
85 fn stack_name(&self) -> &'static str {
86 "ETH>IP>UDP>A2S"
87 }
88 fn keywords(&self) -> Vec<&'static str> {
89 vec!["a2s", "steam query", "source query", "server browser"]
90 }
91 fn description(&self) -> &'static str {
92 "Steam/Source server query client: info, players and rules"
93 }
94 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
95 actions()
96 }
97 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
98 vec![]
99 }
100 fn get_event_types(&self) -> Vec<EventType> {
101 vec![READY_EVENT.clone(), RESPONSE_EVENT.clone()]
102 }
103 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
104 use crate::protocol::metadata::*;
105 ProtocolMetadataV2::builder()
106 .state(DevelopmentState::Experimental)
107 .privilege_requirement(PrivilegeRequirement::None)
108 .well_known_udp_port(27015)
109 .implementation("Tokio UDP: A2S requests, challenge retry, Source split reassembly and decoding")
110 .llm_control("Which queries to send and what to do with each answer")
111 .e2e_testing("tests/client/a2s: scripted fixture with challenges and splits; woozymasta/a2s's server (Go) as the independent peer")
112 .notes("Uncompressed Source responses only (no GoldSource, no bzip2 splits). Each reply datagram has a 5 s deadline, a challenge is retried once, and responses are bounded to 64 KiB in at most 64 packets.")
113 .max_inbound_bytes(wire::MAX_RESPONSE)
114 .build()
115 }
116 fn example_prompt(&self) -> &'static str {
117 "Query the game server at 127.0.0.1:27015 for its info and players"
118 }
119 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
120 let llm = json!({"type":"open_client","protocol":"a2s","remote_addr":"127.0.0.1:27015","instruction":"Report the server's map and player count"});
121 let mut static_example = llm.clone();
122 static_example["event_handlers"] = json!([
123 {"event_pattern":"a2s_ready","handler":{"type":"static","actions":[{"type":"a2s_query","query":"info"}]}},
124 {"event_pattern":"a2s_response","handler":{"type":"static","actions":[{"type":"disconnect"}]}}
125 ]);
126 let mut scripted = static_example.clone();
127 scripted["event_handlers"][0]["handler"] = json!({"type":"script","language":"python","code":"import json\nprint(json.dumps({'actions':[{'type':'a2s_query','query':'players'}]}))"});
128 crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
129 }
130 fn group_name(&self) -> &'static str {
131 "Application"
132 }
133}
134
135impl Client for A2sClientProtocol {
136 fn connect(
137 &self,
138 ctx: ConnectContext,
139 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
140 {
141 Box::pin(super::connect(ctx))
142 }
143 fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
144 match v["type"].as_str() {
145 Some("a2s_query") => {
146 kind(&v)?;
147 Ok(ClientActionResult::Custom {
148 name: "a2s_query".into(),
149 data: v,
150 })
151 }
152 Some("disconnect") => Ok(ClientActionResult::Disconnect),
153 _ => bail!("Unknown A2S client action"),
154 }
155 }
156}