Skip to main content

netget/client/minecraft/
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::minecraft::{
8    actions::{action, parameter},
9    wire,
10};
11use crate::state::app_state::AppState;
12use anyhow::{bail, ensure, Result};
13use serde_json::{json, Value};
14use std::sync::LazyLock;
15
16#[derive(Default)]
17pub struct MinecraftClientProtocol;
18impl MinecraftClientProtocol {
19    pub fn new() -> Self {
20        Self
21    }
22}
23
24fn status_action() -> ActionDefinition {
25    action(
26        "minecraft_status",
27        "Ping the server for its server-list entry over a fresh connection; the answer arrives as minecraft_status_response.",
28        vec![parameter("protocol_version", "number", "Protocol to announce in the handshake, default 767 (1.21.1)", false)],
29        json!({"type":"minecraft_status"}),
30    )
31}
32
33fn legacy_action() -> ActionDefinition {
34    action(
35        "minecraft_legacy_status",
36        "Ping the server the way a 1.6 client does (0xFE 0x01 0xFA MC|PingHost); the answer arrives as minecraft_status_response with legacy true.",
37        vec![],
38        json!({"type":"minecraft_legacy_status"}),
39    )
40}
41
42fn login_action() -> ActionDefinition {
43    action(
44        "minecraft_login",
45        "Attempt to join as a player over a fresh connection and report how the server answered (disconnected, encryption required, or accepted). The client never enters the game.",
46        vec![
47            parameter("username", "string", "Player name, 1 to 16 characters", true),
48            parameter("protocol_version", "number", "Protocol to announce, default 767 (1.21.1)", false),
49            parameter("uuid", "string", "Profile UUID to send where the version sends one; default all zeros", false),
50        ],
51        json!({"type":"minecraft_login","username":"alice"}),
52    )
53}
54
55fn disconnect_action() -> ActionDefinition {
56    action(
57        "disconnect",
58        "Stop this Minecraft client",
59        vec![],
60        json!({"type":"disconnect"}),
61    )
62}
63
64fn actions() -> Vec<ActionDefinition> {
65    vec![
66        status_action(),
67        legacy_action(),
68        login_action(),
69        disconnect_action(),
70    ]
71}
72
73pub static READY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
74    EventType::new(
75        "minecraft_ready",
76        "Ready to ping or join the Minecraft server.",
77        status_action().example.clone(),
78    )
79    .with_parameters(vec![parameter(
80        "remote_addr",
81        "string",
82        "Address of the Minecraft server",
83        true,
84    )])
85    .with_actions(actions())
86});
87
88pub static STATUS_EVENT: LazyLock<EventType> = LazyLock::new(|| {
89    EventType::new(
90        "minecraft_status_response",
91        "The server's server-list entry.",
92        login_action().example.clone(),
93    )
94    .with_parameters(vec![
95        parameter(
96            "legacy",
97            "boolean",
98            "True when this came from a legacy ping",
99            true,
100        ),
101        parameter(
102            "version_name",
103            "string",
104            "Version text the server reports",
105            false,
106        ),
107        parameter(
108            "protocol",
109            "number",
110            "Protocol number the server reports",
111            false,
112        ),
113        parameter(
114            "online_players",
115            "number",
116            "Number of players online now",
117            false,
118        ),
119        parameter(
120            "max_players",
121            "number",
122            "Maximum number of player slots",
123            false,
124        ),
125        parameter("motd", "string", "Message of the day as plain text", true),
126        parameter("sample", "array", "Players listed: [{name, id}]", false),
127        parameter(
128            "has_favicon",
129            "boolean",
130            "Whether the server sent an icon (not included)",
131            false,
132        ),
133        parameter(
134            "enforces_secure_chat",
135            "boolean",
136            "Whether the server enforces signed chat",
137            false,
138        ),
139        parameter(
140            "latency_ms",
141            "number",
142            "Ping round trip, when measured",
143            false,
144        ),
145    ])
146    .with_actions(actions())
147});
148
149pub static LOGIN_EVENT: LazyLock<EventType> = LazyLock::new(|| {
150    EventType::new(
151        "minecraft_login_result",
152        "How the server answered a join attempt.",
153        status_action().example.clone(),
154    )
155    .with_parameters(vec![
156        parameter(
157            "outcome",
158            "string",
159            "disconnected, encryption_required (an online-mode server) or accepted",
160            true,
161        ),
162        parameter(
163            "reason",
164            "string",
165            "For disconnected: the reason as plain text",
166            false,
167        ),
168        parameter(
169            "username",
170            "string",
171            "For accepted: the name the server assigned",
172            false,
173        ),
174        parameter(
175            "uuid",
176            "string",
177            "For accepted: the UUID the server assigned",
178            false,
179        ),
180        parameter(
181            "compression_threshold",
182            "number",
183            "Set when the server enabled compression first",
184            false,
185        ),
186    ])
187    .with_actions(actions())
188});
189
190pub fn protocol_version(v: &Value) -> Result<i32> {
191    match &v["protocol_version"] {
192        Value::Null => Ok(wire::DEFAULT_PROTOCOL),
193        p => {
194            let n = p
195                .as_i64()
196                .ok_or_else(|| anyhow::anyhow!("protocol_version must be an integer"))?;
197            i32::try_from(n).map_err(|_| anyhow::anyhow!("protocol_version is out of range"))
198        }
199    }
200}
201
202pub fn check(v: &Value) -> Result<()> {
203    match v["type"].as_str() {
204        Some("minecraft_status") => {
205            protocol_version(v)?;
206        }
207        Some("minecraft_legacy_status") => {}
208        Some("minecraft_login") => {
209            protocol_version(v)?;
210            let name = v["username"]
211                .as_str()
212                .ok_or_else(|| anyhow::anyhow!("username must be a string"))?;
213            ensure!(
214                (1..=wire::MAX_USERNAME_CHARS).contains(&name.chars().count()),
215                "username must be 1 to {} characters",
216                wire::MAX_USERNAME_CHARS
217            );
218            if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
219                wire::parse_uuid(uuid)?;
220            }
221        }
222        _ => bail!("Unknown Minecraft client action"),
223    }
224    Ok(())
225}
226
227impl Protocol for MinecraftClientProtocol {
228    fn protocol_name(&self) -> &'static str {
229        "Minecraft"
230    }
231    fn stack_name(&self) -> &'static str {
232        "ETH>IP>TCP>Minecraft"
233    }
234    fn keywords(&self) -> Vec<&'static str> {
235        vec!["minecraft", "server list ping", "minecraft status", "slp"]
236    }
237    fn description(&self) -> &'static str {
238        "Minecraft Java Edition client: server-list ping (modern and legacy) and login probing"
239    }
240    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
241        actions()
242    }
243    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
244        vec![]
245    }
246    fn get_event_types(&self) -> Vec<EventType> {
247        vec![
248            READY_EVENT.clone(),
249            STATUS_EVENT.clone(),
250            LOGIN_EVENT.clone(),
251        ]
252    }
253    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
254        use crate::protocol::metadata::*;
255        ProtocolMetadataV2::builder()
256            .state(DevelopmentState::Experimental)
257            .privilege_requirement(PrivilegeRequirement::None)
258            .well_known_port(25565)
259            .implementation("Tokio TCP, one connection per action: handshake, status and ping/pong; the 1.6 legacy ping; handshake and Login Start read through Set Compression, plugin and cookie requests to Disconnect, Encryption Request or Login Success")
260            .llm_control("Which servers to ping or join, as whom, and what to do with each answer")
261            .e2e_testing("tests/client/minecraft: NetGet's own server for every path; node-minecraft-protocol's createServer as the independent peer for status, a login it accepts and one it kicks")
262            .notes("Offline probing only: no Mojang authentication or encryption, so an online-mode server is reported as encryption_required, and an accepted login is reported and closed before play. Status responses are bounded to 32767 characters and decompressed packets to the same size; each read has a 30 s deadline.")
263            .max_inbound_bytes(wire::MAX_CLIENTBOUND_PACKET)
264            .build()
265    }
266    fn example_prompt(&self) -> &'static str {
267        "Ping the Minecraft server at 127.0.0.1:25565 and report its MOTD and player count"
268    }
269    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
270        let llm = json!({"type":"open_client","protocol":"minecraft","remote_addr":"127.0.0.1:25565","instruction":"Report the server's MOTD and player count"});
271        let mut static_example = llm.clone();
272        static_example["event_handlers"] = json!([
273            {"event_pattern":"minecraft_ready","handler":{"type":"static","actions":[{"type":"minecraft_status"}]}},
274            {"event_pattern":"minecraft_status_response","handler":{"type":"static","actions":[{"type":"disconnect"}]}}
275        ]);
276        let mut scripted = static_example.clone();
277        scripted["event_handlers"][0]["handler"] = json!({"type":"script","language":"python","code":"import json\nprint(json.dumps({'actions':[{'type':'minecraft_login','username':'alice'}]}))"});
278        crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
279    }
280    fn group_name(&self) -> &'static str {
281        "Application"
282    }
283}
284
285impl Client for MinecraftClientProtocol {
286    fn connect(
287        &self,
288        ctx: ConnectContext,
289    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
290    {
291        Box::pin(super::connect(ctx))
292    }
293    fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
294        if v["type"] == "disconnect" {
295            return Ok(ClientActionResult::Disconnect);
296        }
297        check(&v)?;
298        Ok(ClientActionResult::Custom {
299            name: v["type"].as_str().unwrap_or_default().to_string(),
300            data: v,
301        })
302    }
303}