Skip to main content

netget/client/a2s/
actions.rs

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}