Add JSON-RPC interface and refactor for MCP support
All checks were successful
Smoke tests / Build and smoke test (push) Successful in 1h0m43s
All checks were successful
Smoke tests / Build and smoke test (push) Successful in 1h0m43s
This commit is contained in:
172
src/jsonrpc.rs
Normal file
172
src/jsonrpc.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::game::SharedState;
|
||||
use crate::commands;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JsonRpcRequest {
|
||||
jsonrpc: String,
|
||||
method: String,
|
||||
params: Option<serde_json::Value>,
|
||||
id: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct JsonRpcResponse {
|
||||
jsonrpc: String,
|
||||
result: Option<serde_json::Value>,
|
||||
error: Option<serde_json::Value>,
|
||||
id: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub async fn run_jsonrpc_server(state: SharedState, port: u16) {
|
||||
let listener = TcpListener::bind(("0.0.0.0", port)).await.unwrap();
|
||||
log::info!("JSON-RPC server listening on 0.0.0.0:{port}");
|
||||
|
||||
let sessions = Arc::new(Mutex::new(HashMap::<usize, String>::new()));
|
||||
|
||||
loop {
|
||||
let (stream, addr) = match listener.accept().await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::error!("Failed to accept connection: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
log::info!("New JSON-RPC connection from {addr:?}");
|
||||
|
||||
let state = state.clone();
|
||||
let sessions = sessions.clone();
|
||||
tokio::spawn(async move {
|
||||
handle_connection(stream, state, sessions).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
mut stream: TcpStream,
|
||||
state: SharedState,
|
||||
sessions: Arc<Mutex<HashMap<usize, String>>>,
|
||||
) {
|
||||
let (reader, mut writer) = stream.split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
let mut line = String::new();
|
||||
|
||||
// Map RPC session ID to player ID
|
||||
let mut current_player_id: Option<usize> = None;
|
||||
|
||||
loop {
|
||||
line.clear();
|
||||
match reader.read_line(&mut line).await {
|
||||
Ok(0) => break, // Connection closed
|
||||
Ok(_) => {
|
||||
let req: JsonRpcRequest = match serde_json::from_str(&line) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let resp = JsonRpcResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
result: None,
|
||||
error: Some(json!({"code": -32700, "message": format!("Parse error: {e}")})),
|
||||
id: None,
|
||||
};
|
||||
let _ = writer.write_all(format!("{}\n", serde_json::to_string(&resp).unwrap()).as_bytes()).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let resp = handle_request(req, &state, &sessions, &mut current_player_id).await;
|
||||
let _ = writer.write_all(format!("{}\n", serde_json::to_string(&resp).unwrap()).as_bytes()).await;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Error reading from JSON-RPC stream: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup session if needed
|
||||
if let Some(pid) = current_player_id {
|
||||
let mut st = state.lock().await;
|
||||
st.remove_player(pid);
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_request(
|
||||
req: JsonRpcRequest,
|
||||
state: &SharedState,
|
||||
_sessions: &Arc<Mutex<HashMap<usize, String>>>,
|
||||
current_player_id: &mut Option<usize>,
|
||||
) -> JsonRpcResponse {
|
||||
let method = req.method.as_str();
|
||||
let id = req.id.clone();
|
||||
|
||||
let result = match method {
|
||||
"login" => {
|
||||
let username = req.params.as_ref()
|
||||
.and_then(|p| p.get("username"))
|
||||
.and_then(|u| u.as_str())
|
||||
.unwrap_or("anonymous");
|
||||
|
||||
let mut st = state.lock().await;
|
||||
let player_id = rand::random::<usize>();
|
||||
|
||||
let saved = st.db.load_player(username);
|
||||
if let Some(saved) = saved {
|
||||
st.load_existing_player(player_id, saved, None, None);
|
||||
*current_player_id = Some(player_id);
|
||||
json!({"status": "success", "session_id": player_id})
|
||||
} else {
|
||||
json!({"status": "error", "message": "Player not found. Create character via SSH first."})
|
||||
}
|
||||
},
|
||||
"list_commands" => {
|
||||
json!([
|
||||
"look", "go", "north", "south", "east", "west", "up", "down",
|
||||
"say", "who", "take", "drop", "inventory", "equip", "use",
|
||||
"examine", "talk", "attack", "defend", "flee", "cast",
|
||||
"spells", "skills", "guild", "stats", "help"
|
||||
])
|
||||
},
|
||||
"execute" => {
|
||||
if let Some(pid) = *current_player_id {
|
||||
let command = req.params.as_ref()
|
||||
.and_then(|p| p.get("command"))
|
||||
.and_then(|c| c.as_str())
|
||||
.unwrap_or("");
|
||||
let args = req.params.as_ref()
|
||||
.and_then(|p| p.get("args"))
|
||||
.and_then(|a| a.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let input = if args.is_empty() { command.to_string() } else { format!("{command} {args}") };
|
||||
let result = commands::execute(&input, pid, state).await;
|
||||
|
||||
json!({
|
||||
"output": strip_ansi(&result.output),
|
||||
"quit": result.quit
|
||||
})
|
||||
} else {
|
||||
json!({"error": "Not logged in"})
|
||||
}
|
||||
},
|
||||
_ => json!({"error": "Method not found"}),
|
||||
};
|
||||
|
||||
JsonRpcResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
result: Some(result),
|
||||
error: None,
|
||||
id,
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_ansi(text: &str) -> String {
|
||||
let re = regex::Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]").unwrap();
|
||||
re.replace_all(text, "").to_string()
|
||||
}
|
||||
Reference in New Issue
Block a user