Files
SCOPENET-MC/panel/server/src/worldmap.rs
T

392 lines
16 KiB
Rust

//! 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: `<data>/map/<server id>/<dimension>/<zoom>/<x>_<y>.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<String>,
pub last_players_at: Option<String>,
pub last_overlay_at: Option<String>,
}
struct PlayersSnap {
at: Instant,
players: Vec<LivePlayer>,
}
pub struct WorldMap {
root: PathBuf,
players: Mutex<HashMap<i64, PlayersSnap>>,
tokens: Mutex<HashMap<String, (i64, Instant)>>,
overlays: Mutex<HashMap<i64, Value>>,
stats: Mutex<HashMap<i64, Stats>>,
}
/// 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<String> {
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>,
}
/// `[u8 zoom][i32 x][i32 y][u32 length][PNG bytes]` repeated, big endian.
pub fn parse_tiles(body: &[u8]) -> Result<Vec<TileRecord>, 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<usize> {
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<u8>, 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<String> = 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::<i32>(), ys.parse::<i32>()) 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<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().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<LivePlayer> {
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::<Value>(&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 `<img>` 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<u8> {
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(&not_png).is_err());
}
#[test]
fn tiles_are_stored_summarised_and_reset() {
let dir = std::env::temp_dir().join(format!("scopenet-map-{}", rand::random::<u64>()));
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());
}
}