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}