//! The SCOPENET Map. //! //! Game servers draw their own world: the plugin/mod renders top-down map tiles from the region files and uploads only //! the small PNGs that changed. The panel just stores them and serves them, together with live player positions and an //! overlay (guild claims, pins) that the game server keeps up to date. There is nothing to install or run beside the panel. //! //! Layout on disk: `/map////_.png`. Zoom 0 is one pixel per block with 256-pixel //! tiles; each higher zoom halves the resolution. use rand::RngCore; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::{Duration, Instant}; pub const TILE_SIZE: u32 = 256; pub const MAX_ZOOM: u8 = 6; const MAX_TILE_BYTES: usize = 1024 * 1024; const MAX_COORD: i32 = 1 << 20; pub const TOKEN_TTL: Duration = Duration::from_secs(3600); /// Player positions older than this mean "the server stopped sending". const PLAYERS_FRESH: Duration = Duration::from_secs(30); const MAX_PLAYERS: usize = 500; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct LivePlayer { pub uuid: String, pub name: String, pub dimension: String, pub x: f64, pub y: f64, pub z: f64, #[serde(default)] pub yaw: f32, } #[derive(Debug, Clone, Default, Serialize)] pub struct Stats { pub tiles_received: u64, pub bytes_received: u64, pub last_upload_at: Option, pub last_players_at: Option, pub last_overlay_at: Option, } struct PlayersSnap { at: Instant, players: Vec, } pub struct WorldMap { root: PathBuf, players: Mutex>, tokens: Mutex>, overlays: Mutex>, stats: Mutex>, } /// A dimension id ("minecraft:the_nether") as a safe folder name ("the_nether"). Other namespaces keep theirs ("mod__space"). pub fn dim_slug(id: &str) -> Option { let id = id.trim(); let trimmed = id.strip_prefix("minecraft:").unwrap_or(id); let slug = match trimmed { "nether" => "the_nether".to_string(), "end" => "the_end".to_string(), other => other.replace(':', "__"), }; let ok = !slug.is_empty() && slug.len() <= 48 && slug.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-' | '.')) && !slug.contains(".."); ok.then_some(slug) } pub fn dim_label(slug: &str) -> String { match slug { "overworld" => "Overworld".into(), "the_nether" => "The Nether".into(), "the_end" => "The End".into(), other => other.rsplit("__").next().unwrap_or(other).replace(['_', '-'], " "), } } /// One decoded upload record. #[derive(Debug, PartialEq)] pub struct TileRecord { pub zoom: u8, pub x: i32, pub y: i32, pub png: Vec, } /// `[u8 zoom][i32 x][i32 y][u32 length][PNG bytes]` repeated, big endian. pub fn parse_tiles(body: &[u8]) -> Result, String> { let mut out = Vec::new(); let mut at = 0usize; while at < body.len() { if body.len() - at < 13 { return Err("truncated tile header".into()); } let zoom = body[at]; let x = i32::from_be_bytes(body[at + 1..at + 5].try_into().unwrap()); let y = i32::from_be_bytes(body[at + 5..at + 9].try_into().unwrap()); let len = u32::from_be_bytes(body[at + 9..at + 13].try_into().unwrap()) as usize; at += 13; if zoom > MAX_ZOOM || x.abs() > MAX_COORD || y.abs() > MAX_COORD { return Err("tile out of range".into()); } if len < 8 || len > MAX_TILE_BYTES || body.len() - at < len { return Err("bad tile length".into()); } let png = &body[at..at + len]; if png[..8] != [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a] { return Err("tiles must be PNG images".into()); } out.push(TileRecord { zoom, x, y, png: png.to_vec() }); at += len; } Ok(out) } fn now_rfc3339() -> String { chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) } impl WorldMap { pub fn new(data_dir: &Path) -> Self { Self { root: data_dir.join("map"), players: Default::default(), tokens: Default::default(), overlays: Default::default(), stats: Default::default(), } } fn server_dir(&self, id: i64) -> PathBuf { self.root.join(id.to_string()) } pub fn tile_path(&self, id: i64, slug: &str, zoom: u8, x: i32, y: i32) -> PathBuf { self.server_dir(id).join(slug).join(zoom.to_string()).join(format!("{x}_{y}.png")) } // ---- tiles --------------------------------------------------------------------------- /// Store a batch. Each tile is written to a temporary file and renamed, so a viewer never sees half a PNG. pub fn store_tiles(&self, id: i64, slug: &str, tiles: &[TileRecord]) -> std::io::Result { for t in tiles { let path = self.tile_path(id, slug, t.zoom, t.x, t.y); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let tmp = path.with_extension("png.tmp"); std::fs::write(&tmp, &t.png)?; std::fs::rename(&tmp, &path)?; } let bytes: u64 = tiles.iter().map(|t| t.png.len() as u64).sum(); let mut stats = self.stats.lock().unwrap(); let s = stats.entry(id).or_default(); s.tiles_received += tiles.len() as u64; s.bytes_received += bytes; s.last_upload_at = Some(now_rfc3339()); Ok(tiles.len()) } /// The PNG and its modification time (seconds), if that tile exists. pub fn read_tile(&self, id: i64, slug: &str, zoom: u8, x: i32, y: i32) -> Option<(Vec, u64)> { let path = self.tile_path(id, slug, zoom, x, y); let bytes = std::fs::read(&path).ok()?; let modified = std::fs::metadata(&path).ok()?.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(); Some((bytes, modified)) } /// Which dimensions have tiles, and how far they reach (in zoom-0 tiles). pub fn summary(&self, id: i64) -> Value { let mut dims = Vec::new(); let Ok(rd) = std::fs::read_dir(self.server_dir(id)) else { return json!({ "dimensions": dims }) }; let mut names: Vec = rd.flatten().filter(|e| e.path().is_dir()).filter_map(|e| e.file_name().to_str().map(String::from)).collect(); names.sort_by_key(|n| (n != "overworld", n != "the_nether", n != "the_end", n.clone())); for slug in names { let (mut count, mut bytes) = (0u64, 0u64); let (mut min_x, mut max_x, mut min_y, mut max_y) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN); if let Ok(files) = std::fs::read_dir(self.server_dir(id).join(&slug).join("0")) { for f in files.flatten() { let name = f.file_name().to_string_lossy().to_string(); let Some((xs, ys)) = name.strip_suffix(".png").and_then(|n| n.split_once('_')) else { continue }; let (Ok(x), Ok(y)) = (xs.parse::(), ys.parse::()) else { continue }; count += 1; bytes += f.metadata().map(|m| m.len()).unwrap_or(0); (min_x, max_x, min_y, max_y) = (min_x.min(x), max_x.max(x), min_y.min(y), max_y.max(y)); } } if count == 0 { continue; } dims.push(json!({ "id": if slug.contains("__") { slug.replacen("__", ":", 1) } else { format!("minecraft:{slug}") }, "slug": slug, "label": dim_label(&slug), "available": true, "tiles": count, "bytes": bytes, "bounds": { "min_x": min_x, "max_x": max_x, "min_y": min_y, "max_y": max_y }, })); } json!({ "dimensions": dims }) } /// Delete every tile and the overlay; game servers notice the new epoch and redraw everything. pub fn reset(&self, id: i64) -> std::io::Result<()> { let dir = self.server_dir(id); let epoch = self.epoch(id) + 1; if dir.exists() { std::fs::remove_dir_all(&dir)?; } std::fs::create_dir_all(&dir)?; std::fs::write(dir.join("epoch"), epoch.to_string())?; self.overlays.lock().unwrap().remove(&id); *self.stats.lock().unwrap().entry(id).or_default() = Stats::default(); Ok(()) } /// Changes whenever the map was reset, telling the game server to start over. pub fn epoch(&self, id: i64) -> u64 { std::fs::read_to_string(self.server_dir(id).join("epoch")).ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0) } /// Remove everything for a deleted server. pub fn forget_server(&self, id: i64) { let _ = std::fs::remove_dir_all(self.server_dir(id)); self.players.lock().unwrap().remove(&id); self.overlays.lock().unwrap().remove(&id); self.stats.lock().unwrap().remove(&id); } pub fn stats(&self, id: i64) -> Stats { self.stats.lock().unwrap().get(&id).cloned().unwrap_or_default() } // ---- players ------------------------------------------------------------------------- pub fn set_players(&self, id: i64, mut players: 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().filter(|c| !c.is_control()).take(48).collect(); } self.players.lock().unwrap().insert(id, PlayersSnap { at: Instant::now(), players }); self.stats.lock().unwrap().entry(id).or_default().last_players_at = Some(now_rfc3339()); } pub fn live_players(&self, id: i64) -> Vec { self.players.lock().unwrap().get(&id).filter(|s| s.at.elapsed() < PLAYERS_FRESH).map(|s| s.players.clone()).unwrap_or_default() } /// Drop a player from every server's roster (account deleted). pub fn forget_player(&self, uuid: &str) { for snap in self.players.lock().unwrap().values_mut() { snap.players.retain(|p| !p.uuid.eq_ignore_ascii_case(uuid)); } } // ---- overlay ------------------------------------------------------------------------- /// The last overlay (claims, pins) a game server sent. Kept on disk so a panel restart doesn't blank the map. pub fn set_overlay(&self, id: i64, overlay: Value) { let dir = self.server_dir(id); if std::fs::create_dir_all(&dir).is_ok() { let _ = std::fs::write(dir.join("overlay.json"), overlay.to_string()); } self.overlays.lock().unwrap().insert(id, overlay); self.stats.lock().unwrap().entry(id).or_default().last_overlay_at = Some(now_rfc3339()); } pub fn overlay(&self, id: i64) -> Value { if let Some(v) = self.overlays.lock().unwrap().get(&id) { return v.clone(); } let loaded = std::fs::read_to_string(self.server_dir(id).join("overlay.json")).ok().and_then(|s| serde_json::from_str::(&s).ok()).unwrap_or_else(|| json!({ "claims": [], "pins": [] })); self.overlays.lock().unwrap().insert(id, loaded.clone()); loaded } // ---- tile tokens --------------------------------------------------------------------- /// A short-lived key that lets `` tags load tiles without an Authorization header. Random, in memory only. pub fn issue_token(&self, id: i64) -> String { let mut bytes = [0u8; 24]; rand::thread_rng().fill_bytes(&mut bytes); let token = hex::encode(bytes); let mut tokens = self.tokens.lock().unwrap(); tokens.retain(|_, (_, at)| at.elapsed() < TOKEN_TTL); tokens.insert(token.clone(), (id, Instant::now())); token } pub fn token_ok(&self, id: i64, token: &str) -> bool { self.tokens.lock().unwrap().get(token).is_some_and(|(sid, at)| *sid == id && at.elapsed() < TOKEN_TTL) } } #[cfg(test)] mod tests { use super::*; const PNG: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]; fn record(zoom: u8, x: i32, y: i32, payload: &[u8]) -> Vec { let mut out = vec![zoom]; out.extend(x.to_be_bytes()); out.extend(y.to_be_bytes()); let mut png = PNG.to_vec(); png.extend_from_slice(payload); out.extend((png.len() as u32).to_be_bytes()); out.extend(png); out } #[test] fn dimension_ids_become_safe_folder_names() { assert_eq!(dim_slug("minecraft:overworld").as_deref(), Some("overworld")); assert_eq!(dim_slug("minecraft:the_nether").as_deref(), Some("the_nether")); assert_eq!(dim_slug("nether").as_deref(), Some("the_nether")); assert_eq!(dim_slug("mymod:caves").as_deref(), Some("mymod__caves")); assert_eq!(dim_slug("../etc"), None); assert_eq!(dim_slug("a/b"), None); assert_eq!(dim_slug(""), None); assert_eq!(dim_label("the_nether"), "The Nether"); assert_eq!(dim_label("mymod__deep_dark"), "deep dark"); } #[test] fn tile_batches_are_parsed_and_checked() { let mut body = record(0, -3, 7, b"abc"); body.extend(record(2, 1, -1, b"zz")); let tiles = parse_tiles(&body).unwrap(); assert_eq!(tiles.len(), 2); assert_eq!((tiles[0].zoom, tiles[0].x, tiles[0].y), (0, -3, 7)); assert_eq!((tiles[1].zoom, tiles[1].x, tiles[1].y), (2, 1, -1)); assert!(parse_tiles(&[]).unwrap().is_empty()); assert!(parse_tiles(&body[..10]).is_err(), "truncated header"); assert!(parse_tiles(&body[..20]).is_err(), "truncated body"); assert!(parse_tiles(&record(9, 0, 0, b"x")).is_err(), "zoom too deep"); assert!(parse_tiles(&record(0, i32::MAX, 0, b"x")).is_err(), "far outside any world"); let mut not_png = record(0, 0, 0, b"x"); not_png[13] = b'X'; assert!(parse_tiles(¬_png).is_err()); } #[test] fn tiles_are_stored_summarised_and_reset() { let dir = std::env::temp_dir().join(format!("scopenet-map-{}", rand::random::())); let map = WorldMap::new(&dir); let tiles = parse_tiles(&[record(0, -1, 2, b"a"), record(0, 3, 4, b"bb"), record(1, 0, 0, b"c")].concat()).unwrap(); assert_eq!(map.store_tiles(5, "overworld", &tiles).unwrap(), 3); map.store_tiles(5, "the_nether", &tiles[..1]).unwrap(); assert!(map.read_tile(5, "overworld", 0, -1, 2).unwrap().0.ends_with(b"a")); assert!(map.read_tile(5, "overworld", 0, 9, 9).is_none()); let s = map.summary(5); let dims = s["dimensions"].as_array().unwrap(); assert_eq!(dims[0]["slug"], "overworld"); assert_eq!(dims[0]["tiles"], 2, "only zoom 0 counts"); assert_eq!(dims[0]["bounds"]["min_x"], -1); assert_eq!(dims[0]["bounds"]["max_y"], 4); assert_eq!(dims[0]["id"], "minecraft:overworld"); assert_eq!(dims[1]["slug"], "the_nether"); assert_eq!(map.stats(5).tiles_received, 4); assert_eq!(map.epoch(5), 0); map.reset(5).unwrap(); assert_eq!(map.epoch(5), 1); assert!(map.summary(5)["dimensions"].as_array().unwrap().is_empty()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn tokens_are_per_server_and_players_are_cleaned() { let map = WorldMap::new(&std::env::temp_dir()); let t = map.issue_token(3); assert!(map.token_ok(3, &t)); assert!(!map.token_ok(4, &t), "a token only opens its own server"); assert!(!map.token_ok(3, "nope")); let p = |name: &str, x: f64| LivePlayer { uuid: "ab-12\u{7}zz".into(), name: format!("{name}\n"), dimension: "minecraft:overworld".into(), x, y: 64.0, z: 0.0, yaw: 0.0 }; map.set_players(3, vec![p("Alex", 1.0), p("Bad", f64::NAN), p("Far", 1e9)]); let live = map.live_players(3); assert_eq!(live.len(), 1); assert_eq!(live[0].name, "Alex"); assert_eq!(live[0].uuid, "ab-12", "only hex digits and dashes survive"); map.forget_player("AB-12"); assert!(map.live_players(3).is_empty()); } }