Skip to main content

netget/server/minecraft/
actions.rs

1use crate::llm::actions::{
2    protocol_trait::{ActionResult, Protocol, Server},
3    ActionDefinition, Parameter, ParameterDefinition,
4};
5use crate::protocol::log_template::LogTemplate;
6use crate::protocol::{EventType, SpawnContext};
7use crate::state::app_state::AppState;
8use anyhow::{bail, ensure, Result};
9use serde_json::{json, Value};
10use std::sync::LazyLock;
11
12#[derive(Default)]
13pub struct MinecraftProtocol;
14impl MinecraftProtocol {
15    pub fn new() -> Self {
16        Self
17    }
18}
19
20pub fn parameter(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
21    Parameter {
22        name: name.into(),
23        type_hint: kind.into(),
24        description: description.into(),
25        required,
26    }
27}
28
29pub fn action(
30    name: &str,
31    description: &str,
32    parameters: Vec<Parameter>,
33    example: Value,
34) -> ActionDefinition {
35    let log_template = match name {
36        "minecraft_status" => LogTemplate::new()
37            .with_info("-> Minecraft status {online_players}/{max_players} {preview(motd,60)}"),
38        "minecraft_disconnect" => {
39            LogTemplate::new().with_info("-> Minecraft login disconnect {preview(reason,80)}")
40        }
41        "minecraft_login" => LogTemplate::new().with_info("-> Minecraft login as {username}"),
42        _ => LogTemplate::new().with_info(format!("-> Minecraft {name}")),
43    };
44    ActionDefinition {
45        name: name.into(),
46        description: description.into(),
47        parameters,
48        example,
49        log_template: Some(log_template),
50    }
51}
52
53fn status_action() -> ActionDefinition {
54    action(
55        "minecraft_status",
56        "Answer a server-list ping with what the server reports. Rust builds the status JSON (or the legacy kick reply) and answers the follow-up ping itself.",
57        vec![
58            parameter("motd", "string", "Message of the day shown in the server list; ยง colour codes allowed", true),
59            parameter("online_players", "number", "Number of players online now", true),
60            parameter("max_players", "number", "Maximum number of player slots", true),
61            parameter("version_name", "string", "Version text, default 1.21.1", false),
62            parameter("protocol", "number", "Protocol number, default 767 (1.21.1); the string \"echo\" repeats the client's own", false),
63            parameter("sample", "array", "Up to 64 players shown on hover: [{name, id?: uuid}]", false),
64            parameter("enforces_secure_chat", "boolean", "Whether the server enforces signed chat", false),
65        ],
66        json!({"type":"minecraft_status","motd":"A NetGet server","online_players":2,"max_players":20,"sample":[{"name":"alice"}]}),
67    )
68}
69
70fn disconnect_action() -> ActionDefinition {
71    action(
72        "minecraft_disconnect",
73        "Refuse a login with the reason the player's client shows on its disconnect screen. NetGet never admits a player into the game.",
74        vec![parameter("reason", "string", "Text shown to the player", true)],
75        json!({"type":"minecraft_disconnect","reason":"You are not whitelisted on this server!"}),
76    )
77}
78
79fn refuse_action() -> ActionDefinition {
80    action(
81        "minecraft_refuse",
82        "Close the connection without answering, as an unreachable or hidden server would look",
83        vec![],
84        json!({"type":"minecraft_refuse"}),
85    )
86}
87
88pub static STATUS_EVENT: LazyLock<EventType> = LazyLock::new(|| {
89    EventType::new(
90        "minecraft_status_request",
91        "A client pinged the server for its server-list entry; report the MOTD, player counts and version.",
92        status_action().example.clone(),
93    )
94    .with_parameters(vec![
95        parameter("protocol_version", "number", "Protocol the client speaks (-1 or a low number from ping tools; null for a pre-1.6 legacy ping)", false),
96        parameter("server_address", "string", "Host name the client dialled, from its handshake", false),
97        parameter("server_port", "number", "Port the client dialled, from its handshake", false),
98        parameter("legacy", "boolean", "True for a pre-1.7 (0xFE) ping, answered with the legacy kick format", true),
99        parameter("remote_addr", "string", "Client address and port", true),
100    ])
101    .with_actions(vec![status_action(), refuse_action()])
102});
103
104pub static LOGIN_EVENT: LazyLock<EventType> = LazyLock::new(|| {
105    EventType::new(
106        "minecraft_login",
107        "A player tried to join. Answer with the disconnect reason they will see; NetGet does not run a game.",
108        disconnect_action().example.clone(),
109    )
110    .with_parameters(vec![
111        parameter("username", "string", "Player name from Login Start", true),
112        parameter("uuid", "string", "Profile UUID the client sent, when its version sends one", false),
113        parameter("protocol_version", "number", "Protocol the client speaks", true),
114        parameter("server_address", "string", "Host name the client dialled", true),
115        parameter("server_port", "number", "Port the client dialled", true),
116        parameter("transfer", "boolean", "True when the client arrived through a server transfer", true),
117        parameter("remote_addr", "string", "Client address and port", true),
118    ])
119    .with_actions(vec![disconnect_action(), refuse_action()])
120});
121
122pub fn check_status(v: &Value) -> Result<()> {
123    ensure!(v["motd"].is_string(), "motd must be a string");
124    for key in ["online_players", "max_players"] {
125        ensure!(v[key].is_i64(), "{key} must be an integer");
126    }
127    ensure!(
128        v.get("version_name").is_none_or(Value::is_string),
129        "version_name must be a string"
130    );
131    ensure!(
132        v.get("enforces_secure_chat").is_none_or(Value::is_boolean),
133        "enforces_secure_chat must be a boolean"
134    );
135    // Builds the JSON once so a sample or size problem is refused here, not on the wire.
136    super::wire::status_json(v, 0)?;
137    Ok(())
138}
139
140impl Protocol for MinecraftProtocol {
141    fn protocol_name(&self) -> &'static str {
142        "Minecraft"
143    }
144    fn stack_name(&self) -> &'static str {
145        "ETH>IP>TCP>Minecraft"
146    }
147    fn description(&self) -> &'static str {
148        "Minecraft Java Edition server-list ping (modern and legacy) and login refusal, answered by the handler"
149    }
150    fn keywords(&self) -> Vec<&'static str> {
151        vec![
152            "minecraft",
153            "server list ping",
154            "slp",
155            "minecraft status",
156            "motd",
157        ]
158    }
159    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
160        vec![]
161    }
162    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
163        vec![status_action(), disconnect_action(), refuse_action()]
164    }
165    fn get_event_types(&self) -> Vec<EventType> {
166        vec![STATUS_EVENT.clone(), LOGIN_EVENT.clone()]
167    }
168    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
169        vec![ParameterDefinition {
170            name: "idle_timeout_secs".into(),
171            type_hint: "number".into(),
172            description: "Seconds to wait for each packet before closing (1..=300)".into(),
173            required: false,
174            example: json!(10),
175            default: Some(json!(super::wire::IO_TIMEOUT.as_secs())),
176        }]
177    }
178    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
179        use crate::protocol::metadata::*;
180        ProtocolMetadataV2::builder()
181            .state(DevelopmentState::Experimental)
182            .privilege_requirement(PrivilegeRequirement::None)
183            .well_known_port(25565)
184            .implementation("Minecraft Java Edition framing over Tokio TCP: handshake, status request/response, ping/pong, Login Start answered by a login Disconnect, and the pre-1.7 0xFE legacy ping in its Beta, 1.4 and 1.6 forms")
185            .llm_control("The server-list entry (MOTD, players, version) per ping, and the disconnect reason each joining player sees")
186            .e2e_testing("tests/server/minecraft: raw packets and bounds; mcstatus (Python, modern and legacy) and node-minecraft-protocol (ping and a real login attempt) as independent clients")
187            .notes("No game: a login is always ended with a Disconnect, so no encryption, compression or play state. No favicon (it is base64 image data). Serverbound packets are capped at 2048 bytes, a handshake address at 255 characters and a name at 16; each read has a 30 s deadline. A handler failure on a ping closes the connection without a fabricated status; on a login it disconnects the player with a generic reason.")
188            .request_only("Every reply answers the packet just read; the server never speaks first")
189            .max_inbound_bytes(super::wire::MAX_SERVERBOUND_PACKET)
190            .build()
191    }
192    fn example_prompt(&self) -> &'static str {
193        "Minecraft server on port 25565 that shows a busy survival server in the server list and refuses logins as not whitelisted"
194    }
195    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
196        let llm = json!({"type":"open_server","base_stack":"minecraft","port":25565,"instruction":"Look like a busy survival server and refuse logins as not whitelisted"});
197        let mut scripted = llm.clone();
198        scripted["event_handlers"] = json!([
199            {"event_pattern":"minecraft_status_request","handler":{"type":"script","language":"python","code":"import json,sys\ne=json.load(sys.stdin)['event']\nprint(json.dumps({'actions':[{'type':'minecraft_status','motd':'Hello '+(e.get('server_address') or ''),'online_players':3,'max_players':50}]}))"}},
200            {"event_pattern":"minecraft_login","handler":{"type":"static","actions":[{"type":"minecraft_disconnect","reason":"You are not whitelisted on this server!"}]}}
201        ]);
202        let mut static_example = llm.clone();
203        static_example["event_handlers"] = json!([
204            {"event_pattern":"minecraft_status_request","handler":{"type":"static","actions":[{"type":"minecraft_status","motd":"A NetGet server","online_players":0,"max_players":20}]}},
205            {"event_pattern":"minecraft_login","handler":{"type":"static","actions":[{"type":"minecraft_disconnect","reason":"Server is in maintenance"}]}}
206        ]);
207        crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
208    }
209    fn group_name(&self) -> &'static str {
210        "Application"
211    }
212}
213
214impl Server for MinecraftProtocol {
215    fn spawn(
216        &self,
217        ctx: SpawnContext,
218    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
219    {
220        Box::pin(super::spawn(ctx))
221    }
222    fn execute_action(&self, v: Value) -> Result<ActionResult> {
223        match v["type"].as_str() {
224            Some("minecraft_status") => {
225                check_status(&v)?;
226                Ok(ActionResult::Custom {
227                    name: "minecraft_status".into(),
228                    data: v,
229                })
230            }
231            Some("minecraft_disconnect") => {
232                let reason = v["reason"]
233                    .as_str()
234                    .ok_or_else(|| anyhow::anyhow!("reason must be a string"))?;
235                super::wire::disconnect_json(reason)?;
236                Ok(ActionResult::Custom {
237                    name: "minecraft_disconnect".into(),
238                    data: v,
239                })
240            }
241            Some("minecraft_refuse") => Ok(ActionResult::Custom {
242                name: "minecraft_refuse".into(),
243                data: v,
244            }),
245            _ => bail!("Unknown Minecraft server action"),
246        }
247    }
248}