//! SCOPENET Map endpoints: ingest from game servers, and what launchers and the admin panel load. use super::servers::{get_server, GameServer}; use crate::auth::{AdminUser, AuthUser}; use crate::error::{AppError, AppResult}; use crate::state::AppState; use crate::worldmap::{dim_slug, parse_tiles, LivePlayer, MAX_ZOOM, TILE_SIZE, TOKEN_TTL}; use axum::body::Bytes; use axum::extract::{Path, Query, State}; use axum::http::{header, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::Json; use serde::Deserialize; use serde_json::{json, Value}; const PLAYER_INTERVAL_MS: u64 = 2000; fn require_enabled(enabled: bool) -> AppResult<()> { if enabled { Ok(()) } else { Err(AppError::forbidden("The map is turned off for this server. Switch it on under Servers in the admin panel.")) } } // --------------------------------------------------------------------------- // Game server -> panel // --------------------------------------------------------------------------- pub async fn game_config(GameServer(server): GameServer, State(state): State) -> Json { Json(json!({ "enabled": server.map_enabled, "epoch": state.worldmap.epoch(server.id), "tile_size": TILE_SIZE, "max_zoom": MAX_ZOOM, "player_interval_ms": PLAYER_INTERVAL_MS, })) } #[derive(Deserialize)] pub struct DimQuery { dim: String, } pub async fn game_tiles(GameServer(server): GameServer, State(state): State, Query(q): Query, body: Bytes) -> AppResult> { require_enabled(server.map_enabled)?; let slug = dim_slug(&q.dim).ok_or_else(|| AppError::bad_request("unknown dimension"))?; let tiles = parse_tiles(&body).map_err(AppError::bad_request)?; let map = state.worldmap.clone(); let id = server.id; let stored = tokio::task::spawn_blocking(move || map.store_tiles(id, &slug, &tiles)).await.map_err(|e| AppError::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))??; Ok(Json(json!({ "stored": stored }))) } #[derive(Deserialize)] pub struct PlayersBody { players: Vec, } pub async fn game_players(GameServer(server): GameServer, State(state): State, Json(b): Json) -> AppResult> { require_enabled(server.map_enabled)?; state.worldmap.set_players(server.id, b.players); Ok(Json(json!({ "ok": true }))) } /// Claims and pins, already drawn into shapes by the game server. Stored as is, size-limited. pub async fn game_overlay(GameServer(server): GameServer, State(state): State, Json(overlay): Json) -> AppResult> { require_enabled(server.map_enabled)?; let ok = overlay.is_object() && overlay.get("claims").is_none_or(|c| c.is_array()) && overlay.get("pins").is_none_or(|p| p.is_array()); if !ok || overlay.to_string().len() > 4 * 1024 * 1024 { return Err(AppError::bad_request("overlay must be an object with claims and pins lists (up to 4 MiB)")); } state.worldmap.set_overlay(server.id, overlay); Ok(Json(json!({ "ok": true }))) } // --------------------------------------------------------------------------- // Launcher and admin viewers // --------------------------------------------------------------------------- async fn info(state: &AppState, id: i64, enabled: bool) -> Value { let summary = state.worldmap.summary(id); let dims = summary["dimensions"].clone(); let has = dims.as_array().is_some_and(|d| !d.is_empty()); let message = if !enabled { "The map is off for this server." } else if !has { "Waiting for the game server to draw its world. The first tiles appear within a minute or two of the server starting." } else { "" }; json!({ "enabled": enabled, "ready": enabled && has, "message": message, "tile_size": TILE_SIZE, "max_zoom": MAX_ZOOM, "tile_base": format!("/api/map/{id}"), "token": if enabled && has { json!(state.worldmap.issue_token(id)) } else { Value::Null }, "token_ttl_secs": TOKEN_TTL.as_secs(), "dimensions": dims, "players": state.worldmap.live_players(id).len(), }) } /// Lets a launcher decide whether to offer a Live Map button, and gives it what it needs to draw the map. pub async fn viewer_info(_: AuthUser, State(state): State, Path(id): Path) -> AppResult> { let server = get_server(&state, id).await?; Ok(Json(info(&state, id, server.map_enabled).await)) } /// Players now, plus the claims and pins the game server last sent. pub async fn viewer_overlay(_: AuthUser, State(state): State, Path(id): Path) -> AppResult> { let server = get_server(&state, id).await?; require_enabled(server.map_enabled)?; let overlay = state.worldmap.overlay(id); Ok(Json(json!({ "players": state.worldmap.live_players(id), "claims": overlay.get("claims").cloned().unwrap_or(json!([])), "pins": overlay.get("pins").cloned().unwrap_or(json!([])), "updated": state.worldmap.stats(id).last_overlay_at, }))) } 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.map_enabled).await; v["stats"] = serde_json::to_value(state.worldmap.stats(id))?; v["epoch"] = json!(state.worldmap.epoch(id)); v["roster"] = serde_json::to_value(state.worldmap.live_players(id))?; Ok(Json(v)) } pub async fn admin_reset(_: AdminUser, State(state): State, Path(id): Path) -> AppResult> { get_server(&state, id).await?; let map = state.worldmap.clone(); tokio::task::spawn_blocking(move || map.reset(id)).await.map_err(|e| AppError::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))??; Ok(Json(json!({ "ok": true }))) } #[derive(Deserialize)] pub struct TokenQuery { #[serde(default)] t: String, } /// `GET /api/map/{server}/{dimension}/{zoom}/{x}/{y}.png?t=`. The token comes from [`viewer_info`], so only signed-in /// players can load tiles, yet plain `` tags work. pub async fn tile(State(state): State, Path((id, dim, z, x, file)): Path<(i64, String, u8, i32, String)>, Query(q): Query, headers: axum::http::HeaderMap) -> Response { let not_found = || (StatusCode::NOT_FOUND, "no tile").into_response(); if !state.worldmap.token_ok(id, &q.t) { return (StatusCode::UNAUTHORIZED, "map link expired").into_response(); } let (Some(slug), Some(y)) = (dim_slug(&dim), file.strip_suffix(".png").and_then(|s| s.parse::().ok())) else { return not_found() }; if z > MAX_ZOOM { return not_found(); } let map = state.worldmap.clone(); let found = tokio::task::spawn_blocking(move || map.read_tile(id, &slug, z, x, y)).await.ok().flatten(); let Some((png, modified)) = found else { return not_found() }; let etag = format!("\"{modified}-{}\"", png.len()); if headers.get(header::IF_NONE_MATCH).and_then(|v| v.to_str().ok()) == Some(etag.as_str()) { return (StatusCode::NOT_MODIFIED, [(header::ETAG, etag)]).into_response(); } let mut resp = (StatusCode::OK, png).into_response(); let h = resp.headers_mut(); h.insert(header::CONTENT_TYPE, HeaderValue::from_static("image/png")); h.insert(header::CACHE_CONTROL, HeaderValue::from_static("private, max-age=30")); if let Ok(v) = HeaderValue::from_str(&etag) { h.insert(header::ETAG, v); } resp }