//! Live player positions and legacy world-mirror ingest from game servers. use super::servers::{get_server, GameServer}; use crate::auth::{AdminUser, AuthUser}; use crate::error::{AppError, AppResult}; use crate::livemap::{parse_chunks, Dim, LivePlayer}; use crate::state::AppState; use axum::body::Bytes; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::extract::{Path, Query, State}; use axum::response::Response; use axum::Json; use base64::Engine; use serde::Deserialize; use serde_json::{json, Value}; pub const CHUNK_INTERVAL_SECS: u64 = 20; pub const PLAYER_INTERVAL_MS: u64 = 1000; const MAX_PLAYERS: usize = 500; /// Settings handed to the game server in `/hello` and `/livemap/config`. pub fn game_config(enabled: bool) -> Value { json!({ "enabled": enabled, "chunk_interval_secs": CHUNK_INTERVAL_SECS, "player_interval_ms": PLAYER_INTERVAL_MS, "max_batch_bytes": 8 * 1024 * 1024, }) } fn require_enabled(enabled: bool) -> AppResult<()> { if enabled { Ok(()) } else { Err(AppError::forbidden("Live Map is turned off for this server. Enable it under Servers in the admin panel.")) } } fn dim_of(s: &str) -> AppResult { Dim::parse(s).ok_or_else(|| AppError::bad_request("unknown dimension (use overworld, the_nether or the_end)")) } #[derive(Deserialize)] pub struct DimQuery { #[serde(default = "overworld")] dim: String, } fn overworld() -> String { "overworld".into() } // --------------------------------------------------------------------------- // Game server → panel // --------------------------------------------------------------------------- pub async fn game_config_route(GameServer(server): GameServer) -> Json { Json(game_config(server.live_map_enabled)) } /// Timestamp tables of the regions the panel already has, base64 per region. pub async fn game_manifest( GameServer(server): GameServer, State(state): State, Query(q): Query, ) -> AppResult> { require_enabled(server.live_map_enabled)?; let dim = dim_of(&q.dim)?; let b64 = base64::engine::general_purpose::STANDARD; let regions: Vec = state .livemap .manifest(server.id, dim) .into_iter() .map(|(x, z, ts)| json!({ "x": x, "z": z, "timestamps": b64.encode(ts) })) .collect(); Ok(Json(json!({ "dimension": dim.slug(), "regions": regions }))) } pub async fn game_chunks( GameServer(server): GameServer, State(state): State, Query(q): Query, body: Bytes, ) -> AppResult> { require_enabled(server.live_map_enabled)?; let dim = dim_of(&q.dim)?; let chunks = parse_chunks(&body).map_err(AppError::bad_request)?; let stored = state.livemap.ingest_chunks(server.id, dim, chunks).await?; Ok(Json(json!({ "stored": stored }))) } pub async fn game_level(GameServer(server): GameServer, State(state): State, body: Bytes) -> AppResult> { require_enabled(server.live_map_enabled)?; // level.dat is a gzip-compressed NBT file. if body.len() < 18 || body.len() > 2 * 1024 * 1024 || body[0] != 0x1f || body[1] != 0x8b { return Err(AppError::bad_request("level.dat must be the gzip file from the world folder (up to 2 MiB)")); } state.livemap.ingest_level(server.id, body.to_vec()).await?; Ok(Json(json!({ "ok": true }))) } #[derive(Deserialize)] pub struct PlayersBody { players: Vec, } fn sanitize(mut players: Vec) -> Vec { players.truncate(MAX_PLAYERS); players.retain(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite() && p.x.abs() < 3e7 && p.z.abs() < 3e7); for p in &mut players { p.name = p.name.chars().filter(|c| !c.is_control()).take(32).collect(); p.uuid = p.uuid.chars().filter(|c| c.is_ascii_hexdigit() || *c == '-').take(36).collect(); p.dimension = p.dimension.chars().take(40).collect(); } players } pub async fn game_players( GameServer(server): GameServer, State(state): State, Json(b): Json, ) -> AppResult> { require_enabled(server.live_map_enabled)?; state.livemap.set_players(server.id, sanitize(b.players)); Ok(Json(json!({ "ok": true }))) } /// Streaming channel for the integration: JSON text frames carry player /// positions (`{"type":"players","players":[…]}`), binary frames carry chunk /// batches (`[dimension byte][chunk records…]`, see `parse_chunks`). pub async fn game_ws(GameServer(server): GameServer, State(state): State, ws: WebSocketUpgrade) -> AppResult { require_enabled(server.live_map_enabled)?; Ok(ws.max_message_size(9 * 1024 * 1024).on_upgrade(move |socket| ws_loop(socket, state, server.id))) } async fn ws_loop(mut socket: WebSocket, state: AppState, id: i64) { let mut last_check = std::time::Instant::now(); while let Some(Ok(msg)) = socket.recv().await { if last_check.elapsed() > std::time::Duration::from_secs(30) { last_check = std::time::Instant::now(); let enabled = get_server(&state, id).await.map(|s| s.live_map_enabled).unwrap_or(false); if !enabled { let _ = socket.send(Message::Text(json!({ "type": "disabled" }).to_string().into())).await; break; } } match msg { Message::Text(t) => { #[derive(Deserialize)] struct Frame { #[serde(default)] players: Vec, } if let Ok(f) = serde_json::from_str::(&t) { state.livemap.set_players(id, sanitize(f.players)); } } Message::Binary(b) => { let reply = match b.split_first() { Some((d, rest)) => match Dim::ALL .get(*d as usize) .copied() .ok_or("unknown dimension") .and_then(|dim| parse_chunks(rest).map(|c| (dim, c))) { Ok((dim, chunks)) => match state.livemap.ingest_chunks(id, dim, chunks).await { Ok(n) => json!({ "type": "stored", "count": n }), Err(e) => json!({ "type": "error", "error": e.to_string() }), }, Err(e) => json!({ "type": "error", "error": e }), }, None => json!({ "type": "error", "error": "empty frame" }), }; if socket.send(Message::Text(reply.to_string().into())).await.is_err() { break; } } Message::Ping(p) => { let _ = socket.send(Message::Pong(p)).await; } Message::Close(_) => break, _ => {} } } } // --------------------------------------------------------------------------- // Launcher / admin viewers // --------------------------------------------------------------------------- fn label(d: Dim) -> &'static str { match d { Dim::Overworld => "Overworld", Dim::Nether => "The Nether", Dim::End => "The End", } } async fn info(state: &AppState, id: i64, enabled: bool) -> Value { let summary = state.livemap.world_summary(id); let dims: Vec = summary["dimensions"] .as_array() .into_iter() .flatten() .filter_map(|d| { let dim = Dim::parse(d["slug"].as_str()?)?; Some(json!({ "id": dim.id(), "slug": dim.slug(), "label": label(dim), "available": d["regions"].as_u64().unwrap_or(0) > 0 })) }) .collect(); let message = if !enabled { "Live player positions are off for this server." } else { "" }; json!({ "enabled": enabled, "ready": enabled, "message": message, "dimensions": dims, "players": state.livemap.live_players(id).len(), }) } /// A signed-in player's own live position for the territory map. pub async fn viewer_info(AuthUser(auth): AuthUser, State(state): State, Path(id): Path) -> AppResult> { let server = get_server(&state, id).await?; let mut result = info(&state, id, server.live_map_enabled).await; result["me"] = serde_json::to_value(state.livemap.live_players(id).into_iter() .find(|player| player.uuid.eq_ignore_ascii_case(&auth.uuid)))?; Ok(Json(result)) } pub async fn admin_status(_: AdminUser, State(state): State, Path(id): Path) -> AppResult> { let server = get_server(&state, id).await?; let mut v = info(&state, id, server.live_map_enabled).await; let stats = state.livemap.stats(id); v["world"] = state.livemap.world_summary(id); v["stats"] = serde_json::to_value(stats)?; v["roster"] = serde_json::to_value(state.livemap.live_players(id))?; Ok(Json(v)) } pub async fn admin_reset(_: AdminUser, State(state): State, Path(id): Path) -> AppResult> { get_server(&state, id).await?; state.livemap.reset(id).await?; Ok(Json(json!({ "ok": true }))) }