247 lines
9.1 KiB
Rust
247 lines
9.1 KiB
Rust
//! 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> {
|
|
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<Value> {
|
|
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<AppState>,
|
|
Query(q): Query<DimQuery>,
|
|
) -> AppResult<Json<Value>> {
|
|
require_enabled(server.live_map_enabled)?;
|
|
let dim = dim_of(&q.dim)?;
|
|
let b64 = base64::engine::general_purpose::STANDARD;
|
|
let regions: Vec<Value> = 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<AppState>,
|
|
Query(q): Query<DimQuery>,
|
|
body: Bytes,
|
|
) -> AppResult<Json<Value>> {
|
|
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<AppState>, body: Bytes) -> AppResult<Json<Value>> {
|
|
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<LivePlayer>,
|
|
}
|
|
|
|
fn sanitize(mut players: Vec<LivePlayer>) -> Vec<LivePlayer> {
|
|
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<AppState>,
|
|
Json(b): Json<PlayersBody>,
|
|
) -> AppResult<Json<Value>> {
|
|
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<AppState>, ws: WebSocketUpgrade) -> AppResult<Response> {
|
|
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<LivePlayer>,
|
|
}
|
|
if let Ok(f) = serde_json::from_str::<Frame>(&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<Value> = 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<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
|
|
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<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
|
|
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<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
|
|
get_server(&state, id).await?;
|
|
state.livemap.reset(id).await?;
|
|
Ok(Json(json!({ "ok": true })))
|
|
}
|