From 9102e2fc52a45e052db1ed8c78fceac776eefe0b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:20:04 +0000 Subject: [PATCH 01/21] Panel: SCOPENET Map storage and endpoints; Vantage proxy removed --- panel/server/src/config.rs | 9 - panel/server/src/lib.rs | 4 +- panel/server/src/livemap.rs | 714 ---------------------------- panel/server/src/main.rs | 1 - panel/server/src/purge.rs | 2 +- panel/server/src/routes/livemap.rs | 296 ------------ panel/server/src/routes/mod.rs | 25 +- panel/server/src/routes/servers.rs | 65 +-- panel/server/src/routes/worldmap.rs | 173 +++++++ panel/server/src/state.rs | 2 +- panel/server/src/worldmap.rs | 391 +++++++++++++++ panel/server/tests/common/mod.rs | 21 +- panel/server/tests/livemap.rs | 183 ------- panel/server/tests/map.rs | 16 +- panel/server/tests/worldmap.rs | 135 ++++++ 15 files changed, 751 insertions(+), 1286 deletions(-) delete mode 100644 panel/server/src/livemap.rs delete mode 100644 panel/server/src/routes/livemap.rs create mode 100644 panel/server/src/routes/worldmap.rs create mode 100644 panel/server/src/worldmap.rs delete mode 100644 panel/server/tests/livemap.rs create mode 100644 panel/server/tests/worldmap.rs diff --git a/panel/server/src/config.rs b/panel/server/src/config.rs index a7b3c4b..44df8f4 100644 --- a/panel/server/src/config.rs +++ b/panel/server/src/config.rs @@ -14,12 +14,6 @@ pub struct Config { pub max_upload_mb: usize, pub public_url: Option, pub trusted_proxies: Vec, - /// Vantage generator binary (defaults to `vantage` on PATH or /opt/vantage). - pub vantage_bin: Option, - /// Minecraft client assets directory handed to Vantage. - pub vantage_assets: Option, - /// Extra arguments appended to `vantage server`. - pub vantage_args: Vec, } fn var(name: &str) -> Option { @@ -41,9 +35,6 @@ impl Config { trusted_proxies: var("SCOPENET_TRUSTED_PROXIES") .map(|s| s.split(',').map(|v| v.trim().parse().expect("SCOPENET_TRUSTED_PROXIES must contain IP addresses")).collect()) .unwrap_or_default(), - vantage_bin: var("SCOPENET_VANTAGE_BIN").map(Into::into), - vantage_assets: var("SCOPENET_VANTAGE_ASSETS").map(Into::into), - vantage_args: var("SCOPENET_VANTAGE_ARGS").map(|s| s.split_whitespace().map(str::to_owned).collect()).unwrap_or_default(), } } diff --git a/panel/server/src/lib.rs b/panel/server/src/lib.rs index a61f405..2ec45dc 100644 --- a/panel/server/src/lib.rs +++ b/panel/server/src/lib.rs @@ -7,7 +7,7 @@ pub mod auth; pub mod config; pub mod db; pub mod error; -pub mod livemap; +pub mod worldmap; pub mod net; pub mod packs; pub mod progression; @@ -63,7 +63,7 @@ pub async fn build_state_with_keys(cfg: config::Config, db: sqlx::SqlitePool, yg keys: Arc::new(auth::Keys::new(&secret)), http: scopenet_core::http::client(), login_guard: Arc::new(auth::LoginGuard::default()), - livemap: Arc::new(livemap::LiveMap::new(&cfg.data_dir, cfg.vantage_bin.clone(), cfg.vantage_assets.clone(), cfg.vantage_args.clone())), + worldmap: Arc::new(worldmap::WorldMap::new(&cfg.data_dir)), cfg: Arc::new(cfg), }) } diff --git a/panel/server/src/livemap.rs b/panel/server/src/livemap.rs deleted file mode 100644 index 29fe098..0000000 --- a/panel/server/src/livemap.rs +++ /dev/null @@ -1,714 +0,0 @@ -//! Central live map. -//! -//! Game servers (the SCOPENET plugin/mod) push the chunks that changed and -//! their players' positions to the panel. The panel keeps a mirror of each -//! server's Anvil region files under `/livemap//world`, runs a -//! Vantage `server` sidecar over that mirror on demand, and proxies the -//! sidecar to authenticated launcher/admin sessions. Nobody has to run -//! Vantage next to their Minecraft server. - -use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, VecDeque}; -use std::io::{Read, Seek, SeekFrom, Write}; -use std::path::{Path, PathBuf}; -use std::process::Stdio; -use std::sync::Mutex; -use std::time::{Duration, Instant}; -use tokio::io::{AsyncBufReadExt, BufReader}; - -pub const SECTOR: usize = 4096; -const HEADER: usize = 2 * SECTOR; -/// A chunk that needs more sectors than a location entry can address lives in -/// an external `.mcc` file; the mirror skips those (they are vanishingly rare). -const MAX_SECTORS: usize = 255; -/// Sidecars idle for this long are stopped and restarted on the next view. -pub const IDLE_STOP: Duration = Duration::from_secs(15 * 60); -/// Player positions older than this are treated as "server stopped sending". -const PLAYERS_FRESH: Duration = Duration::from_secs(45); - -// --------------------------------------------------------------------------- -// Dimensions -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Dim { - Overworld, - Nether, - End, -} - -impl Dim { - pub const ALL: [Dim; 3] = [Dim::Overworld, Dim::Nether, Dim::End]; - - /// Accepts the short slug (`the_nether`) or the namespaced id. - pub fn parse(s: &str) -> Option { - match s.trim().trim_start_matches("minecraft:") { - "overworld" => Some(Dim::Overworld), - "the_nether" | "nether" => Some(Dim::Nether), - "the_end" | "end" => Some(Dim::End), - _ => None, - } - } - pub fn slug(self) -> &'static str { - match self { - Dim::Overworld => "overworld", - Dim::Nether => "the_nether", - Dim::End => "the_end", - } - } - pub fn id(self) -> String { - format!("minecraft:{}", self.slug()) - } - /// Folder inside a vanilla save. - fn folder(self) -> &'static str { - match self { - Dim::Overworld => "", - Dim::Nether => "DIM-1", - Dim::End => "DIM1", - } - } -} - -// --------------------------------------------------------------------------- -// Anvil region writer -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone)] -pub struct Chunk { - pub x: i32, - pub z: i32, - /// Seconds since epoch, as stored in the region header. - pub timestamp: u32, - pub compression: u8, - pub data: Vec, -} - -pub fn region_path(dir: &Path, rx: i32, rz: i32) -> PathBuf { - dir.join(format!("r.{rx}.{rz}.mca")) -} - -fn slot(x: i32, z: i32) -> usize { - (x.rem_euclid(32) + z.rem_euclid(32) * 32) as usize -} - -fn sectors_for(len: usize) -> usize { - len.div_ceil(SECTOR) -} - -fn be32(b: &[u8]) -> u32 { - u32::from_be_bytes([b[0], b[1], b[2], b[3]]) -} - -/// Write `chunks` (all in one region) into `path`, creating it if needed. -/// Chunks older than what the file already holds are ignored. Returns the -/// number of chunks stored. -pub fn write_region(path: &Path, chunks: &[Chunk]) -> std::io::Result { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let mut file = std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(path)?; - let mut len = file.metadata()?.len() as usize; - let mut header = vec![0u8; HEADER]; - if len >= HEADER { - file.read_exact(&mut header)?; - } else { - file.set_len(0)?; - file.write_all(&header)?; - len = HEADER; - } - let mut end = len.div_ceil(SECTOR); - let mut stored = 0; - for c in chunks { - let i = slot(c.x, c.z); - let old_ts = be32(&header[SECTOR + i * 4..]); - if old_ts != 0 && old_ts > c.timestamp { - continue; - } - let body = c.data.len() + 1; - let count = sectors_for(body + 4); - if count > MAX_SECTORS || c.compression & 0x80 != 0 || c.data.is_empty() { - continue; - } - let mut record = Vec::with_capacity(count * SECTOR); - record.extend_from_slice(&(body as u32).to_be_bytes()); - record.push(c.compression); - record.extend_from_slice(&c.data); - record.resize(count * SECTOR, 0); - file.seek(SeekFrom::Start((end * SECTOR) as u64))?; - file.write_all(&record)?; - let loc = ((end as u32) << 8) | count as u32; - header[i * 4..i * 4 + 4].copy_from_slice(&loc.to_be_bytes()); - header[SECTOR + i * 4..SECTOR + i * 4 + 4].copy_from_slice(&c.timestamp.to_be_bytes()); - end += count; - stored += 1; - } - file.seek(SeekFrom::Start(0))?; - file.write_all(&header)?; - file.flush()?; - drop(file); - - // Rewriting a chunk appends it, leaving its old sectors behind. Compact - // once more than half the file is dead space. - let live: usize = (0..1024).map(|i| (be32(&header[i * 4..]) & 0xff) as usize).sum(); - if stored > 0 && end > 2 * (live + 2) + 64 { - compact(path)?; - } - Ok(stored) -} - -fn compact(path: &Path) -> std::io::Result<()> { - let data = std::fs::read(path)?; - if data.len() < HEADER { - return Ok(()); - } - let mut header = vec![0u8; HEADER]; - header[SECTOR..].copy_from_slice(&data[SECTOR..HEADER]); - let mut body: Vec = Vec::new(); - let mut next = 2usize; - for i in 0..1024 { - let loc = be32(&data[i * 4..]); - let (off, count) = ((loc >> 8) as usize, (loc & 0xff) as usize); - if count == 0 || (off + count) * SECTOR > data.len() { - continue; - } - body.extend_from_slice(&data[off * SECTOR..(off + count) * SECTOR]); - header[i * 4..i * 4 + 4].copy_from_slice(&(((next as u32) << 8) | count as u32).to_be_bytes()); - next += count; - } - let tmp = path.with_extension("mca.tmp"); - let mut out = std::fs::File::create(&tmp)?; - out.write_all(&header)?; - out.write_all(&body)?; - out.flush()?; - drop(out); - std::fs::rename(tmp, path) -} - -/// The 4 KiB timestamp table of a region file, or `None` if it doesn't exist. -pub fn read_timestamps(path: &Path) -> Option> { - let mut f = std::fs::File::open(path).ok()?; - f.seek(SeekFrom::Start(SECTOR as u64)).ok()?; - let mut buf = vec![0u8; SECTOR]; - f.read_exact(&mut buf).ok()?; - Some(buf) -} - -/// Parse a batch upload: repeated `i32 x, i32 z, u32 timestamp, u8 -/// compression, u32 length, bytes` records, big-endian. -pub fn parse_chunks(mut b: &[u8]) -> Result, &'static str> { - let mut out = Vec::new(); - while !b.is_empty() { - if b.len() < 17 { - return Err("truncated chunk record"); - } - let x = be32(b) as i32; - let z = be32(&b[4..]) as i32; - let timestamp = be32(&b[8..]); - let compression = b[12]; - let len = be32(&b[13..]) as usize; - b = &b[17..]; - if len > MAX_SECTORS * SECTOR || len > b.len() { - return Err("chunk record exceeds its payload"); - } - if x.unsigned_abs() > 3_750_000 || z.unsigned_abs() > 3_750_000 { - return Err("chunk coordinates are outside the world"); - } - out.push(Chunk { x, z, timestamp, compression, data: b[..len].to_vec() }); - b = &b[len..]; - } - Ok(out) -} - -// --------------------------------------------------------------------------- -// Live players -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct LivePlayer { - pub uuid: String, - pub name: String, - #[serde(default)] - pub dimension: String, - pub x: f64, - pub y: f64, - pub z: f64, - #[serde(default)] - pub yaw: f64, - #[serde(default)] - pub pitch: f64, -} - -struct Snapshot { - at: Instant, - players: Vec, -} - -// --------------------------------------------------------------------------- -// Sidecar supervisor -// --------------------------------------------------------------------------- - -struct Sidecar { - child: tokio::process::Child, - port: u16, - token: String, - last_used: Instant, - log: std::sync::Arc>>, -} - -#[derive(Default, Clone, Serialize)] -pub struct ServerStats { - pub chunks_received: u64, - pub last_chunk_at: Option, - pub last_players_at: Option, -} - -pub struct LiveMap { - root: PathBuf, - bin: Option, - assets: Option, - extra_args: Vec, - players: Mutex>, - stats: Mutex>, - sidecars: tokio::sync::Mutex>, - last_error: Mutex>, - write_lock: tokio::sync::Mutex<()>, - players_written: Mutex>, -} - -fn find_bin(configured: Option<&Path>) -> Option { - let candidates: Vec = match configured { - Some(p) => vec![p.to_path_buf()], - None => { - let mut v: Vec = - std::env::var_os("PATH").map(|p| std::env::split_paths(&p).map(|d| d.join("vantage")).collect()).unwrap_or_default(); - v.push("/opt/vantage/vantage".into()); - v - } - }; - candidates.into_iter().find(|p| p.is_file()) -} - -impl LiveMap { - pub fn new(data_dir: &Path, bin: Option, assets: Option, extra_args: Vec) -> Self { - let assets = assets.or_else(|| { - let p = data_dir.join("livemap").join("assets"); - p.is_dir().then_some(p) - }); - Self { - root: data_dir.join("livemap"), - bin: find_bin(bin.as_deref()), - assets, - extra_args, - players: Mutex::default(), - stats: Mutex::default(), - sidecars: Default::default(), - last_error: Mutex::default(), - write_lock: Default::default(), - players_written: Mutex::default(), - } - } - - pub fn server_dir(&self, id: i64) -> PathBuf { - self.root.join(id.to_string()) - } - pub fn world_dir(&self, id: i64) -> PathBuf { - self.server_dir(id).join("world") - } - pub fn region_dir(&self, id: i64, dim: Dim) -> PathBuf { - let w = self.world_dir(id); - if dim.folder().is_empty() { - w.join("region") - } else { - w.join(dim.folder()).join("region") - } - } - fn cache_dir(&self, id: i64, dim: Dim) -> PathBuf { - self.server_dir(id).join("cache").join(dim.slug()) - } - fn players_file(&self, id: i64) -> PathBuf { - self.server_dir(id).join("players.json") - } - - pub fn generator_ready(&self) -> (bool, bool) { - (self.bin.is_some(), self.assets.as_ref().is_some_and(|a| a.is_dir())) - } - pub fn generator_path(&self) -> Option { - self.bin.as_ref().map(|p| p.display().to_string()) - } - pub fn assets_path(&self) -> Option { - self.assets.as_ref().map(|p| p.display().to_string()) - } - - // ---- ingest ---- - - /// Write a batch of chunks into the server's mirror. Returns how many - /// chunks were stored. - pub async fn ingest_chunks(&self, id: i64, dim: Dim, chunks: Vec) -> std::io::Result { - let dir = self.region_dir(id, dim); - let _guard = self.write_lock.lock().await; - let stored = tokio::task::spawn_blocking(move || { - let mut by_region: HashMap<(i32, i32), Vec> = HashMap::new(); - for c in chunks { - by_region.entry((c.x.div_euclid(32), c.z.div_euclid(32))).or_default().push(c); - } - let mut stored = 0; - for ((rx, rz), list) in by_region { - stored += write_region(®ion_path(&dir, rx, rz), &list)?; - } - Ok::<_, std::io::Error>(stored) - }) - .await - .map_err(std::io::Error::other)??; - let mut stats = self.stats.lock().unwrap(); - let s = stats.entry(id).or_default(); - s.chunks_received += stored as u64; - if stored > 0 { - s.last_chunk_at = Some(crate::db::now()); - } - Ok(stored) - } - - pub async fn ingest_level(&self, id: i64, bytes: Vec) -> std::io::Result<()> { - let dir = self.world_dir(id); - tokio::fs::create_dir_all(&dir).await?; - let tmp = dir.join("level.dat.tmp"); - tokio::fs::write(&tmp, bytes).await?; - tokio::fs::rename(tmp, dir.join("level.dat")).await - } - - /// Timestamp tables for every mirrored region of one dimension, so the - /// game server only uploads chunks that are newer. - pub fn manifest(&self, id: i64, dim: Dim) -> Vec<(i32, i32, Vec)> { - let mut out = Vec::new(); - let Ok(rd) = std::fs::read_dir(self.region_dir(id, dim)) else { return out }; - for e in rd.flatten() { - let name = e.file_name().to_string_lossy().to_string(); - let Some(rest) = name.strip_prefix("r.").and_then(|r| r.strip_suffix(".mca")) else { continue }; - let Some((a, b)) = rest.split_once('.') else { continue }; - let (Ok(rx), Ok(rz)) = (a.parse::(), b.parse::()) else { continue }; - if let Some(ts) = read_timestamps(&e.path()) { - out.push((rx, rz, ts)); - } - } - out - } - - pub fn set_players(&self, id: i64, players: Vec) { - self.players.lock().unwrap().insert(id, Snapshot { at: Instant::now(), players: players.clone() }); - self.stats.lock().unwrap().entry(id).or_default().last_players_at = Some(crate::db::now()); - // Vantage reads this file to decide what to pre-render; throttle it. - let mut written = self.players_written.lock().unwrap(); - if written.get(&id).is_none_or(|t| t.elapsed() > Duration::from_secs(5)) { - written.insert(id, Instant::now()); - let doc = players_doc(&players, None); - let path = self.players_file(id); - if std::fs::create_dir_all(self.server_dir(id)).is_ok() { - let _ = std::fs::write(path, doc.to_string()); - } - } - } - - /// Drop a player from every server's live 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)); - } - } - - 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() - } - - /// `players.json` for one dimension (BlueMap-compatible, which Vantage reads). - pub fn players_json(&self, id: i64, dim: Dim) -> serde_json::Value { - players_doc(&self.live_players(id), Some(dim)) - } - - pub fn stats(&self, id: i64) -> ServerStats { - self.stats.lock().unwrap().get(&id).cloned().unwrap_or_default() - } - - /// Disk usage and regions per dimension. - pub fn world_summary(&self, id: i64) -> serde_json::Value { - let mut dims = Vec::new(); - for dim in Dim::ALL { - let (mut regions, mut bytes) = (0u64, 0u64); - if let Ok(rd) = std::fs::read_dir(self.region_dir(id, dim)) { - for e in rd.flatten() { - if let Ok(m) = e.metadata() { - regions += 1; - bytes += m.len(); - } - } - } - dims.push(serde_json::json!({ "id": dim.id(), "slug": dim.slug(), "regions": regions, "bytes": bytes })); - } - serde_json::json!({ "level_dat": self.world_dir(id).join("level.dat").is_file(), "dimensions": dims }) - } - - pub async fn sidecars_running(&self, id: i64) -> Vec { - self.sidecars.lock().await.keys().filter(|(s, _)| *s == id).map(|(_, d)| d.slug().to_string()).collect() - } - - pub fn last_error(&self, id: i64) -> Option { - let errs = self.last_error.lock().unwrap(); - Dim::ALL.iter().find_map(|d| errs.get(&(id, *d)).cloned()) - } - - // ---- sidecars ---- - - /// Make sure a Vantage sidecar is serving this server/dimension and - /// return its loopback port and bearer token. - pub async fn ensure_sidecar(&self, id: i64, dim: Dim) -> Result<(u16, String), String> { - let bin = self.bin.clone().ok_or("The Vantage generator isn't installed on this panel. See docs/live-map.md.")?; - let assets = self - .assets - .clone() - .filter(|a| a.is_dir()) - .ok_or("Minecraft client assets for Vantage aren't configured. See docs/live-map.md.")?; - let world = self.world_dir(id); - if !world.join("level.dat").is_file() || !self.region_dir(id, dim).is_dir() { - return Err("Waiting for the game server to upload this world.".into()); - } - - let mut sidecars = self.sidecars.lock().await; - if let Some(s) = sidecars.get_mut(&(id, dim)) { - if matches!(s.child.try_wait(), Ok(None)) { - s.last_used = Instant::now(); - return Ok((s.port, s.token.clone())); - } - sidecars.remove(&(id, dim)); - } - - let port = { - let l = std::net::TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?; - l.local_addr().map_err(|e| e.to_string())?.port() - }; - let token = { - use rand::RngCore; - let mut b = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut b); - hex::encode(b) - }; - let cache = self.cache_dir(id, dim); - tokio::fs::create_dir_all(&cache).await.map_err(|e| e.to_string())?; - let players = self.players_file(id); - if !players.exists() { - let _ = std::fs::write(&players, players_doc(&[], None).to_string()); - } - let mut cmd = tokio::process::Command::new(&bin); - cmd.arg("server") - .arg(&world) - .arg("--assets") - .arg(&assets) - .arg("--out") - .arg(&cache) - .args(["--host", "127.0.0.1", "--port"]) - .arg(port.to_string()) - .arg("--players-file") - .arg(&players) - .args(["--dimension", dim.slug()]) - .args(&self.extra_args) - .env("VANTAGE_SERVER_TOKEN", &token) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); - let mut child = cmd.spawn().map_err(|e| format!("couldn't start the Vantage generator: {e}"))?; - let log: std::sync::Arc>> = Default::default(); - if let Some(out) = child.stdout.take() { - tail(out, log.clone()); - } - if let Some(err) = child.stderr.take() { - tail(err, log.clone()); - } - let mut sidecar = Sidecar { child, port, token: token.clone(), last_used: Instant::now(), log }; - - // Wait for it to answer health checks. - let client = reqwest::Client::new(); - let url = format!("http://127.0.0.1:{port}/v1/health"); - let deadline = Instant::now() + Duration::from_secs(30); - loop { - if let Ok(Some(status)) = sidecar.child.try_wait() { - let tail = sidecar - .log - .lock() - .unwrap() - .iter() - .rev() - .take(4) - .cloned() - .collect::>() - .into_iter() - .rev() - .collect::>() - .join(" | "); - let msg = format!("Vantage exited ({status}) {tail}"); - self.last_error.lock().unwrap().insert((id, dim), msg.clone()); - return Err(msg); - } - if client.get(&url).timeout(Duration::from_secs(2)).send().await.is_ok_and(|r| r.status().is_success()) { - break; - } - if Instant::now() > deadline { - let _ = sidecar.child.start_kill(); - let msg = "Vantage didn't become ready within 30 seconds".to_string(); - self.last_error.lock().unwrap().insert((id, dim), msg.clone()); - return Err(msg); - } - tokio::time::sleep(Duration::from_millis(250)).await; - } - self.last_error.lock().unwrap().remove(&(id, dim)); - sidecars.insert((id, dim), sidecar); - Ok((port, token)) - } - - /// Stop sidecars nobody has looked at recently. - pub async fn reap_idle(&self) { - let mut sidecars = self.sidecars.lock().await; - let idle: Vec<_> = sidecars.iter().filter(|(_, s)| s.last_used.elapsed() > IDLE_STOP).map(|(k, _)| *k).collect(); - for key in idle { - if let Some(mut s) = sidecars.remove(&key) { - let _ = s.child.start_kill(); - tracing::info!("stopped idle live-map generator for server {} ({})", key.0, key.1.slug()); - } - } - } - - pub fn spawn_reaper(self: &std::sync::Arc) { - let me = std::sync::Arc::downgrade(self); - tokio::spawn(async move { - loop { - tokio::time::sleep(Duration::from_secs(60)).await; - let Some(lm) = me.upgrade() else { break }; - lm.reap_idle().await; - } - }); - } - - /// Stop every sidecar of a server and delete its mirror and caches. - pub async fn reset(&self, id: i64) -> std::io::Result<()> { - { - let mut sidecars = self.sidecars.lock().await; - let keys: Vec<_> = sidecars.keys().filter(|(s, _)| *s == id).copied().collect(); - for k in keys { - if let Some(mut s) = sidecars.remove(&k) { - let _ = s.child.kill().await; - } - } - } - let _guard = self.write_lock.lock().await; - self.players.lock().unwrap().remove(&id); - self.stats.lock().unwrap().remove(&id); - match tokio::fs::remove_dir_all(self.server_dir(id)).await { - Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e), - _ => Ok(()), - } - } -} - -fn tail(r: R, log: std::sync::Arc>>) { - tokio::spawn(async move { - let mut lines = BufReader::new(r).lines(); - while let Ok(Some(line)) = lines.next_line().await { - let mut l = log.lock().unwrap(); - if l.len() >= 30 { - l.pop_front(); - } - l.push_back(line.chars().take(300).collect()); - } - }); -} - -fn players_doc(players: &[LivePlayer], dim: Option) -> serde_json::Value { - let list: Vec<_> = players - .iter() - .map(|p| { - let here = dim.is_none_or(|d| Dim::parse(&p.dimension).unwrap_or(Dim::Overworld) == d); - serde_json::json!({ - "uuid": p.uuid, - "name": p.name, - "foreign": !here, - "position": { "x": p.x, "y": p.y, "z": p.z }, - "rotation": { "pitch": p.pitch, "yaw": p.yaw, "roll": 0 }, - }) - }) - .collect(); - serde_json::json!({ "players": list }) -} - -/// Sidecar port and token for the proxy, without starting anything. -impl LiveMap { - pub async fn sidecar_log(&self, id: i64, dim: Dim) -> Vec { - self.sidecars.lock().await.get(&(id, dim)).map(|s| s.log.lock().unwrap().iter().cloned().collect()).unwrap_or_default() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn chunk(x: i32, z: i32, ts: u32, byte: u8, len: usize) -> Chunk { - Chunk { x, z, timestamp: ts, compression: 2, data: vec![byte; len] } - } - - fn read_chunk(path: &Path, x: i32, z: i32) -> Option<(u32, Vec)> { - let data = std::fs::read(path).ok()?; - let i = slot(x, z); - let loc = be32(&data[i * 4..]); - if loc == 0 { - return None; - } - let off = (loc >> 8) as usize * SECTOR; - let len = be32(&data[off..]) as usize; - Some((be32(&data[SECTOR + i * 4..]), data[off + 5..off + 4 + len].to_vec())) - } - - #[test] - fn writes_reads_and_rewrites_chunks() { - let dir = tempfile::tempdir().unwrap(); - let path = region_path(dir.path(), 0, 0); - assert_eq!(write_region(&path, &[chunk(1, 2, 10, 7, 5000), chunk(-1 + 32, 0, 10, 9, 10)]).unwrap(), 2); - assert_eq!(read_chunk(&path, 1, 2), Some((10, vec![7; 5000]))); - // Newer replaces, older is ignored. - assert_eq!(write_region(&path, &[chunk(1, 2, 20, 8, 100)]).unwrap(), 1); - assert_eq!(write_region(&path, &[chunk(1, 2, 5, 1, 100)]).unwrap(), 0); - assert_eq!(read_chunk(&path, 1, 2), Some((20, vec![8; 100]))); - assert_eq!(read_chunk(&path, 31, 0), Some((10, vec![9; 10]))); - assert_eq!(be32(&read_timestamps(&path).unwrap()[slot(1, 2) * 4..]), 20); - } - - #[test] - fn compaction_keeps_live_chunks() { - let dir = tempfile::tempdir().unwrap(); - let path = region_path(dir.path(), -1, 3); - for ts in 1..40u32 { - write_region(&path, &[chunk(-32, 96, ts, ts as u8, 20_000)]).unwrap(); - } - write_region(&path, &[chunk(-31, 96, 1, 5, 50)]).unwrap(); - assert!(std::fs::metadata(&path).unwrap().len() < 40 * 20_000 / 2); - assert_eq!(read_chunk(&path, -32, 96), Some((39, vec![39; 20_000]))); - assert_eq!(read_chunk(&path, -31, 96), Some((1, vec![5; 50]))); - } - - #[test] - fn parses_upload_batches() { - let mut b = Vec::new(); - b.extend_from_slice(&(-3i32).to_be_bytes()); - b.extend_from_slice(&4i32.to_be_bytes()); - b.extend_from_slice(&99u32.to_be_bytes()); - b.push(2); - b.extend_from_slice(&3u32.to_be_bytes()); - b.extend_from_slice(&[1, 2, 3]); - let chunks = parse_chunks(&b).unwrap(); - assert_eq!((chunks[0].x, chunks[0].z, chunks[0].timestamp, chunks[0].data.clone()), (-3, 4, 99, vec![1, 2, 3])); - assert!(parse_chunks(&b[..b.len() - 1]).is_err()); - } - - #[test] - fn dimensions_parse() { - assert_eq!(Dim::parse("minecraft:the_nether"), Some(Dim::Nether)); - assert_eq!(Dim::parse("overworld"), Some(Dim::Overworld)); - assert_eq!(Dim::parse("../etc"), None); - } -} diff --git a/panel/server/src/main.rs b/panel/server/src/main.rs index ae6957c..77ae757 100644 --- a/panel/server/src/main.rs +++ b/panel/server/src/main.rs @@ -22,7 +22,6 @@ async fn main() -> anyhow::Result<()> { let bind = cfg.bind.clone(); let state = build_state(cfg, pool).await?; bootstrap_admin(&state).await?; - state.livemap.spawn_reaper(); scopenet_panel::routes::discord::spawn_worker(state.clone()); let listener = tokio::net::TcpListener::bind(&bind).await?; diff --git a/panel/server/src/purge.rs b/panel/server/src/purge.rs index ae5081f..0215fca 100644 --- a/panel/server/src/purge.rs +++ b/panel/server/src/purge.rs @@ -157,6 +157,6 @@ pub async fn purge_user(state: &AppState, user: &UserRow) -> AppResult 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 (generator, assets) = state.livemap.generator_ready(); - 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 has_world = summary["level_dat"].as_bool().unwrap_or(false) && dims.iter().any(|d| d["available"] == true); - let message = if !enabled { - "Live Map is off for this server." - } else if !generator || !assets { - "The map generator isn't set up on the panel yet." - } else if !has_world { - "Waiting for the game server to upload its world." - } else { - "" - }; - json!({ - "enabled": enabled, - "ready": enabled && generator && assets && has_world, - "message": message, - "base": format!("/api/livemap/{id}"), - "dimensions": dims, - "players": state.livemap.live_players(id).len(), - }) -} - -/// What the launcher needs to decide whether to offer the Live Map button. -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.live_map_enabled).await)) -} - -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 (generator, assets) = state.livemap.generator_ready(); - let stats = state.livemap.stats(id); - v["generator"] = json!({ "installed": generator, "path": state.livemap.generator_path() }); - v["assets"] = json!({ "configured": assets, "path": state.livemap.assets_path() }); - v["world"] = state.livemap.world_summary(id); - v["stats"] = serde_json::to_value(stats)?; - v["running"] = json!(state.livemap.sidecars_running(id).await); - v["last_error"] = json!(state.livemap.last_error(id)); - 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 }))) -} - -/// Authenticated reverse proxy to the per-server Vantage sidecar. Clients -/// use `…/api/livemap/{server}/{dimension}/v1/worlds/default/manifest.json` -/// and everything the manifest references. -pub async fn proxy( - _: AuthUser, - State(state): State, - Path((id, dim, path)): Path<(i64, String, String)>, - headers: HeaderMap, -) -> AppResult { - let server = get_server(&state, id).await?; - require_enabled(server.live_map_enabled)?; - let dim = dim_of(&dim)?; - if path.split('/').any(|s| s == ".." || s.is_empty() || s.contains('\\')) || !path.starts_with("v1/worlds/default/") { - return Err(AppError::not_found("not found")); - } - if path == "v1/worlds/default/players.json" { - let mut resp = Json(state.livemap.players_json(id, dim)).into_response(); - resp.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); - return Ok(resp); - } - let (port, token) = state.livemap.ensure_sidecar(id, dim).await.map_err(|m| AppError::new(StatusCode::SERVICE_UNAVAILABLE, m))?; - let mut req = state.http.get(format!("http://127.0.0.1:{port}/{path}")).bearer_auth(token).timeout(std::time::Duration::from_secs(120)); - if let Some(etag) = headers.get(header::IF_NONE_MATCH) { - req = req.header(header::IF_NONE_MATCH, etag); - } - let upstream = req.send().await.map_err(|e| AppError::new(StatusCode::BAD_GATEWAY, format!("map generator unreachable: {e}")))?; - let mut out = Response::builder().status(upstream.status()); - for name in [header::CONTENT_TYPE, header::ETAG, header::CACHE_CONTROL, header::LAST_MODIFIED] { - if let Some(v) = upstream.headers().get(&name) { - out = out.header(name, v); - } - } - // Tiles are immutable per etag; make sure shared caches never keep them. - Ok(out - .header(header::VARY, "Authorization") - .body(Body::from_stream(upstream.bytes_stream())) - .map_err(|e| AppError::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?) -} diff --git a/panel/server/src/routes/mod.rs b/panel/server/src/routes/mod.rs index b94d1b6..25efd70 100644 --- a/panel/server/src/routes/mod.rs +++ b/panel/server/src/routes/mod.rs @@ -10,7 +10,7 @@ pub mod guilds; pub mod discord; pub mod integrations; pub mod landing; -pub mod livemap; +pub mod worldmap; pub mod map; pub mod leveling; pub mod meta; @@ -53,7 +53,8 @@ pub fn api(state: &AppState) -> Router { .route("/avatar/{id}", get(account::avatar)) .route("/servers/public", get(servers::public_servers)) .route("/servers/{id}/leaderboard", get(servers::public_server_leaderboard)) - .route("/servers/{id}/livemap", get(livemap::viewer_info)) + .route("/servers/{id}/map", get(worldmap::viewer_info)) + .route("/servers/{id}/map/overlay", get(worldmap::viewer_overlay)) .route("/servers/{id}/map/actions", post(map::map_action)) .route("/guilds/invites", get(map::my_invites)) .route("/guilds/invites/{id}/accept", post(map::accept_invite)) @@ -154,7 +155,7 @@ pub fn api(state: &AppState) -> Router { .route("/servers", get(servers::list).post(servers::create)) .route("/servers/{id}", get(servers::detail).put(servers::update).delete(servers::remove)) .route("/servers/{id}/token", post(servers::regenerate_token)) - .route("/servers/{id}/livemap", get(livemap::admin_status).delete(livemap::admin_reset)) + .route("/servers/{id}/map", get(worldmap::admin_status).delete(worldmap::admin_reset)) .route("/users/{id}/activity", get(servers::user_activity)) .route("/meta/minecraft", get(meta::minecraft)) .route("/meta/loaders/{loader}", get(meta::loaders)) @@ -225,20 +226,18 @@ pub fn api(state: &AppState) -> Router { .route("/economy/market/buy", post(economy::server_market_buy)) .layer(DefaultBodyLimit::max(2 * 1024 * 1024)); - // Live map ingest: chunk batches are larger than the other game calls. - let livemap_game = Router::new() - .route("/livemap/config", get(livemap::game_config_route)) - .route("/livemap/manifest", get(livemap::game_manifest)) - .route("/livemap/chunks", post(livemap::game_chunks)) - .route("/livemap/level", post(livemap::game_level)) - .route("/livemap/players", post(livemap::game_players)) - .route("/livemap/ws", get(livemap::game_ws)) + // Map ingest: tile batches are larger than the other game calls. + let map_game = Router::new() + .route("/map/config", get(worldmap::game_config)) + .route("/map/tiles", post(worldmap::game_tiles)) + .route("/map/players", post(worldmap::game_players)) + .route("/map/overlay", post(worldmap::game_overlay)) .layer(DefaultBodyLimit::max(10 * 1024 * 1024)); Router::new() - .route("/api/livemap/{id}/{dim}/{*path}", get(livemap::proxy)) + .route("/api/map/{id}/{dim}/{z}/{x}/{file}", get(worldmap::tile)) .nest("/api/v1", launcher) - .nest("/api/server/v1", game.merge(livemap_game)) + .nest("/api/server/v1", game.merge(map_game)) .nest("/api/admin", admin) .merge(crate::yggdrasil::routes().layer(DefaultBodyLimit::max(4 * 1024 * 1024))) .layer(axum::middleware::from_fn_with_state(state.clone(), activity::audit)) diff --git a/panel/server/src/routes/servers.rs b/panel/server/src/routes/servers.rs index b6c6af7..9e57e2f 100644 --- a/panel/server/src/routes/servers.rs +++ b/panel/server/src/routes/servers.rs @@ -61,11 +61,9 @@ fn clip(s: &str, max: usize) -> String { #[derive(Debug, Clone, sqlx::FromRow, Serialize)] pub struct ServerRow { pub instance_id: String, - pub map_url: String, - pub live_map_enabled: bool, - /// Address of this server's BlueMap (https://map.example.com). Shown in the launcher when set. - #[sqlx(default)] - pub map_address: String, + /// Whether this server draws the SCOPENET Map (stored in the old `live_map_enabled` column). + #[sqlx(rename = "live_map_enabled")] + pub map_enabled: bool, /// Servers with the same non-empty name share player balances and guild banks. #[sqlx(default)] pub economy_group: String, @@ -194,7 +192,7 @@ pub async fn hello( "sync_interval_secs": SYNC_INTERVAL_SECS, "access": server.access, "require_launcher": server.require_launcher, - "live_map": super::livemap::game_config(server.live_map_enabled), + "map": json!({ "enabled": server.map_enabled }), }))) } @@ -866,14 +864,15 @@ pub struct ServerInput { allowed_groups: Vec, #[serde(default)] require_launcher: bool, - #[serde(default)] - map_url: String, - #[serde(default)] - live_map_enabled: bool, + /// Draw this server on the SCOPENET Map. On unless switched off. + #[serde(default = "yes")] + map_enabled: bool, #[serde(default)] economy_group: String, - #[serde(default)] - map_address: String, +} + +fn yes() -> bool { + true } fn default_access() -> String { @@ -895,31 +894,6 @@ impl ServerInput { if group.chars().count() > 32 || !group.chars().all(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_')) { return Err(AppError::bad_request("economy groups are up to 32 letters, digits, spaces, - or _")); } - let address = self.map_address.trim(); - if !address.is_empty() { - let url = reqwest::Url::parse(address).map_err(|_| AppError::bad_request("map address must be a full web address like https://map.example.com"))?; - if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { - return Err(AppError::bad_request("map address must start with http:// or https://")); - } - if url.username() != "" || url.password().is_some() || url.fragment().is_some() { - return Err(AppError::bad_request("map address can't contain credentials or a fragment")); - } - if address.len() > 300 { - return Err(AppError::bad_request("map address is too long")); - } - } - if !self.map_url.trim().is_empty() { - let url = reqwest::Url::parse(self.map_url.trim()).map_err(|_| AppError::bad_request("map URL must be an absolute URL"))?; - if url.scheme() != "https" && !(url.scheme() == "http" && matches!(url.host_str(), Some("localhost" | "127.0.0.1"))) { - return Err(AppError::bad_request("map URL must use HTTPS (or localhost for development)")); - } - if url.username() != "" || url.password().is_some() || url.fragment().is_some() || url.query().is_some() { - return Err(AppError::bad_request("map URL cannot contain credentials, query parameters, or a fragment")); - } - if !matches!(url.path().rsplit('/').next(), Some("manifest.json" | "world.json")) { - return Err(AppError::bad_request("map URL must end with manifest.json or world.json")); - } - } Ok(()) } } @@ -936,7 +910,7 @@ pub async fn create(_: AdminUser, State(state): State, Json(input): Js input.validate()?; let token = new_token(); let id: i64 = sqlx::query_scalar( - "INSERT INTO game_servers (name, token_hash, token_hint, access, allowed_groups, require_launcher, created_at, instance_id, map_url, live_map_enabled, economy_group, map_address) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", + "INSERT INTO game_servers (name, token_hash, token_hint, access, allowed_groups, require_launcher, created_at, instance_id, live_map_enabled, economy_group) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", ) .bind(input.name.trim()) .bind(hash_token(&token)) @@ -946,10 +920,8 @@ pub async fn create(_: AdminUser, State(state): State, Json(input): Js .bind(input.require_launcher) .bind(now()) .bind(&input.instance_id) - .bind(input.map_url.trim()) - .bind(input.live_map_enabled) + .bind(input.map_enabled) .bind(input.economy_group.trim()) - .bind(input.map_address.trim()) .fetch_one(&state.db) .await?; let server = get_server(&state, id).await?; @@ -964,16 +936,14 @@ pub async fn update( ) -> AppResult> { input.validate()?; get_server(&state, id).await?; - sqlx::query("UPDATE game_servers SET name = ?, access = ?, allowed_groups = ?, require_launcher = ?, instance_id = ?, map_url = ?, live_map_enabled = ?, economy_group = ?, map_address = ? WHERE id = ?") + sqlx::query("UPDATE game_servers SET name = ?, access = ?, allowed_groups = ?, require_launcher = ?, instance_id = ?, live_map_enabled = ?, economy_group = ? WHERE id = ?") .bind(input.name.trim()) .bind(&input.access) .bind(serde_json::to_string(&input.allowed_groups).unwrap_or_else(|_| "[]".into())) .bind(input.require_launcher) .bind(&input.instance_id) - .bind(input.map_url.trim()) - .bind(input.live_map_enabled) + .bind(input.map_enabled) .bind(input.economy_group.trim()) - .bind(input.map_address.trim()) .bind(id) .execute(&state.db) .await?; @@ -1004,6 +974,7 @@ pub async fn remove(_: AdminUser, State(state): State, Path(id): Path< } } } + state.worldmap.forget_server(id); let done = sqlx::query("DELETE FROM game_servers WHERE id = ?").bind(id).execute(&state.db).await?; if done.rows_affected() == 0 { return Err(AppError::not_found("server not found")); @@ -1168,9 +1139,7 @@ pub async fn public_servers(State(state): State) -> AppResult 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 +} diff --git a/panel/server/src/state.rs b/panel/server/src/state.rs index 6bbf130..150d8ec 100644 --- a/panel/server/src/state.rs +++ b/panel/server/src/state.rs @@ -13,5 +13,5 @@ pub struct AppState { pub ygg: Arc, pub http: reqwest::Client, pub login_guard: Arc, - pub livemap: Arc, + pub worldmap: Arc, } diff --git a/panel/server/src/worldmap.rs b/panel/server/src/worldmap.rs new file mode 100644 index 0000000..6477391 --- /dev/null +++ b/panel/server/src/worldmap.rs @@ -0,0 +1,391 @@ +//! 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()); + } +} diff --git a/panel/server/tests/common/mod.rs b/panel/server/tests/common/mod.rs index 7949600..3953e98 100644 --- a/panel/server/tests/common/mod.rs +++ b/panel/server/tests/common/mod.rs @@ -39,9 +39,6 @@ pub async fn setup_with(tweak: impl FnOnce(&mut Config, &std::path::Path)) -> Te max_upload_mb: 64, public_url: Some("https://panel.test".into()), trusted_proxies: vec!["127.0.0.1".parse().unwrap()], - vantage_bin: None, - vantage_assets: None, - vantage_args: vec![], }; tweak(&mut cfg, dir.path()); let pool = db::connect_memory().await.unwrap(); @@ -66,6 +63,24 @@ impl TestApp { self.send(req).await } + /// POST raw bytes (binary uploads). + pub async fn call_raw(&self, method: &str, uri: &str, token: Option<&str>, body: Vec) -> (StatusCode, Value) { + let mut req = Request::builder().method(method).uri(uri).header("content-type", "application/octet-stream"); + if let Some(t) = token { + req = req.header("authorization", format!("Bearer {t}")); + } + self.send(req.body(Body::from(body)).unwrap()).await + } + + /// GET and keep the raw bytes (images). + pub async fn fetch(&self, uri: &str) -> (StatusCode, Vec) { + let mut req = Request::builder().method("GET").uri(uri).body(Body::empty()).unwrap(); + req.extensions_mut().insert(axum::extract::ConnectInfo("127.0.0.1:12345".parse::().unwrap())); + let resp = self.router.clone().oneshot(req).await.unwrap(); + let status = resp.status(); + (status, axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap().to_vec()) + } + pub async fn send(&self, mut req: Request) -> (StatusCode, Value) { if req.extensions().get::>().is_none() { req.extensions_mut().insert(axum::extract::ConnectInfo("127.0.0.1:12345".parse::().unwrap())); diff --git a/panel/server/tests/livemap.rs b/panel/server/tests/livemap.rs deleted file mode 100644 index 717d00b..0000000 --- a/panel/server/tests/livemap.rs +++ /dev/null @@ -1,183 +0,0 @@ -//! Central live map: ingest from game servers, status for admins, viewer proxy. - -mod common; -use common::*; - -fn record(x: i32, z: i32, ts: u32, data: &[u8]) -> Vec { - let mut b = Vec::new(); - b.extend_from_slice(&x.to_be_bytes()); - b.extend_from_slice(&z.to_be_bytes()); - b.extend_from_slice(&ts.to_be_bytes()); - b.push(2); - b.extend_from_slice(&(data.len() as u32).to_be_bytes()); - b.extend_from_slice(data); - b -} - -async fn post_bytes(t: &TestApp, uri: &str, token: &str, body: Vec) -> (StatusCode, Value) { - let req = Request::builder().method("POST").uri(uri).header("authorization", format!("Bearer {token}")).body(Body::from(body)).unwrap(); - t.send(req).await -} - -#[tokio::test] -async fn live_map_ingest_status_and_proxy() { - let t = setup().await; - let admin = t.login("admin", "supersecret").await; - let (_, v) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "Survival"}))).await; - let id = v["server"]["id"].as_i64().unwrap(); - let token = v["token"].as_str().unwrap().to_string(); - - // Off by default: nothing is accepted. - let (s, _) = post_bytes(&t, "/api/server/v1/livemap/chunks?dim=overworld", &token, record(0, 0, 5, b"abc")).await; - assert_eq!(s, StatusCode::FORBIDDEN); - let (_, hello) = t.call("POST", "/api/server/v1/hello", Some(&token), Some(json!({}))).await; - assert_eq!(hello["live_map"]["enabled"], false); - - let (s, v) = - t.call("PUT", &format!("/api/admin/servers/{id}"), Some(&admin), Some(json!({"name": "Survival", "live_map_enabled": true}))).await; - assert_eq!(s, StatusCode::OK, "{v}"); - assert_eq!(v["live_map_enabled"], true); - let (_, hello) = t.call("POST", "/api/server/v1/hello", Some(&token), Some(json!({}))).await; - assert_eq!(hello["live_map"]["enabled"], true); - - // Chunks land in the region mirror and show up in the manifest. - let mut batch = record(1, 2, 100, b"hello-chunk"); - batch.extend(record(-1, 40, 101, b"other-region")); - let (s, v) = post_bytes(&t, "/api/server/v1/livemap/chunks?dim=overworld", &token, batch).await; - assert_eq!(s, StatusCode::OK, "{v}"); - assert_eq!(v["stored"], 2); - let (s, _) = post_bytes(&t, "/api/server/v1/livemap/chunks?dim=../x", &token, record(0, 0, 1, b"x")).await; - assert_eq!(s, StatusCode::BAD_REQUEST); - let (s, _) = post_bytes(&t, "/api/server/v1/livemap/chunks", &token, vec![1, 2, 3]).await; - assert_eq!(s, StatusCode::BAD_REQUEST); - let (_, m) = t.call("GET", "/api/server/v1/livemap/manifest?dim=overworld", Some(&token), None).await; - assert_eq!(m["regions"].as_array().unwrap().len(), 2); - - // level.dat must be a gzip file. - let (s, _) = post_bytes(&t, "/api/server/v1/livemap/level", &token, b"not gzip at all, sorry!".to_vec()).await; - assert_eq!(s, StatusCode::BAD_REQUEST); - let mut gz = vec![0x1f, 0x8b]; - gz.extend([0u8; 30]); - let (s, _) = post_bytes(&t, "/api/server/v1/livemap/level", &token, gz).await; - assert_eq!(s, StatusCode::OK); - - // Players, sanitised. - let (s, _) = t - .call( - "POST", - "/api/server/v1/livemap/players", - Some(&token), - Some(json!({"players": [ - {"uuid": "b50ad385-829d-3141-a216-7e7d7539ba7f", "name": "Notch", "dimension": "minecraft:the_nether", "x": 10.5, "y": 70.0, "z": -4.0, "yaw": 90.0}, - {"uuid": "bad", "name": "Nowhere", "x": 1e99, "y": 0, "z": 0} - ]})), - ) - .await; - assert_eq!(s, StatusCode::OK); - - // Viewers need a session. - let (s, _) = t.call("GET", &format!("/api/v1/servers/{id}/livemap"), None, None).await; - assert_eq!(s, StatusCode::UNAUTHORIZED); - let (s, info) = t.call("GET", &format!("/api/v1/servers/{id}/livemap"), Some(&admin), None).await; - assert_eq!(s, StatusCode::OK); - assert_eq!(info["enabled"], true); - assert_eq!(info["players"], 1); - assert_eq!(info["base"], format!("/api/livemap/{id}")); - assert!(info["dimensions"][0]["available"].as_bool().unwrap()); - // No generator is installed in the test environment. - assert_eq!(info["ready"], false); - - let (_, pj) = t.call("GET", &format!("/api/livemap/{id}/the_nether/v1/worlds/default/players.json"), Some(&admin), None).await; - assert_eq!(pj["players"][0]["name"], "Notch"); - assert_eq!(pj["players"][0]["foreign"], false); - let (_, pj) = t.call("GET", &format!("/api/livemap/{id}/overworld/v1/worlds/default/players.json"), Some(&admin), None).await; - assert_eq!(pj["players"][0]["foreign"], true); - - let (s, e) = t.call("GET", &format!("/api/livemap/{id}/overworld/v1/worlds/default/manifest.json"), Some(&admin), None).await; - assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE); - assert!(e["error"].as_str().unwrap().contains("generator")); - let (s, _) = t.call("GET", &format!("/api/livemap/{id}/overworld/v1/openapi.json"), Some(&admin), None).await; - assert_eq!(s, StatusCode::NOT_FOUND); - let (s, _) = t.call("GET", &format!("/api/livemap/{id}/overworld/v1/worlds/default/manifest.json"), None, None).await; - assert_eq!(s, StatusCode::UNAUTHORIZED); - - let (s, st) = t.call("GET", &format!("/api/admin/servers/{id}/livemap"), Some(&admin), None).await; - assert_eq!(s, StatusCode::OK); - assert_eq!(st["stats"]["chunks_received"], 2); - assert_eq!(st["generator"]["installed"], false); - - // Public list advertises the map; reset wipes it. - let (_, list) = t.call("GET", "/api/v1/servers/public", None, None).await; - assert_eq!(list["servers"][0]["live_map"], true); - let (s, _) = t.call("DELETE", &format!("/api/admin/servers/{id}/livemap"), Some(&admin), None).await; - assert_eq!(s, StatusCode::OK); - let (_, m) = t.call("GET", "/api/server/v1/livemap/manifest?dim=overworld", Some(&token), None).await; - assert!(m["regions"].as_array().unwrap().is_empty()); -} - -/// A stand-in for `vantage server` that answers health checks and serves a -/// manifest, to exercise the supervisor and proxy end to end. -#[cfg(unix)] -#[tokio::test] -async fn proxy_starts_and_reaches_the_generator() { - if std::process::Command::new("python3").arg("--version").output().is_err() { - return; - } - use std::os::unix::fs::PermissionsExt; - let t = setup_with(|cfg, dir| { - let bin = dir.join("fake-vantage"); - std::fs::write( - &bin, - r#"#!/usr/bin/env python3 -import sys, os, http.server -a = sys.argv -port = int(a[a.index('--port') + 1]) -assert a[1] == 'server' and '--assets' in a and '--players-file' in a -token = os.environ['VANTAGE_SERVER_TOKEN'] -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - if self.path == '/v1/health': - body = b'ok' - elif self.headers.get('Authorization') != 'Bearer ' + token: - self.send_response(401); self.end_headers(); return - else: - body = b'{"fake":true,"path":"' + self.path.encode() + b'"}' - self.send_response(200); self.send_header('ETag', '"v1"'); self.send_header('Content-Type', 'application/json'); self.end_headers(); self.wfile.write(body) - def log_message(self, *a): pass -http.server.ThreadingHTTPServer(('127.0.0.1', port), H).serve_forever() -"#, - ) - .unwrap(); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); - std::fs::create_dir_all(dir.join("assets")).unwrap(); - cfg.vantage_bin = Some(bin); - cfg.vantage_assets = Some(dir.join("assets")); - }) - .await; - let admin = t.login("admin", "supersecret").await; - let (_, v) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "S", "live_map_enabled": true}))).await; - let id = v["server"]["id"].as_i64().unwrap(); - let token = v["token"].as_str().unwrap().to_string(); - - let uri = format!("/api/livemap/{id}/overworld/v1/worlds/default/manifest.json"); - let (s, _) = t.call("GET", &uri, Some(&admin), None).await; - assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE, "no world uploaded yet"); - - post_bytes(&t, &format!("/api/server/v1/livemap/chunks?dim=overworld"), &token, record(0, 0, 5, b"abc")).await; - let mut gz = vec![0x1f, 0x8b]; - gz.extend([0u8; 30]); - post_bytes(&t, "/api/server/v1/livemap/level", &token, gz).await; - - let (_, info) = t.call("GET", &format!("/api/v1/servers/{id}/livemap"), Some(&admin), None).await; - assert_eq!(info["ready"], true, "{info}"); - let (s, body) = t.call("GET", &uri, Some(&admin), None).await; - assert_eq!(s, StatusCode::OK, "{body}"); - assert_eq!(body["fake"], true); - assert_eq!(body["path"], "/v1/worlds/default/manifest.json"); - let (_, st) = t.call("GET", &format!("/api/admin/servers/{id}/livemap"), Some(&admin), None).await; - assert_eq!(st["running"][0], "overworld"); - // Reset stops the generator and deletes the mirror. - t.call("DELETE", &format!("/api/admin/servers/{id}/livemap"), Some(&admin), None).await; - let (_, st) = t.call("GET", &format!("/api/admin/servers/{id}/livemap"), Some(&admin), None).await; - assert!(st["running"].as_array().unwrap().is_empty()); -} diff --git a/panel/server/tests/map.rs b/panel/server/tests/map.rs index 0b452c2..d7c996b 100644 --- a/panel/server/tests/map.rs +++ b/panel/server/tests/map.rs @@ -23,7 +23,7 @@ async fn world() -> World { } let (alex, steve) = (t.login("Alex", "password123").await, t.login("Steve", "password123").await); let (alex_uuid, steve_uuid) = (t.uuid("Alex").await, t.uuid("Steve").await); - let (s, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "SMP", "instance_id": "smp", "map_address": "https://map.example.com"}))).await; + let (s, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "SMP", "instance_id": "smp"}))).await; assert_eq!(s, StatusCode::OK, "{srv}"); let sid = srv["server"]["id"].as_i64().unwrap(); let server = srv["token"].as_str().unwrap().to_string(); @@ -41,20 +41,6 @@ impl World { } } -#[tokio::test] -async fn map_address_is_validated_and_shown_to_launchers() { - let w = world().await; - let (_, list) = w.t.call("GET", "/api/v1/servers/public", None, None).await; - assert_eq!(list["servers"][0]["map_address"], "https://map.example.com"); - for bad in ["not a url", "ftp://map.example.com", "https://user:pw@map.example.com", "https://map.example.com/#x"] { - let (s, _) = w.t.call("PUT", &format!("/api/admin/servers/{}", w.sid), Some(&w.admin), Some(json!({"name": "SMP", "instance_id": "smp", "map_address": bad}))).await; - assert_eq!(s, StatusCode::BAD_REQUEST, "{bad}"); - } - let (s, r) = w.t.call("PUT", &format!("/api/admin/servers/{}", w.sid), Some(&w.admin), Some(json!({"name": "SMP", "instance_id": "smp", "map_address": ""}))).await; - assert_eq!(s, StatusCode::OK, "{r}"); - assert_eq!(r["map_address"], ""); -} - #[tokio::test] async fn tpa_and_messages_are_queued_once_for_the_game_server() { let w = world().await; diff --git a/panel/server/tests/worldmap.rs b/panel/server/tests/worldmap.rs new file mode 100644 index 0000000..e3fa456 --- /dev/null +++ b/panel/server/tests/worldmap.rs @@ -0,0 +1,135 @@ +//! The SCOPENET Map: tiles in from a game server, tiles and overlay out to signed-in viewers. + +mod common; +use common::*; + +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 +} + +struct World { + t: TestApp, + admin: String, + alex: String, + server: String, + sid: i64, +} + +async fn world() -> World { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Alex", "password": "password123"}))).await; + let alex = t.login("Alex", "password123").await; + let (_, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "SMP", "instance_id": "smp"}))).await; + World { t, admin, alex, server: srv["token"].as_str().unwrap().to_string(), sid: srv["server"]["id"].as_i64().unwrap() } +} + +impl World { + async fn upload(&self, dim: &str, body: Vec) -> (StatusCode, Value) { + self.t.call_raw("POST", &format!("/api/server/v1/map/tiles?dim={dim}"), Some(&self.server), body).await + } + async fn tile(&self, token: &str, dim: &str, z: u8, x: i32, y: i32) -> (StatusCode, Vec) { + self.t.fetch(&format!("/api/map/{}/{dim}/{z}/{x}/{y}.png?t={token}", self.sid)).await + } +} + +#[tokio::test] +async fn tiles_flow_from_the_game_server_to_signed_in_viewers() { + let w = world().await; + let (s, cfg) = w.t.call("GET", "/api/server/v1/map/config", Some(&w.server), None).await; + assert_eq!(s, StatusCode::OK); + assert_eq!((cfg["enabled"].as_bool(), cfg["epoch"].as_u64(), cfg["tile_size"].as_u64()), (Some(true), Some(0), Some(256))); + + // Nothing drawn yet. + let (_, info) = w.t.call("GET", &format!("/api/v1/servers/{}/map", w.sid), Some(&w.alex), None).await; + assert_eq!((info["ready"].as_bool(), info["token"].is_null()), (Some(false), true)); + assert!(info["message"].as_str().unwrap().contains("Waiting")); + + let body = [record(0, -1, 2, b"aa"), record(0, 0, 0, b"bb"), record(1, 0, 0, b"cc")].concat(); + let (s, r) = w.upload("minecraft:overworld", body).await; + assert_eq!((s, r["stored"].as_u64()), (StatusCode::OK, Some(3))); + assert_eq!(w.upload("minecraft:the_nether", record(0, 5, 5, b"n")).await.0, StatusCode::OK); + + let (_, info) = w.t.call("GET", &format!("/api/v1/servers/{}/map", w.sid), Some(&w.alex), None).await; + assert_eq!(info["ready"], true); + let dims = info["dimensions"].as_array().unwrap(); + assert_eq!((dims[0]["slug"].as_str(), dims[1]["label"].as_str()), (Some("overworld"), Some("The Nether"))); + assert_eq!(dims[0]["bounds"]["min_y"], 0); + assert_eq!(dims[0]["bounds"]["max_x"], 0); + let token = info["token"].as_str().unwrap().to_string(); + assert_eq!(info["tile_base"], format!("/api/map/{}", w.sid)); + + // The token opens tiles for plain tags. + let (s, bytes) = w.tile(&token, "overworld", 0, -1, 2).await; + assert_eq!(s, StatusCode::OK); + assert!(bytes.ends_with(b"aa") && bytes.starts_with(&PNG)); + assert_eq!(w.tile(&token, "overworld", 0, 7, 7).await.0, StatusCode::NOT_FOUND); + assert_eq!(w.tile(&token, "overworld", 9, 0, 0).await.0, StatusCode::NOT_FOUND); + assert_eq!(w.tile("wrong", "overworld", 0, -1, 2).await.0, StatusCode::UNAUTHORIZED); + assert_eq!(w.tile(&token, "..%2Fetc", 0, 0, 0).await.0, StatusCode::NOT_FOUND); + // Tokens belong to one server. + let (_, other) = w.t.call("POST", "/api/admin/servers", Some(&w.admin), Some(json!({"name": "Other", "instance_id": "o"}))).await; + let other_id = other["server"]["id"].as_i64().unwrap(); + assert_eq!(w.t.fetch(&format!("/api/map/{other_id}/overworld/0/0/0.png?t={token}")).await.0, StatusCode::UNAUTHORIZED); + // Info needs an account. + assert_eq!(w.t.call("GET", &format!("/api/v1/servers/{}/map", w.sid), None, None).await.0, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn bad_uploads_are_refused() { + let w = world().await; + assert_eq!(w.upload("minecraft:overworld", b"not tiles at all".to_vec()).await.0, StatusCode::BAD_REQUEST); + assert_eq!(w.upload("../x", record(0, 0, 0, b"a")).await.0, StatusCode::BAD_REQUEST); + assert_eq!(w.upload("minecraft:overworld", record(12, 0, 0, b"a")).await.0, StatusCode::BAD_REQUEST); + let (s, _) = w.t.call_raw("POST", "/api/server/v1/map/tiles?dim=minecraft:overworld", None, record(0, 0, 0, b"a")).await; + assert_eq!(s, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn players_and_overlay_are_served_to_viewers() { + let w = world().await; + let (s, _) = w.t.call("POST", "/api/server/v1/map/players", Some(&w.server), Some(json!({"players": [ + {"uuid": "11111111-2222-3333-4444-555555555555", "name": "Steve", "dimension": "minecraft:overworld", "x": 10.5, "y": 70.0, "z": -3.0, "yaw": 90.0}]}))).await; + assert_eq!(s, StatusCode::OK); + let overlay = json!({"claims": [{"guild_id": "g1", "name": "Iron", "tag": "IRON", "color": 123}], "pins": [{"id": "spawn-0", "kind": "spawn", "label": "Spawn"}]}); + assert_eq!(w.t.call("POST", "/api/server/v1/map/overlay", Some(&w.server), Some(overlay)).await.0, StatusCode::OK); + assert_eq!(w.t.call("POST", "/api/server/v1/map/overlay", Some(&w.server), Some(json!({"claims": "nope"}))).await.0, StatusCode::BAD_REQUEST); + + let (s, o) = w.t.call("GET", &format!("/api/v1/servers/{}/map/overlay", w.sid), Some(&w.alex), None).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(o["players"][0]["name"], "Steve"); + assert_eq!(o["claims"][0]["tag"], "IRON"); + assert_eq!(o["pins"][0]["kind"], "spawn"); + assert!(o["updated"].is_string()); + let (_, info) = w.t.call("GET", &format!("/api/v1/servers/{}/map", w.sid), Some(&w.alex), None).await; + assert_eq!(info["players"], 1); +} + +#[tokio::test] +async fn the_map_can_be_switched_off_and_reset() { + let w = world().await; + w.upload("minecraft:overworld", record(0, 0, 0, b"a")).await; + let (s, r) = w.t.call("DELETE", &format!("/api/admin/servers/{}/map", w.sid), Some(&w.admin), None).await; + assert_eq!(s, StatusCode::OK, "{r}"); + let (_, cfg) = w.t.call("GET", "/api/server/v1/map/config", Some(&w.server), None).await; + assert_eq!(cfg["epoch"], 1, "game servers see a new epoch and redraw"); + let (_, info) = w.t.call("GET", &format!("/api/v1/servers/{}/map", w.sid), Some(&w.alex), None).await; + assert_eq!(info["ready"], false); + assert_eq!(w.t.call("GET", &format!("/api/admin/servers/{}/map", w.sid), Some(&w.alex), None).await.0, StatusCode::FORBIDDEN, "admin only"); + + let (s, _) = w.t.call("PUT", &format!("/api/admin/servers/{}", w.sid), Some(&w.admin), Some(json!({"name": "SMP", "instance_id": "smp", "map_enabled": false}))).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(w.upload("minecraft:overworld", record(0, 0, 0, b"a")).await.0, StatusCode::FORBIDDEN); + let (_, cfg) = w.t.call("GET", "/api/server/v1/map/config", Some(&w.server), None).await; + assert_eq!(cfg["enabled"], false); + assert_eq!(w.t.call("GET", &format!("/api/v1/servers/{}/map/overlay", w.sid), Some(&w.alex), None).await.0, StatusCode::FORBIDDEN); +} From dbe02dc9bf6be5f0e336205a540711b63640edc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:28:01 +0000 Subject: [PATCH 02/21] Map: Java renderer core (NBT, Anvil, colours, tiles, palette PNG, zoom pyramid, uploader) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT --- .../net/scopenet/worldmap/BlockColors.java | 154 +++++++ .../net/scopenet/worldmap/ChunkSampler.java | 106 +++++ .../java/net/scopenet/worldmap/MapColors.java | 35 ++ .../main/java/net/scopenet/worldmap/Nbt.java | 80 ++++ .../main/java/net/scopenet/worldmap/Png.java | 112 +++++ .../net/scopenet/worldmap/RegionFile.java | 84 ++++ .../net/scopenet/worldmap/RegionRenderer.java | 72 ++++ .../java/net/scopenet/worldmap/TileStore.java | 125 ++++++ .../net/scopenet/worldmap/WorldMapSync.java | 399 ++++++++++++++++++ .../net/scopenet/worldmap/MapRenderTest.java | 224 ++++++++++ 10 files changed, 1391 insertions(+) create mode 100644 integrations/common/src/main/java/net/scopenet/worldmap/BlockColors.java create mode 100644 integrations/common/src/main/java/net/scopenet/worldmap/ChunkSampler.java create mode 100644 integrations/common/src/main/java/net/scopenet/worldmap/MapColors.java create mode 100644 integrations/common/src/main/java/net/scopenet/worldmap/Nbt.java create mode 100644 integrations/common/src/main/java/net/scopenet/worldmap/Png.java create mode 100644 integrations/common/src/main/java/net/scopenet/worldmap/RegionFile.java create mode 100644 integrations/common/src/main/java/net/scopenet/worldmap/RegionRenderer.java create mode 100644 integrations/common/src/main/java/net/scopenet/worldmap/TileStore.java create mode 100644 integrations/common/src/main/java/net/scopenet/worldmap/WorldMapSync.java create mode 100644 integrations/common/src/test/java/net/scopenet/worldmap/MapRenderTest.java diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/BlockColors.java b/integrations/common/src/main/java/net/scopenet/worldmap/BlockColors.java new file mode 100644 index 0000000..6aa7ce4 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/worldmap/BlockColors.java @@ -0,0 +1,154 @@ +package net.scopenet.worldmap; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import static net.scopenet.worldmap.MapColors.*; + +/** + * Which map colour a block shows as. Vanilla blocks follow the real table; anything else (modded blocks) is matched by + * name keywords, then falls back to a stable neutral colour so the same block always looks the same. + */ +public final class BlockColors { + private BlockColors() {} + + private static final Map CACHE = new ConcurrentHashMap<>(); + + private static final Set CLEAR = Set.of("air", "cave_air", "void_air", "barrier", "light", "structure_void", "glass", "glass_pane", + "tinted_glass", "ladder", "lever", "tripwire", "tripwire_hook", "redstone_wire", "comparator", "repeater", "flower_pot", + "moving_piston", "cobweb", "end_rod", "scaffolding", "vine_air"); + private static final String[] CLEAR_PARTS = {"torch", "rail", "button", "pressure_plate", "potted_", "banner", "head", "skull"}; + private static final Set WATERY = Set.of("water", "bubble_column", "kelp", "kelp_plant", "seagrass", "tall_seagrass"); + + private static final String[] DYES = {"light_blue", "light_gray", "white", "orange", "magenta", "yellow", "lime", "pink", "gray", "cyan", + "purple", "blue", "brown", "green", "red", "black"}; + private static final int[] DYE_WOOL = {LIGHT_BLUE, LIGHT_GRAY, SNOW, ORANGE, MAGENTA, YELLOW, LIGHT_GREEN, PINK, GRAY, CYAN, PURPLE, BLUE, + BROWN, GREEN, RED, BLACK}; + private static final int[] DYE_TERRACOTTA = {39, 44, 36, 37, 38, 40, 41, 42, 43, 45, 46, 47, 48, 49, 50, 51}; + private static final String[] DYED = {"wool", "carpet", "concrete", "stained_glass", "glazed_terracotta", "shulker_box", "bed"}; + + private static final String[] STRIP_PREFIX = {"waxed_", "polished_", "chiseled_", "cut_", "smooth_", "cracked_", "mossy_", "stripped_", + "infested_", "weathered_cut_", "budding_", "reinforced_", "large_", "small_", "tall_"}; + private static final String[] STRIP_SUFFIX = {"_stairs", "_slab", "_wall", "_fence_gate", "_fence", "_trapdoor", "_door", "_sign", + "_wall_sign", "_hanging_sign", "_wall_hanging_sign", "_bricks", "_brick", "_tiles", "_tile", "_block", "_pillar"}; + + private static final int[] UNKNOWN = {STONE, DIRT, WOOD, GRAY, BROWN, LIGHT_GRAY, DEEPSLATE}; + + /** 0 for blocks a map looks straight through (air, glass, rails, torches). */ + public static int of(String blockName) { + return CACHE.computeIfAbsent(blockName, BlockColors::classify); + } + + private static int classify(String full) { + String n = full.startsWith("minecraft:") ? full.substring(10) : full.substring(full.indexOf(':') + 1); + int c = lookup(n); + if (c >= 0) return c; + // Peel shape and finish words (cut_sandstone_stairs -> sandstone) and try again. + String s = n; + for (boolean changed = true; changed; ) { + changed = false; + for (String p : STRIP_PREFIX) if (s.startsWith(p) && s.length() > p.length()) { s = s.substring(p.length()); changed = true; } + for (String p : STRIP_SUFFIX) if (s.endsWith(p) && s.length() > p.length()) { s = s.substring(0, s.length() - p.length()); changed = true; } + if (changed && (c = lookup(s)) >= 0) return c; + } + return UNKNOWN[Math.floorMod(n.hashCode(), UNKNOWN.length)]; + } + + /** The colour for a plain name, or -1 when nothing matches. */ + private static int lookup(String n) { + if (CLEAR.contains(n)) return NONE; + for (String p : CLEAR_PARTS) if (n.contains(p)) return NONE; + if (WATERY.contains(n)) return WATER; + if (n.equals("lava")) return FIRE; + for (int i = 0; i < DYES.length; i++) { + String d = DYES[i] + "_"; + if (!n.startsWith(d)) continue; + String rest = n.substring(d.length()); + if (rest.startsWith("terracotta")) return DYE_TERRACOTTA[i]; + for (String k : DYED) if (rest.startsWith(k)) return DYE_WOOL[i]; + } + if (n.equals("terracotta")) return ORANGE; + if (n.equals("grass_block") || n.equals("slime_block")) return GRASS; + if (n.startsWith("crimson_")) return n.contains("nylium") ? CRIMSON_NYLIUM : n.contains("hyphae") ? CRIMSON_HYPHAE + : n.contains("stem") || n.contains("planks") ? CRIMSON_STEM : n.contains("fungus") || n.contains("roots") ? CRIMSON_NYLIUM : -1; + if (n.startsWith("warped_")) return n.contains("nylium") ? WARPED_NYLIUM : n.contains("hyphae") ? WARPED_HYPHAE + : n.contains("wart") ? WARPED_WART : n.contains("stem") || n.contains("planks") ? WARPED_STEM : n.contains("fungus") || n.contains("roots") ? WARPED_NYLIUM : -1; + if (n.contains("deepslate")) return DEEPSLATE; + if (n.contains("tuff")) return TERRACOTTA_GRAY; + if (n.contains("calcite")) return TERRACOTTA_WHITE; + if (n.contains("dripstone")) return TERRACOTTA_BROWN; + if (n.contains("blackstone") || n.contains("basalt") || n.contains("obsidian") || n.equals("coal_block")) return BLACK; + if (n.contains("netherrack") || n.contains("nether_brick") || n.contains("nether_gold") || n.contains("nether_quartz") || n.contains("magma")) return NETHER; + if (n.equals("nether_wart_block") || n.equals("shroomlight")) return RED; + if (n.contains("soul_sand") || n.contains("soul_soil") || n.equals("ancient_debris")) return BROWN; + if (n.equals("glowstone")) return SAND; + if (n.equals("bedrock")) return STONE; + if (n.contains("red_sand")) return ORANGE; + if (n.contains("end_stone")) return SAND; + if (n.contains("sandstone") || n.contains("sand") || n.equals("bone_block")) return SAND; + if (n.contains("gravel")) return STONE; + if (n.contains("podzol")) return PODZOL; + if (n.contains("mycelium")) return PURPLE; + if (n.equals("mud") || n.equals("muddy_mangrove_roots")) return TERRACOTTA_CYAN; + if (n.contains("dirt") || n.contains("farmland") || n.contains("granite") || n.equals("packed_mud")) return DIRT; + if (n.equals("clay")) return CLAY; + if (n.contains("snow")) return SNOW; + if (n.equals("ice") || n.endsWith("_ice")) return ICE; + if (n.contains("copper")) return n.startsWith("exposed") ? TERRACOTTA_LIGHT_GRAY : n.startsWith("weathered") ? WARPED_STEM + : n.startsWith("oxidized") ? WARPED_NYLIUM : ORANGE; + if (n.equals("raw_iron_block")) return RAW_IRON; + if (n.equals("raw_gold_block") || n.equals("gold_block")) return GOLD; + if (n.equals("raw_copper_block")) return ORANGE; + if (n.equals("diamond_block")) return DIAMOND; + if (n.equals("emerald_block")) return EMERALD; + if (n.equals("lapis_block")) return LAPIS; + if (n.equals("redstone_block") || n.equals("tnt") || n.equals("fire")) return FIRE; + if (n.contains("amethyst")) return PURPLE; + if (n.contains("quartz") || n.contains("diorite") || n.equals("sea_lantern")) return QUARTZ; + if (n.contains("andesite")) return STONE; + if (n.contains("purpur") || n.equals("chorus_plant") || n.equals("chorus_flower")) return n.contains("chorus") ? PURPLE : MAGENTA; + if (n.equals("dark_prismarine") || n.contains("prismarine_brick")) return DIAMOND; + if (n.contains("prismarine")) return CYAN; + if (n.contains("_ore")) return STONE; + if (n.contains("iron") || n.equals("chain") || n.equals("lantern") || n.equals("soul_lantern") || n.equals("anvil") || n.contains("cauldron") + || n.equals("hopper") || n.equals("bell") || n.equals("grindstone")) return METAL; + if (n.contains("stone") || n.contains("furnace") || n.contains("dispenser") || n.contains("dropper") || n.contains("observer") + || n.contains("piston") || n.contains("smoker") || n.equals("spawner") || n.equals("lodestone") || n.contains("brick")) return STONE; + if (n.equals("hay_block")) return YELLOW; + if (n.contains("bamboo_block") || n.contains("bamboo_planks") || n.contains("bamboo_mosaic") || n.equals("sponge") || n.equals("wet_sponge")) return YELLOW; + if (n.contains("melon")) return LIGHT_GREEN; + if (n.contains("pumpkin") || n.equals("honey_block") || n.contains("honeycomb")) return ORANGE; + if (n.equals("glow_lichen")) return GLOW_LICHEN; + if (n.equals("moss_block") || n.equals("moss_carpet")) return GREEN; + if (n.contains("red_mushroom")) return RED; + if (n.contains("brown_mushroom")) return DIRT; + if (n.equals("mushroom_stem")) return WOOL; + if (n.equals("sculk") || n.startsWith("sculk_") || n.equals("dragon_egg")) return BLACK; + if (n.equals("beacon")) return DIAMOND; + if (n.contains("cherry_leaves")) return PINK; + if (n.contains("leaves") || n.contains("cactus") || n.equals("sugar_cane") || n.contains("vine") || n.equals("lily_pad") || n.contains("azalea") + || n.contains("bush") || n.contains("fern") || n.contains("grass") || n.contains("sapling") || n.contains("flower") || n.contains("tulip") + || n.contains("poppy") || n.contains("dandelion") || n.contains("orchid") || n.equals("allium") || n.equals("azure_bluet") + || n.contains("daisy") || n.equals("cornflower") || n.equals("lily_of_the_valley") || n.contains("rose") || n.equals("peony") + || n.equals("lilac") || n.equals("sunflower") || n.contains("petals") || n.equals("wheat") || n.equals("carrots") || n.equals("potatoes") + || n.equals("beetroots") || n.equals("bamboo") || n.contains("roots") || n.contains("fungus") || n.contains("wart") || n.equals("dead_bush") + || n.contains("sprouts") || n.contains("pitcher") || n.contains("torchflower")) return PLANT; + if (n.contains("dark_oak") || n.contains("mangrove_roots")) return n.contains("dark_oak") ? BROWN : WOOD; + if (n.contains("mangrove")) return RED; + if (n.contains("spruce")) return PODZOL; + if (n.contains("birch")) return SAND; + if (n.contains("jungle")) return DIRT; + if (n.contains("acacia")) return ORANGE; + if (n.contains("cherry")) return TERRACOTTA_WHITE; + if (n.contains("oak") || n.contains("planks") || n.endsWith("_log") || n.endsWith("_wood") || n.endsWith("_stem") || n.contains("chest") + || n.contains("barrel") || n.contains("crafting") || n.contains("bookshelf") || n.contains("table") || n.contains("loom") + || n.contains("composter") || n.contains("lectern") || n.equals("jukebox") || n.equals("note_block")) return WOOD; + if (n.contains("coal")) return BLACK; + if (n.equals("campfire") || n.equals("soul_campfire")) return PODZOL; + if (n.contains("wool") || n.contains("carpet")) return WOOL; + return -1; + } + + public static boolean isWater(int colour) { return colour == WATER; } +} diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/ChunkSampler.java b/integrations/common/src/main/java/net/scopenet/worldmap/ChunkSampler.java new file mode 100644 index 0000000..40f6411 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/worldmap/ChunkSampler.java @@ -0,0 +1,106 @@ +package net.scopenet.worldmap; + +import java.util.ArrayList; +import java.util.List; + +/** Reads one chunk's blocks and finds, for each of its 256 columns, the block a top-down map would show. */ +public final class ChunkSampler { + private ChunkSampler() {} + + public enum Kind { OVERWORLD, NETHER, END } + + /** Per-column results, indexed {@code z * 16 + x}. */ + public static final class Columns { + public final int[] color = new int[256]; + public final int[] height = new int[256]; + /** For water: how many blocks of water sit above the floor. */ + public final int[] depth = new int[256]; + } + + private record Section(int y, int[] colors, long[] data, int bits) { + int colorAt(int x, int y, int z) { + if (data == null) return colors[0]; + int index = y << 8 | z << 4 | x; + int perLong = 64 / bits; + int word = index / perLong; + if (word >= data.length) return 0; + int v = (int) (data[word] >>> (index % perLong * bits) & ((1L << bits) - 1)); + return v < colors.length ? colors[v] : 0; + } + } + + /** True if the chunk was fully generated and could be read (the columns are then filled in). */ + public static boolean sample(Nbt.Compound chunk, Kind kind, Columns out) { + String status = chunk.string("Status", "minecraft:full"); + if (!status.endsWith("full")) return false; + List
sections = new ArrayList<>(); + for (Object o : chunk.list("sections")) { + if (!(o instanceof Nbt.Compound s)) continue; + Nbt.Compound states = s.compound("block_states"); + if (states == null) continue; + List palette = states.list("palette"); + if (palette.isEmpty()) continue; + int[] colors = new int[palette.size()]; + for (int i = 0; i < colors.length; i++) { + colors[i] = palette.get(i) instanceof Nbt.Compound p ? BlockColors.of(p.string("Name", "minecraft:air")) : 0; + } + long[] data = states.longs("data"); + int bits = Math.max(4, 32 - Integer.numberOfLeadingZeros(colors.length - 1)); + sections.add(new Section(s.integer("Y", 0), colors, colors.length == 1 ? null : data, bits)); + } + if (sections.isEmpty()) return false; + sections.sort((a, b) -> Integer.compare(b.y(), a.y())); + + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + int i = z * 16 + x; + out.color[i] = 0; + out.height[i] = 0; + out.depth[i] = 0; + if (kind == Kind.NETHER) column(sections, x, z, out, i, true); + else column(sections, x, z, out, i, false); + } + } + return true; + } + + private static void column(List
sections, int x, int z, Columns out, int i, boolean roofed) { + boolean pastRoof = !roofed; + int roofColor = 0, roofY = 0; + for (int si = 0; si < sections.size(); si++) { + Section sec = sections.get(si); + for (int ly = 15; ly >= 0; ly--) { + int worldY = sec.y() * 16 + ly; + if (roofed && worldY > 126) continue; + int c = sec.colorAt(x, ly, z); + if (!pastRoof) { + // Under the nether ceiling: skip solid rock until the first open air. + if (c == 0) pastRoof = true; + else if (roofColor == 0) { roofColor = c; roofY = worldY; } + continue; + } + if (c == 0) continue; + out.color[i] = c; + out.height[i] = worldY; + if (c == MapColors.WATER) out.depth[i] = waterDepth(sections, si, ly, x, z); + return; + } + } + if (roofed && roofColor != 0) { // a solid column: show the ceiling instead + out.color[i] = roofColor; + out.height[i] = roofY; + } + } + + private static int waterDepth(List
sections, int si, int ly, int x, int z) { + int depth = 1; + for (int s = si; s < sections.size(); s++) { + Section sec = sections.get(s); + for (int y = s == si ? ly - 1 : 15; y >= 0; y--) { + if (sec.colorAt(x, y, z) != MapColors.WATER) return depth; + if (++depth >= 64) return depth; + } + } + return depth; + } +} diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/MapColors.java b/integrations/common/src/main/java/net/scopenet/worldmap/MapColors.java new file mode 100644 index 0000000..66f9e1a --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/worldmap/MapColors.java @@ -0,0 +1,35 @@ +package net.scopenet.worldmap; + +/** Minecraft's map colour table (1.20) and the four brightness steps a map applies to each colour. */ +public final class MapColors { + private MapColors() {} + + public static final int NONE = 0, GRASS = 1, SAND = 2, WOOL = 3, FIRE = 4, ICE = 5, METAL = 6, PLANT = 7, SNOW = 8, CLAY = 9, DIRT = 10, + STONE = 11, WATER = 12, WOOD = 13, QUARTZ = 14, ORANGE = 15, MAGENTA = 16, LIGHT_BLUE = 17, YELLOW = 18, LIGHT_GREEN = 19, + PINK = 20, GRAY = 21, LIGHT_GRAY = 22, CYAN = 23, PURPLE = 24, BLUE = 25, BROWN = 26, GREEN = 27, RED = 28, BLACK = 29, + GOLD = 30, DIAMOND = 31, LAPIS = 32, EMERALD = 33, PODZOL = 34, NETHER = 35, TERRACOTTA_WHITE = 36, TERRACOTTA_ORANGE = 37, + TERRACOTTA_GRAY = 43, TERRACOTTA_LIGHT_GRAY = 44, TERRACOTTA_CYAN = 45, TERRACOTTA_BROWN = 48, CRIMSON_NYLIUM = 52, + CRIMSON_STEM = 53, CRIMSON_HYPHAE = 54, WARPED_NYLIUM = 55, WARPED_STEM = 56, WARPED_HYPHAE = 57, WARPED_WART = 58, + DEEPSLATE = 59, RAW_IRON = 60, GLOW_LICHEN = 61; + + private static final int[] RGB = { + 0x000000, 0x7FB238, 0xF7E9A3, 0xC7C7C7, 0xFF0000, 0xA0A0FF, 0xA7A7A7, 0x007C00, 0xFFFFFF, 0xA4A8B8, 0x976D4D, 0x707070, + 0x4040FF, 0x8F7748, 0xFFFCF5, 0xD87F33, 0xB24CD8, 0x6699D8, 0xE5E533, 0x7FCC19, 0xF27FA5, 0x4C4C4C, 0x999999, 0x4C7F99, + 0x7F3FB2, 0x334CB2, 0x664C33, 0x667F33, 0x993333, 0x191919, 0xFAEE4D, 0x5CDBD5, 0x4A80FF, 0x00D93A, 0x815631, 0x700200, + 0xD1B1A1, 0x9F5224, 0x95576C, 0x706C8A, 0xBA8524, 0x677535, 0xA04D4E, 0x392923, 0x876B62, 0x575C5C, 0x7A4958, 0x4C3E5C, + 0x4C3223, 0x4C522A, 0x8E3C2E, 0x251610, 0xBD3031, 0x943F61, 0x5C191D, 0x167E86, 0x3A8E8C, 0x562C3E, 0x14B485, 0x646464, + 0xD8AF93, 0x7FA796}; + + public static final int COUNT = RGB.length; + + /** Brightness factors for the four shades: darkest .. brightest, as a map shows low, normal, high and lowest ground. */ + private static final int[] SHADE = {180, 220, 255, 135}; + + /** Opaque ARGB for a colour id at a shade (0 = low, 1 = normal, 2 = high, 3 = lowest); 0 for no colour. */ + public static int argb(int id, int shade) { + if (id <= 0 || id >= COUNT) return 0; + int base = RGB[id], m = SHADE[shade & 3]; + int r = (base >> 16 & 0xFF) * m / 255, g = (base >> 8 & 0xFF) * m / 255, b = (base & 0xFF) * m / 255; + return 0xFF000000 | r << 16 | g << 8 | b; + } +} diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/Nbt.java b/integrations/common/src/main/java/net/scopenet/worldmap/Nbt.java new file mode 100644 index 0000000..8ee7b71 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/worldmap/Nbt.java @@ -0,0 +1,80 @@ +package net.scopenet.worldmap; + +import java.io.DataInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** A small reader for Minecraft's NBT format: just enough to read chunks. Values are plain Java objects. */ +public final class Nbt { + private Nbt() {} + + /** A compound tag: names to values (Byte, Short, Integer, Long, Float, Double, String, byte[], int[], long[], List, Compound). */ + public static final class Compound extends HashMap { + public Compound compound(String key) { return get(key) instanceof Compound c ? c : null; } + + @SuppressWarnings("unchecked") + public List list(String key) { return get(key) instanceof List l ? (List) l : List.of(); } + + public String string(String key, String fallback) { return get(key) instanceof String s ? s : fallback; } + + public long[] longs(String key) { return get(key) instanceof long[] a ? a : null; } + + public int integer(String key, int fallback) { return get(key) instanceof Number n ? n.intValue() : fallback; } + } + + private static final int MAX_DEPTH = 64; + private static final int MAX_ARRAY = 8 * 1024 * 1024; + + /** Reads one named root compound (the form chunks are stored in). */ + public static Compound readRoot(DataInputStream in) throws IOException { + byte type = in.readByte(); + if (type != 10) throw new IOException("NBT root must be a compound, found tag " + type); + in.readUTF(); // root name, usually empty + return readCompound(in, 0); + } + + private static Compound readCompound(DataInputStream in, int depth) throws IOException { + if (depth > MAX_DEPTH) throw new IOException("NBT nested too deeply"); + Compound out = new Compound(); + while (true) { + byte type = in.readByte(); + if (type == 0) return out; + String name = in.readUTF(); + out.put(name, readValue(in, type, depth + 1)); + } + } + + private static Object readValue(DataInputStream in, byte type, int depth) throws IOException { + if (depth > MAX_DEPTH) throw new IOException("NBT nested too deeply"); + switch (type) { + case 1: return in.readByte(); + case 2: return in.readShort(); + case 3: return in.readInt(); + case 4: return in.readLong(); + case 5: return in.readFloat(); + case 6: return in.readDouble(); + case 7: { byte[] a = new byte[length(in)]; in.readFully(a); return a; } + case 8: return in.readUTF(); + case 9: { + byte elementType = in.readByte(); + int n = length(in); + List list = new ArrayList<>(Math.min(n, 4096)); + for (int i = 0; i < n; i++) list.add(readValue(in, elementType, depth + 1)); + return list; + } + case 10: return readCompound(in, depth); + case 11: { int[] a = new int[length(in)]; for (int i = 0; i < a.length; i++) a[i] = in.readInt(); return a; } + case 12: { long[] a = new long[length(in)]; for (int i = 0; i < a.length; i++) a[i] = in.readLong(); return a; } + default: throw new IOException("unknown NBT tag " + type); + } + } + + private static int length(DataInputStream in) throws IOException { + int n = in.readInt(); + if (n < 0 || n > MAX_ARRAY) throw new IOException("NBT array too large: " + n); + return n; + } +} diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/Png.java b/integrations/common/src/main/java/net/scopenet/worldmap/Png.java new file mode 100644 index 0000000..4f463e6 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/worldmap/Png.java @@ -0,0 +1,112 @@ +package net.scopenet.worldmap; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.CRC32; +import java.util.zip.Deflater; + +/** A minimal PNG writer (RGBA, 8 bit), so rendering needs nothing from the JDK's desktop module. */ +public final class Png { + private Png() {} + + /** + * Encodes the image. Map tiles only use the map colour table, so they are written as palette PNGs at the smallest bit depth + * that fits (a typical tile is a few KB instead of tens); anything with more than 256 colours falls back to RGBA. + */ + public static byte[] encode(int[] argb, int width, int height) { + Map palette = new LinkedHashMap<>(); + for (int c : argb) { + int key = (c >>> 24) == 0 ? 0 : c; + if (!palette.containsKey(key)) { + palette.put(key, palette.size()); + if (palette.size() > 256) return rgba(argb, width, height); + } + } + int n = palette.size(); + int depth = n <= 2 ? 1 : n <= 4 ? 2 : n <= 16 ? 4 : 8; + int rowBytes = (width * depth + 7) / 8; + byte[] raw = new byte[height * (1 + rowBytes)]; + int p = 0; + for (int y = 0; y < height; y++) { + raw[p++] = 0; + for (int x = 0; x < width; x++) { + int c = argb[y * width + x]; + int idx = palette.get((c >>> 24) == 0 ? 0 : c); + if (depth == 8) raw[p + x] = (byte) idx; + else raw[p + x * depth / 8] |= (byte) (idx << (8 - depth - (x * depth) % 8)); + } + p += rowBytes; + } + byte[] plte = new byte[n * 3]; + List alpha = new ArrayList<>(); + for (var e : palette.entrySet()) { + int c = e.getKey(), i = e.getValue(); + plte[i * 3] = (byte) (c >> 16); + plte[i * 3 + 1] = (byte) (c >> 8); + plte[i * 3 + 2] = (byte) c; + alpha.add(c == 0 ? 0 : c >>> 24); + } + int last = -1; + for (int i = 0; i < alpha.size(); i++) if (alpha.get(i) != 255) last = i; + byte[] trns = new byte[last + 1]; + for (int i = 0; i <= last; i++) trns[i] = (byte) (int) alpha.get(i); + + ByteArrayOutputStream out = new ByteArrayOutputStream(1024); + out.writeBytes(SIGNATURE); + chunk(out, "IHDR", ByteBuffer.allocate(13).putInt(width).putInt(height).put((byte) depth).put((byte) 3).put((byte) 0).put((byte) 0).put((byte) 0).array()); + chunk(out, "PLTE", plte); + if (trns.length > 0) chunk(out, "tRNS", trns); + chunk(out, "IDAT", deflate(raw)); + chunk(out, "IEND", new byte[0]); + return out.toByteArray(); + } + + private static final byte[] SIGNATURE = {(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'}; + + private static byte[] deflate(byte[] raw) { + Deflater deflater = new Deflater(9); + deflater.setInput(raw); + deflater.finish(); + ByteArrayOutputStream z = new ByteArrayOutputStream(raw.length / 4 + 64); + byte[] buf = new byte[16384]; + while (!deflater.finished()) z.write(buf, 0, deflater.deflate(buf)); + deflater.end(); + return z.toByteArray(); + } + + private static byte[] rgba(int[] argb, int width, int height) { + byte[] raw = new byte[height * (1 + width * 4)]; + int p = 0; + for (int y = 0; y < height; y++) { + raw[p++] = 0; + for (int x = 0; x < width; x++) { + int c = argb[y * width + x]; + raw[p++] = (byte) (c >> 16); + raw[p++] = (byte) (c >> 8); + raw[p++] = (byte) c; + raw[p++] = (byte) (c >>> 24); + } + } + ByteArrayOutputStream out = new ByteArrayOutputStream(1024); + out.writeBytes(SIGNATURE); + chunk(out, "IHDR", ByteBuffer.allocate(13).putInt(width).putInt(height).put((byte) 8).put((byte) 6).put((byte) 0).put((byte) 0).put((byte) 0).array()); + chunk(out, "IDAT", deflate(raw)); + chunk(out, "IEND", new byte[0]); + return out.toByteArray(); + } + + private static void chunk(ByteArrayOutputStream out, String type, byte[] data) { + out.writeBytes(ByteBuffer.allocate(4).putInt(data.length).array()); + byte[] t = type.getBytes(java.nio.charset.StandardCharsets.US_ASCII); + out.writeBytes(t); + out.writeBytes(data); + CRC32 crc = new CRC32(); + crc.update(t); + crc.update(data); + out.writeBytes(ByteBuffer.allocate(4).putInt((int) crc.getValue()).array()); + } +} diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/RegionFile.java b/integrations/common/src/main/java/net/scopenet/worldmap/RegionFile.java new file mode 100644 index 0000000..36d741d --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/worldmap/RegionFile.java @@ -0,0 +1,84 @@ +package net.scopenet.worldmap; + +import java.io.*; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.zip.GZIPInputStream; +import java.util.zip.InflaterInputStream; + +/** + * Read-only access to one Anvil region file (r.X.Z.mca): 32 x 32 chunks. Safe to use while the server is running: + * a chunk caught mid-write simply fails to read and is retried on the next pass. + */ +public final class RegionFile implements AutoCloseable { + private static final int SECTOR = 4096; + + private final FileChannel channel; + private final int[] locations = new int[1024]; + private final int[] timestamps = new int[1024]; + + public RegionFile(Path file) throws IOException { + channel = FileChannel.open(file, StandardOpenOption.READ); + try { + ByteBuffer header = ByteBuffer.allocate(2 * SECTOR); + while (header.hasRemaining()) if (channel.read(header) < 0) break; + header.flip(); + if (header.remaining() == 2 * SECTOR) { + header.asIntBuffer().get(locations, 0, 1024); + header.position(SECTOR); + header.asIntBuffer().get(timestamps, 0, 1024); + } + } catch (IOException | RuntimeException e) { + channel.close(); + throw e; + } + } + + public static int index(int chunkX, int chunkZ) { return (chunkX & 31) + (chunkZ & 31) * 32; } + + /** When this chunk was last saved (seconds since 1970), or 0 if the region has no such chunk. */ + public int timestamp(int index) { return locations[index] == 0 ? 0 : timestamps[index]; } + + /** + * The chunk's NBT, or null if it is missing, uses an unsupported format (external .mcc file or LZ4) or couldn't be read. + * Region files written with LZ4 (a server option since 1.20.5) are skipped. + */ + public Nbt.Compound read(int index) { + int loc = locations[index]; + if (loc == 0) return null; + long offset = (long) (loc >>> 8) * SECTOR; + int sectors = loc & 0xFF; + if (offset < 2 * SECTOR || sectors == 0) return null; + try { + ByteBuffer head = ByteBuffer.allocate(5); + if (read(head, offset) < 5) return null; + head.flip(); + int length = head.getInt(); + int type = head.get() & 0xFF; + if (length < 2 || length - 1 > (long) sectors * SECTOR || type > 3 || type == 0) return null; // 4 = LZ4, 0x80+ = external file + ByteBuffer body = ByteBuffer.allocate(length - 1); + if (read(body, offset + 5) < length - 1) return null; + InputStream raw = new ByteArrayInputStream(body.array()); + InputStream stream = type == 1 ? new GZIPInputStream(raw) : type == 2 ? new InflaterInputStream(raw) : raw; + try (DataInputStream in = new DataInputStream(new BufferedInputStream(stream))) { + return Nbt.readRoot(in); + } + } catch (IOException | RuntimeException e) { + return null; + } + } + + private int read(ByteBuffer buffer, long position) throws IOException { + int total = 0; + while (buffer.hasRemaining()) { + int n = channel.read(buffer, position + total); + if (n < 0) break; + total += n; + } + return total; + } + + @Override public void close() throws IOException { channel.close(); } +} diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/RegionRenderer.java b/integrations/common/src/main/java/net/scopenet/worldmap/RegionRenderer.java new file mode 100644 index 0000000..b9fdbca --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/worldmap/RegionRenderer.java @@ -0,0 +1,72 @@ +package net.scopenet.worldmap; + +import java.io.IOException; +import java.nio.file.Path; + +/** + * Turns one region file (512 x 512 blocks) into four 256 x 256 map tiles using a vanilla map's look: base colours shaded + * by the slope towards the north, and water shaded by depth. + */ +public final class RegionRenderer { + private RegionRenderer() {} + + public static final int TILE = 256; + private static final int SIDE = 512; + + /** Four tile images in the order (0,0) (1,0) (0,1) (1,1) of {@code [tileX][tileY]}, or null if nothing renderable was found. */ + public static int[][] render(Path file, ChunkSampler.Kind kind) throws IOException { + int[] colors = new int[SIDE * SIDE], heights = new int[SIDE * SIDE], depths = new int[SIDE * SIDE]; + ChunkSampler.Columns cols = new ChunkSampler.Columns(); + boolean any = false; + try (RegionFile region = new RegionFile(file)) { + for (int i = 0; i < 1024; i++) { + if (region.timestamp(i) == 0) continue; + Nbt.Compound chunk = region.read(i); + if (chunk == null || !ChunkSampler.sample(chunk, kind, cols)) continue; + int cx = (i & 31) * 16, cz = (i >> 5) * 16; + for (int z = 0; z < 16; z++) { + int dst = (cz + z) * SIDE + cx; + System.arraycopy(cols.color, z * 16, colors, dst, 16); + System.arraycopy(cols.height, z * 16, heights, dst, 16); + System.arraycopy(cols.depth, z * 16, depths, dst, 16); + } + any = true; + } + } + if (!any) return null; + int[] pixels = shade(colors, heights, depths); + int[][] tiles = new int[4][TILE * TILE]; + for (int ty = 0; ty < 2; ty++) { + for (int tx = 0; tx < 2; tx++) { + int[] tile = tiles[tx + ty * 2]; + for (int row = 0; row < TILE; row++) { + System.arraycopy(pixels, (ty * TILE + row) * SIDE + tx * TILE, tile, row * TILE, TILE); + } + } + } + return tiles; + } + + /** Shading as a vanilla map does it: slope against the block to the north, depth for water, plus a light dither. */ + static int[] shade(int[] colors, int[] heights, int[] depths) { + int[] out = new int[SIDE * SIDE]; + for (int z = 0; z < SIDE; z++) { + for (int x = 0; x < SIDE; x++) { + int i = z * SIDE + x; + int c = colors[i]; + if (c == 0) continue; + int shade; + if (c == MapColors.WATER) { + double d = depths[i] * 0.1 + ((x + z) & 1) * 0.2; + shade = d < 0.5 ? 2 : d > 0.9 ? 0 : 1; + } else { + int north = z > 0 && colors[i - SIDE] != 0 ? heights[i - SIDE] : heights[i]; + double d = (heights[i] - north) * 4.0 / 5.0 + (((x + z) & 1) - 0.5) * 0.4; + shade = d > 0.6 ? 2 : d < -0.6 ? 0 : 1; + } + out[i] = MapColors.argb(c, shade); + } + } + return out; + } +} diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/TileStore.java b/integrations/common/src/main/java/net/scopenet/worldmap/TileStore.java new file mode 100644 index 0000000..a5a4169 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/worldmap/TileStore.java @@ -0,0 +1,125 @@ +package net.scopenet.worldmap; + +import java.io.*; +import java.nio.ByteBuffer; +import java.nio.file.*; +import java.util.*; +import java.util.zip.*; + +/** + * The local cache of rendered tiles (raw pixels, compressed), plus the zoom pyramid built from them. Zoom 0 is one pixel per + * block; every level up halves the resolution, down to {@code maxZoom}. + */ +public final class TileStore { + public record Key(String dim, int zoom, int x, int y) { + @Override public String toString() { return dim + "/" + zoom + "/" + x + "/" + y; } + + public static Key parse(String s) { + String[] p = s.split("/"); + return new Key(p[0], Integer.parseInt(p[1]), Integer.parseInt(p[2]), Integer.parseInt(p[3])); + } + } + + private static final int SIZE = RegionRenderer.TILE; + private static final int PIXELS = SIZE * SIZE; + + private final Path root; + private final Map memory = new LinkedHashMap<>(64, 0.75f, true) { + @Override protected boolean removeEldestEntry(Map.Entry e) { return size() > 96; } + }; + + public TileStore(Path root) { this.root = root; } + + private Path file(Key k) { return root.resolve(k.dim()).resolve(Integer.toString(k.zoom())).resolve(k.x() + "_" + k.y() + ".tile"); } + + /** The tile's pixels, or null if it was never rendered. */ + public synchronized int[] load(Key k) { + int[] cached = memory.get(k); + if (cached != null) return cached; + Path f = file(k); + if (!Files.isRegularFile(f)) return null; + try (InflaterInputStream in = new InflaterInputStream(new BufferedInputStream(Files.newInputStream(f)))) { + byte[] raw = in.readAllBytes(); + if (raw.length != PIXELS * 4) return null; + int[] pixels = new int[PIXELS]; + ByteBuffer.wrap(raw).asIntBuffer().get(pixels); + memory.put(k, pixels); + return pixels; + } catch (IOException e) { + return null; + } + } + + public synchronized void save(Key k, int[] pixels) throws IOException { + ByteBuffer raw = ByteBuffer.allocate(PIXELS * 4); + raw.asIntBuffer().put(pixels); + Path f = file(k); + Files.createDirectories(f.getParent()); + Path tmp = f.resolveSibling(f.getFileName() + ".tmp"); + try (DeflaterOutputStream out = new DeflaterOutputStream(Files.newOutputStream(tmp), new Deflater(3))) { + out.write(raw.array()); + } + Files.move(tmp, f, StandardCopyOption.REPLACE_EXISTING); + memory.put(k, pixels); + } + + public synchronized void clear() throws IOException { + memory.clear(); + if (!Files.isDirectory(root)) return; + try (var walk = Files.walk(root)) { + for (Path p : (Iterable) walk.sorted(Comparator.reverseOrder())::iterator) if (!p.equals(root)) Files.deleteIfExists(p); + } + } + + public static boolean blank(int[] pixels) { + for (int p : pixels) if ((p >>> 24) != 0) return false; + return true; + } + + /** Stores a freshly rendered tile and returns true if it differs from what was there. */ + public boolean update(Key k, int[] pixels) throws IOException { + int[] old = load(k); + if (old == null ? blank(pixels) : Arrays.equals(old, pixels)) return false; + save(k, pixels); + return true; + } + + /** + * Rebuilds the parent tile of {@code child} from its four children. Returns the parent's key if it changed, otherwise null. + */ + public Key rebuildParent(Key child) throws IOException { + int px = child.x() >> 1, py = child.y() >> 1, pz = child.zoom() + 1; + int[] out = new int[PIXELS]; + for (int qy = 0; qy < 2; qy++) { + for (int qx = 0; qx < 2; qx++) { + int[] src = load(new Key(child.dim(), child.zoom(), px * 2 + qx, py * 2 + qy)); + if (src == null) continue; + int ox = qx * (SIZE / 2), oy = qy * (SIZE / 2); + for (int y = 0; y < SIZE / 2; y++) { + for (int x = 0; x < SIZE / 2; x++) { + out[(oy + y) * SIZE + ox + x] = average(src[(y * 2) * SIZE + x * 2], src[(y * 2) * SIZE + x * 2 + 1], + src[(y * 2 + 1) * SIZE + x * 2], src[(y * 2 + 1) * SIZE + x * 2 + 1]); + } + } + } + } + Key parent = new Key(child.dim(), pz, px, py); + return update(parent, out) ? parent : null; + } + + /** + * The most common opaque colour of the four pixels (the first wins a tie). Picking a real map colour instead of blending keeps + * zoomed-out tiles crisp and recognisable, and keeps them palette-sized so they compress well. + */ + static int average(int a, int b, int c, int d) { + int[] px = {a, b, c, d}; + int best = 0, bestCount = 0; + for (int i = 0; i < 4; i++) { + if ((px[i] >>> 24) == 0) continue; + int count = 0; + for (int j = 0; j < 4; j++) if (px[j] == px[i]) count++; + if (count > bestCount) { best = px[i]; bestCount = count; } + } + return best; + } +} diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/WorldMapSync.java b/integrations/common/src/main/java/net/scopenet/worldmap/WorldMapSync.java new file mode 100644 index 0000000..4fe29e8 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/worldmap/WorldMapSync.java @@ -0,0 +1,399 @@ +package net.scopenet.worldmap; + +import com.google.gson.*; +import net.scopenet.integration.PanelClient; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.function.Consumer; + +/** + * SCOPENET Map, game-server side. Renders the world's region files to map tiles on a background thread (low priority, one + * region at a time) and uploads only the tiles that changed. Player positions and the claims/pins overlay ride along. + * Nothing here touches the server thread except {@link #offerPlayers} and {@link #offerOverlay}. + */ +public final class WorldMapSync implements AutoCloseable { + public record Player(String uuid, String name, String dimension, double x, double y, double z, float yaw, float pitch) {} + + private record Dim(String id, String dir, ChunkSampler.Kind kind) {} + + private static final Dim[] DIMS = { + new Dim("minecraft:overworld", "overworld", ChunkSampler.Kind.OVERWORLD), + new Dim("minecraft:the_nether", "the_nether", ChunkSampler.Kind.NETHER), + new Dim("minecraft:the_end", "the_end", ChunkSampler.Kind.END)}; + private static final int BATCH_BYTES = 3 * 1024 * 1024; + private static final long RERENDER_GAP_MS = 90_000; + private static final long COARSE_UPLOAD_GAP_MS = 60_000; + + private static final class State { + long epoch = -1; + /** "dim/x.z" -> file mtime (ms) at the time it was rendered. */ + Map regions = new HashMap<>(); + /** Tiles waiting to be uploaded, as {@link TileStore.Key} strings. */ + Set dirty = new HashSet<>(); + } + + private final PanelClient client; + private final Path worldRoot; + private final Path cacheDir; + private final Consumer log; + private final Gson gson = new Gson(); + private final TileStore store; + private final Object wake = new Object(); + private final Thread thread; + private final ScheduledExecutorService live = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "scopenet-map-live"); + t.setDaemon(true); + return t; + }); + + private State state = new State(); + private final Map renderedAt = new HashMap<>(); + private volatile boolean closed; + private volatile boolean enabled; + private volatile boolean wakeRequested; + private volatile int maxZoom = 6; + private volatile long playerIntervalMs = 2000; + private volatile List players = List.of(); + private volatile String overlay; + private long configAt; + private long coarseUploadAt; + private boolean stateDirty; + private boolean warnedMissing; + private String lastOverlaySent; + private long overlaySentAt; + private String lastPlayersSent = ""; + private long playersSentAt; + + public WorldMapSync(PanelClient client, Path worldRoot, Path cacheDir, Consumer log) { + this.client = client; + this.worldRoot = worldRoot; + this.cacheDir = cacheDir; + this.log = log; + this.store = new TileStore(cacheDir.resolve("tiles")); + thread = new Thread(this::run, "scopenet-map"); + thread.setDaemon(true); + thread.setPriority(Thread.MIN_PRIORITY); + } + + public void start() { + loadState(); + thread.start(); + live.scheduleWithFixedDelay(this::sendLive, 4, 1, TimeUnit.SECONDS); + } + + public void offerPlayers(List next) { + players = List.copyOf(next); + } + + /** The claims and pins as JSON ({@code {"claims":[...],"pins":[...]}}); sent when it changes. */ + public void offerOverlay(String json) { + overlay = json; + } + + /** The world was saved: look for changed regions soon. */ + public void requestScan() { + synchronized (wake) { + wakeRequested = true; + wake.notifyAll(); + } + } + + @Override public void close() { + closed = true; + live.shutdownNow(); + thread.interrupt(); + saveState(); + } + + // ---- main loop ---- + + private void run() { + while (!closed) { + long pause = 10_000; + try { + if (!checkConfig()) pause = 60_000; + else { + boolean worked = cycle(); + pause = worked ? 150 : 10_000; + } + } catch (InterruptedException e) { + return; + } catch (PanelClient.HttpFailure e) { + if (e.status == 403 || e.status == 404) enabled = false; + else log.accept("SCOPENET Map: the panel refused an upload (" + e.getMessage() + ")"); + pause = 30_000; + } catch (Exception e) { + log.accept("SCOPENET Map failed: " + e.getClass().getSimpleName() + ": " + e.getMessage()); + pause = 60_000; + } + if (stateDirty) saveState(); + try { + synchronized (wake) { + if (!wakeRequested) wake.wait(pause); + wakeRequested = false; + } + } catch (InterruptedException e) { + return; + } + } + } + + private boolean checkConfig() throws IOException, InterruptedException { + long now = System.currentTimeMillis(); + if (now < configAt) return enabled; + configAt = now + 60_000; + try { + JsonObject config = client.get("map/config"); + boolean on = config.has("enabled") && config.get("enabled").getAsBoolean(); + if (config.has("max_zoom")) maxZoom = Math.max(0, Math.min(6, config.get("max_zoom").getAsInt())); + if (config.has("player_interval_ms")) playerIntervalMs = Math.max(500, config.get("player_interval_ms").getAsLong()); + long epoch = config.has("epoch") ? config.get("epoch").getAsLong() : 0; + if (on && epoch != state.epoch) { + if (state.epoch >= 0) log.accept("SCOPENET Map was reset on the panel: rendering the world again."); + store.clear(); + state = new State(); + state.epoch = epoch; + renderedAt.clear(); + stateDirty = true; + } + if (on && !enabled) log.accept("SCOPENET Map is on: rendering this world and sending it to the panel."); + enabled = on; + } catch (PanelClient.HttpFailure e) { + enabled = false; // an older panel without the map + } + return enabled; + } + + /** One unit of work: render the most useful stale region, then upload what changed. True if anything happened. */ + private boolean cycle() throws IOException, InterruptedException { + if (!Files.isDirectory(worldRoot)) { + if (!warnedMissing) { + warnedMissing = true; + log.accept("SCOPENET Map: world folder " + worldRoot + " not found"); + } + return false; + } + long now = System.currentTimeMillis(); + record Stale(Dim dim, Path file, String key, long mtime, int rx, int rz, double distance) {} + List stale = new ArrayList<>(); + List online = players; + for (Dim dim : DIMS) { + Path dir = regionDir(dim); + if (dir == null) continue; + try (DirectoryStream stream = Files.newDirectoryStream(dir, "r.*.*.mca")) { + for (Path file : stream) { + String[] parts = file.getFileName().toString().split("\\."); + int rx, rz; + try { + rx = Integer.parseInt(parts[1]); + rz = Integer.parseInt(parts[2]); + } catch (RuntimeException e) { + continue; + } + long mtime = Files.getLastModifiedTime(file).toMillis(); + String key = dim.dir() + "/" + rx + "." + rz; + Long done = state.regions.get(key); + if (done != null && mtime <= done) continue; + Long last = renderedAt.get(key); + if (done != null && last != null && now - last < RERENDER_GAP_MS) continue; + if (Files.size(file) < 8192) continue; + double distance = Double.MAX_VALUE; + for (Player p : online) { + if (!dim.id().equals(p.dimension())) continue; + double dx = p.x() - (rx * 512 + 256), dz = p.z() - (rz * 512 + 256); + distance = Math.min(distance, Math.sqrt(dx * dx + dz * dz)); + } + stale.add(new Stale(dim, file, key, mtime, rx, rz, distance)); + } + } + } + boolean worked = false; + if (!stale.isEmpty()) { + stale.sort(Comparator.comparingDouble(Stale::distance).thenComparing(Stale::key)); + Stale next = stale.get(0); + renderRegion(next.dim(), next.file(), next.key(), next.mtime(), next.rx(), next.rz()); + worked = true; + } + if (flushUploads(stale.size() <= 1)) worked = true; + return worked; + } + + private void renderRegion(Dim dim, Path file, String key, long mtime, int rx, int rz) throws IOException { + int[][] tiles; + try { + tiles = RegionRenderer.render(file, dim.kind()); + } catch (IOException | RuntimeException e) { + // A region being written right now; try again after the gap. + renderedAt.put(key, System.currentTimeMillis()); + return; + } + renderedAt.put(key, System.currentTimeMillis()); + List changed = new ArrayList<>(); + if (tiles != null) { + for (int i = 0; i < 4; i++) { + TileStore.Key k = new TileStore.Key(dim.dir(), 0, rx * 2 + (i & 1), rz * 2 + (i >> 1)); + if (store.update(k, tiles[i])) { + changed.add(k); + state.dirty.add(k.toString()); + } + } + } + // Rebuild the zoom pyramid above whatever changed. + List level = changed; + for (int z = 0; z < maxZoom && !level.isEmpty(); z++) { + Set seen = new HashSet<>(); + List next = new ArrayList<>(); + for (TileStore.Key child : level) { + long id = ((long) (child.x() >> 1) << 32) ^ ((child.y() >> 1) & 0xFFFFFFFFL); + if (!seen.add(id)) continue; + TileStore.Key parent = store.rebuildParent(child); + if (parent != null) { + next.add(parent); + state.dirty.add(parent.toString()); + } + } + level = next; + } + state.regions.put(key, mtime); + stateDirty = true; + } + + /** Uploads dirty tiles in batches. Coarse zoom levels wait until the world is mostly done so they aren't re-sent constantly. */ + private boolean flushUploads(boolean caughtUp) throws IOException, InterruptedException { + if (state.dirty.isEmpty()) return false; + long now = System.currentTimeMillis(); + boolean coarseNow = caughtUp || now - coarseUploadAt >= COARSE_UPLOAD_GAP_MS; + List ready = new ArrayList<>(); + for (String s : state.dirty) { + TileStore.Key k = TileStore.Key.parse(s); + if (k.zoom() == 0 || coarseNow) ready.add(k); + } + if (ready.isEmpty()) return false; + ready.sort(Comparator.comparing(TileStore.Key::dim).thenComparing(Comparator.comparingInt(TileStore.Key::zoom).reversed())); + if (coarseNow) coarseUploadAt = now; + boolean sentAny = false; + int batches = 0; + int i = 0; + while (i < ready.size() && batches < 8 && !closed) { + String dimension = ready.get(i).dim(); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + List inBatch = new ArrayList<>(); + while (i < ready.size() && ready.get(i).dim().equals(dimension) && body.size() < BATCH_BYTES) { + TileStore.Key k = ready.get(i++); + int[] pixels = store.load(k); + if (pixels == null) { + state.dirty.remove(k.toString()); + continue; + } + byte[] png = Png.encode(pixels, RegionRenderer.TILE, RegionRenderer.TILE); + body.writeBytes(ByteBuffer.allocate(13).put((byte) k.zoom()).putInt(k.x()).putInt(k.y()).putInt(png.length).array()); + body.writeBytes(png); + inBatch.add(k); + } + if (inBatch.isEmpty()) continue; + client.postBytes("map/tiles?dim=" + idOf(dimension), body.toByteArray()); + for (TileStore.Key k : inBatch) state.dirty.remove(k.toString()); + stateDirty = true; + sentAny = true; + batches++; + } + return sentAny; + } + + private static String idOf(String dir) { + for (Dim d : DIMS) if (d.dir().equals(dir)) return d.id(); + return "minecraft:" + dir; + } + + // ---- live data ---- + + private void sendLive() { + if (closed || !enabled) return; + try { + long now = System.currentTimeMillis(); + List snapshot = players; + JsonArray list = new JsonArray(); + for (Player p : snapshot) { + JsonObject o = new JsonObject(); + o.addProperty("uuid", p.uuid()); + o.addProperty("name", p.name()); + o.addProperty("dimension", p.dimension()); + o.addProperty("x", Math.round(p.x() * 10) / 10.0); + o.addProperty("y", Math.round(p.y() * 10) / 10.0); + o.addProperty("z", Math.round(p.z() * 10) / 10.0); + o.addProperty("yaw", Math.round(p.yaw())); + list.add(o); + } + String text = list.toString(); + if ((!text.equals(lastPlayersSent) && now - playersSentAt >= playerIntervalMs) || now - playersSentAt >= 10_000) { + JsonObject body = new JsonObject(); + body.add("players", list); + client.post("map/players", body); + lastPlayersSent = text; + playersSentAt = now; + } + String ov = overlay; + if (ov != null && (!ov.equals(lastOverlaySent) || now - overlaySentAt >= 5 * 60_000) && now - overlaySentAt >= 10_000) { + client.post("map/overlay", JsonParser.parseString(ov).getAsJsonObject()); + lastOverlaySent = ov; + overlaySentAt = now; + } + } catch (PanelClient.HttpFailure e) { + if (e.status == 403 || e.status == 404) enabled = false; + } catch (Exception ignored) { + // the next tick retries + } + } + + // ---- world layout ---- + + private Path regionDir(Dim dim) { + String name = worldRoot.getFileName() == null ? "world" : worldRoot.getFileName().toString(); + Path parent = worldRoot.toAbsolutePath().getParent(); + List candidates = switch (dim.dir()) { + case "the_nether" -> Arrays.asList(worldRoot.resolve("DIM-1/region"), sibling(parent, name + "_nether", "DIM-1/region"), + worldRoot.resolve("dimensions/minecraft/the_nether/region")); + case "the_end" -> Arrays.asList(worldRoot.resolve("DIM1/region"), sibling(parent, name + "_the_end", "DIM1/region"), + worldRoot.resolve("dimensions/minecraft/the_end/region")); + default -> List.of(worldRoot.resolve("region"), worldRoot.resolve("dimensions/minecraft/overworld/region")); + }; + for (Path p : candidates) if (p != null && Files.isDirectory(p)) return p; + return null; + } + + private static Path sibling(Path parent, String name, String rel) { + return parent == null ? null : parent.resolve(name).resolve(rel); + } + + // ---- state ---- + + private void loadState() { + try { + Path f = cacheDir.resolve("state.json"); + if (Files.isRegularFile(f)) { + State s = gson.fromJson(Files.readString(f), State.class); + if (s != null && s.regions != null && s.dirty != null) state = s; + } + } catch (IOException | RuntimeException e) { + state = new State(); + } + } + + private void saveState() { + try { + Files.createDirectories(cacheDir); + Path f = cacheDir.resolve("state.json"); + Path tmp = f.resolveSibling("state.json.tmp"); + Files.writeString(tmp, gson.toJson(state)); + Files.move(tmp, f, StandardCopyOption.REPLACE_EXISTING); + stateDirty = false; + } catch (IOException e) { + // the cache is only an optimisation + } + } +} diff --git a/integrations/common/src/test/java/net/scopenet/worldmap/MapRenderTest.java b/integrations/common/src/test/java/net/scopenet/worldmap/MapRenderTest.java new file mode 100644 index 0000000..06c45b7 --- /dev/null +++ b/integrations/common/src/test/java/net/scopenet/worldmap/MapRenderTest.java @@ -0,0 +1,224 @@ +package net.scopenet.worldmap; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javax.imageio.ImageIO; +import java.io.*; +import java.nio.ByteBuffer; +import java.nio.file.*; +import java.util.*; +import java.util.zip.Deflater; +import java.util.zip.DeflaterOutputStream; + +import static org.junit.jupiter.api.Assertions.*; + +class MapRenderTest { + // ---- a tiny NBT writer for building chunks ---- + + static final class W { + final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + final DataOutputStream out = new DataOutputStream(bytes); + + W compound(String name) throws IOException { out.writeByte(10); out.writeUTF(name); return this; } + W end() throws IOException { out.writeByte(0); return this; } + W string(String name, String v) throws IOException { out.writeByte(8); out.writeUTF(name); out.writeUTF(v); return this; } + W i(String name, int v) throws IOException { out.writeByte(3); out.writeUTF(name); out.writeInt(v); return this; } + W b(String name, int v) throws IOException { out.writeByte(1); out.writeUTF(name); out.writeByte(v); return this; } + W list(String name, int type, int n) throws IOException { out.writeByte(9); out.writeUTF(name); out.writeByte(type); out.writeInt(n); return this; } + W longs(String name, long[] v) throws IOException { + out.writeByte(12); out.writeUTF(name); out.writeInt(v.length); + for (long l : v) out.writeLong(l); + return this; + } + } + + /** A chunk with one section (y = sectionY) whose blocks come from {@code blockAt}(x,y,z) -> palette index. */ + static byte[] chunk(int sectionY, List palette, TriFunction blockAt) throws IOException { + W w = new W().compound(""); + w.string("Status", "minecraft:full"); + w.list("sections", 10, 1); + w.b("Y", sectionY); + w.compound("block_states"); + w.list("palette", 10, palette.size()); + for (String name : palette) w.string("Name", name).end(); + int bits = Math.max(4, 32 - Integer.numberOfLeadingZeros(palette.size() - 1)); + int per = 64 / bits; + long[] data = new long[(4096 + per - 1) / per]; + for (int y = 0; y < 16; y++) for (int z = 0; z < 16; z++) for (int x = 0; x < 16; x++) { + int idx = y << 8 | z << 4 | x; + data[idx / per] |= (long) blockAt.apply(x, y, z) << (idx % per * bits); + } + w.longs("data", data); + w.end(); // block_states + w.end(); // section + w.end(); // root + return w.bytes.toByteArray(); + } + + interface TriFunction { int apply(int x, int y, int z); } + + /** Writes a region file with the given chunks at (cx, cz) -> NBT bytes, zlib compressed. */ + static Path region(Path dir, Map chunks) throws IOException { + Path file = dir.resolve("r.0.0.mca"); + ByteBuffer header = ByteBuffer.allocate(8192); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + int sector = 2; + for (var e : chunks.entrySet()) { + ByteArrayOutputStream z = new ByteArrayOutputStream(); + try (DeflaterOutputStream d = new DeflaterOutputStream(z, new Deflater())) { d.write(e.getValue()); } + byte[] comp = z.toByteArray(); + int length = comp.length + 1; + int sectors = (length + 4 + 4095) / 4096; + header.putInt(e.getKey() * 4, sector << 8 | sectors); + header.putInt(4096 + e.getKey() * 4, 1_700_000_000); + ByteBuffer rec = ByteBuffer.allocate(sectors * 4096); + rec.putInt(length).put((byte) 2).put(comp); + body.writeBytes(rec.array()); + sector += sectors; + } + Files.createDirectories(dir); + try (OutputStream out = Files.newOutputStream(file)) { + out.write(header.array()); + out.write(body.toByteArray()); + } + return file; + } + + @Test void blockNamesBecomeMapColours() { + assertEquals(MapColors.NONE, BlockColors.of("minecraft:air")); + assertEquals(MapColors.NONE, BlockColors.of("minecraft:glass")); + assertEquals(MapColors.GRASS, BlockColors.of("minecraft:grass_block")); + assertEquals(MapColors.WATER, BlockColors.of("minecraft:water")); + assertEquals(MapColors.STONE, BlockColors.of("minecraft:stone")); + assertEquals(MapColors.STONE, BlockColors.of("minecraft:mossy_cobblestone_stairs")); + assertEquals(MapColors.WOOD, BlockColors.of("minecraft:oak_planks")); + assertEquals(MapColors.WOOD, BlockColors.of("minecraft:oak_stairs")); + assertEquals(MapColors.PLANT, BlockColors.of("minecraft:oak_leaves")); + assertEquals(MapColors.SAND, BlockColors.of("minecraft:cut_sandstone_slab")); + assertEquals(MapColors.NETHER, BlockColors.of("minecraft:netherrack")); + assertEquals(MapColors.RED, BlockColors.of("minecraft:red_wool") == MapColors.RED ? MapColors.RED : -1); + assertEquals(MapColors.LIGHT_BLUE, BlockColors.of("minecraft:light_blue_concrete")); + assertEquals(MapColors.DEEPSLATE, BlockColors.of("minecraft:deepslate_iron_ore")); + assertEquals(MapColors.FIRE, BlockColors.of("minecraft:lava")); + int modded = BlockColors.of("create:brass_casing"); + assertEquals(modded, BlockColors.of("create:brass_casing"), "stable for unknown blocks"); + assertTrue(modded > 0); + } + + @Test void nbtAndRegionFilesRoundTrip(@TempDir Path dir) throws IOException { + byte[] nbt = chunk(4, List.of("minecraft:air", "minecraft:stone"), (x, y, z) -> y < 8 ? 1 : 0); + Path file = region(dir, Map.of(RegionFile.index(3, 5), nbt)); + try (RegionFile r = new RegionFile(file)) { + assertEquals(1_700_000_000, r.timestamp(RegionFile.index(3, 5))); + assertEquals(0, r.timestamp(RegionFile.index(0, 0))); + assertNull(r.read(RegionFile.index(0, 0))); + Nbt.Compound c = r.read(RegionFile.index(3, 5)); + assertNotNull(c); + assertEquals("minecraft:full", c.string("Status", "")); + assertEquals(1, c.list("sections").size()); + } + } + + @Test void topSurfaceAndWaterDepthAreFound() throws IOException { + // y 0..9 stone, 10..12 water in the left half. + byte[] nbt = chunk(4, List.of("minecraft:air", "minecraft:stone", "minecraft:water"), + (x, y, z) -> y < 6 ? 1 : (x < 8 && y < 9) ? 2 : y < 6 ? 1 : 0); + Nbt.Compound c = new Nbt.Compound(); + try (var in = new DataInputStream(new ByteArrayInputStream(nbt))) { c = Nbt.readRoot(in); } + ChunkSampler.Columns cols = new ChunkSampler.Columns(); + assertTrue(ChunkSampler.sample(c, ChunkSampler.Kind.OVERWORLD, cols)); + assertEquals(MapColors.STONE, cols.color[15]); + assertEquals(4 * 16 + 5, cols.height[15]); + assertEquals(MapColors.WATER, cols.color[0]); + assertEquals(4 * 16 + 8, cols.height[0]); + assertEquals(3, cols.depth[0]); + } + + @Test void theNetherIsShownBeneathItsCeiling() throws IOException { + // Section 7 covers y 112..127: bedrock up top (y 124..127 -> local 12..15), open air below, floor of netherrack at local y 3. + byte[] nbt = chunk(7, List.of("minecraft:air", "minecraft:bedrock", "minecraft:netherrack"), + (x, y, z) -> y >= 12 ? 1 : y <= 3 ? 2 : 0); + Nbt.Compound c; + try (var in = new DataInputStream(new ByteArrayInputStream(nbt))) { c = Nbt.readRoot(in); } + ChunkSampler.Columns cols = new ChunkSampler.Columns(); + assertTrue(ChunkSampler.sample(c, ChunkSampler.Kind.NETHER, cols)); + assertEquals(MapColors.NETHER, cols.color[0], "the floor, not the bedrock roof"); + assertEquals(7 * 16 + 3, cols.height[0]); + } + + @Test void regionsRenderIntoPositionedTiles(@TempDir Path dir) throws IOException { + byte[] flat = chunk(0, List.of("minecraft:air", "minecraft:grass_block"), (x, y, z) -> y < 5 ? 1 : 0); + // chunk (17, 2): second tile column (chunk x 16..31), first tile row + Path file = region(dir, Map.of(RegionFile.index(17, 2), flat)); + int[][] tiles = RegionRenderer.render(file, ChunkSampler.Kind.OVERWORLD); + assertNotNull(tiles); + int[] right = tiles[1]; + int px = (17 - 16) * 16 + 4, py = 2 * 16 + 4; // inside the chunk + assertEquals(0xFF, right[py * 256 + px] >>> 24); + int green = right[py * 256 + px]; + assertTrue((green >> 8 & 0xFF) > (green >> 16 & 0xFF), "grass reads as green"); + assertEquals(0, tiles[0][py * 256 + px], "other tiles stay empty"); + assertEquals(0, right[0], "outside the chunk is transparent"); + } + + @Test void slopesAreShadedAgainstTheNorth() { + int n = 512; + int[] colors = new int[n * n], heights = new int[n * n], depths = new int[n * n]; + Arrays.fill(colors, MapColors.STONE); + for (int z = 0; z < n; z++) Arrays.fill(heights, z * n, z * n + n, 64 + z / 8 * 3); + int[] px = RegionRenderer.shade(colors, heights, depths); + int uphill = px[16 * n + 3]; // a step up from the north + int flat = px[17 * n + 3]; // same height as its north neighbour + assertTrue(brightness(uphill) >= brightness(flat)); + for (int z = 0; z < n; z++) Arrays.fill(heights, z * n, z * n + n, 200 - z / 8 * 3); + int[] down = RegionRenderer.shade(colors, heights, depths); + assertTrue(brightness(down[16 * n + 3]) <= brightness(down[17 * n + 3])); + } + + private static int brightness(int argb) { return (argb >> 16 & 0xFF) + (argb >> 8 & 0xFF) + (argb & 0xFF); } + + @Test void pngIsAValidImage() throws IOException { + int[] pixels = new int[256 * 256]; + for (int i = 0; i < pixels.length; i++) pixels[i] = (i % 256 < 128) ? 0xFF336699 : 0; + byte[] png = Png.encode(pixels, 256, 256); + var image = ImageIO.read(new ByteArrayInputStream(png)); + assertEquals(256, image.getWidth()); + assertEquals(0xFF336699, image.getRGB(10, 10)); + assertEquals(0, image.getRGB(200, 10) >>> 24); + assertTrue(png.length < 400, "a two-colour tile is tiny: " + png.length); + } + + @Test void realisticTilesStaySmall(@TempDir Path dir) throws IOException { + byte[] hills = chunk(0, List.of("minecraft:air", "minecraft:grass_block", "minecraft:stone", "minecraft:water"), + (x, y, z) -> y < 3 + (x * 7 + z * 3) % 5 ? 1 : y < 9 && x > 8 ? 3 : 0); + Map chunks = new HashMap<>(); + for (int i = 0; i < 256; i++) chunks.put(i, hills); + int[][] tiles = RegionRenderer.render(region(dir, chunks), ChunkSampler.Kind.OVERWORLD); + byte[] png = Png.encode(tiles[0], 256, 256); + byte[] rgba = Png.encode(tiles[0], 256, 256); + assertTrue(png.length < 20_000, "palette PNG size " + png.length); + var image = ImageIO.read(new ByteArrayInputStream(png)); + for (int i = 0; i < 256 * 256; i += 997) assertEquals(tiles[0][i], image.getRGB(i % 256, i / 256), "pixel " + i); + } + + @Test void zoomLevelsAreBuiltFromTheTilesBelow(@TempDir Path dir) throws IOException { + TileStore store = new TileStore(dir); + int[] red = new int[256 * 256]; + Arrays.fill(red, 0xFFFF0000); + TileStore.Key k = new TileStore.Key("overworld", 0, 3, -2); + assertTrue(store.update(k, red)); + assertFalse(store.update(k, red.clone()), "an identical tile is not a change"); + TileStore.Key parent = store.rebuildParent(k); + assertEquals(new TileStore.Key("overworld", 1, 1, -1), parent); + int[] p = store.load(parent); + // child (3,-2) is the lower-right?? x=3 odd -> right, y=-2 even -> top: quadrant (1,0) + assertEquals(0xFFFF0000, p[10 * 256 + 200]); + assertEquals(0, p[10 * 256 + 10]); + assertEquals(parent, TileStore.Key.parse(parent.toString())); + // a fresh store sees the cached tile + assertArrayEquals(red, new TileStore(dir).load(k)); + store.clear(); + assertNull(new TileStore(dir).load(k)); + } +} From ead4a706c73f56f348fbec88f26928a92c1506ef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:34:21 +0000 Subject: [PATCH 03/21] Map: block colours from real block textures; palette-limited PNG tiles Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT --- .../net/scopenet/worldmap/BlockColors.java | 64 ++- .../net/scopenet/worldmap/ChunkSampler.java | 4 +- .../java/net/scopenet/worldmap/MapColors.java | 3 + .../main/java/net/scopenet/worldmap/Png.java | 34 ++ .../net/scopenet/worldmap/RegionRenderer.java | 24 +- .../net/scopenet/worldmap/block_colors.csv | 418 ++++++++++++++++++ .../net/scopenet/worldmap/MapRenderTest.java | 48 +- 7 files changed, 561 insertions(+), 34 deletions(-) create mode 100644 integrations/common/src/main/resources/net/scopenet/worldmap/block_colors.csv diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/BlockColors.java b/integrations/common/src/main/java/net/scopenet/worldmap/BlockColors.java index 6aa7ce4..94d2f0a 100644 --- a/integrations/common/src/main/java/net/scopenet/worldmap/BlockColors.java +++ b/integrations/common/src/main/java/net/scopenet/worldmap/BlockColors.java @@ -35,13 +35,71 @@ public final class BlockColors { private static final int[] UNKNOWN = {STONE, DIRT, WOOD, GRAY, BROWN, LIGHT_GRAY, DEEPSLATE}; - /** 0 for blocks a map looks straight through (air, glass, rails, torches). */ + /** Water is special-cased by the renderer, which shades it by depth. */ + public static final int WATER_COLOR = 0xFF3F76E4; + + private static final Map TEXTURES = loadTextures(); + private static final Map COLORS = new ConcurrentHashMap<>(); + + /** + * The colour this block shows as on the map (opaque ARGB), or 0 for blocks a map looks straight through (air, glass, rails). + * Vanilla blocks use the average colour of their real top texture; anything without one falls back to the map colour table. + */ public static int of(String blockName) { + return COLORS.computeIfAbsent(blockName, BlockColors::resolve); + } + + /** The nearest vanilla map colour id for a block (0 = see-through); also what the fallback palette uses. */ + public static int mapColor(String blockName) { return CACHE.computeIfAbsent(blockName, BlockColors::classify); } + private static int resolve(String full) { + int id = mapColor(full); + if (id == 0) return 0; + if (id == WATER) return WATER_COLOR; + String n = plain(full); + Integer exact = TEXTURES.get(n); + if (exact != null) return exact; + String s = n; + for (boolean changed = true; changed; ) { + changed = false; + for (String p : STRIP_PREFIX) if (s.startsWith(p) && s.length() > p.length()) { s = s.substring(p.length()); changed = true; } + for (String p : STRIP_SUFFIX) if (s.endsWith(p) && s.length() > p.length()) { s = s.substring(0, s.length() - p.length()); changed = true; } + if (!changed) break; + for (String candidate : new String[]{s, s + "s", s + "_planks", s + "_block"}) { + Integer hit = TEXTURES.get(candidate); + if (hit != null) return hit; + } + } + return MapColors.base(id); + } + + private static String plain(String full) { + return full.startsWith("minecraft:") ? full.substring(10) : full.substring(full.indexOf(':') + 1); + } + + private static Map loadTextures() { + Map out = new java.util.HashMap<>(); + try (java.io.InputStream in = BlockColors.class.getResourceAsStream("block_colors.csv")) { + if (in == null) return out; + for (String line : new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8).split("\n")) { + int comma = line.indexOf(','); + if (comma <= 0 || comma + 7 > line.length()) continue; + try { + out.put(line.substring(0, comma), 0xFF000000 | Integer.parseInt(line.substring(comma + 1, comma + 7).trim(), 16)); + } catch (NumberFormatException ignored) { + // a malformed row just falls back to the map colour table + } + } + } catch (java.io.IOException ignored) { + // no texture table: the map colour table covers everything + } + return out; + } + private static int classify(String full) { - String n = full.startsWith("minecraft:") ? full.substring(10) : full.substring(full.indexOf(':') + 1); + String n = plain(full); int c = lookup(n); if (c >= 0) return c; // Peel shape and finish words (cut_sandstone_stairs -> sandstone) and try again. @@ -150,5 +208,5 @@ public final class BlockColors { return -1; } - public static boolean isWater(int colour) { return colour == WATER; } + public static boolean isWater(int colour) { return colour == WATER_COLOR; } } diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/ChunkSampler.java b/integrations/common/src/main/java/net/scopenet/worldmap/ChunkSampler.java index 40f6411..bf14884 100644 --- a/integrations/common/src/main/java/net/scopenet/worldmap/ChunkSampler.java +++ b/integrations/common/src/main/java/net/scopenet/worldmap/ChunkSampler.java @@ -82,7 +82,7 @@ public final class ChunkSampler { if (c == 0) continue; out.color[i] = c; out.height[i] = worldY; - if (c == MapColors.WATER) out.depth[i] = waterDepth(sections, si, ly, x, z); + if (c == BlockColors.WATER_COLOR) out.depth[i] = waterDepth(sections, si, ly, x, z); return; } } @@ -97,7 +97,7 @@ public final class ChunkSampler { for (int s = si; s < sections.size(); s++) { Section sec = sections.get(s); for (int y = s == si ? ly - 1 : 15; y >= 0; y--) { - if (sec.colorAt(x, y, z) != MapColors.WATER) return depth; + if (sec.colorAt(x, y, z) != BlockColors.WATER_COLOR) return depth; if (++depth >= 64) return depth; } } diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/MapColors.java b/integrations/common/src/main/java/net/scopenet/worldmap/MapColors.java index 66f9e1a..e64d9a2 100644 --- a/integrations/common/src/main/java/net/scopenet/worldmap/MapColors.java +++ b/integrations/common/src/main/java/net/scopenet/worldmap/MapColors.java @@ -25,6 +25,9 @@ public final class MapColors { /** Brightness factors for the four shades: darkest .. brightest, as a map shows low, normal, high and lowest ground. */ private static final int[] SHADE = {180, 220, 255, 135}; + /** The colour's base RGB (opaque ARGB), or 0 for none. */ + public static int base(int id) { return id <= 0 || id >= COUNT ? 0 : 0xFF000000 | RGB[id]; } + /** Opaque ARGB for a colour id at a shade (0 = low, 1 = normal, 2 = high, 3 = lowest); 0 for no colour. */ public static int argb(int id, int shade) { if (id <= 0 || id >= COUNT) return 0; diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/Png.java b/integrations/common/src/main/java/net/scopenet/worldmap/Png.java index 4f463e6..b9cb7b9 100644 --- a/integrations/common/src/main/java/net/scopenet/worldmap/Png.java +++ b/integrations/common/src/main/java/net/scopenet/worldmap/Png.java @@ -3,6 +3,9 @@ package net.scopenet.worldmap; import java.io.ByteArrayOutputStream; import java.nio.ByteBuffer; import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Set; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -18,6 +21,7 @@ public final class Png { * that fits (a typical tile is a few KB instead of tens); anything with more than 256 colours falls back to RGBA. */ public static byte[] encode(int[] argb, int width, int height) { + argb = limitColors(argb); Map palette = new LinkedHashMap<>(); for (int c : argb) { int key = (c >>> 24) == 0 ? 0 : c; @@ -65,6 +69,36 @@ public final class Png { return out.toByteArray(); } + /** + * Keeps a tile within 256 colours: when it has more, the rarest colours are snapped to the nearest common one. Tiles + * rarely get there (blocks have few colours and shading adds only a handful of steps), and the change is hard to see. + */ + static int[] limitColors(int[] argb) { + Map counts = new HashMap<>(); + for (int c : argb) if ((c >>> 24) != 0) counts.merge(c, 1, Integer::sum); + if (counts.size() <= 255) return argb; + List keep = new ArrayList<>(counts.keySet()); + keep.sort((a, b) -> counts.get(b) - counts.get(a)); + List top = new ArrayList<>(keep.subList(0, 255)); + Set kept = new HashSet<>(top); + Map remap = new HashMap<>(); + int[] out = argb.clone(); + for (int i = 0; i < out.length; i++) { + int c = out[i]; + if ((c >>> 24) == 0 || kept.contains(c)) continue; + out[i] = remap.computeIfAbsent(c, k -> { + int best = top.get(0), bestD = Integer.MAX_VALUE; + for (int q : top) { + int dr = (k >> 16 & 0xFF) - (q >> 16 & 0xFF), dg = (k >> 8 & 0xFF) - (q >> 8 & 0xFF), db = (k & 0xFF) - (q & 0xFF); + int d = dr * dr * 2 + dg * dg * 4 + db * db * 3; + if (d < bestD) { bestD = d; best = q; } + } + return best; + }); + } + return out; + } + private static final byte[] SIGNATURE = {(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'}; private static byte[] deflate(byte[] raw) { diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/RegionRenderer.java b/integrations/common/src/main/java/net/scopenet/worldmap/RegionRenderer.java index b9fdbca..93cb354 100644 --- a/integrations/common/src/main/java/net/scopenet/worldmap/RegionRenderer.java +++ b/integrations/common/src/main/java/net/scopenet/worldmap/RegionRenderer.java @@ -47,7 +47,10 @@ public final class RegionRenderer { return tiles; } - /** Shading as a vanilla map does it: slope against the block to the north, depth for water, plus a light dither. */ + /** + * Shading: ground is lit by its slope against the block to the north (a gentle relief effect), water darkens with depth. + * Colours are the blocks' own, so they stay in few steps and compress well. + */ static int[] shade(int[] colors, int[] heights, int[] depths) { int[] out = new int[SIDE * SIDE]; for (int z = 0; z < SIDE; z++) { @@ -55,18 +58,23 @@ public final class RegionRenderer { int i = z * SIDE + x; int c = colors[i]; if (c == 0) continue; - int shade; - if (c == MapColors.WATER) { - double d = depths[i] * 0.1 + ((x + z) & 1) * 0.2; - shade = d < 0.5 ? 2 : d > 0.9 ? 0 : 1; + int percent; + if (c == BlockColors.WATER_COLOR) { + percent = 108 - Math.min(depths[i], 14) * 4; } else { int north = z > 0 && colors[i - SIDE] != 0 ? heights[i - SIDE] : heights[i]; - double d = (heights[i] - north) * 4.0 / 5.0 + (((x + z) & 1) - 0.5) * 0.4; - shade = d > 0.6 ? 2 : d < -0.6 ? 0 : 1; + int diff = heights[i] - north; + percent = diff >= 2 ? 120 : diff == 1 ? 109 : diff == 0 ? 100 : diff == -1 ? 90 : 80; } - out[i] = MapColors.argb(c, shade); + out[i] = scale(c, percent); } } return out; } + + static int scale(int argb, int percent) { + int r = Math.min(255, (argb >> 16 & 0xFF) * percent / 100), g = Math.min(255, (argb >> 8 & 0xFF) * percent / 100), + b = Math.min(255, (argb & 0xFF) * percent / 100); + return 0xFF000000 | r << 16 | g << 8 | b; + } } diff --git a/integrations/common/src/main/resources/net/scopenet/worldmap/block_colors.csv b/integrations/common/src/main/resources/net/scopenet/worldmap/block_colors.csv new file mode 100644 index 0000000..b94985c --- /dev/null +++ b/integrations/common/src/main/resources/net/scopenet/worldmap/block_colors.csv @@ -0,0 +1,418 @@ +stone,989898 +granite,B6775E +polished_granite,BC7A63 +diorite,E5E5E5 +polished_diorite,EAEBED +andesite,A6A6A6 +polished_andesite,A1A3A2 +grass_block,618149 +dirt,A37048 +coarse_dirt,91633F +podzol,6F4913 +cobblestone,9B9B9B +oak_planks,C69A53 +spruce_planks,8B6231 +birch_planks,EAD389 +jungle_planks,C38656 +acacia_planks,CD622C +cherry_planks,FFC2BB +dark_oak_planks,513112 +mangrove_planks,8F3930 +bamboo_planks,EBD051 +bamboo_mosaic,E8CC4F +bedrock,686868 +sand,FFEFB6 +red_sand,E87011 +gravel,A09A99 +gold_ore,B1A17C +deepslate_gold_ore,8C7B5A +iron_ore,A69C93 +deepslate_iron_ore,817871 +coal_ore,808080 +deepslate_coal_ore,5A5A5D +nether_gold_ore,8C3929 +oak_log,B88F4E +spruce_log,845E2F +birch_log,EBD89C +jungle_log,B67F4A +acacia_log,B76235 +cherry_log,E2A6A0 +dark_oak_log,523414 +mangrove_log,7C332A +mangrove_roots,5A4629 +muddy_mangrove_roots,554533 +bamboo_block,A9AC40 +stripped_spruce_log,805E2F +stripped_birch_log,E9CE83 +stripped_jungle_log,C98F57 +stripped_acacia_log,CB642D +stripped_cherry_log,FFB5AC +stripped_dark_oak_log,4F3315 +stripped_oak_log,C39952 +stripped_mangrove_log,852B2B +stripped_bamboo_block,D9BE48 +oak_leaves,4D7515 +spruce_leaves,355C35 +jungle_leaves,557E14 +cherry_leaves,FFB8D3 +dark_oak_leaves,517B16 +mangrove_leaves,456913 +azalea_leaves,6A8B2B +flowering_azalea_leaves,778742 +sponge,EEEA49 +wet_sponge,CFDD45 +lapis_ore,7E8BAC +deepslate_lapis_ore,5B6A8C +lapis_block,1447AB +dispenser,959393 +sandstone,FFF3BB +chiseled_sandstone,FFECAE +cut_sandstone,FFF1B3 +note_block,6B422A +powered_rail,A78151 +detector_rail,967C69 +piston,BB975E +white_wool,FBFFFF +orange_wool,FF7200 +magenta_wool,E741D9 +light_blue_wool,2EC8FF +yellow_wool,FFC40E +lime_wool,7EE207 +pink_wool,FF8BB1 +gray_wool,4A5357 +light_gray_wool,ADADA2 +cyan_wool,07A6B1 +purple_wool,8C20D2 +blue_wool,3137C0 +brown_wool,8B5026 +green_wool,638515 +red_wool,C31E17 +black_wool,18191E +gold_block,FFD328 +iron_block,FFFFFF +bricks,B76F5B +tnt,AD4034 +bookshelf,8F6F3F +chiseled_bookshelf,D9AB5E +mossy_cobblestone,85906F +obsidian,110A1D +spawner,28364C +diamond_ore,91ACAB +deepslate_diamond_ore,628181 +diamond_block,57FFF4 +crafting_table,915228 +farmland,AE764B +furnace,868585 +oak_door,AB8246 +iron_door,EDEBEB +redstone_ore,AB8080 +deepslate_redstone_ore,7F5556 +snow,F9FFFF +ice,85B0FF +cactus,629B28 +clay,C0C9DA +jukebox,714A33 +netherrack,762626 +soul_sand,634938 +soul_soil,5C4334 +basalt,616269 +polished_basalt,79777A +glowstone,D19A5A +nether_portal,6400EA +carved_pumpkin,B75D01 +jack_o_lantern,FFAC27 +cake,FFE1D8 +repeater,C3BFBE +oak_trapdoor,97753A +spruce_trapdoor,7E5D31 +birch_trapdoor,FDEBB8 +jungle_trapdoor,B98053 +acacia_trapdoor,BE602F +cherry_trapdoor,FFC2BB +dark_oak_trapdoor,5C3814 +mangrove_trapdoor,862F29 +bamboo_trapdoor,F2D857 +stone_bricks,959395 +mossy_stone_bricks,8B947E +cracked_stone_bricks,908F90 +chiseled_stone_bricks,919091 +packed_mud,AD7C57 +mud_bricks,A77958 +brown_mushroom_block,B68259 +red_mushroom_block,F42220 +mushroom_stem,F8EEDF +pumpkin,F28404 +melon,83B014 +mycelium,87767A +nether_bricks,36161D +enchanting_table,9C5461 +cauldron,59585A +end_portal_frame,6B9273 +end_stone,FBFFAD +dragon_egg,0E0A12 +redstone_lamp,743C1B +emerald_ore,80A689 +deepslate_emerald_ore,5B7F68 +emerald_block,1CF859 +command_block,D89C78 +beacon,79FFF9 +anvil,585858 +chipped_anvil,585858 +damaged_anvil,585858 +comparator,CBC4C1 +daylight_detector,9F8B6D +redstone_block,D61800 +nether_quartz_ore,8F4844 +hopper,5C5A5C +quartz_block,FFF8EF +chiseled_quartz_block,FFF9EE +quartz_pillar,FFF8EF +activator_rail,8C6654 +dropper,959393 +white_terracotta,FFD5BD +orange_terracotta,C45A1B +magenta_terracotta,B6627E +light_blue_terracotta,8680A7 +yellow_terracotta,E39B15 +lime_terracotta,7C8F36 +pink_terracotta,C45353 +gray_terracotta,463127 +light_gray_terracotta,A57D71 +cyan_terracotta,686F6F +purple_terracotta,904E64 +blue_terracotta,58436F +brown_terracotta,5E3A25 +green_terracotta,5C652D +red_terracotta,AE3E2A +black_terracotta,2D1910 +slime_block,7CEA60 +iron_trapdoor,F6F6F6 +prismarine,6EC6AE +prismarine_bricks,6ED1BF +dark_prismarine,386F59 +sea_lantern,CEF4E6 +hay_block,C9A700 +terracotta,B96A45 +coal_block,141212 +packed_ice,82AFFF +red_sandstone,DD6A10 +chiseled_red_sandstone,DF680A +cut_red_sandstone,E76E0F +smooth_stone_slab,CCCCCC +smooth_stone,C1C1C1 +bamboo_fence_gate,FBDF57 +bamboo_fence,FBE05B +spruce_door,815E32 +birch_door,FFF1C6 +jungle_door,C78B5B +acacia_door,CC693A +cherry_door,FFBBB3 +dark_oak_door,5D3B17 +mangrove_door,89312E +bamboo_door,E9CE53 +chorus_plant,714071 +chorus_flower,B88EB8 +purpur_block,CE92CE +purpur_pillar,D196D1 +end_stone_bricks,F7FFB0 +dirt_path,B59043 +repeating_command_block,967DCF +chain_command_block,9BC4B1 +nether_wart_block,8B0000 +red_nether_bricks,540003 +bone_block,FFFBD6 +observer,787878 +shulker_box,AA6FAA +white_shulker_box,F7FEFF +orange_shulker_box,FF6F00 +magenta_shulker_box,D330C5 +light_blue_shulker_box,23BDFF +yellow_shulker_box,FFBA03 +lime_shulker_box,6ED206 +pink_shulker_box,FF78A4 +gray_shulker_box,42464C +light_gray_shulker_box,97978B +cyan_shulker_box,0892A5 +purple_shulker_box,7615BE +blue_shulker_box,2629AB +brown_shulker_box,814B20 +green_shulker_box,5D7A1C +red_shulker_box,AB1614 +black_shulker_box,1E1E23 +white_glazed_terracotta,DFFFF2 +orange_glazed_terracotta,BCB266 +magenta_glazed_terracotta,FE6AE7 +light_blue_glazed_terracotta,62C2FE +yellow_glazed_terracotta,FFCC4D +lime_glazed_terracotta,C1F02E +pink_glazed_terracotta,FF9DBD +gray_glazed_terracotta,646D71 +light_gray_glazed_terracotta,ACCACC +cyan_glazed_terracotta,358F98 +purple_glazed_terracotta,7F2AB9 +blue_glazed_terracotta,2C43AA +brown_glazed_terracotta,917F63 +green_glazed_terracotta,8BAD47 +red_glazed_terracotta,DD362E +black_glazed_terracotta,521F22 +white_concrete,F6FEFF +orange_concrete,FF6E00 +magenta_concrete,CE29C1 +light_blue_concrete,139EF2 +yellow_concrete,FFB300 +lime_concrete,68CD08 +pink_concrete,FF69A0 +gray_concrete,41454A +light_gray_concrete,98988B +cyan_concrete,098FA6 +purple_concrete,7214BE +blue_concrete,272AAE +brown_concrete,75431C +green_concrete,566F24 +red_concrete,AD1717 +black_concrete,090B12 +white_concrete_powder,FCFFFF +orange_concrete_powder,FF8608 +magenta_concrete_powder,EA55DF +light_blue_concrete_powder,45D3FF +yellow_concrete_powder,FFD624 +lime_concrete_powder,8FE71C +pink_concrete_powder,FFA1C4 +gray_concrete_powder,5C6266 +light_gray_concrete_powder,BCBCB4 +cyan_concrete_powder,1AB2C0 +purple_concrete_powder,9931D8 +blue_concrete_powder,474BCB +brown_concrete_powder,986036 +green_concrete_powder,73912B +red_concrete_powder,CD312C +black_concrete_powder,1E1F26 +dead_tube_coral_block,9F9590 +dead_brain_coral_block,978E8A +dead_bubble_coral_block,A0958F +dead_fire_coral_block,A0958F +dead_horn_coral_block,A29993 +tube_coral_block,2559FB +brain_coral_block,FD5EBB +bubble_coral_block,C90BC5 +fire_coral_block,C71827 +horn_coral_block,FFE939 +sea_pickle,6D7627 +blue_ice,649EFF +bamboo,EBD051 +scaffolding,CF9A4B +loom,AD8E68 +barrel,A3753C +smoker,686562 +blast_furnace,616163 +cartography_table,7E684C +fletching_table,F0D999 +lectern,D3A258 +smithing_table,444555 +stonecutter,968F86 +warped_stem,388586 +stripped_warped_stem,349C97 +warped_nylium,2A8B79 +warped_wart_block,0C9194 +crimson_stem,89334F +stripped_crimson_stem,943B5E +crimson_nylium,9F1717 +shroomlight,FF8F35 +crimson_planks,7B3351 +warped_planks,2C7F78 +crimson_trapdoor,7E3553 +warped_trapdoor,2F9186 +crimson_door,8B395B +warped_door,2A9A92 +structure_block,6B586E +jigsaw,615263 +target,FFB8A8 +bee_nest,F6BD48 +beehive,C0954F +honey_block,FFB41D +honeycomb_block,FF9A06 +netherite_block,514A4C +ancient_debris,734C41 +crying_obsidian,230549 +respawn_anchor,520BB6 +lodestone,B3B5B9 +blackstone,332B32 +polished_blackstone,403944 +polished_blackstone_bricks,3A323C +cracked_polished_blackstone_bricks,362C34 +chiseled_polished_blackstone,403944 +gilded_blackstone,43312C +chiseled_nether_bricks,39191F +cracked_nether_bricks,31151A +quartz_bricks,FFF9EF +amethyst_block,9A69E9 +budding_amethyst,9968E3 +tuff,84857B +polished_tuff,757F78 +chiseled_tuff,71786E +tuff_bricks,777C73 +chiseled_tuff_bricks,868A80 +calcite,FEFFFA +powder_snow,F9FFFF +sculk_sensor,005466 +calibrated_sculk_sensor,165D7A +sculk,0D2028 +sculk_vein,013A47 +sculk_catalyst,0F252E +copper_block,EA7650 +exposed_copper,C49375 +weathered_copper,7DBB80 +oxidized_copper,58C69D +copper_ore,979892 +deepslate_copper_ore,70716C +oxidized_cut_copper,56BB96 +weathered_cut_copper,80B17D +exposed_cut_copper,BC8F73 +cut_copper,E97551 +oxidized_chiseled_copper,5AC49D +weathered_chiseled_copper,78B782 +exposed_chiseled_copper,BC8C72 +chiseled_copper,E06E49 +copper_door,EA7954 +exposed_copper_door,C89079 +oxidized_copper_door,59C39D +weathered_copper_door,80B77F +copper_trapdoor,E87550 +exposed_copper_trapdoor,C49377 +oxidized_copper_trapdoor,5AC49D +weathered_copper_trapdoor,7DBB80 +copper_grate,E97650 +exposed_copper_grate,C49377 +weathered_copper_grate,7BB980 +oxidized_copper_grate,58C49B +copper_bulb,BE5F37 +exposed_copper_bulb,A57E66 +weathered_copper_bulb,6B9A75 +oxidized_copper_bulb,4CA182 +dripstone_block,A37F6A +azalea,78972E +flowering_azalea,879446 +moss_block,6A852E +big_dripleaf,83AC31 +rooted_dirt,B07853 +mud,494549 +deepslate,6A6A6D +cobbled_deepslate,5E5E62 +polished_deepslate,585859 +deepslate_tiles,424243 +deepslate_bricks,555557 +chiseled_deepslate,424242 +cracked_deepslate_bricks,4E4E4F +cracked_deepslate_tiles,3F3F3F +smooth_basalt,57575F +raw_iron_block,CBA07A +raw_copper_block,BC7955 +raw_gold_block,FFBC1D +ochre_froglight,FFF9CD +verdant_froglight,EDFFEC +pearlescent_froglight,FFF9F8 +reinforced_deepslate,61645F +crafter,897677 +trial_spawner,2D455D +vault,3E5360 +heavy_core,626873 diff --git a/integrations/common/src/test/java/net/scopenet/worldmap/MapRenderTest.java b/integrations/common/src/test/java/net/scopenet/worldmap/MapRenderTest.java index 06c45b7..eb2669f 100644 --- a/integrations/common/src/test/java/net/scopenet/worldmap/MapRenderTest.java +++ b/integrations/common/src/test/java/net/scopenet/worldmap/MapRenderTest.java @@ -86,23 +86,29 @@ class MapRenderTest { } @Test void blockNamesBecomeMapColours() { - assertEquals(MapColors.NONE, BlockColors.of("minecraft:air")); - assertEquals(MapColors.NONE, BlockColors.of("minecraft:glass")); - assertEquals(MapColors.GRASS, BlockColors.of("minecraft:grass_block")); - assertEquals(MapColors.WATER, BlockColors.of("minecraft:water")); - assertEquals(MapColors.STONE, BlockColors.of("minecraft:stone")); - assertEquals(MapColors.STONE, BlockColors.of("minecraft:mossy_cobblestone_stairs")); - assertEquals(MapColors.WOOD, BlockColors.of("minecraft:oak_planks")); - assertEquals(MapColors.WOOD, BlockColors.of("minecraft:oak_stairs")); - assertEquals(MapColors.PLANT, BlockColors.of("minecraft:oak_leaves")); - assertEquals(MapColors.SAND, BlockColors.of("minecraft:cut_sandstone_slab")); - assertEquals(MapColors.NETHER, BlockColors.of("minecraft:netherrack")); - assertEquals(MapColors.RED, BlockColors.of("minecraft:red_wool") == MapColors.RED ? MapColors.RED : -1); - assertEquals(MapColors.LIGHT_BLUE, BlockColors.of("minecraft:light_blue_concrete")); - assertEquals(MapColors.DEEPSLATE, BlockColors.of("minecraft:deepslate_iron_ore")); - assertEquals(MapColors.FIRE, BlockColors.of("minecraft:lava")); - int modded = BlockColors.of("create:brass_casing"); - assertEquals(modded, BlockColors.of("create:brass_casing"), "stable for unknown blocks"); + assertEquals(MapColors.NONE, BlockColors.mapColor("minecraft:air")); + assertEquals(MapColors.NONE, BlockColors.mapColor("minecraft:glass")); + assertEquals(MapColors.GRASS, BlockColors.mapColor("minecraft:grass_block")); + assertEquals(MapColors.WATER, BlockColors.mapColor("minecraft:water")); + assertEquals(MapColors.STONE, BlockColors.mapColor("minecraft:stone")); + assertEquals(MapColors.STONE, BlockColors.mapColor("minecraft:mossy_cobblestone_stairs")); + assertEquals(MapColors.WOOD, BlockColors.mapColor("minecraft:oak_planks")); + assertEquals(MapColors.WOOD, BlockColors.mapColor("minecraft:oak_stairs")); + assertEquals(MapColors.PLANT, BlockColors.mapColor("minecraft:oak_leaves")); + assertEquals(MapColors.SAND, BlockColors.mapColor("minecraft:cut_sandstone_slab")); + assertEquals(MapColors.NETHER, BlockColors.mapColor("minecraft:netherrack")); + assertEquals(MapColors.RED, BlockColors.mapColor("minecraft:red_wool") == MapColors.RED ? MapColors.RED : -1); + assertEquals(MapColors.LIGHT_BLUE, BlockColors.mapColor("minecraft:light_blue_concrete")); + assertEquals(MapColors.DEEPSLATE, BlockColors.mapColor("minecraft:deepslate_iron_ore")); + assertEquals(MapColors.FIRE, BlockColors.mapColor("minecraft:lava")); + assertEquals(0, BlockColors.of("minecraft:air")); + assertEquals(BlockColors.WATER_COLOR, BlockColors.of("minecraft:water")); + int grass = BlockColors.of("minecraft:grass_block"); + assertTrue((grass >> 8 & 0xFF) > (grass >> 16 & 0xFF) && (grass >> 8 & 0xFF) > (grass & 0xFF), "grass is green"); + assertEquals(BlockColors.of("minecraft:oak_planks"), BlockColors.of("minecraft:oak_stairs"), "shapes share their material's colour"); + assertTrue(BlockColors.of("create:brass_casing") != 0, "modded blocks still get a colour"); + int modded = BlockColors.mapColor("create:brass_casing"); + assertEquals(modded, BlockColors.mapColor("create:brass_casing"), "stable for unknown blocks"); assertTrue(modded > 0); } @@ -128,9 +134,9 @@ class MapRenderTest { try (var in = new DataInputStream(new ByteArrayInputStream(nbt))) { c = Nbt.readRoot(in); } ChunkSampler.Columns cols = new ChunkSampler.Columns(); assertTrue(ChunkSampler.sample(c, ChunkSampler.Kind.OVERWORLD, cols)); - assertEquals(MapColors.STONE, cols.color[15]); + assertEquals(BlockColors.of("minecraft:stone"), cols.color[15]); assertEquals(4 * 16 + 5, cols.height[15]); - assertEquals(MapColors.WATER, cols.color[0]); + assertEquals(BlockColors.WATER_COLOR, cols.color[0]); assertEquals(4 * 16 + 8, cols.height[0]); assertEquals(3, cols.depth[0]); } @@ -143,7 +149,7 @@ class MapRenderTest { try (var in = new DataInputStream(new ByteArrayInputStream(nbt))) { c = Nbt.readRoot(in); } ChunkSampler.Columns cols = new ChunkSampler.Columns(); assertTrue(ChunkSampler.sample(c, ChunkSampler.Kind.NETHER, cols)); - assertEquals(MapColors.NETHER, cols.color[0], "the floor, not the bedrock roof"); + assertEquals(BlockColors.of("minecraft:netherrack"), cols.color[0], "the floor, not the bedrock roof"); assertEquals(7 * 16 + 3, cols.height[0]); } @@ -165,7 +171,7 @@ class MapRenderTest { @Test void slopesAreShadedAgainstTheNorth() { int n = 512; int[] colors = new int[n * n], heights = new int[n * n], depths = new int[n * n]; - Arrays.fill(colors, MapColors.STONE); + Arrays.fill(colors, BlockColors.of("minecraft:stone")); for (int z = 0; z < n; z++) Arrays.fill(heights, z * n, z * n + n, 64 + z / 8 * 3); int[] px = RegionRenderer.shade(colors, heights, depths); int uphill = px[16 * n + 3]; // a step up from the north From d98cc61ee6d72236223e20551f72b5f03da50914 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:43:11 +0000 Subject: [PATCH 04/21] Map: game servers send tiles, players and overlay; BlueMap and LiveMapSync removed from the Java side Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT --- integrations/bluemap/build.gradle | 16 -- .../java/net/scopenet/bluemap/MapAddon.java | 211 ------------------ .../resources/assets/scopenet/guild-home.svg | 1 - .../main/resources/assets/scopenet/guild.svg | 1 - .../main/resources/assets/scopenet/home.svg | 1 - .../main/resources/assets/scopenet/market.svg | 1 - .../resources/assets/scopenet/scopenet.css | 64 ------ .../resources/assets/scopenet/scopenet.js | 115 ---------- .../main/resources/assets/scopenet/shop.svg | 1 - .../main/resources/assets/scopenet/spawn.svg | 1 - .../main/resources/assets/scopenet/warp.svg | 1 - .../java/net/scopenet/core/map/MapModel.java | 2 +- .../net/scopenet/core/map/MapOverlay.java | 81 +++++++ .../net/scopenet/core/map/MapService.java | 2 +- .../net/scopenet/integration/Integration.java | 40 +++- .../net/scopenet/integration/LiveMapSync.java | 70 ------ .../net/scopenet/integration/Settings.java | 12 +- .../net/scopenet/worldmap/WorldMapSync.java | 11 +- .../net/scopenet/core/MapOverlayTest.java | 29 +++ .../scopenet/worldmap/WorldMapSyncTest.java | 78 +++++++ integrations/fabric/build.gradle | 2 - .../fabric/src/main/resources/fabric.mod.json | 2 +- .../scopenet/fabric/features/BlueMapHook.java | 28 --- .../fabric/features/FabricFeatures.java | 11 +- .../java/net/scopenet/minecraft/Bridge.java | 8 +- integrations/paper/build.gradle | 4 +- .../net/scopenet/paper/ClientLinkBridge.java | 22 +- .../net/scopenet/paper/ScopenetPlugin.java | 25 ++- .../paper/src/main/resources/config.yml | 20 +- .../paper/src/main/resources/plugin.yml | 2 +- integrations/settings.gradle | 2 +- 31 files changed, 268 insertions(+), 596 deletions(-) delete mode 100644 integrations/bluemap/build.gradle delete mode 100644 integrations/bluemap/src/main/java/net/scopenet/bluemap/MapAddon.java delete mode 100644 integrations/bluemap/src/main/resources/assets/scopenet/guild-home.svg delete mode 100644 integrations/bluemap/src/main/resources/assets/scopenet/guild.svg delete mode 100644 integrations/bluemap/src/main/resources/assets/scopenet/home.svg delete mode 100644 integrations/bluemap/src/main/resources/assets/scopenet/market.svg delete mode 100644 integrations/bluemap/src/main/resources/assets/scopenet/scopenet.css delete mode 100644 integrations/bluemap/src/main/resources/assets/scopenet/scopenet.js delete mode 100644 integrations/bluemap/src/main/resources/assets/scopenet/shop.svg delete mode 100644 integrations/bluemap/src/main/resources/assets/scopenet/spawn.svg delete mode 100644 integrations/bluemap/src/main/resources/assets/scopenet/warp.svg create mode 100644 integrations/common/src/main/java/net/scopenet/core/map/MapOverlay.java delete mode 100644 integrations/common/src/main/java/net/scopenet/integration/LiveMapSync.java create mode 100644 integrations/common/src/test/java/net/scopenet/core/MapOverlayTest.java create mode 100644 integrations/common/src/test/java/net/scopenet/worldmap/WorldMapSyncTest.java delete mode 100644 integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/BlueMapHook.java diff --git a/integrations/bluemap/build.gradle b/integrations/bluemap/build.gradle deleted file mode 100644 index 1d09c23..0000000 --- a/integrations/bluemap/build.gradle +++ /dev/null @@ -1,16 +0,0 @@ -// SCOPENET's BlueMap addon: guild claims, spawn, warps, shops and a player side panel on the 3D map. -// BlueMap's API is the same on Paper, Fabric and Forge, so this one module serves them all. It is only ever called -// when BlueMap is installed, and BlueMap provides these classes at runtime (compileOnly). -repositories { - maven { url = 'https://repo.bluecolored.de/releases' } -} -configurations.compileClasspath { - attributes { - attribute(org.gradle.api.attributes.java.TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 21) - } -} -dependencies { - implementation project(':common') - compileOnly 'de.bluecolored:bluemap-api:2.7.4' - implementation 'com.google.code.gson:gson:2.10.1' -} diff --git a/integrations/bluemap/src/main/java/net/scopenet/bluemap/MapAddon.java b/integrations/bluemap/src/main/java/net/scopenet/bluemap/MapAddon.java deleted file mode 100644 index aa8b107..0000000 --- a/integrations/bluemap/src/main/java/net/scopenet/bluemap/MapAddon.java +++ /dev/null @@ -1,211 +0,0 @@ -package net.scopenet.bluemap; - -import com.flowpowered.math.vector.Vector2d; -import com.flowpowered.math.vector.Vector3d; -import com.google.gson.JsonObject; -import de.bluecolored.bluemap.api.BlueMapAPI; -import de.bluecolored.bluemap.api.BlueMapMap; -import de.bluecolored.bluemap.api.markers.Marker; -import de.bluecolored.bluemap.api.markers.MarkerSet; -import de.bluecolored.bluemap.api.markers.POIMarker; -import de.bluecolored.bluemap.api.markers.ShapeMarker; -import de.bluecolored.bluemap.api.math.Color; -import de.bluecolored.bluemap.api.math.Shape; -import net.scopenet.core.map.ChunkRegions; -import net.scopenet.core.map.MapData; -import net.scopenet.core.map.MapModel; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.util.*; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.function.Supplier; -import java.util.logging.Logger; - -/** - * Puts SCOPENET on the BlueMap 3D map: - *
    - *
  • guild land as coloured regions, with the guild's icon and name,
  • - *
  • spawn, warps, guild homes (and homes, if the admin allows) as pins,
  • - *
  • markets and shops as pins that list what is for sale,
  • - *
  • a script that opens a side panel when you click a player.
  • - *
- * Start it once from the plugin or mod. It waits for BlueMap to come up, so the load order doesn't matter, and does - * nothing if BlueMap isn't installed. - */ -public final class MapAddon { - /** Where the addon gets its data. Both calls must be safe from any thread. */ - public interface Feed { - MapData snapshot(); - JsonObject playersJson(); - } - - static final String WEB_DIR = "assets/scopenet"; - static final List ASSETS = List.of("scopenet.js", "scopenet.css", "spawn.svg", "warp.svg", "home.svg", "guild-home.svg", "market.svg", "shop.svg", "guild.svg"); - static final String CLAIMS = "scopenet-claims", GUILDS = "scopenet-guilds", PLACES = "scopenet-places", SHOPS = "scopenet-shops"; - static final float CLAIM_Y = 66f; - - private final Feed feed; - private final Logger log; - private ScheduledExecutorService worker; - private volatile boolean enabled; - - private MapAddon(Feed feed, Logger log) { this.feed = feed; this.log = log; } - - /** Hook into BlueMap. Returns a handle to stop it. */ - public static MapAddon start(Feed feed, Logger log) { - MapAddon addon = new MapAddon(feed, log); - BlueMapAPI.onEnable(addon::enable); - BlueMapAPI.onDisable(api -> addon.disable()); - return addon; - } - - public void stop() { disable(); } - - // ---- lifecycle ------------------------------------------------------------------------- - - private synchronized void enable(BlueMapAPI api) { - try { - Path root = api.getWebApp().getWebRoot().resolve(WEB_DIR); - Files.createDirectories(root); - for (String name : ASSETS) copy(name, root.resolve(name)); - api.getWebApp().registerScript(WEB_DIR + "/scopenet.js"); - api.getWebApp().registerStyle(WEB_DIR + "/scopenet.css"); - } catch (IOException | RuntimeException e) { - log.warning("SCOPENET could not add its files to BlueMap: " + e); - return; - } - enabled = true; - worker = Executors.newSingleThreadScheduledExecutor(r -> { - Thread t = new Thread(r, "scopenet-bluemap"); - t.setDaemon(true); - return t; - }); - worker.scheduleWithFixedDelay(() -> refresh(api), 2, 10, TimeUnit.SECONDS); - log.info("SCOPENET is adding guild claims, pins and the player panel to BlueMap."); - } - - private synchronized void disable() { - enabled = false; - if (worker != null) worker.shutdownNow(); - worker = null; - } - - private static void copy(String name, Path target) throws IOException { - try (InputStream in = MapAddon.class.getResourceAsStream("/" + WEB_DIR + "/" + name)) { - if (in == null) throw new IOException("missing resource " + name); - Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); - } - } - - // ---- refresh --------------------------------------------------------------------------- - - private void refresh(BlueMapAPI api) { - if (!enabled) return; - try { - MapData data = feed.snapshot(); - writePlayers(api); - for (BlueMapMap map : api.getMaps()) update(map, data); - } catch (RuntimeException | IOException e) { - log.fine("SCOPENET map refresh failed: " + e); - } - } - - private void writePlayers(BlueMapAPI api) throws IOException { - Path file = api.getWebApp().getWebRoot().resolve(WEB_DIR).resolve("players.json"); - Path tmp = file.resolveSibling("players.json.tmp"); - Files.writeString(tmp, feed.playersJson().toString(), StandardCharsets.UTF_8); - Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING); - } - - private void update(BlueMapMap map, MapData data) { - String dimension = MapModel.dimensionOf(map.getId(), map.getName()); - set(map, CLAIMS, "Guild claims", claimMarkers(data, dimension)); - set(map, GUILDS, "Guild names", guildMarkers(data, dimension)); - set(map, PLACES, "Spawn, warps & homes", pinMarkers(data, dimension, false)); - set(map, SHOPS, "Markets & shops", pinMarkers(data, dimension, true)); - } - - /** Replace a marker set's contents in place, so the map doesn't flicker. */ - private static void set(BlueMapMap map, String id, String label, Map wanted) { - MarkerSet set = map.getMarkerSets().computeIfAbsent(id, k -> MarkerSet.builder().label(label).toggleable(true).defaultHidden(false).build()); - set.getMarkers().keySet().removeIf(key -> !wanted.containsKey(key)); - wanted.forEach(set::put); - } - - // ---- markers --------------------------------------------------------------------------- - - private static Shape shape(ChunkRegions.Ring ring) { - List points = new ArrayList<>(); - for (ChunkRegions.Point p : ring.points()) points.add(new Vector2d(p.x(), p.z())); - return new Shape(points); - } - - private static Map claimMarkers(MapData data, String dimension) { - Map out = new LinkedHashMap<>(); - for (MapData.ClaimRegion c : data.claims()) { - if (!c.dimension().equals(dimension)) continue; - Shape[] holes = c.holes().stream().map(MapAddon::shape).toArray(Shape[]::new); - ShapeMarker marker = ShapeMarker.builder() - .label(c.name()) - .shape(shape(c.outer()), CLAIM_Y) - .holes(holes) - .lineColor(new Color(c.color(), 0.95f)) - .fillColor(new Color(c.color(), 0.28f)) - .lineWidth(3) - .depthTestEnabled(false) - .detail(c.detail()) - .build(); - out.put("claim-" + c.id(), marker); - } - return out; - } - - /** One icon and name per area, in the middle of it, fading out when you are far away. */ - private static Map guildMarkers(MapData data, String dimension) { - Map out = new LinkedHashMap<>(); - for (MapData.ClaimRegion c : data.claims()) { - if (!c.dimension().equals(dimension)) continue; - POIMarker.Builder builder = POIMarker.builder() - .label(c.name() + " [" + c.tag() + "]") - .position(c.labelX(), CLAIM_Y + 1, c.labelZ()) - .styleClasses("scopenet-guild-icon") - .maxDistance(1600) - .detail(c.detail()); - builder = c.icon().isEmpty() ? builder.icon(WEB_DIR + "/guild.svg", 16, 16) : builder.icon(c.icon(), 16, 16); - out.put("guild-" + c.id(), builder.build()); - } - return out; - } - - private static Map pinMarkers(MapData data, String dimension, boolean shops) { - Map out = new LinkedHashMap<>(); - for (MapData.Pin p : data.pins()) { - boolean isShop = p.kind().equals("market") || p.kind().equals("shop"); - if (!p.dimension().equals(dimension) || isShop != shops) continue; - String icon = switch (p.kind()) { - case "spawn" -> "spawn.svg"; - case "warp" -> "warp.svg"; - case "home" -> "home.svg"; - case "guild_home" -> "guild-home.svg"; - case "market" -> "market.svg"; - default -> "shop.svg"; - }; - out.put(p.id(), POIMarker.builder() - .label(p.label()) - .position(new Vector3d(p.x(), p.y() + 1, p.z())) - .icon(WEB_DIR + "/" + icon, 16, 16) - .styleClasses("scopenet-pin", "scopenet-" + p.kind().replace('_', '-')) - .maxDistance(2400) - .detail(p.detail()) - .build()); - } - return out; - } -} diff --git a/integrations/bluemap/src/main/resources/assets/scopenet/guild-home.svg b/integrations/bluemap/src/main/resources/assets/scopenet/guild-home.svg deleted file mode 100644 index bb2f090..0000000 --- a/integrations/bluemap/src/main/resources/assets/scopenet/guild-home.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/integrations/bluemap/src/main/resources/assets/scopenet/guild.svg b/integrations/bluemap/src/main/resources/assets/scopenet/guild.svg deleted file mode 100644 index 2028d40..0000000 --- a/integrations/bluemap/src/main/resources/assets/scopenet/guild.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/integrations/bluemap/src/main/resources/assets/scopenet/home.svg b/integrations/bluemap/src/main/resources/assets/scopenet/home.svg deleted file mode 100644 index cdb1214..0000000 --- a/integrations/bluemap/src/main/resources/assets/scopenet/home.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/integrations/bluemap/src/main/resources/assets/scopenet/market.svg b/integrations/bluemap/src/main/resources/assets/scopenet/market.svg deleted file mode 100644 index 5ad9750..0000000 --- a/integrations/bluemap/src/main/resources/assets/scopenet/market.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/integrations/bluemap/src/main/resources/assets/scopenet/scopenet.css b/integrations/bluemap/src/main/resources/assets/scopenet/scopenet.css deleted file mode 100644 index ee98a19..0000000 --- a/integrations/bluemap/src/main/resources/assets/scopenet/scopenet.css +++ /dev/null @@ -1,64 +0,0 @@ -/* SCOPENET on BlueMap: marker styling and the player side panel. Loaded by BlueMap through the addon. */ - -/* Guild icons sit on the map as small rounded badges, whatever size the uploaded image is. */ -.scopenet-guild-icon img, -.bm-marker-poi.scopenet-guild-icon img { - width: 34px !important; - height: 34px !important; - object-fit: cover; - border-radius: 9px; - border: 2px solid rgba(255, 255, 255, 0.9); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.55); - background: #1a1d25; -} -.scopenet-pin img, -.bm-marker-poi.scopenet-pin img { - width: 26px !important; - height: 26px !important; - filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.55)); -} - -/* Popup text inside marker details. */ -.scopenet-detail { font-size: 13px; line-height: 1.45; min-width: 160px; } -.scopenet-detail span { opacity: 0.7; } -.scopenet-detail code { background: rgba(255, 255, 255, 0.08); padding: 1px 5px; border-radius: 4px; } -.scopenet-list { margin: 4px 0 0; padding-left: 16px; } - -/* Player panel (shown in a plain browser; inside the SCOPENET launcher the launcher shows its own). */ -#scopenet-panel { - position: fixed; - top: 64px; - right: 14px; - width: 300px; - max-height: calc(100vh - 90px); - overflow-y: auto; - z-index: 50; - padding: 16px; - border-radius: 14px; - background: rgba(20, 22, 30, 0.96); - color: #e6e8ee; - border: 1px solid rgba(255, 255, 255, 0.1); - box-shadow: 0 16px 48px -12px rgba(0, 0, 0, 0.75); - font-family: system-ui, -apple-system, "Segoe UI", sans-serif; - font-size: 13px; - animation: scopenet-in 0.18s ease; -} -@keyframes scopenet-in { from { opacity: 0; transform: translateX(18px); } } -#scopenet-panel button.close { - position: absolute; top: 8px; right: 8px; width: 26px; height: 26px; border: 0; border-radius: 8px; - background: transparent; color: #9aa0ae; font-size: 18px; cursor: pointer; -} -#scopenet-panel button.close:hover { background: rgba(255, 255, 255, 0.08); color: #fff; } -#scopenet-panel .head { display: flex; gap: 12px; align-items: center; padding-right: 26px; } -#scopenet-panel .head img { width: 56px; height: 56px; border-radius: 16px; border: 3px solid #8b6cff; background: #1a1d25; image-rendering: pixelated; } -#scopenet-panel h3 { margin: 0 0 2px; font-size: 17px; } -#scopenet-panel .title { color: #fbbf24; font-size: 12px; } -#scopenet-panel .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0; } -#scopenet-panel .chip { padding: 2px 9px; border-radius: 99px; background: rgba(255, 255, 255, 0.08); font-size: 11px; font-weight: 650; } -#scopenet-panel .chip.lv { color: #8b6cff; } -#scopenet-panel .chip.guild { color: #22d3ee; } -#scopenet-panel .chip.rank { color: #fbbf24; } -#scopenet-panel .facts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } -#scopenet-panel .facts div { padding: 8px 10px; border-radius: 10px; background: rgba(255, 255, 255, 0.05); color: #9aa0ae; font-size: 11px; } -#scopenet-panel .facts strong { display: block; color: #e6e8ee; font-size: 14px; margin-top: 2px; } -#scopenet-panel .note { margin: 14px 0 0; padding: 10px 12px; border-radius: 10px; background: rgba(139, 108, 255, 0.12); color: #c9bfff; font-size: 12px; line-height: 1.4; } diff --git a/integrations/bluemap/src/main/resources/assets/scopenet/scopenet.js b/integrations/bluemap/src/main/resources/assets/scopenet/scopenet.js deleted file mode 100644 index 3f93b23..0000000 --- a/integrations/bluemap/src/main/resources/assets/scopenet/scopenet.js +++ /dev/null @@ -1,115 +0,0 @@ -/* - * SCOPENET on BlueMap. Registered by the SCOPENET addon, so it runs inside BlueMap's own page: the same in a browser and - * inside the SCOPENET launcher. - * - * - Click a player on the map to see who they are (level, guild, rank, title...), read from players.json that the - * server keeps up to date next to this file. - * - Inside the launcher (this page is in an iframe) the click is handed to the launcher, which shows the full side panel - * with the actions that need a signed-in account: teleport request, message, guild invite. - */ -(function () { - 'use strict'; - var BASE = 'assets/scopenet/'; - var PLAYER_PREFIX = 'bm-marker-bm-player-'; - var embedded = window.parent && window.parent !== window; - var players = {}; - var selected = null; - - function esc(text) { - return String(text == null ? '' : text).replace(/[&<>"']/g, function (c) { - return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; - }); - } - - /** The uuid behind a player marker element, or null. BlueMap ids them "bm-marker-bm-player-". */ - function uuidFromId(id) { - return typeof id === 'string' && id.indexOf(PLAYER_PREFIX) === 0 ? id.substring(PLAYER_PREFIX.length) : null; - } - - /** Which player marker is under this screen point? Works even where the marker ignores pointer events. */ - function playerAt(x, y) { - var nodes = document.querySelectorAll('[id^="' + PLAYER_PREFIX + '"]'); - for (var i = 0; i < nodes.length; i++) { - var r = nodes[i].getBoundingClientRect(); - if (r.width === 0 && r.height === 0) continue; - if (x >= r.left - 6 && x <= r.right + 6 && y >= r.top - 6 && y <= r.bottom + 6) return uuidFromId(nodes[i].id); - } - return null; - } - - function hours(seconds) { - seconds = Number(seconds) || 0; - return seconds >= 3600 ? Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm' : Math.floor(seconds / 60) + 'm'; - } - - function panel() { - var el = document.getElementById('scopenet-panel'); - if (!el) { el = document.createElement('div'); el.id = 'scopenet-panel'; document.body.appendChild(el); } - return el; - } - - function render() { - var el = document.getElementById('scopenet-panel'); - if (!selected) { if (el) el.remove(); return; } - var p = players[selected] || players[selected.toLowerCase()] || {}; - el = panel(); - var name = p.name || 'Player'; - el.innerHTML = - '' + - '
' + - '

' + esc(name) + '

' + (p.title ? '
' + esc(p.title) + '
' : '') + '
' + - '
' + - 'Level ' + esc(p.level || 1) + '' + - (p.guild ? '[' + esc(p.guildTag) + '] ' + esc(p.guild) + '' : '') + - (p.rank ? '' + esc(p.rank) + '' : '') + '
' + - '
' + - '
Playtime' + esc(hours(p.playtime)) + '
' + - '
Server level' + esc(p.serverLevel || 1) + '
' + - (p.guildRole ? '
Guild role' + esc(p.guildRole) + '
' : '') + '
' + - '

Open this map in the SCOPENET launcher to send a teleport request or message, or to invite ' + esc(name) + ' to your guild.

'; - el.querySelector('.close').addEventListener('click', function () { select(null); }); - } - - function select(uuid) { - selected = uuid; - if (embedded) { - window.parent.postMessage(uuid - ? { source: 'scopenet-map', type: 'player', uuid: uuid, name: (players[uuid] || {}).name || '' } - : { source: 'scopenet-map', type: 'deselect' }, '*'); - } else { - render(); - } - } - - function refresh() { - fetch(BASE + 'players.json?t=' + Date.now(), { cache: 'no-store' }) - .then(function (r) { return r.ok ? r.json() : null; }) - .then(function (j) { if (j && j.players) { players = j.players; if (!embedded && selected) render(); } }) - .catch(function () { /* the file appears once the server has written it */ }); - } - - // A click is a press and release in nearly the same place; a drag moves the map. - var down = null; - document.addEventListener('pointerdown', function (e) { down = { x: e.clientX, y: e.clientY, t: Date.now() }; }, true); - document.addEventListener('pointerup', function (e) { - var start = down; - down = null; - if (!start || e.button > 0) return; - if (Math.hypot(e.clientX - start.x, e.clientY - start.y) > 6 || Date.now() - start.t > 700) return; - if (e.target && e.target.closest && e.target.closest('#scopenet-panel')) return; - var uuid = playerAt(e.clientX, e.clientY); - if (uuid) select(uuid); - else if (selected) select(null); - }, true); - - window.addEventListener('message', function (e) { - if (e.source === window.parent && e.data && e.data.source === 'scopenet-launcher' && e.data.type === 'hello') { /* acknowledged */ } - }); - - refresh(); - setInterval(refresh, 10000); - if (embedded) window.parent.postMessage({ source: 'scopenet-map', type: 'ready' }, '*'); - - // Exposed for tests. - window.__scopenet = { uuidFromId: uuidFromId, esc: esc, hours: hours }; -})(); diff --git a/integrations/bluemap/src/main/resources/assets/scopenet/shop.svg b/integrations/bluemap/src/main/resources/assets/scopenet/shop.svg deleted file mode 100644 index 9e1f3f0..0000000 --- a/integrations/bluemap/src/main/resources/assets/scopenet/shop.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/integrations/bluemap/src/main/resources/assets/scopenet/spawn.svg b/integrations/bluemap/src/main/resources/assets/scopenet/spawn.svg deleted file mode 100644 index 63c3e83..0000000 --- a/integrations/bluemap/src/main/resources/assets/scopenet/spawn.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/integrations/bluemap/src/main/resources/assets/scopenet/warp.svg b/integrations/bluemap/src/main/resources/assets/scopenet/warp.svg deleted file mode 100644 index 3feb25d..0000000 --- a/integrations/bluemap/src/main/resources/assets/scopenet/warp.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/integrations/common/src/main/java/net/scopenet/core/map/MapModel.java b/integrations/common/src/main/java/net/scopenet/core/map/MapModel.java index dfdb3fe..935a00e 100644 --- a/integrations/common/src/main/java/net/scopenet/core/map/MapModel.java +++ b/integrations/common/src/main/java/net/scopenet/core/map/MapModel.java @@ -52,7 +52,7 @@ public final class MapModel { return ((int) Math.round((r + m) * 255) << 16) | ((int) Math.round((g + m) * 255) << 8) | (int) Math.round((b + m) * 255); } - /** Which Minecraft dimension a BlueMap map shows, judged from its id and name ("world_nether" -> the nether). */ + /** Which Minecraft dimension a world id or name means ("world_nether" -> the nether). */ public static String dimensionOf(String id, String name) { List words = Arrays.asList(((id == null ? "" : id) + " " + (name == null ? "" : name)).toLowerCase(Locale.ROOT).split("[^a-z]+")); if (words.contains("nether")) return "minecraft:the_nether"; diff --git a/integrations/common/src/main/java/net/scopenet/core/map/MapOverlay.java b/integrations/common/src/main/java/net/scopenet/core/map/MapOverlay.java new file mode 100644 index 0000000..4fb9416 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/map/MapOverlay.java @@ -0,0 +1,81 @@ +package net.scopenet.core.map; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.util.List; +import java.util.Locale; + +/** + * The claims and pins as the JSON the panel's map viewer draws ({@code {"claims":[...],"pins":[...]}}). Details are plain text + * lines, never HTML: the viewer sets them as text, so nothing a player types can inject markup. + */ +public final class MapOverlay { + private MapOverlay() {} + + public static JsonObject toJson(MapData data) { + JsonArray claims = new JsonArray(); + for (MapData.ClaimRegion c : data.claims()) { + JsonObject o = new JsonObject(); + o.addProperty("id", c.id()); + o.addProperty("guild_id", c.guildId()); + o.addProperty("name", c.name()); + o.addProperty("tag", c.tag()); + o.addProperty("icon", c.icon()); + o.addProperty("color", String.format(Locale.ROOT, "#%06x", c.color() & 0xFFFFFF)); + o.addProperty("dimension", c.dimension()); + o.addProperty("chunks", c.chunks()); + o.addProperty("label_x", c.labelX()); + o.addProperty("label_z", c.labelZ()); + o.add("outer", ring(c.outer())); + JsonArray holes = new JsonArray(); + for (ChunkRegions.Ring hole : c.holes()) holes.add(ring(hole)); + o.add("holes", holes); + o.add("lines", lines(c.detail())); + claims.add(o); + } + JsonArray pins = new JsonArray(); + for (MapData.Pin p : data.pins()) { + JsonObject o = new JsonObject(); + o.addProperty("id", p.id()); + o.addProperty("kind", p.kind()); + o.addProperty("label", p.label()); + o.addProperty("dimension", p.dimension()); + o.addProperty("x", p.x()); + o.addProperty("y", p.y()); + o.addProperty("z", p.z()); + o.add("lines", lines(p.detail())); + pins.add(o); + } + JsonObject root = new JsonObject(); + root.add("claims", claims); + root.add("pins", pins); + return root; + } + + private static JsonArray ring(ChunkRegions.Ring ring) { + JsonArray out = new JsonArray(); + for (ChunkRegions.Point p : ring.points()) { + JsonArray pair = new JsonArray(); + pair.add(p.x()); + pair.add(p.z()); + out.add(pair); + } + return out; + } + + /** Turns the model's escaped HTML into plain lines (the markup there is ours; the text inside is already escaped). */ + static JsonArray lines(String html) { + String text = html == null ? "" : html + .replaceAll("(?i)||||]*>", "\n") + .replaceAll("<[^>]*>", ""); + text = text.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'") + .replace("·", "·").replace("&", "&"); + JsonArray out = new JsonArray(); + for (String line : text.split("\n")) { + String t = line.trim(); + if (!t.isEmpty()) out.add(t.length() > 200 ? t.substring(0, 200) : t); + } + return out; + } +} diff --git a/integrations/common/src/main/java/net/scopenet/core/map/MapService.java b/integrations/common/src/main/java/net/scopenet/core/map/MapService.java index 18fc3b8..46e6769 100644 --- a/integrations/common/src/main/java/net/scopenet/core/map/MapService.java +++ b/integrations/common/src/main/java/net/scopenet/core/map/MapService.java @@ -9,7 +9,7 @@ import net.scopenet.integration.ClaimIndex; import java.util.*; /** - * Gathers everything the map needs, from any thread. The map software (BlueMap) asks for a {@link MapData} every + * Gathers everything the map needs, from any thread. The map overlay asks for a {@link MapData} every * few seconds; the market listings are refreshed here on a slower clock so the map never waits on the panel. */ public final class MapService { diff --git a/integrations/common/src/main/java/net/scopenet/integration/Integration.java b/integrations/common/src/main/java/net/scopenet/integration/Integration.java index 6c144fc..3551d11 100644 --- a/integrations/common/src/main/java/net/scopenet/integration/Integration.java +++ b/integrations/common/src/main/java/net/scopenet/integration/Integration.java @@ -20,7 +20,8 @@ public final class Integration implements AutoCloseable { private final AtomicBoolean syncing = new AtomicBoolean(); private volatile boolean closed; private volatile Consumer notificationHandler; - private volatile LiveMapSync liveMap; + private volatile net.scopenet.worldmap.WorldMapSync map; + private volatile java.util.function.Supplier mapOverlay; private boolean registered; private JsonObject pending; private long nextSync; @@ -144,17 +145,34 @@ public final class Integration implements AutoCloseable { this.notificationHandler = handler; } - /** Start sending player positions for the in-app BlueMap territory view. */ - public void enableLiveMap() { - if (closed || liveMap != null) return; - liveMap = new LiveMapSync(client); - liveMap.start(); + /** + * Start the SCOPENET Map: render this world's region files to tiles and send them, player positions and the claims + * overlay to the panel. {@code worldRoot} is the level folder (e.g. {@code world}); {@code cacheDir} holds the rendered tiles. + */ + public void enableMap(java.nio.file.Path worldRoot, java.nio.file.Path cacheDir) { + if (closed || map != null) return; + map = new net.scopenet.worldmap.WorldMapSync(client, worldRoot, cacheDir, log); + if (mapOverlay != null) map.setOverlay(mapOverlay); + map.start(); + } + + /** Where the map gets its claims and pins (JSON text); may be called before or after {@link #enableMap}. */ + public void setMapOverlay(java.util.function.Supplier overlay) { + mapOverlay = overlay; + net.scopenet.worldmap.WorldMapSync current = map; + if (current != null) current.setOverlay(overlay); } /** Hand over a snapshot of player positions; call about once a second on the server thread. */ - public void liveMapPlayers(java.util.List players) { - LiveMapSync map = liveMap; - if (map != null) map.offerPlayers(players); + public void mapPlayers(java.util.List players) { + net.scopenet.worldmap.WorldMapSync current = map; + if (current != null) current.offerPlayers(players); + } + + /** The world was just saved: pick up its changed regions soon. */ + public void mapSaved() { + net.scopenet.worldmap.WorldMapSync current = map; + if (current != null) current.requestScan(); } public ChunkCheckResult checkChunk(String dimension, int chunkX, int chunkZ, UUID uuid) { @@ -173,8 +191,8 @@ public final class Integration implements AutoCloseable { @Override public void close() { closed = true; - LiveMapSync map = liveMap; - if (map != null) map.close(); + net.scopenet.worldmap.WorldMapSync current = map; + if (current != null) current.close(); client.close(); loginWorker.shutdownNow(); activity.leaveAll(); diff --git a/integrations/common/src/main/java/net/scopenet/integration/LiveMapSync.java b/integrations/common/src/main/java/net/scopenet/integration/LiveMapSync.java deleted file mode 100644 index 0657a8d..0000000 --- a/integrations/common/src/main/java/net/scopenet/integration/LiveMapSync.java +++ /dev/null @@ -1,70 +0,0 @@ -package net.scopenet.integration; - -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; -import java.util.List; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -/** Sends live player positions to the panel for the in-app BlueMap territory view. */ -public final class LiveMapSync implements AutoCloseable { - public record Player(String uuid, String name, String dimension, double x, double y, double z, float yaw, float pitch) {} - - private final PanelClient client; - private final ScheduledExecutorService worker = Executors.newSingleThreadScheduledExecutor(task -> { - Thread thread = new Thread(task, "scopenet-positions"); - thread.setDaemon(true); - return thread; - }); - private volatile List players = List.of(); - private volatile boolean changed; - private volatile boolean closed; - private long lastSent; - - public LiveMapSync(PanelClient client) { this.client = client; } - - public void start() { worker.scheduleWithFixedDelay(this::sendSafely, 1, 1, TimeUnit.SECONDS); } - - public void offerPlayers(List next) { - if (!next.equals(players)) { - players = List.copyOf(next); - changed = true; - } - } - - private void sendSafely() { - if (closed) return; - long now = System.nanoTime(); - if (!changed && now - lastSent < TimeUnit.SECONDS.toNanos(10)) return; - JsonArray list = new JsonArray(); - for (Player p : players) { - JsonObject entry = new JsonObject(); - entry.addProperty("uuid", p.uuid()); - entry.addProperty("name", p.name()); - entry.addProperty("dimension", p.dimension()); - entry.addProperty("x", p.x()); - entry.addProperty("y", p.y()); - entry.addProperty("z", p.z()); - entry.addProperty("yaw", p.yaw()); - entry.addProperty("pitch", p.pitch()); - list.add(entry); - } - JsonObject body = new JsonObject(); - body.add("players", list); - try { - client.post("livemap/players", body); - changed = false; - lastSent = now; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } catch (Exception ignored) { - // Keep the latest snapshot and retry on the next interval. - } - } - - @Override public void close() { - closed = true; - worker.shutdownNow(); - } -} diff --git a/integrations/common/src/main/java/net/scopenet/integration/Settings.java b/integrations/common/src/main/java/net/scopenet/integration/Settings.java index dae69fd..3e88541 100644 --- a/integrations/common/src/main/java/net/scopenet/integration/Settings.java +++ b/integrations/common/src/main/java/net/scopenet/integration/Settings.java @@ -107,12 +107,12 @@ public record Settings( ); } - /** Local opt-out for the central live map (the panel also has a per-server switch). */ - public static boolean liveMapEnabled(Path path) { + /** Local opt-out for the SCOPENET Map (the panel also has a per-server switch). */ + public static boolean mapEnabled(Path path) { try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { Properties properties = new Properties(); properties.load(reader); - return Boolean.parseBoolean(properties.getProperty("livemap.enabled", "true")); + return Boolean.parseBoolean(properties.getProperty("map.enabled", properties.getProperty("livemap.enabled", "true"))); } catch (IOException e) { return true; } @@ -139,9 +139,9 @@ public record Settings( essentials.enabled=true economy.enabled=true - # Send world chunks and player positions to the panel's central Live Map. - # It only runs when Live Map is also enabled for this server in the panel. - livemap.enabled=true + # Draw this world on the SCOPENET Map: tiles, player positions, claims and pins go to the panel. + # It only runs when the map is also enabled for this server in the panel. + map.enabled=true """; Files.writeString(path, defaultProps, StandardCharsets.UTF_8); } diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/WorldMapSync.java b/integrations/common/src/main/java/net/scopenet/worldmap/WorldMapSync.java index 4fe29e8..825aa07 100644 --- a/integrations/common/src/main/java/net/scopenet/worldmap/WorldMapSync.java +++ b/integrations/common/src/main/java/net/scopenet/worldmap/WorldMapSync.java @@ -59,7 +59,7 @@ public final class WorldMapSync implements AutoCloseable { private volatile int maxZoom = 6; private volatile long playerIntervalMs = 2000; private volatile List players = List.of(); - private volatile String overlay; + private volatile java.util.function.Supplier overlay; private long configAt; private long coarseUploadAt; private boolean stateDirty; @@ -90,9 +90,9 @@ public final class WorldMapSync implements AutoCloseable { players = List.copyOf(next); } - /** The claims and pins as JSON ({@code {"claims":[...],"pins":[...]}}); sent when it changes. */ - public void offerOverlay(String json) { - overlay = json; + /** Where the claims and pins come from ({@code {"claims":[...],"pins":[...]}}); asked every few seconds, sent when it changes. */ + public void setOverlay(java.util.function.Supplier source) { + overlay = source; } /** The world was saved: look for changed regions soon. */ @@ -337,7 +337,8 @@ public final class WorldMapSync implements AutoCloseable { lastPlayersSent = text; playersSentAt = now; } - String ov = overlay; + java.util.function.Supplier source = overlay; + String ov = source == null || now - overlaySentAt < 10_000 ? null : source.get(); if (ov != null && (!ov.equals(lastOverlaySent) || now - overlaySentAt >= 5 * 60_000) && now - overlaySentAt >= 10_000) { client.post("map/overlay", JsonParser.parseString(ov).getAsJsonObject()); lastOverlaySent = ov; diff --git a/integrations/common/src/test/java/net/scopenet/core/MapOverlayTest.java b/integrations/common/src/test/java/net/scopenet/core/MapOverlayTest.java new file mode 100644 index 0000000..dc65f4e --- /dev/null +++ b/integrations/common/src/test/java/net/scopenet/core/MapOverlayTest.java @@ -0,0 +1,29 @@ +package net.scopenet.core; + +import com.google.gson.JsonObject; +import net.scopenet.core.map.*; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class MapOverlayTest { + @Test void overlayCarriesShapesAndPlainText() { + var ring = new ChunkRegions.Ring(List.of(new ChunkRegions.Point(0, 0), new ChunkRegions.Point(16, 0), new ChunkRegions.Point(16, 16), new ChunkRegions.Point(0, 16))); + var claim = new MapData.ClaimRegion("g:o:0", "g", "Iron ", "IRN", "", 0x3366CC, "minecraft:overworld", ring, List.of(), 1, 8, 8, + "
Iron <Wolves> [IRN]
1 claimed chunk in this area
"); + var pin = new MapData.Pin("warp-a", "warp", "Warp: a", "minecraft:overworld", 5, 64, 6, + "
Warp a
/warp a
"); + JsonObject json = MapOverlay.toJson(new MapData(List.of(claim), List.of(pin), List.of())); + JsonObject c = json.getAsJsonArray("claims").get(0).getAsJsonObject(); + assertEquals("#3366cc", c.get("color").getAsString()); + assertEquals(4, c.getAsJsonArray("outer").size()); + assertEquals("Iron [IRN]", c.getAsJsonArray("lines").get(0).getAsString()); + assertEquals("1 claimed chunk in this area", c.getAsJsonArray("lines").get(1).getAsString()); + JsonObject p = json.getAsJsonArray("pins").get(0).getAsJsonObject(); + assertEquals("warp", p.get("kind").getAsString()); + assertEquals("/warp a", p.getAsJsonArray("lines").get(1).getAsString()); + assertFalse(json.toString().contains(" y < 5 ? 1 : 0); + MapRenderTest.region(dir.resolve("world/region"), Map.of(RegionFile.index(1, 1), flat, RegionFile.index(20, 3), flat)); + + List tiles = new CopyOnWriteArrayList<>(); + List players = new CopyOnWriteArrayList<>(); + List overlays = new CopyOnWriteArrayList<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/api/server/v1/map/config", ex -> reply(ex, "{\"enabled\":true,\"epoch\":3,\"max_zoom\":2,\"player_interval_ms\":500}")); + server.createContext("/api/server/v1/map/tiles", ex -> { + assertEquals("dim=minecraft:overworld", ex.getRequestURI().getRawQuery()); + ByteBuffer body = ByteBuffer.wrap(ex.getRequestBody().readAllBytes()); + while (body.hasRemaining()) { + int zoom = body.get() & 0xFF, x = body.getInt(), y = body.getInt(), len = body.getInt(); + byte[] png = new byte[len]; + body.get(png); + assertEquals((byte) 0x89, png[0]); + tiles.add(zoom + "/" + x + "/" + y); + } + reply(ex, "{\"stored\":1}"); + }); + server.createContext("/api/server/v1/map/players", ex -> { players.add(JsonParser.parseString(new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject()); reply(ex, "{}"); }); + server.createContext("/api/server/v1/map/overlay", ex -> { overlays.add(JsonParser.parseString(new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject()); reply(ex, "{}"); }); + server.start(); + PanelClient client = new PanelClient(Settings.of("http://127.0.0.1:" + server.getAddress().getPort(), "sn_" + "a".repeat(40))); + WorldMapSync sync = new WorldMapSync(client, dir.resolve("world"), dir.resolve("cache"), System.err::println); + sync.setOverlay(() -> "{\"claims\":[],\"pins\":[{\"id\":\"spawn\"}]}"); + sync.offerPlayers(List.of(new WorldMapSync.Player("11111111-1111-1111-1111-111111111111", "Steve", "minecraft:overworld", 10.5, 64, 20, 90, 0))); + try { + sync.start(); + long deadline = System.currentTimeMillis() + 20_000; + while ((tiles.size() < 5 || players.isEmpty() || overlays.isEmpty()) && System.currentTimeMillis() < deadline) Thread.sleep(100); + // chunk (1,1) is in tile 0/0/0, chunk (20,3) in 0/1/0; zoom 1 and 2 are built above them + assertTrue(tiles.contains("0/0/0"), tiles.toString()); + assertTrue(tiles.contains("0/1/0"), tiles.toString()); + assertTrue(tiles.contains("1/0/0"), tiles.toString()); + assertTrue(tiles.contains("2/0/0"), tiles.toString()); + assertEquals("Steve", players.get(0).getAsJsonArray("players").get(0).getAsJsonObject().get("name").getAsString()); + assertEquals("spawn", overlays.get(0).getAsJsonArray("pins").get(0).getAsJsonObject().get("id").getAsString()); + int sent = tiles.size(); + Thread.sleep(1500); + assertEquals(sent, tiles.size(), "an unchanged world isn't uploaded again"); + } finally { + sync.close(); + server.stop(0); + } + // the state survives a restart: a new sync with the same cache sends nothing + assertTrue(java.nio.file.Files.isRegularFile(dir.resolve("cache/state.json"))); + } + + private static void reply(com.sun.net.httpserver.HttpExchange ex, String json) throws java.io.IOException { + byte[] b = json.getBytes(StandardCharsets.UTF_8); + ex.sendResponseHeaders(200, b.length); + ex.getResponseBody().write(b); + ex.close(); + } +} diff --git a/integrations/fabric/build.gradle b/integrations/fabric/build.gradle index 613d817..2fe3891 100644 --- a/integrations/fabric/build.gradle +++ b/integrations/fabric/build.gradle @@ -16,7 +16,6 @@ dependencies { implementation project(':common') if (features) { modImplementation 'net.fabricmc.fabric-api:fabric-api:0.92.2+1.20.1' - implementation project(':bluemap') // Optional at runtime: present only when the LuckPerms mod is installed. compileOnly 'net.luckperms:api:5.4' } @@ -32,6 +31,5 @@ processResources { jar { archiveBaseName = "scopenet-fabric-${mc}" from project(':common').sourceSets.main.output - if (features) from project(':bluemap').sourceSets.main.output } base.archivesName = "scopenet-fabric-${mc}" diff --git a/integrations/fabric/src/main/resources/fabric.mod.json b/integrations/fabric/src/main/resources/fabric.mod.json index 535e65d..547db89 100644 --- a/integrations/fabric/src/main/resources/fabric.mod.json +++ b/integrations/fabric/src/main/resources/fabric.mod.json @@ -7,5 +7,5 @@ "entrypoints": { "server": ["net.scopenet.fabric.ScopenetFabric"] }, "mixins": ["scopenet.mixins.json"], "depends": { "fabricloader": ">=0.16.10", "minecraft": "${mc}", "java": ">=${java}" }, - "suggests": { "fabric-api": "*", "luckperms": "*", "placeholder-api": "*", "bluemap": "*" } + "suggests": { "fabric-api": "*", "luckperms": "*", "placeholder-api": "*" } } diff --git a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/BlueMapHook.java b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/BlueMapHook.java deleted file mode 100644 index d1d97c5..0000000 --- a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/BlueMapHook.java +++ /dev/null @@ -1,28 +0,0 @@ -package net.scopenet.fabric.features; - -import net.scopenet.bluemap.MapAddon; -import net.scopenet.core.map.MapData; -import net.scopenet.core.map.MapService; - -import java.util.logging.Logger; - -/** Kept apart so BlueMap's classes are only loaded when BlueMap is installed. */ -final class BlueMapHook { - private BlueMapHook() {} - - /** - * Registered while the mod initialises (BlueMap reads its script list when it starts), so the data comes from - * {@code service}, which only exists once the server is running: until then the map is simply empty. - */ - static Object start(java.util.function.Supplier service, Logger log) { - return MapAddon.start(new MapAddon.Feed() { - @Override public MapData snapshot() { MapService s = service.get(); return s == null ? MapData.empty() : s.snapshot(); } - @Override public com.google.gson.JsonObject playersJson() { - MapService s = service.get(); - return s == null ? net.scopenet.core.map.MapModel.playersJson("", java.util.List.of(), System.currentTimeMillis()) : s.playersJson(); - } - }, log); - } - - static void stop(Object handle) { ((MapAddon) handle).stop(); } -} diff --git a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricFeatures.java b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricFeatures.java index 156e571..a34a5a4 100644 --- a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricFeatures.java +++ b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricFeatures.java @@ -56,17 +56,12 @@ public final class FabricFeatures implements Runnable { private FabricConfig config; private ActionPoller poller; private MapService map; - private Object blueMap; private int ticks; private String placeholderStatus = "off"; CommandSet commandSet() { return commands; } @Override public void run() { - // Hook into BlueMap early: it reads the list of web scripts when it starts. - if (FabricConfig.load().bool("bluemap.enabled", true) && net.fabricmc.loader.api.FabricLoader.getInstance().isModLoaded("bluemap")) { - try { blueMap = BlueMapHook.start(() -> map, LOG); } catch (Throwable t) { LOG.warning("BlueMap addon could not start: " + t); } - } ServerLifecycleEvents.SERVER_STARTED.register(this::started); ServerLifecycleEvents.SERVER_STOPPING.register(s -> stopping()); ServerTickEvents.END_SERVER_TICK.register(s -> tick()); @@ -116,10 +111,11 @@ public final class FabricFeatures implements Runnable { integration.onNotifications(events -> link.onPanelEvents(events)); Bridge.claimBypass = p -> perms.has(p, "scopenet.claims.bypass"); - // Actions from the map, and the BlueMap addon (claims, pins, player panel) when BlueMap is installed. + // Actions from the map, and the claims and pins the SCOPENET Map draws. poller = new ActionPoller(env, (from, to) -> commands.essentials.tpa(from, new String[]{to.name()})); map = new MapService(env, integration.client().claims(), commands.mapSource(), cache, integration.settings().panel().toString(), - config.bool("bluemap.show_homes", false), s.getMotd()); + config.bool("map.show_homes", false), s.getMotd()); + integration.setMapOverlay(() -> { MapService current = map; return current == null ? "{\"claims\":[],\"pins\":[]}" : net.scopenet.core.map.MapOverlay.toJson(current.snapshot()).toString(); }); registerCommands(s.getCommands().getDispatcher()); registerPlaceholders(); ScopenetEconomy.install(new ScopenetEconomy(env.panel, r -> platform.runAsync(r))); @@ -130,7 +126,6 @@ public final class FabricFeatures implements Runnable { private void stopping() { ScopenetEconomy.uninstall(); Bridge.claimBypass = p -> false; - if (blueMap != null) { try { BlueMapHook.stop(blueMap); } catch (Throwable ignored) { } blueMap = null; } if (platform != null) platform.shutdown(); commands = null; map = null; diff --git a/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java b/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java index ff6a8c3..ff40336 100644 --- a/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java +++ b/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java @@ -33,7 +33,7 @@ public final class Bridge { })); integration.startClaimSync(); Path config = Path.of("config/scopenet.properties"); - if (Settings.liveMapEnabled(config)) integration.enableLiveMap(); + if (Settings.mapEnabled(config)) integration.enableMap(server.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT), Path.of("config/scopenet/map")); } catch (Exception e) { // Throwing aborts startup; silently disabling an auth mod is unsafe. throw new IllegalStateException("SCOPENET cannot start: " + e.getMessage(), e); @@ -71,13 +71,13 @@ public final class Bridge { current.tick(); if (++mapTicks >= 20) { mapTicks = 0; - List positions = new ArrayList<>(); + List positions = new ArrayList<>(); for (ServerPlayer player : server.getPlayerList().getPlayers()) { - positions.add(new LiveMapSync.Player(player.getUUID().toString(), player.getName().getString(), Compat.dimension(player), + positions.add(new net.scopenet.worldmap.WorldMapSync.Player(player.getUUID().toString(), player.getName().getString(), Compat.dimension(player), Math.round(player.getX() * 10) / 10.0, Math.round(player.getY() * 10) / 10.0, Math.round(player.getZ() * 10) / 10.0, Math.round(player.getYRot()), Math.round(player.getXRot()))); } - current.liveMapPlayers(positions); + current.mapPlayers(positions); } } diff --git a/integrations/paper/build.gradle b/integrations/paper/build.gradle index ca2d36d..c59a7e4 100644 --- a/integrations/paper/build.gradle +++ b/integrations/paper/build.gradle @@ -6,7 +6,6 @@ repositories { dependencies { implementation project(':common') implementation project(':api') - implementation project(':bluemap') implementation 'com.google.code.gson:gson:2.10.1' compileOnly 'org.spigotmc:spigot-api:1.20.1-R0.1-SNAPSHOT' // Optional integrations: the plugin detects these at runtime and works without them. @@ -23,7 +22,6 @@ jar { duplicatesStrategy = DuplicatesStrategy.EXCLUDE from project(':common').sourceSets.main.output from project(':api').sourceSets.main.output - from project(':bluemap').sourceSets.main.output - from { configurations.runtimeClasspath.findAll { it.name.endsWith('.jar') && !it.name.startsWith('common-') && !it.name.startsWith('api-') && !it.name.startsWith('bluemap-') }.collect { zipTree(it) } } + from { configurations.runtimeClasspath.findAll { it.name.endsWith('.jar') && !it.name.startsWith('common-') && !it.name.startsWith('api-') }.collect { zipTree(it) } } exclude 'META-INF/*.SF', 'META-INF/*.RSA', 'META-INF/*.DSA' } diff --git a/integrations/paper/src/main/java/net/scopenet/paper/ClientLinkBridge.java b/integrations/paper/src/main/java/net/scopenet/paper/ClientLinkBridge.java index b42c203..e33f648 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/ClientLinkBridge.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/ClientLinkBridge.java @@ -31,7 +31,6 @@ final class ClientLinkBridge implements PluginMessageListener { private final ShopPoints shops; private ActionPoller poller; private MapService mapService; - private Object blueMap; private EconomyHandler economy; ClientLinkBridge(ScopenetPlugin plugin, Integration integration) { @@ -64,7 +63,7 @@ final class ClientLinkBridge implements PluginMessageListener { } /** - * Shop points, actions from the map, and the BlueMap addon. Called once the command handlers exist; any of them may be + * Shop points, actions from the map, and the claims and pins for the SCOPENET Map. Called once the command handlers exist; any of them may be * null when that system is switched off. */ void startMap(EssentialsHandler essentials, GuildHandler guilds, EconomyHandler economy) { @@ -81,11 +80,9 @@ final class ClientLinkBridge implements PluginMessageListener { @Override public Collection shops() { return shops.all(); } }; mapService = new MapService(env, integration.client().claims(), source, cache, integration.settings().panel().toString(), - plugin.getConfig().getBoolean("bluemap.show-homes", false), Bukkit.getServer().getName()); + plugin.getConfig().getBoolean("map.show-homes", false), Bukkit.getServer().getName()); + integration.setMapOverlay(() -> net.scopenet.core.map.MapOverlay.toJson(mapService.snapshot()).toString()); Bukkit.getScheduler().runTaskTimer(plugin, () -> { poller.tick(); mapService.tick(); }, 60, 20); - if (plugin.getConfig().getBoolean("bluemap.enabled", true) && Bukkit.getPluginManager().getPlugin("BlueMap") != null) { - blueMap = BlueMapHook.start(mapService, plugin.getLogger()); - } } /** /market point add|remove|list, on the block the player is looking at (or standing on). */ @@ -113,20 +110,7 @@ final class ClientLinkBridge implements PluginMessageListener { } } - /** Kept apart so BlueMap's classes are only loaded when BlueMap is installed. */ - private static final class BlueMapHook { - static Object start(MapService service, Logger log) { - return net.scopenet.bluemap.MapAddon.start(new net.scopenet.bluemap.MapAddon.Feed() { - @Override public net.scopenet.core.map.MapData snapshot() { return service.snapshot(); } - @Override public com.google.gson.JsonObject playersJson() { return service.playersJson(); } - }, log); - } - - static void stop(Object handle) { ((net.scopenet.bluemap.MapAddon) handle).stop(); } - } - void stop() { - if (blueMap != null) { BlueMapHook.stop(blueMap); blueMap = null; } Bukkit.getMessenger().unregisterOutgoingPluginChannel(plugin, Wire.S2C); Bukkit.getMessenger().unregisterIncomingPluginChannel(plugin, Wire.C2S, this); } diff --git a/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java b/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java index e071d21..94b025c 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java @@ -51,10 +51,19 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener { // Claim protection runs from a local copy of the panel's claims, refreshed in the background. integration.startClaimSync(); - // Live positions center the in-app BlueMap claim view on the player. - if (getConfig().getBoolean("livemap.enabled", true)) { - integration.enableLiveMap(); - Bukkit.getScheduler().runTaskTimer(this, this::snapshotPlayers, 40, 20); + // The SCOPENET Map: renders the world to tiles and sends them, positions, claims and pins to the panel. + // Started on the first tick so the worlds exist. + if (getConfig().getBoolean("map.enabled", getConfig().getBoolean("livemap.enabled", true))) { + Bukkit.getScheduler().runTask(this, () -> { + if (integration == null || Bukkit.getWorlds().isEmpty()) return; + integration.enableMap(Bukkit.getWorlds().get(0).getWorldFolder().toPath(), getDataFolder().toPath().resolve("map")); + Bukkit.getScheduler().runTaskTimer(this, this::snapshotPlayers, 40, 20); + }); + getServer().getPluginManager().registerEvents(new org.bukkit.event.Listener() { + @org.bukkit.event.EventHandler public void onSave(org.bukkit.event.world.WorldSaveEvent event) { + if (integration != null) integration.mapSaved(); + } + }, this); } // Developer API: other plugins use SCOPENET through ScopenetApiProvider / the services manager, @@ -114,7 +123,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener { bindCommand("autoclaim", guilds); } - // Physical shops, actions from the map, and (when BlueMap is installed) claims, pins and the player panel on it. + // Physical shops, actions from the map, and the claims and pins the SCOPENET Map draws. clientLink.startMap(essentialsHandler, guildHandler, economy); // Reloading an auth plugin cannot silently admit existing sessions. @@ -160,7 +169,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener { private void snapshotPlayers() { if (integration == null) return; - java.util.List list = new java.util.ArrayList<>(); + java.util.List list = new java.util.ArrayList<>(); for (Player p : Bukkit.getOnlinePlayers()) { Location at = p.getLocation(); String dim = switch (at.getWorld().getEnvironment()) { @@ -169,11 +178,11 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener { default -> "minecraft:overworld"; }; // Rounded so an idle roster doesn't look "changed" on every tick. - list.add(new LiveMapSync.Player(p.getUniqueId().toString(), p.getName(), dim, + list.add(new net.scopenet.worldmap.WorldMapSync.Player(p.getUniqueId().toString(), p.getName(), dim, Math.round(at.getX() * 10) / 10.0, Math.round(at.getY() * 10) / 10.0, Math.round(at.getZ() * 10) / 10.0, Math.round(at.getYaw()), Math.round(at.getPitch()))); } - integration.liveMapPlayers(list); + integration.mapPlayers(list); } public static String dimension(World world) { diff --git a/integrations/paper/src/main/resources/config.yml b/integrations/paper/src/main/resources/config.yml index d6db894..4c90bbe 100644 --- a/integrations/paper/src/main/resources/config.yml +++ b/integrations/paper/src/main/resources/config.yml @@ -51,10 +51,13 @@ essentials: # RTP search radius rtp-radius: 2500 -livemap: - # Send live player positions to the panel for the launcher BlueMap claim view. - # Configure BlueMap's public address and enable live positions in the panel. +map: + # The SCOPENET Map: this server renders its world to map tiles in the background and sends only what changed, + # with player positions, guild claims and pins, to the panel. Players see it in the launcher; admins in the panel. + # It also needs the map switched on for this server in the panel (it is by default). enabled: true + # Show every player's homes as pins. Off by default: homes are private. + show-homes: false economy: # Server Economy commands (/balance, /pay, /baltop, /shop, /sell, /market, /orders, /trade, /transactions) @@ -93,17 +96,6 @@ integrations: enabled: true report-interval-seconds: 30 -# ============================================================================== -# BlueMap -# With BlueMap installed, SCOPENET adds guild claims (coloured, with the guild's icon and name), spawn, warps, guild -# homes, markets and shops to the 3D map, and a player panel when you click someone. Put the map's web address in the -# panel (Servers -> this server -> Map address) and players get a Live Map button in the launcher. -# ============================================================================== -bluemap: - enabled: true - # Show every player's homes as pins. Off by default: homes are private, and the map is public to anyone with the link. - show-homes: false - # ============================================================================== # Client mod link # Sends balance, level, guild and claim info to players who have the optional SCOPENET diff --git a/integrations/paper/src/main/resources/plugin.yml b/integrations/paper/src/main/resources/plugin.yml index 3d1c682..dda1abb 100644 --- a/integrations/paper/src/main/resources/plugin.yml +++ b/integrations/paper/src/main/resources/plugin.yml @@ -4,7 +4,7 @@ main: net.scopenet.paper.ScopenetPlugin api-version: '1.20' description: SCOPENET private authentication, access control and activity reporting. load: STARTUP -softdepend: [LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard, WorldEdit, spark, BlueMap] +softdepend: [LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard, WorldEdit, spark] commands: scopenet: diff --git a/integrations/settings.gradle b/integrations/settings.gradle index 558b8bb..91a2dcc 100644 --- a/integrations/settings.gradle +++ b/integrations/settings.gradle @@ -6,7 +6,7 @@ pluginManagement { } } rootProject.name = 'scopenet-integrations' -include 'common', 'api', 'paper', 'bluemap' +include 'common', 'api', 'paper' // Build loaders separately: Forge and Loom use different Gradle generations. if (providers.gradleProperty('loader').orNull == 'fabric') include 'fabric' if (providers.gradleProperty('loader').orNull == 'forge') include 'forge' From 5346b1f56055f12634e27459c8f620748cc921fa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:43:34 +0000 Subject: [PATCH 05/21] Test tidy --- .../src/test/java/net/scopenet/worldmap/WorldMapSyncTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integrations/common/src/test/java/net/scopenet/worldmap/WorldMapSyncTest.java b/integrations/common/src/test/java/net/scopenet/worldmap/WorldMapSyncTest.java index d05afe6..20f757f 100644 --- a/integrations/common/src/test/java/net/scopenet/worldmap/WorldMapSyncTest.java +++ b/integrations/common/src/test/java/net/scopenet/worldmap/WorldMapSyncTest.java @@ -50,7 +50,7 @@ class WorldMapSyncTest { try { sync.start(); long deadline = System.currentTimeMillis() + 20_000; - while ((tiles.size() < 5 || players.isEmpty() || overlays.isEmpty()) && System.currentTimeMillis() < deadline) Thread.sleep(100); + while ((tiles.size() < 4 || players.isEmpty() || overlays.isEmpty()) && System.currentTimeMillis() < deadline) Thread.sleep(100); // chunk (1,1) is in tile 0/0/0, chunk (20,3) in 0/1/0; zoom 1 and 2 are built above them assertTrue(tiles.contains("0/0/0"), tiles.toString()); assertTrue(tiles.contains("0/1/0"), tiles.toString()); From f428bd210f15dcd18fdd2eb0020f199353ca4cd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:47:10 +0000 Subject: [PATCH 06/21] Rank title renames now reach every player (server ranks recomputed too) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT --- panel/server/src/routes/leveling.rs | 22 ++++++++++++++++--- panel/server/tests/rank_titles.rs | 33 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 panel/server/tests/rank_titles.rs diff --git a/panel/server/src/routes/leveling.rs b/panel/server/src/routes/leveling.rs index 1df29fb..3c46009 100644 --- a/panel/server/src/routes/leveling.rs +++ b/panel/server/src/routes/leveling.rs @@ -297,14 +297,25 @@ fn validate_rank_mapping(data: &serde_json::Map) -> AppResult<()> Ok(()) } +/// Titles and rank names are shown from a copy kept on each player, so they must be recomputed whenever a reward is +/// created, renamed, moved or deleted. Otherwise a rename on the admin page never reaches the launcher. async fn refresh_global_titles(state: &AppState) -> AppResult<()> { - let players: Vec<(String, i64)> = sqlx::query_as("SELECT uuid, global_xp FROM user_levels").fetch_all(&state.db).await?; + let mut tx = state.db.begin().await?; + let players: Vec<(String, i64)> = sqlx::query_as("SELECT uuid, global_xp FROM user_levels").fetch_all(&mut *tx).await?; for (uuid, xp) in players { let level = level_from_xp(xp).0; let title: Option = sqlx::query_scalar("SELECT reward_name FROM level_rewards WHERE level_type='global' AND reward_type='title' AND level_req<=? ORDER BY level_req DESC,id DESC LIMIT 1") - .bind(level).fetch_optional(&state.db).await?; - sqlx::query("UPDATE user_levels SET title=? WHERE uuid=?").bind(title).bind(uuid).execute(&state.db).await?; + .bind(level).fetch_optional(&mut *tx).await?; + sqlx::query("UPDATE user_levels SET title=? WHERE uuid=?").bind(title).bind(uuid).execute(&mut *tx).await?; } + let members: Vec<(i64, String, i64)> = sqlx::query_as("SELECT server_id, uuid, server_xp FROM server_levels").fetch_all(&mut *tx).await?; + for (server_id, uuid, xp) in members { + let level = level_from_xp(xp).0; + let rank: Option = sqlx::query_scalar("SELECT reward_name FROM level_rewards WHERE level_type='server' AND server_id=? AND reward_type='title' AND level_req<=? ORDER BY level_req DESC,id DESC LIMIT 1") + .bind(server_id).bind(level).fetch_optional(&mut *tx).await?; + sqlx::query("UPDATE server_levels SET rank_name=? WHERE server_id=? AND uuid=?").bind(rank).bind(server_id).bind(uuid).execute(&mut *tx).await?; + } + tx.commit().await?; Ok(()) } @@ -379,12 +390,17 @@ pub async fn admin_update_reward( sqlx::query( "UPDATE level_rewards SET + level_type = COALESCE(?, level_type), + server_id = CASE WHEN ? = 1 THEN ? ELSE server_id END, level_req = ?, reward_type = ?, reward_name = ?, reward_data = ? WHERE id = ?", ) + .bind(payload.level_type.clone()) + .bind(payload.level_type.is_some() as i64) + .bind(payload.server_id) .bind(level_req) .bind(payload.reward_type) .bind(reward_name) diff --git a/panel/server/tests/rank_titles.rs b/panel/server/tests/rank_titles.rs new file mode 100644 index 0000000..82857c2 --- /dev/null +++ b/panel/server/tests/rank_titles.rs @@ -0,0 +1,33 @@ +mod common; +use common::*; + +#[tokio::test] +async fn renaming_a_rank_title_reaches_players() { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + let (_, user) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username":"Steve","password":"password123"}))).await; + assert!(user.get("id").is_some() || user.get("user").is_some(), "{user}"); + let steve = t.login("Steve", "password123").await; + let uuid = t.uuid("Steve").await; + let (_, server) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name":"SMP","instance_id":"smp"}))).await; + let sid = server["server"]["id"].as_i64().unwrap(); + sqlx::query("INSERT INTO server_levels (server_id, uuid, server_xp, server_level, updated_at) VALUES (?, ?, 100000, 10, 'now') ON CONFLICT(server_id, uuid) DO UPDATE SET server_xp = 100000") + .bind(sid).bind(&uuid).execute(&t.db).await.unwrap(); + sqlx::query("INSERT INTO user_levels (uuid, global_xp, global_level, updated_at) VALUES (?, 100000, 10, 'now') ON CONFLICT(uuid) DO UPDATE SET global_xp = 100000").bind(&uuid).execute(&t.db).await.unwrap(); + + let (s, made) = t.call("POST", "/api/admin/rewards", Some(&admin), Some(json!({"level_type":"server","server_id":sid,"level_req":2,"reward_type":"title","reward_name":"Squire"}))).await; + assert_eq!(s, StatusCode::OK, "{made}"); + let id = made["id"].as_i64().unwrap(); + let rank = |v: &Value| v["server_levels"][0]["rank_name"].clone(); + let (_, levels) = t.call("GET", &format!("/api/v1/levels/player/{uuid}"), Some(&steve), None).await; + assert_eq!(rank(&levels), "Squire", "{levels}"); + + let (s, r) = t.call("PUT", &format!("/api/admin/rewards/{id}"), Some(&admin), Some(json!({"level_type":"server","server_id":sid,"level_req":2,"reward_type":"title","reward_name":"Knight"}))).await; + assert_eq!(s, StatusCode::OK, "{r}"); + let (_, levels) = t.call("GET", &format!("/api/v1/levels/player/{uuid}"), Some(&steve), None).await; + assert_eq!(rank(&levels), "Knight", "a renamed rank shows up straight away"); + + assert_eq!(t.call("DELETE", &format!("/api/admin/rewards/{id}"), Some(&admin), None).await.0, StatusCode::OK); + let (_, levels) = t.call("GET", &format!("/api/v1/levels/player/{uuid}"), Some(&steve), None).await; + assert!(rank(&levels).is_null(), "{levels}"); +} From c37cde5cc8e2b1276774bcdb8ef3dd2bb7b43ff1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:49:13 +0000 Subject: [PATCH 07/21] Player names open profiles across the launcher and admin panel Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT --- launcher/src/components/GuildWallet.svelte | 3 ++- launcher/src/components/MapPlayerPanel.svelte | 3 ++- launcher/src/components/PlayerLink.svelte | 21 +++++++++++++++++++ launcher/src/pages/Guilds.svelte | 7 ++++--- launcher/src/pages/Social.svelte | 13 ++++++------ launcher/src/pages/Stats.svelte | 5 +++-- panel/server/src/routes/map.rs | 10 ++++----- panel/web/src/components/PlayerLink.svelte | 18 ++++++++++++++++ panel/web/src/pages/Activity.svelte | 3 ++- panel/web/src/pages/GuildsAdmin.svelte | 5 +++-- panel/web/src/pages/LevelingAdmin.svelte | 3 ++- panel/web/src/pages/ServerDetail.svelte | 5 +++-- panel/web/src/pages/Users.svelte | 10 +++++++++ 13 files changed, 82 insertions(+), 24 deletions(-) create mode 100644 launcher/src/components/PlayerLink.svelte create mode 100644 panel/web/src/components/PlayerLink.svelte diff --git a/launcher/src/components/GuildWallet.svelte b/launcher/src/components/GuildWallet.svelte index 0454fba..e817f05 100644 --- a/launcher/src/components/GuildWallet.svelte +++ b/launcher/src/components/GuildWallet.svelte @@ -1,4 +1,5 @@ + +{#if uuid} + +{:else} + {#if children}{@render children()}{:else}{name}{/if} +{/if} + + diff --git a/launcher/src/pages/Guilds.svelte b/launcher/src/pages/Guilds.svelte index 3509c44..a58d2f4 100644 --- a/launcher/src/pages/Guilds.svelte +++ b/launcher/src/pages/Guilds.svelte @@ -1,4 +1,5 @@ + +{#if uuid} + { e.preventDefault(); go(`users/${uuid}`); }}>{#if children}{@render children()}{:else}{name}{/if} +{:else} + {#if children}{@render children()}{:else}{name}{/if} +{/if} + + diff --git a/panel/web/src/pages/Activity.svelte b/panel/web/src/pages/Activity.svelte index 405c9c4..364fcc7 100644 --- a/panel/web/src/pages/Activity.svelte +++ b/panel/web/src/pages/Activity.svelte @@ -1,4 +1,5 @@ + +
+
+ Extra rewards {actions.length ? `${actions.length} attached` : 'none yet'} + +
+ + {#if adding} + + {/if} + + {#each actions as a, i} + {@const Icon = meta(a.type).icon} +
+ +
+ {meta(a.type).label} + {#if a.type === 'money'} + + {:else if a.type === 'item'} + + + {:else if a.type === 'permission'} + + + {:else if a.type === 'group'} + + {:else if a.type === 'claim_chunks'} + + {:else if a.type === 'badge'} + + {:else if a.type === 'message'} + + {:else if a.type === 'command'} + + {/if} + {#if ['money', 'item', 'permission', 'group', 'command', 'message'].includes(a.type) && servers.length > 1} + + {/if} +
+ +
+ {/each} + {#each items as it}{/each} + {#if !actions.length && !adding}

Give money, items, permissions, claim chunks and more when this is earned.

{/if} +
+ + diff --git a/panel/web/src/lib/rewards.ts b/panel/web/src/lib/rewards.ts new file mode 100644 index 0000000..3d5aefd --- /dev/null +++ b/panel/web/src/lib/rewards.ts @@ -0,0 +1,23 @@ +import { get, put } from './api'; + +export type RewardAction = { + type: 'money' | 'item' | 'permission' | 'group' | 'claim_chunks' | 'badge' | 'message' | 'command'; + amount?: number; + item?: string; + node?: string; + value?: boolean; + minutes?: number; + group?: string; + badge?: string; + text?: string; + command?: string; + server_id?: number | null; +}; + +export type RewardSource = 'quest' | 'achievement' | 'level_reward'; + +export const loadBundle = (kind: RewardSource, id: string | number) => + get<{ actions: RewardAction[] }>(`/api/admin/reward-bundles/${kind}/${encodeURIComponent(String(id))}`).then((r) => r.actions ?? []); + +export const saveBundle = (kind: RewardSource, id: string | number, actions: RewardAction[]) => + put(`/api/admin/reward-bundles/${kind}/${encodeURIComponent(String(id))}`, { actions }); diff --git a/panel/web/src/pages/AchievementEditor.svelte b/panel/web/src/pages/AchievementEditor.svelte index d34911a..82ee88e 100644 --- a/panel/web/src/pages/AchievementEditor.svelte +++ b/panel/web/src/pages/AchievementEditor.svelte @@ -2,6 +2,8 @@ import { onMount } from 'svelte'; import { Plus, Pencil, Trash2, Trophy, Sparkles, Shield, Swords, Pickaxe, Compass, Users } from '@lucide/svelte'; import Modal from '../components/Modal.svelte'; + import RewardBundleEditor from '../components/RewardBundleEditor.svelte'; + import { loadBundle, saveBundle, type RewardAction } from '../lib/rewards'; import { get, post, put, del } from '../lib/api'; import { toast, toastError } from '../lib/toast.svelte'; import type { Achievement } from '../lib/types'; @@ -12,6 +14,7 @@ let filterCategory = $state('all'); let filterFrame = $state('all'); + let bundle = $state([]); let draft = $state | null>(null); let draftOpen = $state(false); let saving = $state(false); @@ -77,11 +80,14 @@ stat_type: 'blocks_broken', target_count: 100, }; + bundle = []; draftOpen = true; } function openEdit(a: Achievement) { draft = { ...a }; + bundle = []; + loadBundle('achievement', a.id).then((b) => (bundle = b)).catch(() => {}); draftOpen = true; } @@ -100,6 +106,7 @@ await post('/api/admin/achievements', draft); toast('Achievement created'); } + await saveBundle('achievement', draft.id!, bundle); draftOpen = false; draft = null; await loadAchievements(); @@ -368,6 +375,8 @@ + + {/if} + + + +

These stack with the multipliers in each server's plugin/mod config (server levels default to 1.5× there). Quest and achievement XP is set per quest/achievement.

+ +
+

Economy & guild rules

+

Apply on every server. Home limits, cooldowns and RTP are set per server in the plugin/mod config, and ranks can raise home limits with permission nodes (scopenet.homes.10).

+
+ + + + + + +
+
{:else}
From 280f9eaf1bd10562bc00f3b411fb68e7ebb2814e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 01:15:05 +0000 Subject: [PATCH 12/21] SCOPENET Map viewer: shared canvas engine, launcher window and claim terrain, admin map card; BlueMap/Vantage UI removed --- Dockerfile | 2 + launcher/src-tauri/src/commands.rs | 103 +-- launcher/src-tauri/src/lib.rs | 4 +- launcher/src-tauri/src/state.rs | 4 - launcher/src/components/BlueMapModal.svelte | 80 -- launcher/src/components/ChunkMap.svelte | 21 +- ...lueMapTiles.svelte => ClaimTerrain.svelte} | 124 ++-- launcher/src/components/MapModal.svelte | 68 ++ launcher/src/lib/mock.ts | 10 +- launcher/src/pages/Home.svelte | 14 +- launcher/tsconfig.json | 6 +- launcher/vite.config.ts | 5 +- panel/web/src/components/LiveMapPanel.svelte | 52 -- panel/web/src/components/MapPanel.svelte | 120 +++ panel/web/src/components/ServerForm.svelte | 7 +- panel/web/src/lib/types.ts | 14 +- panel/web/src/pages/ServerDetail.svelte | 11 +- panel/web/src/pages/Servers.svelte | 4 +- panel/web/tsconfig.json | 6 +- panel/web/vite.config.ts | 4 + shared/map/MapViewer.svelte | 238 ++++++ shared/map/engine.ts | 687 ++++++++++++++++++ shared/map/feed.ts | 51 ++ shared/map/index.ts | 6 + shared/map/types.ts | 89 +++ 25 files changed, 1387 insertions(+), 343 deletions(-) delete mode 100644 launcher/src/components/BlueMapModal.svelte rename launcher/src/components/{BlueMapTiles.svelte => ClaimTerrain.svelte} (59%) create mode 100644 launcher/src/components/MapModal.svelte delete mode 100644 panel/web/src/components/LiveMapPanel.svelte create mode 100644 panel/web/src/components/MapPanel.svelte create mode 100644 shared/map/MapViewer.svelte create mode 100644 shared/map/engine.ts create mode 100644 shared/map/feed.ts create mode 100644 shared/map/index.ts create mode 100644 shared/map/types.ts diff --git a/Dockerfile b/Dockerfile index 38f8b7c..acdf99b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,8 @@ FROM --platform=$BUILDPLATFORM node:22-alpine AS web WORKDIR /src/panel/web COPY panel/web/package.json panel/web/package-lock.json ./ RUN npm ci --no-audit --no-fund +# The map viewer is shared with the launcher (imported as @scopenet/map). +COPY shared/ /src/shared/ COPY panel/web/ ./ RUN npm run build diff --git a/launcher/src-tauri/src/commands.rs b/launcher/src-tauri/src/commands.rs index 0409624..d6d75c1 100644 --- a/launcher/src-tauri/src/commands.rs +++ b/launcher/src-tauri/src/commands.rs @@ -635,90 +635,37 @@ pub async fn get_public_servers(state: State<'_, AppState>) -> Res, - etag: Option, -} - -/// Native transport for BlueMap settings and 2D tiles. Only paths under the -/// BlueMap address supplied by the panel can be fetched. -#[tauri::command] -pub async fn fetch_bluemap_resource(state: State<'_, AppState>, server_id: i64, url: String, etag: Option) -> Res { - let configured = state.map_urls.lock().unwrap().get(&server_id).cloned().ok_or("map not configured for this server")?; - let base = reqwest::Url::parse(&format!("{}/", configured.trim_end_matches('/'))).map_err(err)?; - let target = reqwest::Url::parse(&url).map_err(err)?; - let root = base.clone(); - if !matches!(target.scheme(), "http" | "https") - || target.origin() != base.origin() - || !target.path().starts_with(root.path()) - || target.username() != "" - || target.password().is_some() - || target.fragment().is_some() - { - return Err("map asset is outside the configured BlueMap site".into()); - } - fetch_map_asset(target, etag, None).await -} - -/// What the launcher needs to offer a Live Map button for one game server. -#[tauri::command] -pub async fn get_livemap_info(state: State<'_, AppState>, server_id: i64) -> Res { - let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/livemap")).await?; - let resp = req.send().await.map_err(err)?; - if !resp.status().is_success() { - let body: serde_json::Value = resp.json().await.unwrap_or_default(); - return Err(body["error"].as_str().unwrap_or("unable to load the live map").to_string()); - } resp.json().await.map_err(err) } -async fn fetch_map_asset(target: reqwest::Url, etag: Option, bearer: Option) -> Res { - use base64::Engine; - if target.as_str().len() > 2048 { - return Err("map asset URL is too long".into()); +/// What the SCOPENET Map needs for one game server: which dimensions have tiles, the tile key, and where tiles live (made absolute, +/// because the map loads them straight from the panel as images). +#[tauri::command] +pub async fn get_map_info(state: State<'_, AppState>, server_id: i64) -> Res { + let panel = state.panel_url().ok_or("no panel configured")?; + let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/map")).await?; + let resp = req.send().await.map_err(err)?; + if !resp.status().is_success() { + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + return Err(body["error"].as_str().unwrap_or("unable to load the map").to_string()); } - let client = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .timeout(std::time::Duration::from_secs(60)) - .build() - .map_err(err)?; - let mut request = client.get(target); - if let Some(token) = bearer { - request = request.bearer_auth(token); + let mut info: serde_json::Value = resp.json().await.map_err(err)?; + if let Some(base) = info["tile_base"].as_str().map(str::to_owned) { + info["tile_base"] = serde_json::json!(format!("{}{}", panel.trim_end_matches('/'), base)); } - if let Some(etag) = etag.filter(|e| e.len() < 256 && !e.contains('\n') && !e.contains('\r')) { - request = request.header(reqwest::header::IF_NONE_MATCH, etag); + Ok(info) +} + +/// Players now, plus the claims and pins the game server last sent. +#[tauri::command] +pub async fn get_map_overlay(state: State<'_, AppState>, server_id: i64) -> Res { + let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/map/overlay")).await?; + let resp = req.send().await.map_err(err)?; + if !resp.status().is_success() { + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + return Err(body["error"].as_str().unwrap_or("unable to load the map").to_string()); } - let response = request.send().await.map_err(err)?; - let status = response.status().as_u16(); - let etag = response.headers().get(reqwest::header::ETAG).and_then(|v| v.to_str().ok()).map(str::to_owned); - if status == 304 { - return Ok(MapResource { status, data: None, etag }); - } - if !response.status().is_success() { - return Ok(MapResource { status, data: None, etag }); - } - if response.content_length().is_some_and(|n| n > 64 * 1024 * 1024) { - return Err("BlueMap asset exceeds 64 MiB".into()); - } - let bytes = response.bytes().await.map_err(err)?; - if bytes.len() > 64 * 1024 * 1024 { - return Err("BlueMap asset exceeds 64 MiB".into()); - } - Ok(MapResource { status, data: Some(base64::engine::general_purpose::STANDARD.encode(bytes)), etag }) + resp.json().await.map_err(err) } // --------------------------------------------------------------------------- diff --git a/launcher/src-tauri/src/lib.rs b/launcher/src-tauri/src/lib.rs index 91d2a7e..b22856f 100644 --- a/launcher/src-tauri/src/lib.rs +++ b/launcher/src-tauri/src/lib.rs @@ -97,8 +97,8 @@ pub fn run() { commands::get_player_stats, commands::get_leaderboard, commands::get_public_servers, - commands::fetch_bluemap_resource, - commands::get_livemap_info, + commands::get_map_info, + commands::get_map_overlay, commands::get_my_level, commands::get_my_quests, commands::get_reward_summaries, diff --git a/launcher/src-tauri/src/state.rs b/launcher/src-tauri/src/state.rs index a4a1f3e..6800827 100644 --- a/launcher/src-tauri/src/state.rs +++ b/launcher/src-tauri/src/state.rs @@ -4,7 +4,6 @@ use crate::settings::Settings; use scopenet_core::Layout; use scopenet_shared::LauncherManifest; use std::path::PathBuf; -use std::collections::HashMap; use std::sync::{Mutex, RwLock}; /// Values baked in at build time by CI (see `.github/workflows/release.yml`). @@ -41,8 +40,6 @@ pub struct AppState { pub accounts: RwLock, pub secrets: Secrets, pub manifest: RwLock>, - /// BlueMap addresses received from the panel, keyed by linked game server. - pub map_urls: Mutex>, /// Install/launch pipeline in progress (abortable). pub task: Mutex>, pub game: Mutex>, @@ -61,7 +58,6 @@ impl AppState { settings: RwLock::new(settings), accounts: RwLock::new(accounts), manifest: RwLock::new(manifest), - map_urls: Mutex::new(HashMap::new()), task: Mutex::new(None), game: Mutex::new(Vec::new()), data_dir, diff --git a/launcher/src/components/BlueMapModal.svelte b/launcher/src/components/BlueMapModal.svelte deleted file mode 100644 index 091a1bc..0000000 --- a/launcher/src/components/BlueMapModal.svelte +++ /dev/null @@ -1,80 +0,0 @@ - - - e.key === 'Escape' && (selected ? (selected = null) : onclose())} /> - - - - diff --git a/launcher/src/components/ChunkMap.svelte b/launcher/src/components/ChunkMap.svelte index 9864184..dca3e67 100644 --- a/launcher/src/components/ChunkMap.svelte +++ b/launcher/src/components/ChunkMap.svelte @@ -2,15 +2,15 @@ import { onMount } from 'svelte'; import { Compass, Globe, LocateFixed, RefreshCw, Server as ServerIcon, Shield } from '@lucide/svelte'; import { invoke } from '../lib/tauri'; - import { toast } from '../lib/store.svelte'; + import { activeAccount, toast } from '../lib/store.svelte'; import type { GuildClaim } from '../lib/types'; - import BlueMapTiles from './BlueMapTiles.svelte'; + import ClaimTerrain from './ClaimTerrain.svelte'; let { instanceId, myGuildId = null, myGuildTag = '', onclaim, onunclaim }: { instanceId: string; myGuildId?: string | null; myGuildTag?: string; onclaim?: (claim: GuildClaim) => void; onunclaim?: (claimId: number) => void; } = $props(); - type GameServer = { id: number; name: string; instance_id: string; map_address?: string; live_map?: boolean }; + type GameServer = { id: number; name: string; instance_id: string; map?: boolean }; type Player = { x: number; z: number; dimension: string }; let servers = $state([]); let serverId = $state(null); @@ -32,9 +32,10 @@ async function refreshPosition(id: number) { try { - const info = await invoke<{ me?: Player | null }>('get_livemap_info', { serverId: id }); + const info = await invoke<{ players: Array }>('get_map_overlay', { serverId: id }); if (serverId !== id) return; - player = info.me ?? null; + const mine = activeAccount()?.uuid?.toLowerCase(); + player = info.players.find((p) => p.uuid.toLowerCase() === mine) ?? null; if (player && centeredServer !== id) { centeredServer = id; dimension = (dimensions.find((d) => d.id === player?.dimension)?.id ?? 'minecraft:overworld'); @@ -134,20 +135,20 @@ - {#if selectedServer?.map_address} - {#key `${serverId}:${selectedServer.map_address}`} - { centerX = x; centerZ = z; }} /> {/key} {:else} -
Configure this server's BlueMap address in the panel to show its 2D terrain and claim land.
+
The map is switched off for this server. An admin can turn it on under Servers in the panel.
{/if} diff --git a/launcher/src/components/BlueMapTiles.svelte b/launcher/src/components/ClaimTerrain.svelte similarity index 59% rename from launcher/src/components/BlueMapTiles.svelte rename to launcher/src/components/ClaimTerrain.svelte index 1f46adc..0d18f15 100644 --- a/launcher/src/components/BlueMapTiles.svelte +++ b/launcher/src/components/ClaimTerrain.svelte @@ -1,113 +1,74 @@ -
{ if (e.key === 'Enter') onpaint([{ x: Math.floor(worldX / 16), z: Math.floor(worldZ / 16) }], 'claim'); }} onpointerdown={down} onpointermove={move} onpointerup={up} onpointercancel={up} onwheel={wheel} oncontextmenu={(e) => e.preventDefault()}> {#each visibleTiles as tile (tile.key)} - {#if images[tile.key]}{/if} + (broken[tile.url] = true)} style:left="{tile.left}px" style:top="{tile.top}px" style:width="{tile.size}px" style:height="{tile.size}px" /> {/each}
@@ -191,10 +152,10 @@ {/each} {#if playerHere}
{/if} - {#if loading}
Loading BlueMap tiles…
{/if} - {#if error}
{error}
{/if} + {#if loading}
Loading the map…
{/if} + {#if error && !loading}
{error}
{/if}
Left click or drag: claim · Right click or drag: unclaim · Alt drag: pan · Wheel: zoom
-
{mapId} · {Math.floor(worldX)}, {Math.floor(worldZ)}
+
{Math.floor(worldX)}, {Math.floor(worldZ)}
diff --git a/launcher/src/components/MapModal.svelte b/launcher/src/components/MapModal.svelte new file mode 100644 index 0000000..2405158 --- /dev/null +++ b/launcher/src/components/MapModal.svelte @@ -0,0 +1,68 @@ + + + e.key === 'Escape' && onclose()} /> + + + + diff --git a/launcher/src/lib/mock.ts b/launcher/src/lib/mock.ts index 9205531..6807625 100644 --- a/launcher/src/lib/mock.ts +++ b/launcher/src/lib/mock.ts @@ -137,10 +137,12 @@ export async function mockInvoke(cmd: string, args: Record): Promis await sleep(40); switch (cmd) { case 'get_public_servers': - return { servers: [{ id: 1, name: 'Survival SMP', instance_id: 'survival-smp', map_address: '', live_map: true, online: true, players_online: 42, players_max: 100 }] }; - case 'get_livemap_info': - return { enabled: true, ready: true, message: '', me: null, players: 0, - dimensions: [{ id: 'minecraft:overworld', slug: 'overworld', label: 'Overworld', available: true }, { id: 'minecraft:the_nether', slug: 'the_nether', label: 'The Nether', available: false }, { id: 'minecraft:the_end', slug: 'the_end', label: 'The End', available: false }] }; + return { servers: [{ id: 1, name: 'Survival SMP', instance_id: 'survival-smp', map: true, online: true, players_online: 42, players_max: 100 }] }; + case 'get_map_info': + return { enabled: true, ready: false, message: 'Waiting for the game server to draw its world.', tile_size: 256, max_zoom: 6, tile_base: 'https://panel.example/api/map/1', token: null, token_ttl_secs: 3600, players: 0, + dimensions: [] }; + case 'get_map_overlay': + return { players: [], claims: [], pins: [], updated: null }; case 'get_player_stats': return { username: 'Notch', uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', total: null, servers: [], events: [] }; case 'get_leaderboard': diff --git a/launcher/src/pages/Home.svelte b/launcher/src/pages/Home.svelte index 1547577..e93f8a2 100644 --- a/launcher/src/pages/Home.svelte +++ b/launcher/src/pages/Home.svelte @@ -4,7 +4,7 @@ import ServerStatus from '../components/ServerStatus.svelte'; import NewsFeed from '../components/NewsFeed.svelte'; import Console from '../components/Console.svelte'; - import BlueMapModal from '../components/BlueMapModal.svelte'; + import MapModal from '../components/MapModal.svelte'; import { abs, activeAccount, app, instances, selectedInstance } from '../lib/store.svelte'; import { loaderName, shortLoaderVersion } from '../lib/format'; import { invoke, openUrl } from '../lib/tauri'; @@ -13,8 +13,8 @@ const b = $derived(app.manifest!.branding); // Lucide ships no brand logos, so each platform gets a recognisable generic icon. const linkIcon: Record = { discord: MessageCircle, website: Globe, store: ShoppingBag, youtube: CirclePlay, twitch: Tv, github: Code, twitter: AtSign }; - // A game server linked to this instance with BlueMap configured. - let mapServer = $state<{ id: number; name: string; map_address?: string } | null>(null); + // A game server linked to this instance that draws the SCOPENET Map. + let mapServer = $state<{ id: number; name: string } | null>(null); let mapOpen = $state(false); $effect(() => { const id = inst?.id; @@ -22,8 +22,8 @@ mapOpen = false; if (!id) return; let alive = true; - invoke<{ servers: Array<{ id: number; name: string; instance_id: string; map_address?: string }> }>('get_public_servers') - .then((r) => { if (alive) mapServer = r.servers.find((s) => s.instance_id === id && s.map_address) ?? null; }) + invoke<{ servers: Array<{ id: number; name: string; instance_id: string; map?: boolean }> }>('get_public_servers') + .then((r) => { if (alive) mapServer = r.servers.find((s) => s.instance_id === id && s.map) ?? null; }) .catch(() => {}); return () => { alive = false; }; }); @@ -57,7 +57,7 @@ - {#if mapServer}{/if} + {#if mapServer}{/if} {#if inst.server && b.features.server_status}{/if}
@@ -82,7 +82,7 @@ {#if app.consoleOpen}{/if} - {#if mapOpen && mapServer?.map_address} (mapOpen = false)} />{/if} + {#if mapOpen && mapServer} (mapOpen = false)} />{/if} diff --git a/panel/web/src/components/MapPanel.svelte b/panel/web/src/components/MapPanel.svelte new file mode 100644 index 0000000..08af75a --- /dev/null +++ b/panel/web/src/components/MapPanel.svelte @@ -0,0 +1,120 @@ + + +
+
+

SCOPENET Map

+ {enabled ? 'On' : 'Off'} + + +
+ + {#if !enabled} +

The map is off for this server. Switch it on in the server's settings and the plugin or mod starts drawing the world.

+ {:else} +
+
Tiles{totalTiles.toLocaleString()}
+
Size{formatBytes(totalBytes)}
+
Online{status?.players ?? 0}
+
Last tiles{status?.stats.last_upload_at ? timeAgo(status.stats.last_upload_at) : '—'}
+
Last players{status?.stats.last_players_at ? timeAgo(status.stats.last_players_at) : '—'}
+
+ {#if status?.dimensions.length} +

{#each status.dimensions as d, i}{i ? ' · ' : ''}{d.label}: {d.tiles.toLocaleString()} tiles ({formatBytes(d.bytes)}){/each}

+ {/if} +
+ {#key nonce} + + {#snippet player(p: LivePlayer, close: () => void)} +
+ +
{p.dimension.replace('minecraft:', '').replace('the_', '')}
+

X {Math.round(p.x)} · Y {Math.round(p.y)} · Z {Math.round(p.z)}

+ /tp {p.name} +
+ {/snippet} +
+ {/key} +
+ {/if} +
+ + +

This deletes every map tile stored on the panel. The game server notices and draws the world again from its region files, starting near the players. The map stays empty for a while on big worlds.

+
+ + +
+
+ + diff --git a/panel/web/src/components/ServerForm.svelte b/panel/web/src/components/ServerForm.svelte index 73a45b1..f97a599 100644 --- a/panel/web/src/components/ServerForm.svelte +++ b/panel/web/src/components/ServerForm.svelte @@ -18,11 +18,10 @@ -
diff --git a/panel/web/src/lib/types.ts b/panel/web/src/lib/types.ts index de9e32a..3d7a082 100644 --- a/panel/web/src/lib/types.ts +++ b/panel/web/src/lib/types.ts @@ -93,8 +93,7 @@ export interface AuthServerInfo { public_url: string; yggdrasil_url: string; pub export interface GameServer { instance_id: string; - live_map_enabled: boolean; - map_address: string; + map_enabled: boolean; economy_group: string; id: number; name: string; @@ -144,18 +143,19 @@ export interface ServerDetail { export interface UserActivity { servers: PlayerStats[]; events: ServerEvent[]; online_on: { id: number; name: string }[] } -export interface LiveMapStatus { +/** What the admin map card shows: tiles per dimension plus when the game server last sent things. */ +export interface MapStatus { enabled: boolean; ready: boolean; message: string; - dimensions: { id: string; slug: string; label: string; available: boolean }[]; + dimensions: { id: string; slug: string; label: string; tiles: number; bytes: number }[]; players: number; - world: { level_dat: boolean; dimensions: { id: string; slug: string; regions: number; bytes: number }[] }; - stats: { chunks_received: number; last_chunk_at: string | null; last_players_at: string | null }; + epoch: number; + stats: { tiles_received: number; bytes_received: number; last_upload_at: string | null; last_players_at: string | null; last_overlay_at: string | null }; roster: { uuid: string; name: string; dimension: string; x: number; y: number; z: number }[]; } -export type ServerDraft = { instance_id?: string; name: string; live_map_enabled: boolean; economy_group?: string; map_address?: string; access: 'all' | 'members' | 'groups'; allowed_groups: string[]; require_launcher: boolean }; +export type ServerDraft = { instance_id?: string; name: string; map_enabled: boolean; economy_group?: string; access: 'all' | 'members' | 'groups'; allowed_groups: string[]; require_launcher: boolean }; export interface HostedDownload { platform: 'windows' | 'mac' | 'linux'; diff --git a/panel/web/src/pages/ServerDetail.svelte b/panel/web/src/pages/ServerDetail.svelte index 9123c9b..b2924b0 100644 --- a/panel/web/src/pages/ServerDetail.svelte +++ b/panel/web/src/pages/ServerDetail.svelte @@ -1,12 +1,12 @@ + +
+ + +
+
+ {#if dims.length > 1} +
+ {#each dims as d} + + {/each} +
+ {:else if dims.length === 1} + {dims[0].label} + {/if} +
+ + + +
+ {#if toolbar}{@render toolbar()}{/if} +
+ +
+ + {#if listOpen && everyone.length} +
    + {#each everyone as p (p.uuid)} +
  • + +
  • + {/each} +
+ {/if} +
+
+ +
+ {#if hint}{hint}{/if} + {#if cursor}X {fmt(cursor.x)} · Z {fmt(cursor.z)}{:else} {/if}{zoomText} +
+ +
+ {#if me}{/if} + +
+ + +
+
+ + {#if selection} + + {/if} + + {#if waiting} +
+
+ {info?.enabled === false ? 'The map is off for this server' : 'Drawing the world…'} + {error || info?.message || 'Connecting to the map.'} +
+ {:else if error} +
{error}
+ {/if} +
+ + diff --git a/shared/map/engine.ts b/shared/map/engine.ts new file mode 100644 index 0000000..f28601f --- /dev/null +++ b/shared/map/engine.ts @@ -0,0 +1,687 @@ +import type { LivePlayer, MapBounds, MapClaim, MapDimension, MapInfo, MapLayers, MapOverlay, MapPin, MapSelection } from './types'; +import { dimSlug } from './types'; + +/** A framework-free map renderer: pans and zooms the tile pyramid on a canvas and draws claims, pins and players on top. */ + +export interface Theme { + background: string; + grid: string; + label: string; + labelBg: string; + accent: string; + self: string; +} + +const DARK: Theme = { background: '#0d1220', grid: 'rgba(255,255,255,0.045)', label: '#f4f6ff', labelBg: 'rgba(10,14,26,0.78)', accent: '#8b6cff', self: '#34d399' }; + +const PIN_COLORS: Record = { + spawn: '#fbbf24', warp: '#38bdf8', home: '#a78bfa', guild_home: '#fb7185', market: '#34d399', shop: '#f59e0b' +}; + +export interface MapViewOptions { + tileUrl: (slug: string, zoom: number, x: number, y: number) => string; + avatarUrl?: (uuid: string) => string; + theme?: Partial; + selfUuid?: string | null; + onselect?: (selection: MapSelection | null) => void; + onhover?: (hover: { cursor: { x: number; z: number } | null; label: string | null }) => void; + onview?: (view: { x: number; z: number; scale: number }) => void; +} + +interface Tile { img: HTMLImageElement; state: 'loading' | 'ok' | 'missing'; at: number; used: number } + +const MIN_SCALE = 1 / 128; +const MAX_SCALE = 20; +const TILE = 256; +const MAX_LOADS = 10; + +export class MapView { + private ctx: CanvasRenderingContext2D; + private theme: Theme; + private info: MapInfo | null = null; + private dimension: MapDimension | null = null; + private overlay: MapOverlay = { players: [], claims: [], pins: [] }; + private layers: MapLayers = { claims: true, pins: true, players: true }; + private tiles = new Map(); + private queue: string[] = []; + private loading = 0; + private icons = new Map(); + private paths = new Map(); + private width = 300; + private height = 200; + private dpr = 1; + private cx = 0; + private cz = 0; + private scale = 1; + private hoverId: string | null = null; + private selected: MapSelection | null = null; + private following: string | null = null; + private raf = 0; + private fly: { fromX: number; fromZ: number; fromS: number; toX: number; toZ: number; toS: number; start: number; ms: number } | null = null; + private pointers = new Map(); + private drag: { x: number; y: number; moved: number } | null = null; + private pinchStart: { dist: number; scale: number } | null = null; + private disposed = false; + private resizeObserver: ResizeObserver; + + constructor(private canvas: HTMLCanvasElement, private opts: MapViewOptions) { + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('canvas is not available'); + this.ctx = ctx; + this.theme = { ...DARK, ...(opts.theme ?? {}) }; + canvas.style.touchAction = 'none'; + canvas.addEventListener('pointerdown', this.down); + canvas.addEventListener('pointermove', this.move); + canvas.addEventListener('pointerup', this.up); + canvas.addEventListener('pointercancel', this.up); + canvas.addEventListener('pointerleave', this.leave); + canvas.addEventListener('wheel', this.wheel, { passive: false }); + canvas.addEventListener('dblclick', this.dbl); + this.resizeObserver = new ResizeObserver(() => this.resize()); + this.resizeObserver.observe(canvas); + this.resize(); + } + + // ---- public API -------------------------------------------------------------------------------------- + + setInfo(info: MapInfo) { + const tokenChanged = this.info?.token !== info.token || this.info?.tile_base !== info.tile_base; + this.info = info; + if (tokenChanged) { + // New tile key: tiles that failed earlier may work now; loaded ones stay. + for (const [k, t] of this.tiles) if (t.state !== 'ok') this.tiles.delete(k); + } + const keep = this.dimension ? info.dimensions.find((d) => d.slug === this.dimension!.slug) : undefined; + this.dimension = keep ?? info.dimensions[0] ?? null; + if (!keep && this.dimension) this.frameDimension(); + this.invalidate(); + } + + setOverlay(overlay: MapOverlay) { + this.overlay = overlay; + this.paths.clear(); + if (this.following) { + const p = overlay.players.find((q) => q.uuid === this.following); + if (p) { + if (dimSlug(p.dimension) !== this.dimension?.slug) this.setDimension(p.dimension); + this.fly = { fromX: this.cx, fromZ: this.cz, fromS: this.scale, toX: p.x, toZ: p.z, toS: this.scale, start: performance.now(), ms: 700 }; + } + } + if (this.selected?.type === 'player') { + const fresh = overlay.players.find((p) => p.uuid === (this.selected as { player: LivePlayer }).player.uuid); + if (fresh) this.selected = { type: 'player', player: fresh }; + } + this.invalidate(); + } + + setLayers(layers: MapLayers) { this.layers = { ...layers }; this.invalidate(); } + + setSelf(uuid: string | null | undefined) { this.opts.selfUuid = uuid ?? null; this.invalidate(); } + + /** Switch dimension by id (`minecraft:the_nether`) or slug. */ + setDimension(idOrSlug: string) { + const slug = dimSlug(idOrSlug); + const next = this.info?.dimensions.find((d) => d.slug === slug); + if (!next || next.slug === this.dimension?.slug) return; + this.dimension = next; + this.select(null); + this.frameDimension(); + this.invalidate(); + } + + get currentDimension(): MapDimension | null { return this.dimension; } + + follow(uuid: string | null) { this.following = uuid; if (uuid) this.invalidate(); } + + flyTo(x: number, z: number, scale?: number) { + this.following = null; + this.fly = { fromX: this.cx, fromZ: this.cz, fromS: this.scale, toX: x, toZ: z, toS: clamp(scale ?? this.scale, MIN_SCALE, MAX_SCALE), start: performance.now(), ms: 520 }; + this.invalidate(); + } + + zoomBy(factor: number) { + this.fly = null; + this.zoomAround(this.width / 2, this.height / 2, factor, true); + } + + select(selection: MapSelection | null) { + this.selected = selection; + this.opts.onselect?.(selection); + this.invalidate(); + } + + frameDimension() { + const b = this.dimension?.bounds; + if (!b) return; + this.fly = null; + const [minX, maxX, minZ, maxZ] = worldBounds(b); + this.cx = (minX + maxX) / 2; + this.cz = (minZ + maxZ) / 2; + const fit = Math.min(this.width / Math.max(1, maxX - minX), this.height / Math.max(1, maxZ - minZ)) * 0.9; + this.scale = clamp(fit, 0.05, 2); + this.emitView(); + } + + pinsOfKind(kind: string): MapPin[] { return this.overlay.pins.filter((p) => p.kind === kind && dimSlug(p.dimension) === this.dimension?.slug); } + + getView() { return { x: this.cx, z: this.cz, scale: this.scale }; } + + destroy() { + this.disposed = true; + cancelAnimationFrame(this.raf); + this.resizeObserver.disconnect(); + const c = this.canvas; + c.removeEventListener('pointerdown', this.down); + c.removeEventListener('pointermove', this.move); + c.removeEventListener('pointerup', this.up); + c.removeEventListener('pointercancel', this.up); + c.removeEventListener('pointerleave', this.leave); + c.removeEventListener('wheel', this.wheel); + c.removeEventListener('dblclick', this.dbl); + this.tiles.clear(); + } + + // ---- geometry --------------------------------------------------------------------------------------- + + private resize() { + const rect = this.canvas.getBoundingClientRect(); + this.dpr = Math.min(window.devicePixelRatio || 1, 2.5); + this.width = Math.max(50, rect.width); + this.height = Math.max(50, rect.height); + this.canvas.width = Math.round(this.width * this.dpr); + this.canvas.height = Math.round(this.height * this.dpr); + this.invalidate(); + } + + private toScreen(x: number, z: number) { return { x: (x - this.cx) * this.scale + this.width / 2, y: (z - this.cz) * this.scale + this.height / 2 }; } + private toWorld(sx: number, sy: number) { return { x: (sx - this.width / 2) / this.scale + this.cx, z: (sy - this.height / 2) / this.scale + this.cz }; } + + private zoomAround(sx: number, sy: number, factor: number, animate = false) { + const before = this.toWorld(sx, sy); + const target = clamp(this.scale * factor, MIN_SCALE, MAX_SCALE); + if (animate) { + // Keep the point under (sx, sy) fixed while easing the scale. + const toX = before.x - (sx - this.width / 2) / target; + const toZ = before.z - (sy - this.height / 2) / target; + this.fly = { fromX: this.cx, fromZ: this.cz, fromS: this.scale, toX, toZ, toS: target, start: performance.now(), ms: 220 }; + } else { + this.scale = target; + this.cx = before.x - (sx - this.width / 2) / this.scale; + this.cz = before.z - (sy - this.height / 2) / this.scale; + } + this.following = null; + this.invalidate(); + } + + // ---- input -------------------------------------------------------------------------------------------- + + private localPoint(e: PointerEvent | WheelEvent | MouseEvent) { + const r = this.canvas.getBoundingClientRect(); + return { x: e.clientX - r.left, y: e.clientY - r.top }; + } + + private down = (e: PointerEvent) => { + this.canvas.setPointerCapture(e.pointerId); + const p = this.localPoint(e); + this.pointers.set(e.pointerId, p); + this.fly = null; + if (this.pointers.size === 1) this.drag = { x: p.x, y: p.y, moved: 0 }; + if (this.pointers.size === 2) { + const [a, b] = [...this.pointers.values()]; + this.pinchStart = { dist: Math.hypot(a.x - b.x, a.y - b.y), scale: this.scale }; + this.drag = null; + } + }; + + private move = (e: PointerEvent) => { + const p = this.localPoint(e); + if (this.pointers.has(e.pointerId)) this.pointers.set(e.pointerId, p); + if (this.pinchStart && this.pointers.size === 2) { + const [a, b] = [...this.pointers.values()]; + const dist = Math.hypot(a.x - b.x, a.y - b.y); + this.zoomAround((a.x + b.x) / 2, (a.y + b.y) / 2, (this.pinchStart.scale * dist / this.pinchStart.dist) / this.scale); + return; + } + if (this.drag && this.pointers.has(e.pointerId)) { + const dx = p.x - this.drag.x, dy = p.y - this.drag.y; + this.drag.moved += Math.abs(dx) + Math.abs(dy); + this.drag.x = p.x; this.drag.y = p.y; + if (this.drag.moved > 3) { + this.cx -= dx / this.scale; + this.cz -= dy / this.scale; + this.following = null; + this.canvas.style.cursor = 'grabbing'; + this.invalidate(); + this.emitView(); + } + return; + } + const hit = this.hitTest(p.x, p.y); + const id = hit ? hitId(hit) : null; + if (id !== this.hoverId) { this.hoverId = id; this.invalidate(); } + this.canvas.style.cursor = hit ? 'pointer' : 'grab'; + const w = this.toWorld(p.x, p.y); + this.opts.onhover?.({ cursor: { x: w.x, z: w.z }, label: hit ? hitLabel(hit) : null }); + }; + + private up = (e: PointerEvent) => { + const wasClick = this.drag && this.drag.moved <= 3 && this.pointers.size === 1; + this.pointers.delete(e.pointerId); + if (this.pointers.size < 2) this.pinchStart = null; + if (wasClick) { + const p = this.localPoint(e); + const hit = this.hitTest(p.x, p.y); + this.select(hit); + } + if (this.pointers.size === 0) { this.drag = null; this.canvas.style.cursor = 'grab'; } + }; + + private leave = () => { + if (this.hoverId) { this.hoverId = null; this.invalidate(); } + this.opts.onhover?.({ cursor: null, label: null }); + }; + + private wheel = (e: WheelEvent) => { + e.preventDefault(); + const p = this.localPoint(e); + const delta = e.deltaMode === 1 ? e.deltaY * 18 : e.deltaY; + this.fly = null; + this.zoomAround(p.x, p.y, Math.exp(-delta * 0.0016)); + this.emitView(); + }; + + private dbl = (e: MouseEvent) => { + const p = this.localPoint(e); + if (this.hitTest(p.x, p.y)) return; + this.zoomAround(p.x, p.y, 2, true); + }; + + private emitView() { this.opts.onview?.({ x: this.cx, z: this.cz, scale: this.scale }); } + + // ---- hit testing -------------------------------------------------------------------------------------- + + private visibleSlug() { return this.dimension?.slug ?? ''; } + + private hitTest(sx: number, sy: number): MapSelection | null { + const slug = this.visibleSlug(); + if (this.layers.players) { + let best: LivePlayer | null = null, bestD = 15 * 15; + for (const p of this.overlay.players) { + if (dimSlug(p.dimension) !== slug) continue; + const s = this.toScreen(p.x, p.z); + const d = (s.x - sx) ** 2 + (s.y - sy) ** 2; + if (d < bestD) { best = p; bestD = d; } + } + if (best) return { type: 'player', player: best }; + } + if (this.layers.pins) { + let best: MapPin | null = null, bestD = 15 * 15; + for (const p of this.overlay.pins) { + if (dimSlug(p.dimension) !== slug) continue; + const s = this.toScreen(p.x, p.z); + const d = (s.x - sx) ** 2 + (s.y - sy) ** 2; + if (d < bestD) { best = p; bestD = d; } + } + if (best) return { type: 'pin', pin: best }; + } + if (this.layers.claims) { + const w = this.toWorld(sx, sy); + for (const c of this.overlay.claims) { + if (dimSlug(c.dimension) !== slug) continue; + if (inside(c.outer, w.x, w.z) && !c.holes.some((h) => inside(h, w.x, w.z))) return { type: 'claim', claim: c }; + } + } + return null; + } + + // ---- tiles -------------------------------------------------------------------------------------------- + + private tileKey(slug: string, z: number, x: number, y: number) { return `${slug}/${z}/${x}/${y}`; } + + private tileAt(slug: string, z: number, x: number, y: number, load: boolean): Tile | null { + const key = this.tileKey(slug, z, x, y); + const t = this.tiles.get(key); + if (t) { + t.used = performance.now(); + if (t.state === 'missing' && load && performance.now() - t.at > 20000) { this.tiles.delete(key); return this.tileAt(slug, z, x, y, load); } + return t; + } + if (!load || !this.info?.token) return null; + const img = new Image(); + img.decoding = 'async'; + const tile: Tile = { img, state: 'loading', at: performance.now(), used: performance.now() }; + this.tiles.set(key, tile); + this.queue.push(key); + this.pump(); + this.evict(); + return tile; + } + + private pump() { + while (this.loading < MAX_LOADS && this.queue.length) { + const key = this.queue.pop()!; // newest first: what is on screen now matters most + const tile = this.tiles.get(key); + if (!tile || tile.state !== 'loading') continue; + const [slug, z, x, y] = key.split('/'); + this.loading++; + tile.img.onload = () => { tile.state = 'ok'; this.loading--; this.pump(); this.invalidate(); }; + tile.img.onerror = () => { tile.state = 'missing'; tile.at = performance.now(); this.loading--; this.pump(); }; + tile.img.src = this.opts.tileUrl(slug, Number(z), Number(x), Number(y)); + } + } + + private evict() { + if (this.tiles.size <= 900) return; + const old = [...this.tiles.entries()].sort((a, b) => a[1].used - b[1].used).slice(0, 250); + for (const [k] of old) this.tiles.delete(k); + } + + private inBounds(z: number, x: number, y: number): boolean { + const b = this.dimension?.bounds; + if (!b) return true; + const span = 2 ** z; + return x * span <= b.max_x && (x + 1) * span - 1 >= b.min_x && y * span <= b.max_y && (y + 1) * span - 1 >= b.min_y; + } + + // ---- drawing ------------------------------------------------------------------------------------------ + + invalidate() { + if (this.disposed || this.raf) return; + this.raf = requestAnimationFrame((t) => { this.raf = 0; this.frame(t); }); + } + + private frame(now: number) { + if (this.fly) { + const f = this.fly; + const k = Math.min(1, (now - f.start) / f.ms); + const e = 1 - (1 - k) ** 3; + this.scale = f.fromS * (f.toS / f.fromS) ** e; + this.cx = f.fromX + (f.toX - f.fromX) * e; + this.cz = f.fromZ + (f.toZ - f.fromZ) * e; + if (k >= 1) this.fly = null; + this.emitView(); + this.invalidate(); + } + this.draw(); + } + + private draw() { + const ctx = this.ctx; + ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + ctx.fillStyle = this.theme.background; + ctx.fillRect(0, 0, this.width, this.height); + if (!this.dimension || !this.info) return; + this.drawTiles(ctx); + ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + if (this.layers.claims) this.drawClaims(ctx); + if (this.layers.pins) this.drawPins(ctx); + if (this.layers.players) this.drawPlayers(ctx); + } + + private drawTiles(ctx: CanvasRenderingContext2D) { + const slug = this.dimension!.slug; + const maxZoom = this.info!.max_zoom; + const zoom = clamp(Math.floor(Math.log2(1 / this.scale)), 0, maxZoom); + const span = TILE * 2 ** zoom; // world blocks per tile + const px = span * this.scale; // screen pixels per tile + const tl = this.toWorld(0, 0), br = this.toWorld(this.width, this.height); + const x0 = Math.floor(tl.x / span), x1 = Math.floor(br.x / span), y0 = Math.floor(tl.z / span), y1 = Math.floor(br.z / span); + ctx.imageSmoothingEnabled = this.scale < 1; + ctx.imageSmoothingQuality = 'high'; + // A faint chunk grid when close enough to matter. + if (this.scale >= 3) this.drawGrid(ctx); + for (let ty = y0; ty <= y1; ty++) { + for (let tx = x0; tx <= x1; tx++) { + if (!this.inBounds(zoom, tx, ty)) continue; + const dx = Math.floor((tx * span - this.cx) * this.scale + this.width / 2); + const dy = Math.floor((ty * span - this.cz) * this.scale + this.height / 2); + const dsize = Math.ceil(px) + 1; // overlap by a pixel so seams never show + const tile = this.tileAt(slug, zoom, tx, ty, true); + if (tile?.state === 'ok') { ctx.drawImage(tile.img, dx, dy, dsize, dsize); continue; } + // Not here yet: show the blurry parent underneath, then the real one replaces it. + for (let up = 1; zoom + up <= maxZoom; up++) { + const parent = this.tileAt(slug, zoom + up, tx >> up, ty >> up, tile?.state === 'loading' && up === 1); + if (parent?.state === 'ok') { + const part = TILE / 2 ** up; + const sx = (tx & (2 ** up - 1)) * part, sy = (ty & (2 ** up - 1)) * part; + ctx.drawImage(parent.img, sx, sy, part, part, dx, dy, dsize, dsize); + break; + } + } + } + } + } + + private drawGrid(ctx: CanvasRenderingContext2D) { + const step = 16 * this.scale; + const a = this.toWorld(0, 0); + ctx.strokeStyle = this.theme.grid; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let x = Math.floor(a.x / 16) * 16; (x - this.cx) * this.scale + this.width / 2 < this.width + step; x += 16) { + const sx = Math.round((x - this.cx) * this.scale + this.width / 2) + 0.5; + ctx.moveTo(sx, 0); ctx.lineTo(sx, this.height); + } + for (let z = Math.floor(a.z / 16) * 16; (z - this.cz) * this.scale + this.height / 2 < this.height + step; z += 16) { + const sy = Math.round((z - this.cz) * this.scale + this.height / 2) + 0.5; + ctx.moveTo(0, sy); ctx.lineTo(this.width, sy); + } + ctx.stroke(); + } + + private claimPath(c: MapClaim): Path2D { + let p = this.paths.get(c.id); + if (!p) { + p = new Path2D(); + for (const ring of [c.outer, ...c.holes]) { + ring.forEach(([x, z], i) => (i ? p!.lineTo(x, z) : p!.moveTo(x, z))); + p.closePath(); + } + this.paths.set(c.id, p); + } + return p; + } + + private drawClaims(ctx: CanvasRenderingContext2D) { + const slug = this.visibleSlug(); + const tl = this.toWorld(0, 0), br = this.toWorld(this.width, this.height); + const picked = this.selected?.type === 'claim' ? this.selected.claim.id : null; + for (const c of this.overlay.claims) { + if (dimSlug(c.dimension) !== slug || !c.outer.length) continue; + const xs = c.outer.map((p) => p[0]), zs = c.outer.map((p) => p[1]); + if (Math.max(...xs) < tl.x || Math.min(...xs) > br.x || Math.max(...zs) < tl.z || Math.min(...zs) > br.z) continue; + const hot = c.id === this.hoverId || c.id === picked; + ctx.save(); + ctx.setTransform(this.dpr * this.scale, 0, 0, this.dpr * this.scale, this.dpr * (this.width / 2 - this.cx * this.scale), this.dpr * (this.height / 2 - this.cz * this.scale)); + const path = this.claimPath(c); + ctx.fillStyle = withAlpha(c.color, hot ? 0.46 : 0.28); + ctx.fill(path, 'evenodd'); + ctx.lineJoin = 'round'; + ctx.lineWidth = (hot ? 3 : 2) / this.scale; + ctx.strokeStyle = withAlpha(c.color, 0.95); + ctx.stroke(path); + ctx.restore(); + } + // Labels on top of all shapes, once they are big enough to read. + for (const c of this.overlay.claims) { + if (dimSlug(c.dimension) !== slug || !c.outer.length) continue; + const xs = c.outer.map((p) => p[0]), zs = c.outer.map((p) => p[1]); + const w = (Math.max(...xs) - Math.min(...xs)) * this.scale, h = (Math.max(...zs) - Math.min(...zs)) * this.scale; + if (w < 46 || h < 30) continue; + const s = this.toScreen(c.label_x, c.label_z); + if (s.x < -80 || s.y < -40 || s.x > this.width + 80 || s.y > this.height + 40) continue; + const showName = w > 150 && h > 50; + this.chip(ctx, s.x, s.y, showName ? `${c.tag ? `[${c.tag}] ` : ''}${c.name}` : c.tag || c.name, c.color, c.icon); + } + } + + private drawPins(ctx: CanvasRenderingContext2D) { + const slug = this.visibleSlug(); + const picked = this.selected?.type === 'pin' ? this.selected.pin.id : null; + for (const p of this.overlay.pins) { + if (dimSlug(p.dimension) !== slug) continue; + const s = this.toScreen(p.x, p.z); + if (s.x < -30 || s.y < -30 || s.x > this.width + 30 || s.y > this.height + 30) continue; + const hot = p.id === this.hoverId || p.id === picked; + const color = PIN_COLORS[p.kind] ?? '#94a3b8'; + const r = hot ? 14 : 12; + ctx.save(); + ctx.shadowColor = 'rgba(0,0,0,0.5)'; ctx.shadowBlur = 8; ctx.shadowOffsetY = 2; + ctx.beginPath(); ctx.arc(s.x, s.y, r, 0, Math.PI * 2); + ctx.fillStyle = color; ctx.fill(); + ctx.restore(); + ctx.lineWidth = 2; ctx.strokeStyle = '#fff'; ctx.stroke(); + drawGlyph(ctx, p.kind, s.x, s.y, r * 0.58); + if (hot || this.scale >= 0.9) this.tag(ctx, s.x, s.y + r + 9, p.label); + } + } + + private drawPlayers(ctx: CanvasRenderingContext2D) { + const slug = this.visibleSlug(); + const picked = this.selected?.type === 'player' ? this.selected.player.uuid : null; + for (const p of this.overlay.players) { + if (dimSlug(p.dimension) !== slug) continue; + const s = this.toScreen(p.x, p.z); + if (s.x < -30 || s.y < -30 || s.x > this.width + 30 || s.y > this.height + 30) continue; + const me = !!this.opts.selfUuid && p.uuid.toLowerCase() === this.opts.selfUuid.toLowerCase(); + const hot = p.uuid === this.hoverId || p.uuid === picked; + const ring = me ? this.theme.self : this.theme.accent; + const r = hot ? 14 : 12; + // facing direction: Minecraft yaw 0 = south (+z), 90 = west (-x) + const a = (p.yaw * Math.PI) / 180; + const fx = -Math.sin(a), fz = Math.cos(a); + ctx.save(); + ctx.translate(s.x, s.y); + ctx.rotate(Math.atan2(fz, fx)); + ctx.beginPath(); ctx.moveTo(r + 9, 0); ctx.lineTo(r + 1, -5); ctx.lineTo(r + 1, 5); ctx.closePath(); + ctx.fillStyle = ring; ctx.fill(); + ctx.restore(); + ctx.save(); + ctx.shadowColor = 'rgba(0,0,0,0.55)'; ctx.shadowBlur = 8; ctx.shadowOffsetY = 2; + ctx.beginPath(); ctx.arc(s.x, s.y, r + 2, 0, Math.PI * 2); + ctx.fillStyle = ring; ctx.fill(); + ctx.restore(); + const img = this.opts.avatarUrl ? this.icon(this.opts.avatarUrl(p.uuid)) : null; + ctx.save(); + ctx.beginPath(); ctx.arc(s.x, s.y, r, 0, Math.PI * 2); ctx.clip(); + if (img) { ctx.imageSmoothingEnabled = false; ctx.drawImage(img, s.x - r, s.y - r, r * 2, r * 2); } + else { ctx.fillStyle = '#1e293b'; ctx.fillRect(s.x - r, s.y - r, r * 2, r * 2); ctx.fillStyle = '#fff'; ctx.font = '700 12px system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText((p.name[0] ?? '?').toUpperCase(), s.x, s.y + 1); } + ctx.restore(); + this.tag(ctx, s.x, s.y - r - 12, p.name, me ? this.theme.self : undefined); + } + } + + private icon(url: string): HTMLImageElement | null { + if (!url) return null; + const got = this.icons.get(url); + if (got === 'failed') return null; + if (got) return got.complete && got.naturalWidth ? got : null; + const img = new Image(); + img.onload = () => this.invalidate(); + img.onerror = () => this.icons.set(url, 'failed'); + img.src = url; + this.icons.set(url, img); + return null; + } + + /** A small rounded label with an optional round icon, centred on (x, y). */ + private chip(ctx: CanvasRenderingContext2D, x: number, y: number, text: string, color: string, iconUrl: string) { + ctx.font = '700 12px system-ui, -apple-system, Segoe UI, sans-serif'; + const img = iconUrl ? this.icon(iconUrl) : null; + const iconW = img ? 20 : 0; + const w = ctx.measureText(text).width + 18 + iconW, h = 24; + ctx.save(); + ctx.shadowColor = 'rgba(0,0,0,0.45)'; ctx.shadowBlur = 8; ctx.shadowOffsetY = 2; + roundRect(ctx, x - w / 2, y - h / 2, w, h, 12); + ctx.fillStyle = this.theme.labelBg; ctx.fill(); + ctx.restore(); + ctx.lineWidth = 1.5; ctx.strokeStyle = withAlpha(color, 0.9); ctx.stroke(); + let left = x - w / 2 + 9; + if (img) { + ctx.save(); ctx.beginPath(); ctx.arc(left + 8, y, 8, 0, Math.PI * 2); ctx.clip(); ctx.drawImage(img, left, y - 8, 16, 16); ctx.restore(); + left += 20; + } + ctx.fillStyle = this.theme.label; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillText(text, left, y + 0.5); + } + + private tag(ctx: CanvasRenderingContext2D, x: number, y: number, text: string, color?: string) { + ctx.font = '600 11px system-ui, -apple-system, Segoe UI, sans-serif'; + const w = ctx.measureText(text).width + 12; + roundRect(ctx, x - w / 2, y - 9, w, 18, 9); + ctx.fillStyle = this.theme.labelBg; ctx.fill(); + if (color) { ctx.lineWidth = 1; ctx.strokeStyle = color; ctx.stroke(); } + ctx.fillStyle = this.theme.label; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(text, x, y + 0.5); + } +} + +// ---- helpers -------------------------------------------------------------------------------------------- + +function clamp(v: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, v)); } + +/** Zoom-0 tile bounds -> world block bounds [minX, maxX, minZ, maxZ]. */ +function worldBounds(b: MapBounds): [number, number, number, number] { + return [b.min_x * TILE, (b.max_x + 1) * TILE, b.min_y * TILE, (b.max_y + 1) * TILE]; +} + +function hitId(s: MapSelection): string { return s.type === 'player' ? s.player.uuid : s.type === 'pin' ? s.pin.id : s.claim.id; } +function hitLabel(s: MapSelection): string { return s.type === 'player' ? s.player.name : s.type === 'pin' ? s.pin.label : `${s.claim.tag ? `[${s.claim.tag}] ` : ''}${s.claim.name}`; } + +function inside(ring: [number, number][], x: number, z: number): boolean { + let in_ = false; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const [ax, az] = ring[i], [bx, bz] = ring[j]; + if ((az > z) !== (bz > z) && x < ((bx - ax) * (z - az)) / (bz - az) + ax) in_ = !in_; + } + return in_; +} + +export function withAlpha(color: string, alpha: number): string { + const m = /^#?([0-9a-f]{6})$/i.exec(color.trim()); + if (!m) return color; + const n = parseInt(m[1], 16); + return `rgba(${n >> 16}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`; +} + +function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); +} + +/** A tiny white symbol for each kind of pin, drawn as shapes so no icon font is needed. */ +function drawGlyph(ctx: CanvasRenderingContext2D, kind: string, x: number, y: number, r: number) { + ctx.save(); + ctx.translate(x, y); + ctx.fillStyle = '#fff'; + ctx.strokeStyle = '#fff'; + ctx.beginPath(); + switch (kind) { + case 'spawn': // star + for (let i = 0; i < 10; i++) { const a = -Math.PI / 2 + (i * Math.PI) / 5, rr = i % 2 ? r * 0.45 : r * 1.15; ctx.lineTo(Math.cos(a) * rr, Math.sin(a) * rr); } + ctx.closePath(); ctx.fill(); + break; + case 'warp': // diamond with a hole + ctx.moveTo(0, -r * 1.2); ctx.lineTo(r * 0.95, 0); ctx.lineTo(0, r * 1.2); ctx.lineTo(-r * 0.95, 0); ctx.closePath(); ctx.fill(); + break; + case 'home': // house + ctx.moveTo(-r * 1.1, 0); ctx.lineTo(0, -r * 1.1); ctx.lineTo(r * 1.1, 0); ctx.lineTo(r * 0.7, 0); ctx.lineTo(r * 0.7, r); ctx.lineTo(-r * 0.7, r); ctx.lineTo(-r * 0.7, 0); ctx.closePath(); ctx.fill(); + break; + case 'guild_home': // flag + ctx.fillRect(-r * 0.8, -r * 1.1, r * 0.28, r * 2.2); + ctx.moveTo(-r * 0.52, -r * 1.1); ctx.lineTo(r * 1.0, -r * 0.5); ctx.lineTo(-r * 0.52, 0.1 * r); ctx.closePath(); ctx.fill(); + break; + case 'market': // $ + ctx.font = `800 ${Math.round(r * 2.3)}px system-ui, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('$', 0, r * 0.1); + break; + default: // shop: a bag + ctx.moveTo(-r * 0.9, -r * 0.3); ctx.lineTo(r * 0.9, -r * 0.3); ctx.lineTo(r * 0.7, r * 1.0); ctx.lineTo(-r * 0.7, r * 1.0); ctx.closePath(); ctx.fill(); + ctx.lineWidth = Math.max(1.2, r * 0.22); ctx.beginPath(); ctx.arc(0, -r * 0.3, r * 0.5, Math.PI, 0); ctx.stroke(); + } + ctx.restore(); +} diff --git a/shared/map/feed.ts b/shared/map/feed.ts new file mode 100644 index 0000000..b91d192 --- /dev/null +++ b/shared/map/feed.ts @@ -0,0 +1,51 @@ +import type { MapInfo, MapOverlay } from './types'; + +export interface FeedOptions { + loadInfo: () => Promise; + loadOverlay: () => Promise; + onInfo: (info: MapInfo) => void; + onOverlay: (overlay: MapOverlay) => void; + onError?: (message: string) => void; + /** How often players move on the map. */ + overlayMs?: number; +} + +/** + * Keeps a map fresh: the overlay (players, claims, pins) every couple of seconds while the window is visible, and the info + * (which carries the tile key) when it is about to expire. Returns a function that stops everything. + */ +export function startFeed(o: FeedOptions): () => void { + let stopped = false; + let infoTimer: ReturnType | undefined; + let overlayTimer: ReturnType | undefined; + let ready = false; + + const fail = (e: unknown) => o.onError?.(typeof e === 'string' ? e : (e as { message?: string })?.message ?? 'Could not reach the map'); + + async function info() { + try { + const next = await o.loadInfo(); + if (stopped) return; + ready = next.ready; + o.onInfo(next); + // The tile key is good for an hour: renew it with plenty of time to spare. While waiting for the first tiles, check often. + const wait = next.ready ? Math.max(30, Math.min(next.token_ttl_secs * 0.6, 1200)) * 1000 : 8000; + infoTimer = setTimeout(info, wait); + } catch (e) { + fail(e); + if (!stopped) infoTimer = setTimeout(info, 10000); + } + } + + async function overlay() { + if (stopped) return; + if (ready && !document.hidden) { + try { o.onOverlay(await o.loadOverlay()); } catch (e) { fail(e); } + } + if (!stopped) overlayTimer = setTimeout(overlay, o.overlayMs ?? 2500); + } + + info(); + overlayTimer = setTimeout(overlay, 600); + return () => { stopped = true; clearTimeout(infoTimer); clearTimeout(overlayTimer); }; +} diff --git a/shared/map/index.ts b/shared/map/index.ts new file mode 100644 index 0000000..f9f1dfd --- /dev/null +++ b/shared/map/index.ts @@ -0,0 +1,6 @@ +export { MapView, withAlpha } from './engine'; +export type { MapViewOptions, Theme } from './engine'; +export { startFeed } from './feed'; +export type { FeedOptions } from './feed'; +export { dimSlug } from './types'; +export type * from './types'; diff --git a/shared/map/types.ts b/shared/map/types.ts new file mode 100644 index 0000000..e202313 --- /dev/null +++ b/shared/map/types.ts @@ -0,0 +1,89 @@ +// What the panel's map endpoints return (see panel/server/src/routes/worldmap.rs), shared by the launcher and the admin panel. + +export interface MapBounds { min_x: number; max_x: number; min_y: number; max_y: number } + +export interface MapDimension { + id: string; + slug: string; + label: string; + available: boolean; + tiles: number; + bytes: number; + bounds: MapBounds | null; +} + +export interface MapInfo { + enabled: boolean; + ready: boolean; + message: string; + tile_size: number; + max_zoom: number; + /** Where tiles live, e.g. `https://panel.example.com/api/map/3`. */ + tile_base: string; + /** Short-lived key that goes on each tile address. */ + token: string | null; + token_ttl_secs: number; + dimensions: MapDimension[]; + players: number; +} + +export interface LivePlayer { + uuid: string; + name: string; + dimension: string; + x: number; + y: number; + z: number; + yaw: number; +} + +export interface MapClaim { + id: string; + guild_id: string; + name: string; + tag: string; + icon: string; + color: string; + dimension: string; + chunks: number; + label_x: number; + label_z: number; + outer: [number, number][]; + holes: [number, number][][]; + lines: string[]; +} + +export type PinKind = 'spawn' | 'warp' | 'home' | 'guild_home' | 'market' | 'shop'; + +export interface MapPin { + id: string; + kind: PinKind | string; + label: string; + dimension: string; + x: number; + y: number; + z: number; + lines: string[]; +} + +export interface MapOverlay { + players: LivePlayer[]; + claims: MapClaim[]; + pins: MapPin[]; + updated?: string | null; +} + +export type MapSelection = + | { type: 'player'; player: LivePlayer } + | { type: 'pin'; pin: MapPin } + | { type: 'claim'; claim: MapClaim }; + +export interface MapLayers { claims: boolean; pins: boolean; players: boolean } + +/** Same naming as the panel: `minecraft:the_nether` -> `the_nether`, `mod:dim` -> `mod__dim`. */ +export function dimSlug(id: string): string { + const trimmed = id.startsWith('minecraft:') ? id.slice(10) : id; + if (trimmed === 'nether') return 'the_nether'; + if (trimmed === 'end') return 'the_end'; + return trimmed.replace(':', '__'); +} From 74ca07fe93d1ae795a96e57d106afa18ecc4a438 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 01:15:52 +0000 Subject: [PATCH 13/21] Docs and examples for the SCOPENET Map, rewards and limits; BlueMap/Vantage docs removed --- docs/bluemap.md | 76 ---------------------- docs/deployment.md | 5 +- docs/live-map.md | 21 ------ docs/map.md | 44 +++++++++++++ docs/ranks-guilds-and-warps.md | 2 +- docs/rewards-and-limits.md | 70 ++++++++++++++++++++ examples/fabric-server/scopenet.properties | 42 +++++++++--- examples/paper/config.yml | 53 +++++++++++---- 8 files changed, 190 insertions(+), 123 deletions(-) delete mode 100644 docs/bluemap.md delete mode 100644 docs/live-map.md create mode 100644 docs/map.md create mode 100644 docs/rewards-and-limits.md diff --git a/docs/bluemap.md b/docs/bluemap.md deleted file mode 100644 index 9b52d5d..0000000 --- a/docs/bluemap.md +++ /dev/null @@ -1,76 +0,0 @@ -# BlueMap: the 3D map - -SCOPENET uses [BlueMap](https://bluemap.bluecolored.de/) for the 3D world map. BlueMap runs on each Minecraft server as a plugin or mod and serves the map on its own web address. SCOPENET adds to it, and the launcher shows it inside the app exactly as a browser would. - -## What you get - -| On the map | Where it comes from | -|---|---| -| **Guild claims** as coloured regions with the guild's icon and name (one colour per guild, outlines follow the real shape, holes included) | the claim index | -| **Spawn, warps and guild homes** as pins | the server | -| **Homes** as pins (off by default, see privacy) | the server | -| **Markets and shops** as pins that list what is for sale | shop points + the panel's market | -| **Players** with BlueMap's own live markers; **click one** for a side panel | BlueMap + SCOPENET | - -The player panel shows level, guild, rank, title, playtime and server level. Inside the launcher it also offers **Send teleport request**, **Send message** and **Invite to my guild** (you need to be signed in with a SCOPENET account, and the other player must be online on that server; inviting needs a leader or officer role). In a plain browser it shows the info and points you to the launcher for the actions. - -## Set it up - -1. **Install BlueMap** on the server: the Paper plugin, or the Fabric/Forge mod, from . Start the server once. BlueMap asks you to accept a download of the Minecraft client files in its config (`core.conf`: `accept-download: true`), then renders the world. -2. **Make its web address reachable.** BlueMap serves on port 8100 by default. Put it behind HTTPS with a reverse proxy (recommended; see below) or open the port. -3. **Install the SCOPENET plugin or Fabric mod** (Paper, or Fabric 1.20.1). It finds BlueMap by itself and adds its markers and script. Nothing else to configure. -4. **Tell the panel the address:** *Servers → your server → Settings → Map address (BlueMap)*, for example `https://map.example.com`. Players then see a **Live Map** button for that server in the launcher. - -`bluemap.enabled` (Paper `config.yml`, Fabric `scopenet.properties`) turns the addon off. `bluemap.show-homes` / `bluemap.show_homes` shows every player's homes. - -## Shop points (physical shops) - -A shop point is a block that opens the market or the server shop when right-clicked. It works with the normal windows (chest window on Paper; the client mod's shop and market windows where installed) and appears as a pin on the map. - -``` -/market point add [market|shop] (look at a block; needs scopenet.command.market.point, default op) -/market point remove -/market point list -``` - -`market` opens the player market and lists recent listings on the map pin; `shop` opens the server shop. - -## Inside the launcher - -The launcher loads your map address in a frame, so everything BlueMap does in a browser works there too. SCOPENET's script on the map page passes player clicks to the launcher, which shows the side panel. A few things make it work or not: - -* **Use HTTPS.** Loading an `http://` map inside an HTTPS page can be blocked as mixed content on some systems. -* **Don't forbid framing.** If your reverse proxy sends `X-Frame-Options: DENY` or a `Content-Security-Policy: frame-ancestors` that excludes the launcher, the map won't load in the app. Remove that header for the map address. The launcher's **Browser** button opens the same map in your browser either way. -* The map address is checked by the panel (web address only, no credentials). - -## Privacy - -* The map is public to anyone who knows its address, like any BlueMap. **Homes are off by default** for that reason. Claims and guild names are visible by design. -* `players.json`, which drives the browser panel, holds only public facts (name, level, title, guild, rank, playtime). Never balances. -* Actions from the map go through the panel, are rate-limited, and need a signed-in account. A message or teleport request reaches only players who are online on that server. - -## Reverse proxy example (nginx) - -```nginx -server { - server_name map.example.com; - listen 443 ssl; - location / { - proxy_pass http://127.0.0.1:8100; - proxy_set_header Host $host; - proxy_http_version 1.1; - proxy_set_header Connection ""; # BlueMap uses server-sent events for live updates - proxy_buffering off; - } -} -``` - -## How it works (for developers) - -* `net.scopenet.core.map` builds the map data without any map software: `ChunkRegions` turns chunks into outlines (tested with an area-conservation check), `MapModel` builds pins and regions and escapes every name, `MapService` gathers it from any thread. -* `integrations/bluemap` (`MapAddon`) is the only code that touches BlueMap's API. It copies `scopenet.js`, `scopenet.css` and the icons into BlueMap's web root, registers them, and refreshes the marker sets every 10 seconds. -* Map actions are queued in the panel (`server_actions`); the server collects them every 3 seconds (`ActionPoller`) and carries them out with its normal rules. Guild invitations are real invitations (`guild_invites`) that the other player accepts in the launcher or with `/guild accept`. - -## Not verified yet - -The BlueMap addon, the Paper and Fabric wiring, and the launcher's map view have not been compiled or run against a real BlueMap and Minecraft: the build environment cannot download them. The geometry, map model, shop points, action poller and guild-invite commands are unit-tested, and so are the panel endpoints. Build it, and report what BlueMap says in its log and what the map shows. diff --git a/docs/deployment.md b/docs/deployment.md index 81d2ab4..799e0d8 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -23,9 +23,8 @@ The panel listens on `PANEL_PORT` (default `8080`). Docker checks `docker compose ps` before directing players to it. If `ADMIN_PASSWORD` is blank on first start, the generated password appears in the panel logs. -To show a 2D world map and claim land in the launcher, install BlueMap on the -game server and configure its public web address in the panel. See -[Live map setup](live-map.md). +Servers draw their own world map and send it to the panel; nothing extra to +install or host. See [The SCOPENET Map](map.md). The named `panel-data` volume holds the database, uploads, hosted launcher downloads, and the generated JWT signing key. Preserve that volume when diff --git a/docs/live-map.md b/docs/live-map.md deleted file mode 100644 index 38970a1..0000000 --- a/docs/live-map.md +++ /dev/null @@ -1,21 +0,0 @@ -# Live map and land claims - -SCOPENET uses [BlueMap](https://bluemap.bluecolored.de/) for terrain. Install BlueMap on the Minecraft server and make its web app reachable from players' computers. In the panel's **Servers → server settings**, enter the BlueMap **Map address**. The launcher opens the same map in its Live Map window and reads BlueMap's low resolution 2D tile images for the Guilds territory view. - -The terrain address must serve BlueMap's `settings.json`, per-map `settings.json`, and low resolution PNG tiles from the same URL path. A URL such as `https://maps.example.com/` works. If BlueMap is hosted below a path, include the trailing slash, for example `https://example.com/bluemap/`. - -## Center on the player - -Turn on **Live player positions** in the server settings and leave `livemap.enabled: true` in the SCOPENET plugin or mod configuration. The game server sends current positions to the panel. The panel returns each signed-in player only their own position to the launcher. The **Find me** button centers the territory view on it; the blue dot marks their position. Positions expire when the server stops sending updates. - -The BlueMap map itself remains available when position relay is off. In that case, players can pan manually, but **Find me** is unavailable. - -## Claim controls - -In **Guilds & Territories**, select the game server and dimension. Left click a chunk to claim it for your guild, or drag across chunks to claim an area. Right click or right drag unclaims your guild's chunks. Alt drag pans; the wheel zooms. The overlay shows your guild's claims in green and other guilds' claims in red. The panel checks membership and claim limits for every request; only successful claims appear in the final map state. - -BlueMap must have rendered the area to show terrain. Claims can still be managed by the in-game `/claim` and `/unclaim` commands when the web map is unavailable. - -## Existing world mirror - -Older SCOPENET game integrations uploaded region chunks to the panel under `/api/server/v1/livemap`. The panel retains those ingest endpoints for compatibility with existing servers, but the launcher terrain comes from BlueMap. Current integrations send player positions only. diff --git a/docs/map.md b/docs/map.md new file mode 100644 index 0000000..27bc0c4 --- /dev/null +++ b/docs/map.md @@ -0,0 +1,44 @@ +# The SCOPENET Map + +Every server draws its own world map. There is nothing to install, host or configure besides the plugin or mod you already run: the game server renders its world to small tiles in the background, sends only what changed to the panel, and the launcher and admin panel show them in a fast native map. + +What's on it: + +- **Terrain** in the look of a Minecraft map, coloured from the real block textures (modded blocks get a matching colour). The Nether is drawn below its ceiling, so you see caves and lava lakes, not bedrock. +- **Guild land** as coloured outlines with the guild's icon and name, holes and all. +- **Spawn, warps, guild homes, markets and shop points** as pins you can click. +- **Players online**, with their head and facing direction. Click one to open the player panel (profile, teleport request, message in game, invite to your guild). +- **Dimensions**: Overworld, Nether and End tabs appear as soon as the server has drawn them. + +## Turn it on + +It is on by default for every server. To change it: + +1. In the admin panel open **Servers → your server → Settings** and use the **SCOPENET Map** switch. +2. In the plugin/mod config: `map.enabled` (Paper `config.yml`) or `map.enabled=true` (Fabric `scopenet.properties`). The old `livemap.enabled` key is still read. + +The first tiles appear a minute or two after the server starts. The server draws the regions closest to players first, then works through the rest quietly in the background at low priority. + +## In the launcher + +Players with a linked server get a **Live Map** button on the Home screen. Pan by dragging, zoom with the wheel or the + and − buttons, double click to zoom in. Use the toggles to hide claims, places or players, **Online** to jump to a player, the crosshair to find yourself and the star to go to spawn. The **Guilds → Territory** claim painter uses the same tiles. + +## In the admin panel + +**Servers → your server** shows a map card with the number of tiles, how much space they use, when the server last sent something and the live map itself. **Redraw world** deletes the stored tiles; the server notices and draws everything again (useful after a world reset or a renderer update). + +## How it works + +- The game server reads its region files (`region/*.mca`), works out the top block of every column, and writes 256 × 256 pixel tiles at one pixel per block. Zoomed-out levels are built from them, keeping real map colours so coastlines and roads stay sharp. +- Tiles are palette PNGs, so a typical tile is a few kilobytes. Only tiles that changed are uploaded, and a region is redrawn at most every 90 seconds while players are building. +- The panel stores tiles under its data folder (`map//…`) and serves them to signed-in players only, with a short-lived key on every address and ETag caching. +- Player positions go up about every two seconds; claims and pins whenever they change. +- A local cache lives next to the plugin (`plugins/SCOPENET/map`) or mod (`config/scopenet/map`) so a restart doesn't redraw anything. + +Disk use is small: a world of 500 explored regions is roughly 50–150 MB on the panel. Servers using region files compressed with LZ4 (an opt-in setting in recent Minecraft versions) are not drawn; leave `region-file-compression` on its default. + +## Troubleshooting + +- **"Drawing the world…" never ends.** The server has to be connected to the panel with a valid token, have `map.enabled` on, and have its world folder readable by the Minecraft process. The server log says `SCOPENET Map is on` when it starts drawing. +- **A flat grey or missing area.** The chunk was never generated, or is saved in an older world format (before Minecraft 1.18). +- **Colours look off for a modded block.** Unknown blocks get a colour from their name; vanilla blocks use their texture colour. diff --git a/docs/ranks-guilds-and-warps.md b/docs/ranks-guilds-and-warps.md index 97a9df9..77ca413 100644 --- a/docs/ranks-guilds-and-warps.md +++ b/docs/ranks-guilds-and-warps.md @@ -12,7 +12,7 @@ Paper chat shows `[Guild tag] [Level title] [LuckPerms group display] nickname: Players can request to join a guild from the launcher directory. Leaders and officers review those requests from the guild roster. Leaders can edit the guild description, message of the day, icon and banner from **Guild settings**. They can create and assign custom roles with permissions for invites, kicks, claims, posts and guild details. Deleting a role returns its members to `member`. -The in-app territory view uses the configured BlueMap 2D tile address for each server. It centers on the player's last position when position relay is enabled. Left click or drag to claim; right click or drag to unclaim. On Paper, `/autoclaim on` claims each new unclaimed chunk entered while walking; `/autoclaim off` stops it. Auto-claim stops on a failed claim so it does not repeatedly send requests at the claim limit or in another guild's territory. +The in-app territory view draws on the server's SCOPENET Map tiles (see [map.md](map.md)). It centers on the player's last position while they are in game. Left click or drag to claim; right click or drag to unclaim. On Paper, `/autoclaim on` claims each new unclaimed chunk entered while walking; `/autoclaim off` stops it. Auto-claim stops on a failed claim so it does not repeatedly send requests at the claim limit or in another guild's territory. ## Warps and item sharing on Paper diff --git a/docs/rewards-and-limits.md b/docs/rewards-and-limits.md new file mode 100644 index 0000000..ff25ccf --- /dev/null +++ b/docs/rewards-and-limits.md @@ -0,0 +1,70 @@ +# Rewards, limits and permission nodes + +## Rewards beyond XP + +Quests, achievements and rank milestones (level rewards) can hand out more than XP and a title. In the admin panel, open any quest, achievement or milestone and use **Extra rewards**: + +| Reward | What happens | Where it happens | +|---|---|---| +| **Money** | Credits the balance, once per economy group (or one chosen server) | Panel, instantly | +| **Claim chunks** | Raises the land limit of the player's guild while they are a member | Panel, instantly | +| **Badge** | Shows a badge on their profile | Panel, instantly | +| **Item** | Puts the item in their inventory (anything that doesn't fit drops at their feet). Vanilla ids such as `diamond` or `modid:item` | Game server, when they are online | +| **Permission** | Grants a permission node, permanently or for a number of days | Game server (LuckPerms by default) | +| **Group** | Adds them to a permission group | Game server (LuckPerms by default) | +| **Message** | Sends a chat message | Game server | +| **Command** | Runs a console command with `{player}` and `{uuid}` filled in | Game server, only if the server allows it | + +Players see short summaries ("$250.00", "Diamond x3", "+4 claim chunks") next to each quest and achievement in the launcher. Commands are shown only as "A special reward". + +Items, permissions, groups and messages are queued in the panel and handed to the game server once the player is online there. Anything that fails (an unknown item, LuckPerms missing) is retried a few times and then kept in the delivery log with the reason. A reward earned on one server can be delivered by whichever server the player is on, unless you pin it to a server. + +### Server settings + +Paper `config.yml` / Fabric `scopenet.properties`: + +```yaml +rewards: + enabled: true + allow-commands: false # let the panel run its own console commands. Leave off unless every panel admin is trusted. + permission-command: "lp user {player} permission set {node} true" + permission-temp-command: "lp user {player} permission settemp {node} {value} {duration}" + group-command: "lp user {player} parent add {group}" +``` + +Using another permissions plugin? Change the command templates. Placeholders: `{player}`, `{uuid}`, `{node}`, `{value}`, `{duration}`, `{group}`. + +## What you can configure + +### Per server (plugin/mod config) + +| Setting | Meaning | +|---|---| +| `essentials.max-homes` | Homes every player can set | +| `essentials.home-name-max-length` | Longest home name | +| `essentials.max-warps` | Most warps the server may have (0 = no limit) | +| `essentials.tpa-timeout-seconds` | How long a `/tpa` request stays open | +| `essentials.rtp-radius`, `rtp-min-radius`, `rtp-attempts` | Where `/rtp` sends players, and how hard it tries | +| `essentials.cooldowns.` | Seconds between uses of `spawn`, `home`, `back`, `tpa`, `rtp`, `warp` | +| `map.enabled`, `map.show-homes` | The SCOPENET Map and whether homes appear on it | +| `economy.currency-symbol` | The symbol shown next to money | +| `rewards.*` | See above | +| Feature switches | `leveling`, `quests`, `achievements`, `guilds`, `social`, `essentials`, `economy` | + +### Network-wide (admin panel → Progression → Economy & guild rules) + +Starting balance, chunks a new guild may claim, extra claims per member and per guild level, guild member limit, and market listings per player. These apply on every server and take effect immediately. + +## Permission nodes that change limits + +Give these with LuckPerms (or any permissions plugin) to ranks: + +| Node | Effect | +|---|---| +| `scopenet.homes.` | Homes limit for the player, e.g. `scopenet.homes.10`. The highest one a player has wins and never lowers the base `max-homes` | +| `scopenet.homes.unlimited` | No home limit | +| `scopenet.cooldown.bypass` | Skip every command cooldown | + +Operators and anyone with `*` get the top tier on Paper. On Fabric these nodes are off for everyone until a permissions system (such as the LuckPerms mod) grants them; operators still skip cooldowns. + +Claim chunks are a guild limit, so they are changed with the network rules above or with **Claim chunks** rewards, not with a permission node. diff --git a/examples/fabric-server/scopenet.properties b/examples/fabric-server/scopenet.properties index 5ba77dd..7890a1e 100644 --- a/examples/fabric-server/scopenet.properties +++ b/examples/fabric-server/scopenet.properties @@ -21,15 +21,31 @@ social.chat_prefixes=true essentials.enabled=true economy.enabled=true -# Send world chunks and player positions to the panel's Live Map -# (also needs Live Map switched on for this server in the panel). -livemap.enabled=true +# Draw this world on the SCOPENET Map: tiles, player positions, guild land and pins go to the panel +# (also needs the map switched on for this server in the panel, which it is by default). +map.enabled=true # --- Essentials (1.20.1) ----------------------------------------------------- # /spawn /home /sethome /delhome /back /tpa /tpaccept /tpdeny /rtp /warp /playtime +# Homes every player can set. Raise it for a rank with a permission node: scopenet.homes.10 gives 10 homes, +# scopenet.homes.25 gives 25 (highest wins), scopenet.homes.unlimited removes the limit. essentials.max_homes=5 -essentials.rtp_radius=2500 +essentials.home_name_max_length=32 +# Most warps the server may have (0 = no limit). +essentials.max_warps=0 essentials.tpa_timeout_seconds=60 +# /rtp picks a random safe spot between rtp_min_radius and rtp_radius blocks from 0,0. +essentials.rtp_radius=2500 +essentials.rtp_min_radius=0 +essentials.rtp_attempts=15 +# Seconds between uses (0 = none). Starts when the command actually teleports. +# Players with scopenet.cooldown.bypass skip all of these. +essentials.cooldown.spawn=0 +essentials.cooldown.home=0 +essentials.cooldown.back=0 +essentials.cooldown.tpa=0 +essentials.cooldown.rtp=0 +essentials.cooldown.warp=0 # --- Economy (1.20.1) -------------------------------------------------------- # Shown next to amounts. @@ -53,9 +69,15 @@ placeholders.enabled=true # Send balance, level, guild and claim info to players who have the SCOPENET client mod. clientlink.enabled=true -# --- BlueMap (1.20.1) -------------------------------------------------------- -# With the BlueMap mod installed, adds guild claims, spawn, warps, guild homes, markets and shops to the 3D map, and a -# player panel when you click someone. Set the map's web address in the panel (Servers -> this server -> Map address). -bluemap.enabled=true -# Show every player's homes as pins. Off by default: homes are private and the map is public to anyone with the link. -bluemap.show_homes=false +# --- Map pins ---------------------------------------------------------------- +# Show every player's homes as pins on the map. Off by default: homes are private. +map.show_homes=false + +# --- Rewards ----------------------------------------------------------------- +# Quests, achievements and rank milestones can give items, permissions, groups, messages... (set in the admin panel). +rewards.enabled=true +# Let the panel run its own console commands as rewards. Leave off unless you trust every panel admin. +rewards.allow_commands=false +rewards.permission_command=lp user {player} permission set {node} true +rewards.permission_temp_command=lp user {player} permission settemp {node} {value} {duration} +rewards.group_command=lp user {player} parent add {group} diff --git a/examples/paper/config.yml b/examples/paper/config.yml index 600a84d..dc106e4 100644 --- a/examples/paper/config.yml +++ b/examples/paper/config.yml @@ -58,15 +58,38 @@ social: essentials: # /spawn /home /sethome /delhome /back /tpa /tpaccept /tpdeny /rtp /warp /playtime enabled: true - # Homes per player. + # Homes every player can set. Raise it for a rank with a permission node: + # scopenet.homes.10 gives 10 homes, scopenet.homes.25 gives 25 (highest wins), + # scopenet.homes.unlimited removes the limit. Operators and "*" get the top tier. max-homes: 5 - # /rtp picks a random safe spot within this many blocks of 0,0. + # Longest allowed home name. + home-name-max-length: 32 + # Most warps the server may have (0 = no limit). + max-warps: 0 + # How long a /tpa request stays open. + tpa-timeout-seconds: 60 + # /rtp picks a random safe spot between rtp-min-radius and rtp-radius blocks from 0,0. rtp-radius: 2500 + rtp-min-radius: 0 + # Random spots tried before /rtp gives up. + rtp-attempts: 15 + # Seconds between uses (0 = none). Starts when the command actually teleports. + # Players with scopenet.cooldown.bypass skip all of these. + cooldowns: + spawn: 0 + home: 0 + back: 0 + tpa: 0 + rtp: 0 + warp: 0 -livemap: - # Send live player positions to the panel so the launcher can center its - # BlueMap territory view. Configure BlueMap's public address in the panel. +map: + # The SCOPENET Map: this server draws its world to map tiles in the background and + # sends only what changed, with players, guild land and pins, to the panel. + # It also needs the map switched on for this server in the panel (on by default). enabled: true + # Show every player's homes as pins. Off by default: homes are private. + show-homes: false economy: # /balance /pay /baltop /shop /sell /market /orders /trade /transactions @@ -113,15 +136,21 @@ integrations: report-interval-seconds: 30 # ============================================================================== -# BlueMap -# With BlueMap installed, SCOPENET adds guild claims (coloured, with the guild's icon and name), spawn, warps, guild -# homes, markets and shops to the 3D map, and a player panel when you click someone. Put the map's web address in the -# panel (Servers -> this server -> Map address) and players get a Live Map button in the launcher. +# Rewards +# Quests, achievements and rank milestones can give more than XP: money, items, permissions, groups, claim +# chunks, badges and messages (set them in the admin panel). The panel hands items, permissions and commands +# to this server when the player is online. Permissions and groups use console commands, so any permissions +# plugin works; the defaults are LuckPerms'. Placeholders: {player} {uuid} {node} {value} {duration} {group}. # ============================================================================== -bluemap: +rewards: enabled: true - # Show every player's homes as pins. Off by default: homes are private, and the map is public to anyone with the link. - show-homes: false + # Let the panel run its own console commands as rewards. Off by default: anyone who can edit rewards in the + # panel could otherwise run commands on this server. Only switch it on if you trust every panel admin. + allow-commands: false + permission-command: "lp user {player} permission set {node} true" + permission-deny-command: "lp user {player} permission set {node} false" + permission-temp-command: "lp user {player} permission settemp {node} {value} {duration}" + group-command: "lp user {player} parent add {group}" # ============================================================================== # Client mod link From 8e1f04c867db388c66bbd97ba4a9c47b07241ec9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 01:17:02 +0000 Subject: [PATCH 14/21] README links --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 1375e9c..c9193f9 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,8 @@ docker-compose.yml Panel deployment - [Admin guide](docs/admin-guide.md) — deploying, HTTPS, modpacks, players, backups - [Official server setup](docs/server-integration.md) — private authentication, supported versions, activity and UUID protection +- [The SCOPENET Map](docs/map.md) — the built-in world map: terrain, guild land, shops and players +- [Rewards, limits and permission nodes](docs/rewards-and-limits.md) — what quests can grant, and every setting you can change - [Architecture](docs/architecture.md) — how the pieces fit, API reference - [Development](docs/development.md) — running everything locally, tests - [Deployment](docs/deployment.md) — local Compose build, health check, upgrades and backups From 6c58042a32cd119d2f5f24744f536d4111939da5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 01:18:13 +0000 Subject: [PATCH 15/21] Map: individual blocks at close zoom (texture, bevel, chunk lines, hover outline) --- shared/map/engine.ts | 89 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 3 deletions(-) diff --git a/shared/map/engine.ts b/shared/map/engine.ts index f28601f..0089e7e 100644 --- a/shared/map/engine.ts +++ b/shared/map/engine.ts @@ -31,7 +31,7 @@ export interface MapViewOptions { interface Tile { img: HTMLImageElement; state: 'loading' | 'ok' | 'missing'; at: number; used: number } const MIN_SCALE = 1 / 128; -const MAX_SCALE = 20; +const MAX_SCALE = 48; const TILE = 256; const MAX_LOADS = 10; @@ -63,6 +63,8 @@ export class MapView { private pinchStart: { dist: number; scale: number } | null = null; private disposed = false; private resizeObserver: ResizeObserver; + private texture: CanvasPattern | null = null; + private hoverBlock: { x: number; z: number } | null = null; constructor(private canvas: HTMLCanvasElement, private opts: MapViewOptions) { const ctx = canvas.getContext('2d'); @@ -261,6 +263,8 @@ export class MapView { if (id !== this.hoverId) { this.hoverId = id; this.invalidate(); } this.canvas.style.cursor = hit ? 'pointer' : 'grab'; const w = this.toWorld(p.x, p.y); + const block = { x: Math.floor(w.x), z: Math.floor(w.z) }; + if (this.scale >= 8 && (!this.hoverBlock || this.hoverBlock.x !== block.x || this.hoverBlock.z !== block.z)) { this.hoverBlock = block; this.invalidate(); } this.opts.onhover?.({ cursor: { x: w.x, z: w.z }, label: hit ? hitLabel(hit) : null }); }; @@ -277,7 +281,7 @@ export class MapView { }; private leave = () => { - if (this.hoverId) { this.hoverId = null; this.invalidate(); } + if (this.hoverId || this.hoverBlock) { this.hoverId = null; this.hoverBlock = null; this.invalidate(); } this.opts.onhover?.({ cursor: null, label: null }); }; @@ -413,6 +417,7 @@ export class MapView { if (!this.dimension || !this.info) return; this.drawTiles(ctx); ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + if (this.scale >= 6) this.drawBlocks(ctx); if (this.layers.claims) this.drawClaims(ctx); if (this.layers.pins) this.drawPins(ctx); if (this.layers.players) this.drawPlayers(ctx); @@ -429,7 +434,7 @@ export class MapView { ctx.imageSmoothingEnabled = this.scale < 1; ctx.imageSmoothingQuality = 'high'; // A faint chunk grid when close enough to matter. - if (this.scale >= 3) this.drawGrid(ctx); + if (this.scale >= 3 && this.scale < 6) this.drawGrid(ctx); for (let ty = y0; ty <= y1; ty++) { for (let tx = x0; tx <= x1; tx++) { if (!this.inBounds(zoom, tx, ty)) continue; @@ -452,6 +457,51 @@ export class MapView { } } + /** + * Close up, every block is its own cell: a fine texture, a light and a dark edge, stronger chunk lines, and an outline on the + * block under the cursor. The tiles only know each block's colour, so the texture is drawn on top rather than stored. + */ + private drawBlocks(ctx: CanvasRenderingContext2D) { + const strength = clamp((this.scale - 6) / 10, 0, 1); + if (!this.texture) this.texture = makeTexture(ctx); + if (this.texture) { + ctx.save(); + ctx.globalAlpha = 0.55 * strength + 0.25; + ctx.globalCompositeOperation = 'overlay'; + const k = this.scale / 16 * this.dpr; + // Pattern covers 8 x 8 blocks (128 px); align it to the world so blocks stay put while panning. + this.texture.setTransform(new DOMMatrix([k, 0, 0, k, this.dpr * (this.width / 2 - this.cx * this.scale), this.dpr * (this.height / 2 - this.cz * this.scale)])); + ctx.fillStyle = this.texture; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + ctx.restore(); + } + ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + // Chunk borders every 16 blocks. + const a = this.toWorld(0, 0); + ctx.strokeStyle = 'rgba(255,255,255,0.34)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + for (let x = Math.floor(a.x / 16) * 16; (x - this.cx) * this.scale + this.width / 2 < this.width + 20; x += 16) { + const sx = Math.round((x - this.cx) * this.scale + this.width / 2) + 0.5; + ctx.moveTo(sx, 0); ctx.lineTo(sx, this.height); + } + for (let z = Math.floor(a.z / 16) * 16; (z - this.cz) * this.scale + this.height / 2 < this.height + 20; z += 16) { + const sy = Math.round((z - this.cz) * this.scale + this.height / 2) + 0.5; + ctx.moveTo(0, sy); ctx.lineTo(this.width, sy); + } + ctx.stroke(); + if (this.hoverBlock && this.scale >= 8) { + const s = this.toScreen(this.hoverBlock.x, this.hoverBlock.z); + ctx.lineWidth = 2; + ctx.strokeStyle = '#ffffff'; + ctx.strokeRect(s.x + 1, s.y + 1, this.scale - 2, this.scale - 2); + ctx.lineWidth = 1; + ctx.strokeStyle = 'rgba(0,0,0,0.6)'; + ctx.strokeRect(s.x - 0.5, s.y - 0.5, this.scale + 1, this.scale + 1); + } + } + private drawGrid(ctx: CanvasRenderingContext2D) { const step = 16 * this.scale; const a = this.toWorld(0, 0); @@ -685,3 +735,36 @@ function drawGlyph(ctx: CanvasRenderingContext2D, kind: string, x: number, y: nu } ctx.restore(); } + +/** + * An 8 x 8 block texture sheet (16 px per block): fine noise that differs block to block, a light top-left edge and a dark + * bottom-right edge, as grey values for the "overlay" blend (mid grey = no change). + */ +function makeTexture(ctx: CanvasRenderingContext2D): CanvasPattern | null { + const c = document.createElement('canvas'); + c.width = c.height = 128; + const g = c.getContext('2d'); + if (!g) return null; + g.fillStyle = '#808080'; + g.fillRect(0, 0, 128, 128); + let seed = 1234567; + const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; }; + for (let by = 0; by < 8; by++) { + for (let bx = 0; bx < 8; bx++) { + const ox = bx * 16, oy = by * 16; + const base = 120 + Math.floor(rnd() * 16); // each block a touch lighter or darker + g.fillStyle = `rgb(${base},${base},${base})`; + g.fillRect(ox, oy, 16, 16); + for (let i = 0; i < 4; i++) { // 4 x 4 chunky speckles, like a texture + for (let j = 0; j < 4; j++) { + const v = base + Math.floor((rnd() - 0.5) * 34); + g.fillStyle = `rgb(${v},${v},${v})`; + g.fillRect(ox + i * 4, oy + j * 4, 4, 4); + } + } + g.fillStyle = 'rgba(255,255,255,0.55)'; g.fillRect(ox, oy, 16, 1); g.fillRect(ox, oy, 1, 16); + g.fillStyle = 'rgba(0,0,0,0.6)'; g.fillRect(ox, oy + 15, 16, 1); g.fillRect(ox + 15, oy, 1, 16); + } + } + return ctx.createPattern(c, 'repeat'); +} From 6fd78f32ea0890fff9114a4eba6568f0563ee530 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 01:37:42 +0000 Subject: [PATCH 16/21] UI: text wraps inside its box everywhere (global wrapping, shrinkable flex items, wrapping buttons, unknown icon fallback) --- launcher/src/app.css | 15 ++++++++++++++- launcher/src/components/PlayerLink.svelte | 2 +- launcher/src/components/Rail.svelte | 2 +- launcher/src/components/ServerStatus.svelte | 2 +- launcher/src/lib/mock.ts | 15 +++++++++++++++ panel/web/src/app.css | 21 +++++++++++++++++++-- panel/web/src/components/PlayerLink.svelte | 2 +- panel/web/src/lib/achievementIcons.ts | 4 ++-- 8 files changed, 54 insertions(+), 9 deletions(-) diff --git a/launcher/src/app.css b/launcher/src/app.css index dd103d0..25379bc 100644 --- a/launcher/src/app.css +++ b/launcher/src/app.css @@ -40,10 +40,17 @@ html, body { -webkit-font-smoothing: antialiased; user-select: none; -webkit-user-select: none; + /* Long names and unbroken strings wrap inside their box instead of spilling out of it. */ + overflow-wrap: anywhere; } #app { height: 100%; } +/* Flex and grid items may shrink below their content, so long names wrap or truncate inside their box instead of pushing it wider. */ +*, *::before, *::after { min-width: 0; } +svg, img, canvas, video, input[type='checkbox'], input[type='radio'] { flex-shrink: 0; } + + h1, h2, h3, h4 { margin: 0; letter-spacing: -0.015em; } p { margin: 0; } a { color: var(--accent); text-decoration: none; } @@ -85,8 +92,11 @@ button { font-weight: 520; font-size: 0.9rem; transition: background 0.15s, border-color 0.15s, opacity 0.15s; - white-space: nowrap; + /* Labels may wrap when space runs out; compact buttons below stay on one line. */ + white-space: normal; + max-width: 100%; } +button.primary, button.ghost, button.danger, button.icon, button.tool { white-space: nowrap; } button:hover { background: color-mix(in srgb, var(--text) 11%, transparent); } button:active { background: color-mix(in srgb, var(--text) 14%, transparent); } button:disabled { opacity: 0.45; cursor: not-allowed; transform: none; } @@ -154,3 +164,6 @@ label.field { display: flex; flex-direction: column; gap: 0.45rem; font-size: 0. @keyframes spin { to { transform: rotate(360deg); } } @keyframes fade-up { from { opacity: 0; transform: translateY(0.6rem); } to { opacity: 1; transform: none; } } @keyframes fade { from { opacity: 0; } } + +code, pre, kbd, samp { overflow-wrap: anywhere; word-break: break-word; } +pre { white-space: pre-wrap; } diff --git a/launcher/src/components/PlayerLink.svelte b/launcher/src/components/PlayerLink.svelte index c023691..40e2121 100644 --- a/launcher/src/components/PlayerLink.svelte +++ b/launcher/src/components/PlayerLink.svelte @@ -15,7 +15,7 @@ {/if} diff --git a/launcher/src/components/Rail.svelte b/launcher/src/components/Rail.svelte index ab026ba..88cf0ba 100644 --- a/launcher/src/components/Rail.svelte +++ b/launcher/src/components/Rail.svelte @@ -86,7 +86,7 @@ .inst.active { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--text); border-color: color-mix(in srgb, var(--accent) 55%, transparent); box-shadow: 0 0 16px color-mix(in srgb, var(--accent) 30%, transparent); } .inst.active::before { content: ''; position: absolute; left: -0.75rem; top: 0.65rem; bottom: 0.65rem; width: 3.5px; border-radius: 0 3px 3px 0; background: var(--accent); box-shadow: 0 0 8px var(--accent); } .inst.running::after { content: ''; position: absolute; right: -0.2rem; bottom: -0.2rem; width: 0.8rem; height: 0.8rem; border-radius: 50%; background: var(--success); border: 2px solid var(--bg); } - .tip { position: fixed; left: 5.2rem; transform: translateY(0); font-style: normal; font-size: 0.82rem; font-weight: 600; white-space: nowrap; padding: 0.4rem 0.75rem; border-radius: 0.5rem; background: var(--surface); border: 1px solid var(--line-strong); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); opacity: 0; pointer-events: none; transition: opacity 0.15s; z-index: 50; color: var(--text); } + .tip { position: fixed; left: 5.2rem; transform: translateY(0); font-style: normal; font-size: 0.82rem; font-weight: 600; max-width: 16rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0.4rem 0.75rem; border-radius: 0.5rem; background: var(--surface); border: 1px solid var(--line-strong); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); opacity: 0; pointer-events: none; transition: opacity 0.15s; z-index: 50; color: var(--text); } .inst:hover .tip { opacity: 1; } .bottom { display: flex; flex-direction: column; align-items: center; gap: 0.4rem; } .big { width: 2.6rem; height: 2.6rem; position: relative; border-radius: var(--radius-sm); transition: transform 0.12s; } diff --git a/launcher/src/components/ServerStatus.svelte b/launcher/src/components/ServerStatus.svelte index 390533c..b8af75f 100644 --- a/launcher/src/components/ServerStatus.svelte +++ b/launcher/src/components/ServerStatus.svelte @@ -67,7 +67,7 @@ .ph.on { background: color-mix(in srgb, var(--accent) 30%, transparent); } .info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.05rem; } .name { gap: 0.5rem; } - .name strong { font-size: 0.85rem; font-weight: 560; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .name strong { min-width: 0; flex: 0 1 auto; font-size: 0.85rem; font-weight: 560; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .state { font-size: 0.72rem; font-weight: 520; color: var(--muted); display: inline-flex; align-items: center; gap: 0.3rem; } .state::before { content: ''; width: 0.45rem; height: 0.45rem; border-radius: 50%; background: currentColor; } .state.online { color: var(--success); } diff --git a/launcher/src/lib/mock.ts b/launcher/src/lib/mock.ts index 6807625..027afad 100644 --- a/launcher/src/lib/mock.ts +++ b/launcher/src/lib/mock.ts @@ -133,7 +133,22 @@ const wallet = { }; const mockWallet = () => JSON.parse(JSON.stringify(wallet)); +// `?stress` in the dev URL makes every name, title and description much longer, to check that no screen lets text spill out of its box. +const STRESS = typeof location !== 'undefined' && new URLSearchParams(location.search).has('stress'); +const TEXT_KEYS = /(^|_)(name|title|username|description|motd|message|desc|label|bio|tagline|text|rank|guild)(_|$)/i; +function stress(value: unknown, key = ''): unknown { + if (Array.isArray(value)) return value.map((v) => stress(v, key)); + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, stress(v, k)])); + if (typeof value === 'string' && TEXT_KEYS.test(key) && /^[A-Za-z][A-Za-z '&:,.\-]{2,60}$/.test(value)) return `${value} With An Extraordinarily Long Unbroken Continuation`; + return value; +} + export async function mockInvoke(cmd: string, args: Record): Promise { + const result = await mockInvokeInner(cmd, args); + return STRESS ? stress(result) : result; +} + +async function mockInvokeInner(cmd: string, args: Record): Promise { await sleep(40); switch (cmd) { case 'get_public_servers': diff --git a/panel/web/src/app.css b/panel/web/src/app.css index 59bc7dc..29e5059 100644 --- a/panel/web/src/app.css +++ b/panel/web/src/app.css @@ -38,7 +38,12 @@ body { -webkit-font-smoothing: antialiased; } -body { background: var(--bg); } +body { background: var(--bg); overflow-wrap: anywhere; } + +/* Flex and grid items may shrink below their content, so long names wrap or truncate inside their box instead of pushing it wider. */ +*, *::before, *::after { min-width: 0; } +svg, img, canvas, video, input[type='checkbox'], input[type='radio'] { flex-shrink: 0; } + a { color: var(--accent-2); @@ -143,8 +148,11 @@ button, .btn { gap: 8px; font-weight: 520; transition: background 0.15s, border-color 0.15s, opacity 0.15s; - white-space: nowrap; + /* Labels may wrap when space runs out; compact buttons below stay on one line. */ + white-space: normal; + max-width: 100%; } +button.primary, button.ghost, button.danger, button.icon, .btn { white-space: nowrap; } button:hover { background: var(--surface-3); } button:disabled { opacity: 0.5; cursor: not-allowed; } button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { @@ -299,3 +307,12 @@ table.table { @media (max-width: 900px) { .page { padding: 20px 16px 48px; } } + +/* Code and preformatted text wrap inside their box rather than running past it. */ +code, pre, kbd, samp { overflow-wrap: anywhere; word-break: break-word; } +pre { white-space: pre-wrap; } +@media (max-width: 700px) { + button, .btn, button.primary, button.ghost, button.danger { white-space: normal; } + .page > header, .page header { flex-wrap: wrap; gap: 10px; } + .table-wrap { overflow-x: auto; } +} diff --git a/panel/web/src/components/PlayerLink.svelte b/panel/web/src/components/PlayerLink.svelte index f82ec33..bd98cce 100644 --- a/panel/web/src/components/PlayerLink.svelte +++ b/panel/web/src/components/PlayerLink.svelte @@ -13,6 +13,6 @@ {/if} diff --git a/panel/web/src/lib/achievementIcons.ts b/panel/web/src/lib/achievementIcons.ts index d75ce5b..bd5c1fc 100644 --- a/panel/web/src/lib/achievementIcons.ts +++ b/panel/web/src/lib/achievementIcons.ts @@ -98,12 +98,12 @@ const isId = (v: string) => /^[a-z0-9_]+$/.test(v); export interface ResolvedIcon { emoji: string; background: string; borderColor: string; glow: string } export function resolveIcon(a: { icon_item?: string | null; icon_bg?: string | null; icon_border?: string | null }): ResolvedIcon { - const item = (a.icon_item ?? '').trim(); + const item = (a.icon_item ?? '').trim().replace(/^minecraft:/, ''); let emoji = '🏆'; if (item) { const known = ITEM_ICONS.find((i) => i.id === item); if (known) emoji = known.emoji; - else if (!isId(item)) emoji = item; // already an emoji or symbol + else if (!isId(item) && [...item].length <= 4) emoji = item; // already an emoji or symbol else emoji = KEYWORDS.find(([re]) => re.test(item))?.[1] ?? '🏆'; } From f9b0dcedffdceb02b6dcfa2f7d4781f75cf02894 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 01:43:00 +0000 Subject: [PATCH 17/21] Discord: customizable announcements and live embeds (status, guilds, leaderboard, baltop) backend --- panel/server/src/embeds.rs | 641 ++++++++++++++++++++++ panel/server/src/lib.rs | 1 + panel/server/src/routes/discord.rs | 43 +- panel/server/src/routes/discord_studio.rs | 155 ++++++ panel/server/src/routes/mod.rs | 7 + panel/server/src/routes/servers.rs | 34 ++ panel/server/tests/discord_studio.rs | 58 ++ 7 files changed, 924 insertions(+), 15 deletions(-) create mode 100644 panel/server/src/embeds.rs create mode 100644 panel/server/src/routes/discord_studio.rs create mode 100644 panel/server/tests/discord_studio.rs diff --git a/panel/server/src/embeds.rs b/panel/server/src/embeds.rs new file mode 100644 index 0000000..4c735d8 --- /dev/null +++ b/panel/server/src/embeds.rs @@ -0,0 +1,641 @@ +//! Discord messages the panel posts: announcements (achievements, new players, new guilds) built from admin-edited templates, +//! and live embeds (server status, active guilds, leaderboards, richest players) that are edited in place on a schedule. +//! +//! Everything goes through a webhook, so no bot is needed. Templates use `{placeholders}`; unknown ones are left as typed. + +use crate::error::{AppError, AppResult}; +use crate::routes::discord; +use crate::state::AppState; +use crate::store; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use std::collections::BTreeMap; +use std::time::Duration; + +#[derive(Clone, Serialize, Deserialize, PartialEq, Debug, Default)] +#[serde(default)] +pub struct Field { + pub name: String, + pub value: String, + pub inline: bool, +} + +/// Everything about how one Discord message looks. All text may contain placeholders. +#[derive(Clone, Serialize, Deserialize, PartialEq, Debug)] +#[serde(default)] +pub struct EmbedStyle { + pub enabled: bool, + /// Webhook display name and picture (empty = SCOPENET's). + pub username: String, + pub avatar_url: String, + /// Plain text above the embed. Mentions work here, e.g. `<@&roleid>`. + pub content: String, + pub title: String, + pub url: String, + pub description: String, + /// `#rrggbb` + pub color: String, + pub thumbnail: String, + pub image: String, + pub author_name: String, + pub author_icon: String, + pub footer: String, + pub footer_icon: String, + /// Show the time the message was made. + pub timestamp: bool, + pub fields: Vec, +} + +impl Default for EmbedStyle { + fn default() -> Self { + Self { + enabled: true, username: String::new(), avatar_url: String::new(), content: String::new(), title: String::new(), url: String::new(), + description: String::new(), color: "#8b6cff".into(), thumbnail: String::new(), image: String::new(), author_name: String::new(), + author_icon: String::new(), footer: String::new(), footer_icon: String::new(), timestamp: true, fields: vec![], + } + } +} + +fn field(name: &str, value: &str, inline: bool) -> Field { + Field { name: name.into(), value: value.into(), inline } +} + +/// What the three announcements say until an admin changes them. +pub fn default_templates() -> BTreeMap { + let mut m = BTreeMap::new(); + m.insert( + "achievement".to_string(), + EmbedStyle { + title: "🏆 Achievement unlocked".into(), + description: "**{player}** earned **{achievement}**\n*{achievement_description}*".into(), + color: "#fcd34d".into(), + thumbnail: "{avatar}".into(), + footer: "{category} · {server_name}".into(), + fields: vec![field("Reward", "+{xp} XP", true), field("Level", "{level}", true), field("Guild", "{guild_line}", true)], + ..Default::default() + }, + ); + m.insert( + "member".to_string(), + EmbedStyle { + enabled: false, + title: "👋 New player".into(), + description: "Welcome **{player}**! You are player number **{members_total}**.".into(), + color: "#6ee7b7".into(), + thumbnail: "{avatar}".into(), + footer: "{server_name}".into(), + ..Default::default() + }, + ); + m.insert( + "guild".to_string(), + EmbedStyle { + title: "🛡️ New guild".into(), + description: "**{guild}** [{tag}] was founded by **{leader}**.\n{guild_description}".into(), + color: "#22d3ee".into(), + thumbnail: "{leader_avatar}".into(), + footer: "{guilds_total} guilds in total · {server_name}".into(), + fields: vec![field("Leader", "{leader}", true), field("Members", "{members}", true), field("Land", "{claims} chunks", true)], + ..Default::default() + }, + ); + m +} + +pub async fn templates(state: &AppState) -> AppResult> { + let mut stored: BTreeMap = store::kv_get(state, "discord_templates").await?; + for (k, v) in default_templates() { + stored.entry(k).or_insert(v); + } + Ok(stored) +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +pub type Vars = BTreeMap; + +/// Replace `{name}` with its value. Unknown placeholders stay as typed so mistakes are visible. +pub fn fill(text: &str, vars: &Vars) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(start) = rest.find('{') { + out.push_str(&rest[..start]); + let after = &rest[start + 1..]; + match after.find('}') { + Some(end) if !after[..end].contains(char::is_whitespace) && !after[..end].contains('{') => { + match vars.get(&after[..end]) { + Some(v) => out.push_str(v), + None => { + out.push('{'); + out.push_str(&after[..end]); + out.push('}'); + } + } + rest = &after[end + 1..]; + } + _ => { + out.push('{'); + rest = after; + } + } + } + out.push_str(rest); + out +} + +fn cut(s: &str, max: usize) -> String { + if s.chars().count() <= max { s.to_string() } else { format!("{}…", s.chars().take(max.saturating_sub(1)).collect::()) } +} + +fn color(s: &str) -> u32 { + u32::from_str_radix(s.trim().trim_start_matches('#'), 16).ok().filter(|c| *c <= 0xFFFFFF).unwrap_or(0x8b6cff) +} + +fn https(s: String) -> Option { + let s = s.trim().to_string(); + (s.starts_with("https://") || s.starts_with("http://")).then_some(s) +} + +/// The webhook body for a style and its values, kept within Discord's limits. +pub fn render(style: &EmbedStyle, vars: &Vars, extra_description: Option<&str>) -> Value { + let mut e = Map::new(); + let title = fill(&style.title, vars); + if !title.trim().is_empty() { + e.insert("title".into(), json!(cut(&title, 256))); + } + if let Some(u) = https(fill(&style.url, vars)) { + e.insert("url".into(), json!(u)); + } + let mut desc = fill(&style.description, vars); + if let Some(extra) = extra_description { + desc = desc.replace("{rows}", extra); + } + if !desc.trim().is_empty() { + e.insert("description".into(), json!(cut(&desc, 4096))); + } + e.insert("color".into(), json!(color(&style.color))); + if let Some(u) = https(fill(&style.thumbnail, vars)) { + e.insert("thumbnail".into(), json!({ "url": u })); + } + if let Some(u) = https(fill(&style.image, vars)) { + e.insert("image".into(), json!({ "url": u })); + } + let author = fill(&style.author_name, vars); + if !author.trim().is_empty() { + let mut a = Map::new(); + a.insert("name".into(), json!(cut(&author, 256))); + if let Some(u) = https(fill(&style.author_icon, vars)) { + a.insert("icon_url".into(), json!(u)); + } + e.insert("author".into(), Value::Object(a)); + } + let footer = fill(&style.footer, vars); + if !footer.trim().is_empty() { + let mut f = Map::new(); + f.insert("text".into(), json!(cut(&footer, 2048))); + if let Some(u) = https(fill(&style.footer_icon, vars)) { + f.insert("icon_url".into(), json!(u)); + } + e.insert("footer".into(), Value::Object(f)); + } + if style.timestamp { + e.insert("timestamp".into(), json!(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true))); + } + let fields: Vec = style + .fields + .iter() + .map(|f| (fill(&f.name, vars), fill(&f.value, vars), f.inline)) + .filter(|(n, v, _)| !n.trim().is_empty() && !v.trim().is_empty()) + .take(25) + .map(|(n, v, i)| json!({ "name": cut(&n, 256), "value": cut(&v, 1024), "inline": i })) + .collect(); + if !fields.is_empty() { + e.insert("fields".into(), json!(fields)); + } + let mut body = Map::new(); + body.insert("username".into(), json!(cut(&if style.username.trim().is_empty() { "SCOPENET".to_string() } else { fill(&style.username, vars) }, 80))); + if let Some(u) = https(fill(&style.avatar_url, vars)) { + body.insert("avatar_url".into(), json!(u)); + } + let content = fill(&style.content, vars); + if !content.trim().is_empty() { + body.insert("content".into(), json!(cut(&content, 2000))); + } + // Mentions only where the admin wrote them in the message text; never from player-supplied names. + body.insert("allowed_mentions".into(), json!({ "parse": ["roles", "users"] })); + body.insert("embeds".into(), json!([Value::Object(e)])); + Value::Object(body) +} + +/// Player-supplied text can't ping anyone or break formatting out of its place. +pub fn plain(s: &str) -> String { + s.chars().filter(|c| !c.is_control()).collect::().replace('@', "@\u{200b}").replace('`', "'").chars().take(120).collect() +} + +fn base_url(state: &AppState) -> String { + state.cfg.public_url.clone().unwrap_or_default().trim_end_matches('/').to_string() +} + +pub fn avatar(state: &AppState, uuid: &str) -> String { + let base = base_url(state); + if base.is_empty() || uuid.is_empty() { String::new() } else { format!("{base}/api/v1/avatar/{uuid}?size=128") } +} + +async fn server_name(state: &AppState) -> String { + store::branding(state).await.map(|b| b.name).unwrap_or_default() +} + +/// Values common to every message. +async fn common(state: &AppState) -> Vars { + let mut v = Vars::new(); + v.insert("server_name".into(), server_name(state).await); + v.insert("panel".into(), base_url(state)); + v.insert("time".into(), format!("", chrono::Utc::now().timestamp())); + v.insert("updated".into(), format!("", chrono::Utc::now().timestamp())); + v +} + +pub const ANNOUNCEMENT_PLACEHOLDERS: [(&str, &[&str]); 3] = [ + ("achievement", &["player", "uuid", "avatar", "achievement", "achievement_description", "xp", "category", "level", "rank_title", "guild", "guild_tag", "guild_line", "server_name", "panel", "time"]), + ("member", &["player", "uuid", "avatar", "members_total", "server_name", "panel", "time"]), + ("guild", &["guild", "tag", "leader", "leader_avatar", "guild_description", "members", "claims", "guilds_total", "server_name", "panel", "time"]), +]; + +pub async fn player_vars(state: &AppState, uuid: &str, name: &str) -> Vars { + let mut v = common(state).await; + v.insert("player".into(), plain(name)); + v.insert("uuid".into(), uuid.to_string()); + v.insert("avatar".into(), avatar(state, uuid)); + let level: Option<(i64, Option)> = sqlx::query_as("SELECT global_level, title FROM user_levels WHERE uuid = ?").bind(uuid).fetch_optional(&state.db).await.ok().flatten(); + let (l, t) = level.unwrap_or((1, None)); + v.insert("level".into(), l.to_string()); + v.insert("rank_title".into(), plain(&t.unwrap_or_default())); + let guild: Option<(String, String)> = sqlx::query_as("SELECT g.name, g.tag FROM guild_members m JOIN guilds g ON g.id = m.guild_id WHERE m.uuid = ?").bind(uuid).fetch_optional(&state.db).await.ok().flatten(); + let (gn, gt) = guild.unwrap_or_default(); + v.insert("guild".into(), plain(&gn)); + v.insert("guild_tag".into(), plain(>)); + v.insert("guild_line".into(), if gn.is_empty() { "No guild".into() } else { format!("[{}] {}", plain(>), plain(&gn)) }); + v +} + +pub async fn guild_vars(state: &AppState, guild_id: &str) -> Vars { + let mut v = common(state).await; + let row: Option<(String, String, String, String, i64, i64)> = sqlx::query_as( + "SELECT g.name, g.tag, g.description, g.leader_uuid, (SELECT COUNT(*) FROM guild_members WHERE guild_id = g.id), (SELECT COUNT(*) FROM guild_claims WHERE guild_id = g.id) FROM guilds g WHERE g.id = ?", + ) + .bind(guild_id) + .fetch_optional(&state.db) + .await + .ok() + .flatten(); + let (name, tag, desc, leader_uuid, members, claims) = row.unwrap_or_default(); + let leader: String = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ?").bind(&leader_uuid).fetch_optional(&state.db).await.ok().flatten().unwrap_or_default(); + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guilds").fetch_one(&state.db).await.unwrap_or(0); + v.insert("guild".into(), plain(&name)); + v.insert("tag".into(), plain(&tag)); + v.insert("guild_description".into(), plain(&desc)); + v.insert("leader".into(), plain(&leader)); + v.insert("leader_avatar".into(), avatar(state, &leader_uuid)); + v.insert("members".into(), members.to_string()); + v.insert("claims".into(), claims.to_string()); + v.insert("guilds_total".into(), total.to_string()); + v +} + +/// Example values, for previews and test messages. +pub async fn sample_vars(state: &AppState, kind: &str) -> Vars { + let mut v = common(state).await; + let me = state.cfg.admin_username.clone(); + let pairs: Vec<(&str, String)> = match kind { + "achievement" => vec![("player", me), ("uuid", "00000000-0000-0000-0000-000000000000".into()), ("avatar", String::new()), ("achievement", "Deep Diver".into()), + ("achievement_description", "Mine 5,000 blocks below Y 0".into()), ("xp", "250".into()), ("category", "Mining".into()), ("level", "14".into()), + ("rank_title", "Veteran".into()), ("guild", "Iron Wolves".into()), ("guild_tag", "IRON".into()), ("guild_line", "[IRON] Iron Wolves".into())], + "member" => vec![("player", me), ("uuid", String::new()), ("avatar", String::new()), ("members_total", "128".into())], + _ => vec![("guild", "Iron Wolves".into()), ("tag", "IRON".into()), ("leader", me), ("leader_avatar", String::new()), ("guild_description", "Defenders of the realm.".into()), + ("members", "1".into()), ("claims", "0".into()), ("guilds_total", "12".into())], + }; + for (k, val) in pairs { + v.insert(k.into(), val); + } + v +} + +// --------------------------------------------------------------------------- +// Sending +// --------------------------------------------------------------------------- + +pub async fn post(state: &AppState, webhook: &str, body: &Value) -> AppResult<()> { + let resp = state.http.post(webhook).json(body).timeout(Duration::from_secs(10)).send().await.map_err(|e| AppError::bad_request(format!("Discord could not be reached: {e}")))?; + if !resp.status().is_success() { + return Err(AppError::bad_request(format!("Discord refused the message (HTTP {})", resp.status().as_u16()))); + } + Ok(()) +} + +/// Post a new message and return its id (so it can be edited later). +async fn post_returning_id(state: &AppState, webhook: &str, body: &Value) -> AppResult { + let resp = state.http.post(format!("{webhook}?wait=true")).json(body).timeout(Duration::from_secs(10)).send().await.map_err(|e| AppError::bad_request(format!("Discord could not be reached: {e}")))?; + if !resp.status().is_success() { + return Err(AppError::bad_request(format!("Discord refused the message (HTTP {})", resp.status().as_u16()))); + } + let v: Value = resp.json().await.map_err(|_| AppError::bad_request("Discord sent an unexpected answer"))?; + Ok(v["id"].as_str().unwrap_or_default().to_string()) +} + +pub async fn announce_event(state: &AppState, kind: &str, vars: &Vars) -> AppResult { + let s = discord::load(state).await?; + if s.webhook_url.is_empty() { + return Ok(false); + } + let style = templates(state).await?.remove(kind).unwrap_or_default(); + if !style.enabled { + return Ok(false); + } + post(state, &s.webhook_url, &render(&style, vars, None)).await?; + Ok(true) +} + +// --------------------------------------------------------------------------- +// Live embeds +// --------------------------------------------------------------------------- + +pub const LIVE_KINDS: [&str; 4] = ["status", "guilds", "leaderboard", "baltop"]; + +#[derive(Clone, Serialize, Deserialize, PartialEq, Debug)] +#[serde(default)] +pub struct LiveConfig { + pub style: EmbedStyle, + /// Where to post. Empty = the announcement webhook. + pub webhook_url: String, + /// Seconds between updates (at least 60). + pub interval_secs: u64, + /// Rows shown (leaderboards, guilds). + pub limit: u32, + /// leaderboard: `playtime`, `level`, `kills`, `blocks`. + pub sort: String, + /// baltop: the economy of this server (0 = the first one). + pub server_id: i64, + /// One line per row; `{rows}` in the description is replaced by them. + pub row: String, + #[serde(skip_serializing)] + pub message_id: String, +} + +impl Default for LiveConfig { + fn default() -> Self { + Self { style: EmbedStyle::default(), webhook_url: String::new(), interval_secs: 120, limit: 10, sort: "playtime".into(), server_id: 0, row: String::new(), message_id: String::new() } + } +} + +pub fn default_live() -> BTreeMap { + let mut m = BTreeMap::new(); + m.insert("status".to_string(), LiveConfig { + style: EmbedStyle { + enabled: false, + title: "🟢 Server status".into(), + description: "{rows}\n\n**{players_online}** players online across **{servers_online}/{servers_total}** servers".into(), + color: "#34d399".into(), footer: "{server_name} · updated".into(), ..Default::default() + }, + row: "{status_icon} **{server}** — {players}/{max} players · TPS {tps} · {version}".into(), + ..Default::default() + }); + m.insert("guilds".to_string(), LiveConfig { + style: EmbedStyle { + enabled: false, title: "🛡️ Active guilds".into(), description: "{rows}\n\n{guilds_total} guilds · {claims_total} claimed chunks".into(), + color: "#22d3ee".into(), footer: "{server_name}".into(), ..Default::default() + }, + row: "**{rank}. [{tag}] {guild}** — level {level} · {members} members · {claims} chunks · led by {leader}".into(), + ..Default::default() + }); + m.insert("leaderboard".to_string(), LiveConfig { + style: EmbedStyle { + enabled: false, title: "🏆 Leaderboard — {sort_label}".into(), description: "{rows}".into(), + color: "#fcd34d".into(), footer: "{server_name}".into(), ..Default::default() + }, + row: "`{rank}.` **{player}** — {value}".into(), + ..Default::default() + }); + m.insert("baltop".to_string(), LiveConfig { + style: EmbedStyle { + enabled: false, title: "💰 Richest players".into(), description: "{rows}\n\nTotal in circulation: **{total}**".into(), + color: "#f59e0b".into(), footer: "{server_name}".into(), ..Default::default() + }, + row: "`{rank}.` **{player}** — {balance}".into(), + ..Default::default() + }); + m +} + +pub async fn live_configs(state: &AppState) -> AppResult> { + let mut stored: BTreeMap = store::kv_get(state, "discord_live").await?; + for (k, v) in default_live() { + stored.entry(k).or_insert(v); + } + Ok(stored) +} + +pub async fn save_live(state: &AppState, configs: &BTreeMap) -> AppResult<()> { + // message_id is runtime state; keep it out of the JSON the admin edits but in storage. + let mut stored = serde_json::Map::new(); + for (k, c) in configs { + let mut v = serde_json::to_value(c)?; + v["message_id"] = json!(c.message_id); + stored.insert(k.clone(), v); + } + store::kv_set(state, "discord_live", &Value::Object(stored)).await +} + +fn money(v: f64) -> String { + let whole = v.round() as i64; + let s = whole.abs().to_string(); + let mut out = String::new(); + for (i, ch) in s.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + out.push(','); + } + out.push(ch); + } + format!("${}{}", if whole < 0 { "-" } else { "" }, out.chars().rev().collect::()) +} + +fn hours(secs: i64) -> String { + if secs >= 3600 { format!("{}h {}m", secs / 3600, (secs % 3600) / 60) } else { format!("{}m", secs / 60) } +} + +/// The embed body for one live kind, built from current data. +pub async fn build_live(state: &AppState, kind: &str, cfg: &LiveConfig) -> AppResult { + let mut vars = common(state).await; + let mut rows: Vec = Vec::new(); + let default_row = default_live().remove(kind).map(|c| c.row).unwrap_or_default(); + let row_tpl = if cfg.row.trim().is_empty() { default_row } else { cfg.row.clone() }; + let limit = cfg.limit.clamp(1, 25) as i64; + match kind { + "status" => { + let list = crate::routes::servers::status_rows(state).await?; + vars.insert("servers_total".into(), list.len().to_string()); + vars.insert("servers_online".into(), list.iter().filter(|s| s.online).count().to_string()); + vars.insert("players_online".into(), list.iter().map(|s| s.players).sum::().to_string()); + vars.insert("players_max".into(), list.iter().map(|s| s.max_players).sum::().to_string()); + for s in list { + let mut r = vars.clone(); + r.insert("status_icon".into(), if s.online { "🟢" } else { "🔴" }.into()); + r.insert("server".into(), plain(&s.name)); + r.insert("status".into(), if s.online { "Online" } else { "Offline" }.into()); + r.insert("players".into(), s.players.to_string()); + r.insert("max".into(), if s.max_players > 0 { s.max_players.to_string() } else { "–".into() }); + r.insert("tps".into(), s.tps.map(|t| format!("{t:.1}")).unwrap_or_else(|| "–".into())); + r.insert("version".into(), plain(&if s.version.is_empty() { s.software.clone() } else { format!("{} {}", s.software, s.version) })); + r.insert("last_seen".into(), s.last_seen.as_deref().and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()).map(|t| format!("", t.timestamp())).unwrap_or_else(|| "never".into())); + rows.push(fill(&row_tpl, &r)); + } + } + "guilds" => { + let list: Vec<(String, String, i64, i64, i64, String, String)> = sqlx::query_as( + "SELECT g.name, g.tag, g.level, (SELECT COUNT(*) FROM guild_members WHERE guild_id = g.id), (SELECT COUNT(*) FROM guild_claims WHERE guild_id = g.id), COALESCE(u.username, ''), g.leader_uuid + FROM guilds g LEFT JOIN users u ON u.uuid = g.leader_uuid ORDER BY g.level DESC, g.xp DESC, 4 DESC LIMIT ?", + ) + .bind(limit) + .fetch_all(&state.db) + .await?; + let totals: (i64, i64) = sqlx::query_as("SELECT (SELECT COUNT(*) FROM guilds), (SELECT COUNT(*) FROM guild_claims)").fetch_one(&state.db).await?; + vars.insert("guilds_total".into(), totals.0.to_string()); + vars.insert("claims_total".into(), totals.1.to_string()); + for (i, (name, tag, level, members, claims, leader, leader_uuid)) in list.into_iter().enumerate() { + let mut r = vars.clone(); + for (k, v) in [("rank", (i + 1).to_string()), ("guild", plain(&name)), ("tag", plain(&tag)), ("level", level.to_string()), ("members", members.to_string()), + ("claims", claims.to_string()), ("leader", plain(&leader)), ("leader_avatar", avatar(state, &leader_uuid))] { + r.insert(k.into(), v); + } + rows.push(fill(&row_tpl, &r)); + } + } + "leaderboard" => { + let (sort_label, order, unit) = match cfg.sort.as_str() { + "level" => ("Level", "COALESCE(l.global_xp, 0)", "level"), + "kills" => ("Player kills", "kills", "kills"), + "blocks" => ("Blocks broken", "blocks", "blocks"), + _ => ("Playtime", "playtime", "playtime"), + }; + vars.insert("sort_label".into(), sort_label.into()); + let sql = format!( + "SELECT s.uuid, COALESCE(u.username, MAX(s.name)), SUM(s.playtime_secs) AS playtime, SUM(s.player_kills) AS kills, SUM(s.blocks_broken) AS blocks, COALESCE(l.global_level, 1), SUM(s.deaths), SUM(s.mob_kills) + FROM player_stats s LEFT JOIN users u ON u.uuid = s.uuid LEFT JOIN user_levels l ON l.uuid = s.uuid GROUP BY s.uuid ORDER BY {order} DESC LIMIT ?" + ); + let list: Vec<(String, String, i64, i64, i64, i64, i64, i64)> = sqlx::query_as(&sql).bind(limit).fetch_all(&state.db).await?; + for (i, (uuid, name, playtime, kills, blocks, level, deaths, mobs)) in list.into_iter().enumerate() { + let value = match unit { "level" => format!("level {level}"), "kills" => format!("{kills} kills"), "blocks" => format!("{blocks} blocks"), _ => hours(playtime) }; + let mut r = vars.clone(); + for (k, v) in [("rank", (i + 1).to_string()), ("player", plain(&name)), ("avatar", avatar(state, &uuid)), ("value", value), ("playtime", hours(playtime)), + ("kills", kills.to_string()), ("blocks", blocks.to_string()), ("level", level.to_string()), ("deaths", deaths.to_string()), ("mob_kills", mobs.to_string())] { + r.insert(k.into(), v); + } + rows.push(fill(&row_tpl, &r)); + } + } + _ => { + let sid: i64 = if cfg.server_id > 0 { cfg.server_id } else { sqlx::query_scalar("SELECT COALESCE(MIN(id), 0) FROM game_servers").fetch_one(&state.db).await? }; + let economy = crate::routes::servers::economy_scope(&state.db, sid).await?; + let list: Vec<(String, String, f64)> = sqlx::query_as("SELECT uuid, username, balance FROM server_economy WHERE server_id = ? ORDER BY balance DESC LIMIT ?").bind(economy).bind(limit).fetch_all(&state.db).await?; + let total: f64 = sqlx::query_scalar("SELECT COALESCE(SUM(balance), 0) FROM server_economy WHERE server_id = ?").bind(economy).fetch_one(&state.db).await?; + vars.insert("total".into(), money(total)); + for (i, (uuid, name, balance)) in list.into_iter().enumerate() { + let mut r = vars.clone(); + for (k, v) in [("rank", (i + 1).to_string()), ("player", plain(&name)), ("avatar", avatar(state, &uuid)), ("balance", money(balance))] { + r.insert(k.into(), v); + } + rows.push(fill(&row_tpl, &r)); + } + } + } + let rows_text = if rows.is_empty() { "*Nothing to show yet.*".to_string() } else { rows.join("\n") }; + Ok(render(&cfg.style, &vars, Some(&rows_text))) +} + +/// Post or update one live embed. Returns the stored message id. +pub async fn refresh_live(state: &AppState, kind: &str, force_new: bool) -> AppResult<()> { + let mut all = live_configs(state).await?; + let Some(cfg) = all.get(kind).cloned() else { return Err(AppError::bad_request("unknown embed")) }; + let main = discord::load(state).await?; + let hook = if cfg.webhook_url.trim().is_empty() { main.webhook_url.clone() } else { cfg.webhook_url.trim().to_string() }; + if hook.is_empty() || !discord::valid_webhook(&hook) { + return Err(AppError::bad_request("set a Discord webhook first (Discord settings, or this embed's own webhook)")); + } + let body = build_live(state, kind, &cfg).await?; + let mut id = if force_new { String::new() } else { cfg.message_id.clone() }; + if !id.is_empty() { + let resp = state.http.patch(format!("{hook}/messages/{id}")).json(&body).timeout(Duration::from_secs(10)).send().await.map_err(|e| AppError::bad_request(format!("Discord could not be reached: {e}")))?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + id.clear(); // the message was deleted in Discord: post a fresh one + } else if !resp.status().is_success() { + return Err(AppError::bad_request(format!("Discord refused the update (HTTP {})", resp.status().as_u16()))); + } + } + if id.is_empty() { + id = post_returning_id(state, &hook, &body).await?; + } + if let Some(c) = all.get_mut(kind) { + c.message_id = id; + } + save_live(state, &all).await +} + +/// Called every half minute: refresh the embeds that are due. +pub async fn tick_live(state: &AppState, last: &mut BTreeMap) { + let Ok(all) = live_configs(state).await else { return }; + for (kind, cfg) in all { + if !cfg.style.enabled { + continue; + } + let every = Duration::from_secs(cfg.interval_secs.max(60)); + if last.get(&kind).is_some_and(|t| t.elapsed() < every) { + continue; + } + last.insert(kind.clone(), std::time::Instant::now()); + if let Err(e) = refresh_live(state, &kind, false).await { + tracing::debug!("live embed {kind}: {}", e.message); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn placeholders_are_filled_and_unknown_ones_kept() { + let mut v = Vars::new(); + v.insert("player".into(), "Steve".into()); + assert_eq!(fill("Hi {player}, {nope} and {a b} {", &v), "Hi Steve, {nope} and {a b} {"); + assert_eq!(fill("{player}{player}", &v), "SteveSteve"); + } + + #[test] + fn embeds_stay_inside_discords_limits_and_drop_empties() { + let mut v = Vars::new(); + v.insert("long".into(), "x".repeat(5000)); + v.insert("img".into(), "javascript:alert(1)".into()); + let style = EmbedStyle { title: "{long}".into(), description: "{long}".into(), thumbnail: "{img}".into(), color: "nonsense".into(), fields: vec![field("", "empty name", false), field("ok", "{long}", true)], ..Default::default() }; + let body = render(&style, &v, None); + let e = &body["embeds"][0]; + assert!(e["title"].as_str().unwrap().chars().count() <= 256); + assert!(e["description"].as_str().unwrap().chars().count() <= 4096); + assert!(e.get("thumbnail").is_none(), "only web addresses become images"); + assert_eq!(e["color"], 0x8b6cff); + assert_eq!(e["fields"].as_array().unwrap().len(), 1); + assert!(e["fields"][0]["value"].as_str().unwrap().chars().count() <= 1024); + } + + #[test] + fn player_text_cannot_ping() { + assert_eq!(plain("@everyone `x`"), "@\u{200b}everyone 'x'"); + } + + #[test] + fn money_and_hours_read_well() { + assert_eq!(money(1234567.4), "$1,234,567"); + assert_eq!(hours(3700), "1h 1m"); + assert_eq!(hours(300), "5m"); + } +} diff --git a/panel/server/src/lib.rs b/panel/server/src/lib.rs index 701eef1..7970f5d 100644 --- a/panel/server/src/lib.rs +++ b/panel/server/src/lib.rs @@ -6,6 +6,7 @@ pub mod auth; pub mod config; pub mod db; +pub mod embeds; pub mod error; pub mod worldmap; pub mod net; diff --git a/panel/server/src/routes/discord.rs b/panel/server/src/routes/discord.rs index b7554ab..eb9bdd9 100644 --- a/panel/server/src/routes/discord.rs +++ b/panel/server/src/routes/discord.rs @@ -11,6 +11,7 @@ use crate::routes::connections::ConnectionsSettings; use crate::state::AppState; use crate::store; use axum::extract::{Path, State}; +use crate::embeds; use axum::Json; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -336,36 +337,46 @@ pub async fn announce(state: &AppState) -> AppResult { } let mut sent = 0; if s.notify_achievements { - let rows: Vec<(String, String, String)> = sqlx::query_as( - "SELECT COALESCE(u.username, ua.user_uuid), a.title, ua.unlocked_at FROM user_achievements ua + let rows: Vec<(String, String, String, String, i64, String, String)> = sqlx::query_as( + "SELECT ua.user_uuid, COALESCE(u.username, ua.user_uuid), a.title, a.description, a.xp_reward, a.category, ua.unlocked_at FROM user_achievements ua JOIN achievements a ON a.id = ua.achievement_id LEFT JOIN users u ON u.uuid = ua.user_uuid WHERE ua.unlocked_at > ? ORDER BY ua.unlocked_at LIMIT 10", ) .bind(&cursor.achievements) .fetch_all(&state.db) .await?; - for (who, title, at) in rows { - post_webhook(state, &s.webhook_url, "Achievement unlocked", &format!("**{}** earned **{}**", clip(&who), clip(&title)), 0xfcd34d).await; + for (uuid, who, title, desc, xp, category, at) in rows { + let mut vars = embeds::player_vars(state, &uuid, &who).await; + for (k, v) in [("achievement", embeds::plain(&title)), ("achievement_description", embeds::plain(&desc)), ("xp", xp.to_string()), ("category", embeds::plain(&category))] { + vars.insert(k.into(), v); + } + if embeds::announce_event(state, "achievement", &vars).await.unwrap_or(false) { + sent += 1; + } cursor.achievements = at; - sent += 1; } } if s.notify_guilds { - let rows: Vec<(String, String, String)> = - sqlx::query_as("SELECT name, tag, created_at FROM guilds WHERE created_at > ? ORDER BY created_at LIMIT 10").bind(&cursor.guilds).fetch_all(&state.db).await?; - for (name, tag, at) in rows { - post_webhook(state, &s.webhook_url, "New guild", &format!("**{}** [{}] was founded", clip(&name), clip(&tag)), 0x22d3ee).await; + let rows: Vec<(String, String)> = sqlx::query_as("SELECT id, created_at FROM guilds WHERE created_at > ? ORDER BY created_at LIMIT 10").bind(&cursor.guilds).fetch_all(&state.db).await?; + for (id, at) in rows { + let vars = embeds::guild_vars(state, &id).await; + if embeds::announce_event(state, "guild", &vars).await.unwrap_or(false) { + sent += 1; + } cursor.guilds = at; - sent += 1; } } if s.notify_members { - let rows: Vec<(String, String)> = - sqlx::query_as("SELECT username, created_at FROM users WHERE status = 'active' AND created_at > ? ORDER BY created_at LIMIT 10").bind(&cursor.members).fetch_all(&state.db).await?; - for (name, at) in rows { - post_webhook(state, &s.webhook_url, "New player", &format!("Welcome **{}**!", clip(&name)), 0x6ee7b7).await; + let rows: Vec<(String, String, String)> = + sqlx::query_as("SELECT uuid, username, created_at FROM users WHERE status = 'active' AND created_at > ? ORDER BY created_at LIMIT 10").bind(&cursor.members).fetch_all(&state.db).await?; + for (uuid, name, at) in rows { + let mut vars = embeds::player_vars(state, &uuid, &name).await; + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE status = 'active'").fetch_one(&state.db).await.unwrap_or(0); + vars.insert("members_total".into(), total.to_string()); + if embeds::announce_event(state, "member", &vars).await.unwrap_or(false) { + sent += 1; + } cursor.members = at; - sent += 1; } } store::kv_set(state, "discord_cursor", &cursor).await?; @@ -385,11 +396,13 @@ pub async fn test_webhook(_: AdminUser, State(state): State) -> AppRes pub fn spawn_worker(state: AppState) { tokio::spawn(async move { let mut tick = 0u64; + let mut live_last = std::collections::BTreeMap::new(); loop { tokio::time::sleep(Duration::from_secs(30)).await; if let Err(e) = announce(&state).await { tracing::debug!("discord announce: {e:?}"); } + embeds::tick_live(&state, &mut live_last).await; tick += 1; if tick % 30 == 0 { if let Err(e) = sync_all(&state).await { diff --git a/panel/server/src/routes/discord_studio.rs b/panel/server/src/routes/discord_studio.rs new file mode 100644 index 0000000..d9e28f7 --- /dev/null +++ b/panel/server/src/routes/discord_studio.rs @@ -0,0 +1,155 @@ +//! Admin editing of Discord message templates and live embeds, with previews built from real data. + +use crate::auth::AdminUser; +use crate::embeds::{self, EmbedStyle, LiveConfig}; +use crate::error::{AppError, AppResult}; +use crate::routes::discord; +use crate::state::AppState; +use crate::store; +use axum::extract::{Path, State}; +use axum::Json; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::BTreeMap; + +fn check_style(s: &EmbedStyle) -> AppResult<()> { + let too_long = |t: &str, n: usize| t.chars().count() > n; + if too_long(&s.title, 600) || too_long(&s.description, 4000) || too_long(&s.footer, 600) || too_long(&s.content, 2000) || s.fields.len() > 25 { + return Err(AppError::bad_request("a Discord message part is too long (title 256, text 4096, footer 2048, 25 fields)")); + } + for url in [&s.url, &s.thumbnail, &s.image, &s.avatar_url, &s.author_icon, &s.footer_icon] { + let u = url.trim(); + if !u.is_empty() && !u.starts_with('{') && !(u.starts_with("https://") || u.starts_with("http://")) { + return Err(AppError::bad_request("image and link addresses must start with https:// (or be a placeholder like {avatar})")); + } + } + Ok(()) +} + +pub async fn get_all(_: AdminUser, State(state): State) -> AppResult> { + let templates = embeds::templates(&state).await?; + let live = embeds::live_configs(&state).await?; + let mut live_out = serde_json::Map::new(); + for (k, c) in &live { + let mut v = serde_json::to_value(c)?; + v["posted"] = json!(!c.message_id.is_empty()); + v["webhook_set"] = json!(!c.webhook_url.is_empty()); + v["webhook_url"] = json!(""); + live_out.insert(k.clone(), v); + } + let placeholders: BTreeMap<&str, Vec<&str>> = embeds::ANNOUNCEMENT_PLACEHOLDERS.iter().map(|(k, v)| (*k, v.to_vec())).collect(); + Ok(Json(json!({ + "templates": templates, + "live": live_out, + "placeholders": placeholders, + "live_placeholders": { + "status": ["rows", "servers_online", "servers_total", "players_online", "players_max", "server_name", "updated", "time"], + "status_row": ["status_icon", "server", "status", "players", "max", "tps", "version", "last_seen"], + "guilds": ["rows", "guilds_total", "claims_total", "server_name", "updated", "time"], + "guilds_row": ["rank", "guild", "tag", "level", "members", "claims", "leader", "leader_avatar"], + "leaderboard": ["rows", "sort_label", "server_name", "updated", "time"], + "leaderboard_row": ["rank", "player", "avatar", "value", "playtime", "kills", "blocks", "level", "deaths", "mob_kills"], + "baltop": ["rows", "total", "server_name", "updated", "time"], + "baltop_row": ["rank", "player", "avatar", "balance"], + }, + "webhook_set": !discord::load(&state).await?.webhook_url.is_empty(), + }))) +} + +#[derive(Deserialize)] +pub struct TemplatesBody { + templates: BTreeMap, +} + +pub async fn put_templates(_: AdminUser, State(state): State, Json(b): Json) -> AppResult> { + let known = embeds::default_templates(); + let mut next = BTreeMap::new(); + for (k, v) in b.templates { + if !known.contains_key(&k) { + return Err(AppError::bad_request("unknown message")); + } + check_style(&v)?; + next.insert(k, v); + } + store::kv_set(&state, "discord_templates", &next).await?; + Ok(Json(json!({ "ok": true }))) +} + +#[derive(Deserialize)] +pub struct LiveBody { + live: BTreeMap, +} + +pub async fn put_live(_: AdminUser, State(state): State, Json(b): Json) -> AppResult> { + let mut all = embeds::live_configs(&state).await?; + for (k, mut v) in b.live { + let Some(old) = all.get(&k) else { return Err(AppError::bad_request("unknown embed")) }; + check_style(&v.style)?; + let hook = v.webhook_url.trim().to_string(); + v.webhook_url = match hook.as_str() { + "" => old.webhook_url.clone(), // blank keeps the saved one; "-" clears it + "-" => String::new(), + h if discord::valid_webhook(h) => h.to_string(), + _ => return Err(AppError::bad_request("that is not a Discord webhook address")), + }; + v.interval_secs = v.interval_secs.clamp(60, 86_400); + v.limit = v.limit.clamp(1, 25); + if !["playtime", "level", "kills", "blocks"].contains(&v.sort.as_str()) { + v.sort = "playtime".into(); + } + v.message_id = old.message_id.clone(); + all.insert(k, v); + } + embeds::save_live(&state, &all).await?; + Ok(Json(json!({ "ok": true }))) +} + +#[derive(Deserialize)] +pub struct PreviewBody { + /// `achievement`, `member`, `guild` or a live kind + kind: String, + style: Option, + live: Option, +} + +/// What the message would look like right now, as the exact JSON Discord receives. +pub async fn preview(_: AdminUser, State(state): State, Json(b): Json) -> AppResult> { + if embeds::LIVE_KINDS.contains(&b.kind.as_str()) { + let cfg = b.live.ok_or_else(|| AppError::bad_request("missing embed settings"))?; + check_style(&cfg.style)?; + return Ok(Json(embeds::build_live(&state, &b.kind, &cfg).await?)); + } + let style = b.style.ok_or_else(|| AppError::bad_request("missing message settings"))?; + check_style(&style)?; + if !embeds::default_templates().contains_key(&b.kind) { + return Err(AppError::bad_request("unknown message")); + } + let vars = embeds::sample_vars(&state, &b.kind).await; + Ok(Json(embeds::render(&style, &vars, None))) +} + +/// Send the saved message to Discord now (a sample announcement, or a live embed refresh). +pub async fn send_now(_: AdminUser, State(state): State, Path(kind): Path) -> AppResult> { + if embeds::LIVE_KINDS.contains(&kind.as_str()) { + embeds::refresh_live(&state, &kind, false).await?; + return Ok(Json(json!({ "ok": true }))); + } + let templates = embeds::templates(&state).await?; + let style = templates.get(&kind).ok_or_else(|| AppError::bad_request("unknown message"))?; + let s = discord::load(&state).await?; + if s.webhook_url.is_empty() { + return Err(AppError::bad_request("save a webhook first")); + } + let vars = embeds::sample_vars(&state, &kind).await; + embeds::post(&state, &s.webhook_url, &embeds::render(style, &vars, None)).await?; + Ok(Json(json!({ "ok": true }))) +} + +/// Forget the posted message so the next update creates a new one (use after moving the embed to another channel). +pub async fn repost(_: AdminUser, State(state): State, Path(kind): Path) -> AppResult> { + if !embeds::LIVE_KINDS.contains(&kind.as_str()) { + return Err(AppError::bad_request("unknown embed")); + } + embeds::refresh_live(&state, &kind, true).await?; + Ok(Json(json!({ "ok": true }))) +} diff --git a/panel/server/src/routes/mod.rs b/panel/server/src/routes/mod.rs index f4709c6..4390c6c 100644 --- a/panel/server/src/routes/mod.rs +++ b/panel/server/src/routes/mod.rs @@ -8,6 +8,7 @@ pub mod economy; pub mod guild_bank; pub mod guilds; pub mod discord; +pub mod discord_studio; pub mod integrations; pub mod landing; pub mod worldmap; @@ -175,6 +176,12 @@ pub fn api(state: &AppState) -> Router { .route("/rewards/{id}", put(leveling::admin_update_reward).delete(leveling::admin_delete_reward)) .route("/levels/rewards", get(leveling::list_rewards).post(leveling::admin_create_reward)) .route("/levels/rewards/{id}", put(leveling::admin_update_reward).delete(leveling::admin_delete_reward)) + .route("/discord/studio", get(discord_studio::get_all)) + .route("/discord/studio/templates", put(discord_studio::put_templates)) + .route("/discord/studio/live", put(discord_studio::put_live)) + .route("/discord/studio/preview", post(discord_studio::preview)) + .route("/discord/studio/send/{kind}", post(discord_studio::send_now)) + .route("/discord/studio/repost/{kind}", post(discord_studio::repost)) .route("/reward-bundles/{kind}/{id}", get(rewards::admin_get).put(rewards::admin_put)) .route("/reward-deliveries", get(rewards::admin_deliveries)) .route("/quests", get(quests::admin_list_quests).post(quests::admin_create_quest)) diff --git a/panel/server/src/routes/servers.rs b/panel/server/src/routes/servers.rs index 9e57e2f..2544796 100644 --- a/panel/server/src/routes/servers.rs +++ b/panel/server/src/routes/servers.rs @@ -1315,3 +1315,37 @@ mod tests { assert!(!ips_match("8.8.8.8", "1.1.1.1")); } } + +/// One server's public status, for Discord embeds and the like. +pub struct StatusRow { + pub id: i64, + pub name: String, + pub online: bool, + pub players: i64, + pub max_players: i64, + pub tps: Option, + pub software: String, + pub version: String, + pub last_seen: Option, +} + +pub async fn status_rows(state: &AppState) -> AppResult> { + let rows: Vec = sqlx::query_as("SELECT * FROM game_servers ORDER BY id").fetch_all(&state.db).await?; + Ok(rows + .into_iter() + .map(|s| { + let online = s.is_online(); + StatusRow { + id: s.id, + name: s.name.clone(), + online, + players: if online { s.online_count } else { 0 }, + max_players: s.max_players, + tps: if online { s.tps } else { None }, + software: s.software.clone().unwrap_or_default(), + version: s.mc_version.clone().unwrap_or_default(), + last_seen: s.last_seen.clone(), + } + }) + .collect()) +} diff --git a/panel/server/tests/discord_studio.rs b/panel/server/tests/discord_studio.rs new file mode 100644 index 0000000..8c0937a --- /dev/null +++ b/panel/server/tests/discord_studio.rs @@ -0,0 +1,58 @@ +mod common; +use common::*; + +#[tokio::test] +async fn discord_messages_are_customised_and_previewed_with_real_data() { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + let (s, all) = t.call("GET", "/api/admin/discord/studio", Some(&admin), None).await; + assert_eq!(s, StatusCode::OK, "{all}"); + assert_eq!(all["templates"].as_object().unwrap().len(), 3); + assert_eq!(all["live"].as_object().unwrap().len(), 4); + assert!(all["placeholders"]["achievement"].as_array().unwrap().iter().any(|p| p == "achievement_description")); + + // Edit the achievement message; bad image addresses are refused. + let mut tpl = all["templates"].clone(); + tpl["achievement"]["title"] = json!("🎉 {player} did it!"); + tpl["achievement"]["image"] = json!("javascript:evil"); + let (s, _) = t.call("PUT", "/api/admin/discord/studio/templates", Some(&admin), Some(json!({"templates": tpl}))).await; + assert_eq!(s, StatusCode::BAD_REQUEST); + tpl["achievement"]["image"] = json!("https://cdn.example.com/banner.png"); + let (s, _) = t.call("PUT", "/api/admin/discord/studio/templates", Some(&admin), Some(json!({"templates": tpl.clone()}))).await; + assert_eq!(s, StatusCode::OK); + let (s, p) = t.call("POST", "/api/admin/discord/studio/preview", Some(&admin), Some(json!({"kind": "achievement", "style": tpl["achievement"]}))).await; + assert_eq!(s, StatusCode::OK, "{p}"); + let e = &p["embeds"][0]; + assert_eq!(e["title"], "🎉 admin did it!"); + assert_eq!(e["image"]["url"], "https://cdn.example.com/banner.png"); + assert!(e["description"].as_str().unwrap().contains("Deep Diver")); + assert_eq!(e["fields"][0]["name"], "Reward"); + + // Live embeds show real data: a server, a guild and a rich player. + t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Steve", "password": "password123"}))).await; + let steve_token = t.login("Steve", "password123").await; + let (_, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "Survival SMP", "instance_id": "smp"}))).await; + let sid = srv["server"]["id"].as_i64().unwrap(); + t.call("POST", "/api/v1/guilds", Some(&steve_token), Some(json!({"instance_id": "smp", "name": "Iron Wolves", "tag": "IRON"}))).await; + sqlx::query("INSERT INTO server_economy (server_id, uuid, username, balance, updated_at) VALUES (?, ?, 'Steve', 123456, 'now')").bind(sid).bind(t.uuid("Steve").await).execute(&t.db).await.unwrap(); + for (kind, needle) in [("status", "Survival SMP"), ("guilds", "Iron Wolves"), ("baltop", "$123,456"), ("leaderboard", "Leaderboard")] { + let cfg = all["live"][kind].clone(); + let (s, p) = t.call("POST", "/api/admin/discord/studio/preview", Some(&admin), Some(json!({"kind": kind, "live": cfg}))).await; + assert_eq!(s, StatusCode::OK, "{kind}: {p}"); + assert!(p.to_string().contains(needle), "{kind} should mention {needle}: {p}"); + } + + // Interval and row limits are clamped; the webhook is write-only. + let mut live = all["live"].clone(); + live["status"]["interval_secs"] = json!(5); + live["status"]["limit"] = json!(500); + live["status"]["webhook_url"] = json!("https://evil.example.com/hook"); + assert_eq!(t.call("PUT", "/api/admin/discord/studio/live", Some(&admin), Some(json!({"live": live.clone()}))).await.0, StatusCode::BAD_REQUEST); + live["status"]["webhook_url"] = json!("https://discord.com/api/webhooks/1/abc"); + assert_eq!(t.call("PUT", "/api/admin/discord/studio/live", Some(&admin), Some(json!({"live": live}))).await.0, StatusCode::OK); + let (_, again) = t.call("GET", "/api/admin/discord/studio", Some(&admin), None).await; + assert_eq!(again["live"]["status"]["interval_secs"], 60); + assert_eq!(again["live"]["status"]["limit"], 25); + assert_eq!(again["live"]["status"]["webhook_set"], true); + assert_eq!(again["live"]["status"]["webhook_url"], ""); +} From 2cc009d66f9e50d90490255f3cdadbd03df67ba6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 01:44:59 +0000 Subject: [PATCH 18/21] Admin Discord studio: editor, live preview, live embeds --- panel/web/src/App.svelte | 3 + panel/web/src/components/EmbedEditor.svelte | 80 ++++++++ panel/web/src/components/EmbedPreview.svelte | 79 ++++++++ panel/web/src/components/Sidebar.svelte | 3 +- panel/web/src/lib/types.ts | 10 + panel/web/src/pages/DiscordStudio.svelte | 189 +++++++++++++++++++ 6 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 panel/web/src/components/EmbedEditor.svelte create mode 100644 panel/web/src/components/EmbedPreview.svelte create mode 100644 panel/web/src/pages/DiscordStudio.svelte diff --git a/panel/web/src/App.svelte b/panel/web/src/App.svelte index 954de30..207b7b8 100644 --- a/panel/web/src/App.svelte +++ b/panel/web/src/App.svelte @@ -18,6 +18,7 @@ import AchievementEditor from './pages/AchievementEditor.svelte'; import LevelingAdmin from './pages/LevelingAdmin.svelte'; import Progression from './pages/Progression.svelte'; + import DiscordStudio from './pages/DiscordStudio.svelte'; import GuildsAdmin from './pages/GuildsAdmin.svelte'; import { get } from './lib/api'; import { route } from './lib/router.svelte'; @@ -99,6 +100,8 @@ {:else if route.name === 'branding'} (brand = b)} /> + {:else if route.name === 'discord'} + {:else if route.name === 'landing-builder'} {:else if route.name === 'settings'} diff --git a/panel/web/src/components/EmbedEditor.svelte b/panel/web/src/components/EmbedEditor.svelte new file mode 100644 index 0000000..1db0261 --- /dev/null +++ b/panel/web/src/components/EmbedEditor.svelte @@ -0,0 +1,80 @@ + + +
+ + + {#if placeholders.length} +
+ Placeholders click to insert +
{#each placeholders as p}{/each}
+
+ {/if} + +
+ + + + + + + + + + + + + +
+ + +
+
Fields
+ {#each style.fields ?? [] as f, i} +
+ + + + +
+ {/each} +
+
+ + diff --git a/panel/web/src/components/EmbedPreview.svelte b/panel/web/src/components/EmbedPreview.svelte new file mode 100644 index 0000000..824f62d --- /dev/null +++ b/panel/web/src/components/EmbedPreview.svelte @@ -0,0 +1,79 @@ + + +
+
{#if body?.avatar_url}{:else}S{/if}
+
+
{body?.username ?? 'SCOPENET'}APPToday
+ {#if body?.content}
{@html md(body.content)}
{/if} + {#if e} +
+
+
+ {#if e.author}
{#if e.author.icon_url}{/if}{e.author.name}
{/if} + {#if e.title}
{e.title}
{/if} + {#if e.description}
{@html md(e.description)}
{/if} + {#if e.fields?.length} +
+ {#each e.fields as f}
{f.name}
{@html md(f.value)}
{/each} +
+ {/if} +
+ {#if e.thumbnail}{/if} +
+ {#if e.image}{/if} + {#if e.footer || e.timestamp} + + {/if} +
+ {/if} +
+
+ + diff --git a/panel/web/src/components/Sidebar.svelte b/panel/web/src/components/Sidebar.svelte index d834a8d..ca39ddd 100644 --- a/panel/web/src/components/Sidebar.svelte +++ b/panel/web/src/components/Sidebar.svelte @@ -1,5 +1,5 @@ + +
+
+
+

Discord

+

Customise the messages the panel posts, and keep live status boards in your channels.{#if data && !data.webhook_set} Add a webhook under Settings → Discord first.{/if}

+
+
+ +
+ + +
+ + {#if data && style} +
+ + +
+ {#if isLive} + {#key current}{/key} + {:else} + {#key current}{/key} + {/if} + + {#if isLive} + {@const c = data.live[current]} +
+

Live settings

+
+ + + {#if current !== 'status'}{/if} + {#if current === 'leaderboard'} + + {/if} + {#if current === 'baltop'} + + {/if} + +
+

{c.posted ? 'The embed is posted and is edited in place on every update.' : 'Not posted yet. Save and press Update now.'}

+
+ {/if} + +
+ + + {#if isLive && data.live[current].posted}{/if} +
+
+ +
+

Preview

+

Built from your real data{isLive ? '' : ' (sample names for announcements)'}.

+ {#if preview}{:else}

Loading preview…

{/if} +
+
+ {:else} +

Loading…

+ {/if} +
+ + From cd985a1a7bcb784a7c8c61208bcd49a125e4f3dc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 01:49:02 +0000 Subject: [PATCH 19/21] Emails: templates, branded HTML, audiences, opt-out, log; admin UI --- panel/server/src/db.rs | 15 + panel/server/src/routes/connections.rs | 19 +- panel/server/src/routes/emails.rs | 424 ++++++++++++++++++++++++ panel/server/src/routes/mod.rs | 7 + panel/server/tests/emails.rs | 59 ++++ panel/web/src/App.svelte | 3 + panel/web/src/components/Sidebar.svelte | 3 +- panel/web/src/pages/Emails.svelte | 201 +++++++++++ 8 files changed, 723 insertions(+), 8 deletions(-) create mode 100644 panel/server/src/routes/emails.rs create mode 100644 panel/server/tests/emails.rs create mode 100644 panel/web/src/pages/Emails.svelte diff --git a/panel/server/src/db.rs b/panel/server/src/db.rs index 99161ad..c59aa12 100644 --- a/panel/server/src/db.rs +++ b/panel/server/src/db.rs @@ -759,6 +759,21 @@ const MIGRATIONS: &[&str] = &[ VALUES (NEW.user_uuid, 'achievement', NEW.achievement_id, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')); END; "#, + // Emails sent from the panel: players can opt out of non-essential mail, and every send is logged. + r#" + ALTER TABLE users ADD COLUMN email_optout INTEGER NOT NULL DEFAULT 0; + CREATE TABLE email_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subject TEXT NOT NULL, + audience TEXT NOT NULL, + total INTEGER NOT NULL DEFAULT 0, + sent INTEGER NOT NULL DEFAULT 0, + failed INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + started_at TEXT NOT NULL, + finished_at TEXT + ); + "#, ]; pub async fn connect(data_dir: &Path) -> Result { diff --git a/panel/server/src/routes/connections.rs b/panel/server/src/routes/connections.rs index cb9784d..8a61d52 100644 --- a/panel/server/src/routes/connections.rs +++ b/panel/server/src/routes/connections.rs @@ -27,7 +27,7 @@ pub struct ConnectionsSettings { pub sender_name: String, } -async fn settings(state: &AppState) -> AppResult { +pub async fn settings(state: &AppState) -> AppResult { store::kv_get(state, "connections_settings").await } @@ -111,6 +111,11 @@ pub async fn public_config(State(state): State) -> AppResult AppResult<()> { + send_mail(state, to, subject, body, None).await +} + +/// Send one message, as plain text and, when given, an HTML version of the same content. +pub async fn send_mail(state: &AppState, to: &str, subject: &str, body: &str, html: Option<&str>) -> AppResult<()> { let s = settings(state).await?; if s.resend_api_key.is_empty() || s.sender_email.is_empty() { return Err(AppError::bad_request("configure Resend SMTP and a sender email in Settings first")); @@ -119,12 +124,12 @@ async fn send_email(state: &AppState, to: &str, subject: &str, body: &str) -> Ap if s.sender_name.is_empty() { s.sender_email.parse() } else { format!("{} <{}>", s.sender_name, s.sender_email).parse() } .map_err(|_| AppError::bad_request("invalid sender email"))?; let to: Mailbox = to.parse().map_err(|_| AppError::bad_request("invalid recipient email"))?; - let message = Message::builder() - .from(from) - .to(to) - .subject(subject) - .body(body.to_string()) - .map_err(|_| AppError::bad_request("invalid email message"))?; + let builder = Message::builder().from(from).to(to).subject(subject); + let message = match html { + Some(h) => builder.multipart(lettre::message::MultiPart::alternative_plain_html(body.to_string(), h.to_string())), + None => builder.body(body.to_string()), + } + .map_err(|_| AppError::bad_request("invalid email message"))?; let smtp = AsyncSmtpTransport::::relay("smtp.resend.com") .map_err(|e| AppError::bad_request(format!("SMTP configuration failed: {e}")))? .credentials(Credentials::new("resend".into(), s.resend_api_key)) diff --git a/panel/server/src/routes/emails.rs b/panel/server/src/routes/emails.rs new file mode 100644 index 0000000..3df975b --- /dev/null +++ b/panel/server/src/routes/emails.rs @@ -0,0 +1,424 @@ +//! Emails the admin sends to players: reusable templates with placeholders, a branded HTML layout generated from plain text, +//! audiences (everyone, a group, chosen players), opt-out links, and a log of what went out. + +use crate::auth::AdminUser; +use crate::embeds::{fill, plain, Vars}; +use crate::error::{AppError, AppResult}; +use crate::routes::connections; +use crate::state::AppState; +use crate::store; +use axum::extract::{Query, State}; +use axum::response::Html; +use axum::Json; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::time::Duration; + +const MAX_RECIPIENTS: usize = 2000; + +#[derive(Clone, Serialize, Deserialize, Default, PartialEq, Debug)] +#[serde(default)] +pub struct EmailTemplate { + pub id: String, + pub name: String, + pub subject: String, + pub body: String, +} + +fn defaults() -> Vec { + vec![ + EmailTemplate { + id: "welcome".into(), + name: "Welcome".into(), + subject: "Welcome to {brand}, {player}!".into(), + body: "# Welcome, {player}!\n\nYour account on **{brand}** is ready. Download the launcher, sign in and join the server.\n\n[button: Open {brand}]({panel_url})\n\nSee you in game!".into(), + }, + EmailTemplate { + id: "news".into(), + name: "News & updates".into(), + subject: "News from {brand}".into(), + body: "Hi {player},\n\nHere is what is new on {brand}:\n\n- First thing\n- Second thing\n\n[button: Read more]({panel_url})".into(), + }, + EmailTemplate { + id: "event".into(), + name: "Event announcement".into(), + subject: "{brand} event: don't miss it".into(), + body: "# A new event is starting\n\nHi {player}, you are level **{level}**{rank_title_suffix}. Come and join in!\n\n[button: Join now]({panel_url})".into(), + }, + ] +} + +pub async fn templates(state: &AppState) -> AppResult> { + let stored: Vec = store::kv_get(state, "email_templates").await?; + Ok(if stored.is_empty() { defaults() } else { stored }) +} + +pub const PLACEHOLDERS: [&str; 11] = ["player", "uuid", "email", "level", "rank_title", "rank_title_suffix", "guild", "guild_tag", "brand", "panel_url", "unsubscribe_url"]; + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +fn esc(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} + +fn safe_url(u: &str) -> Option { + let u = u.trim(); + (u.starts_with("https://") || u.starts_with("http://")).then(|| esc(u)) +} + +/// `**bold**`, `*italic*`, `[text](url)` and `[button: text](url)` inside one already-escaped line. +fn inline(line: &str, accent: &str) -> String { + let mut out = String::new(); + let mut rest = line.to_string(); + // links and buttons first, from the raw (escaped) text + while let Some(open) = rest.find('[') { + let Some(mid) = rest[open..].find("](").map(|i| open + i) else { break }; + let Some(close) = rest[mid..].find(')').map(|i| mid + i) else { break }; + let label = &rest[open + 1..mid]; + let url = rest[mid + 2..close].replace("&", "&"); + out.push_str(&emphasis(&rest[..open])); + match (safe_url(&url), label.strip_prefix("button:")) { + (Some(u), Some(text)) => out.push_str(&format!( + "{}", + text.trim() + )), + (Some(u), None) => out.push_str(&format!("{label}")), + _ => out.push_str(label), + } + rest = rest[close + 1..].to_string(); + } + out.push_str(&emphasis(&rest)); + out +} + +fn emphasis(s: &str) -> String { + let mut out = String::new(); + let mut bold = false; + for (i, part) in s.split("**").enumerate() { + if i > 0 { + out.push_str(if bold { "" } else { "" }); + bold = !bold; + } + out.push_str(part); + } + if bold { + out.push_str(""); + } + out +} + +/// Plain text to the branded HTML layout. Everything the admin or a player typed is escaped first. +pub fn html(body: &str, brand: &str, accent: &str, footer: &str) -> String { + let accent = if accent.len() == 7 && accent.starts_with('#') && accent[1..].chars().all(|c| c.is_ascii_hexdigit()) { accent } else { "#8b6cff" }; + let mut content = String::new(); + let mut list = false; + for raw in body.lines() { + let line = esc(raw.trim_end()); + let bullet = line.trim_start().strip_prefix("- ").map(str::to_string); + if let Some(item) = &bullet { + if !list { + content.push_str("
    "); + list = true; + } + content.push_str(&format!("
  • {}
  • ", inline(item, accent))); + continue; + } + if list { + content.push_str("
"); + list = false; + } + if line.trim().is_empty() { + continue; + } + if let Some(h) = line.strip_prefix("# ") { + content.push_str(&format!("

{}

", inline(h, accent))); + } else if let Some(h) = line.strip_prefix("## ") { + content.push_str(&format!("

{}

", inline(h, accent))); + } else { + content.push_str(&format!("

{}

", inline(&line, accent))); + } + } + if list { + content.push_str(""); + } + format!( + "
\ + \ + \ + \ + \ + \ +
{brand}
{content}
{footer}
", + brand = esc(brand), + ) +} + +fn unsub_secret_key() -> &'static str { + "email_unsubscribe_secret" +} + +async fn unsub_secret(state: &AppState) -> AppResult { + let mut s: String = store::kv_get(state, unsub_secret_key()).await?; + if s.is_empty() { + s = format!("{}{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple()); + store::kv_set(state, unsub_secret_key(), &s).await?; + } + Ok(s) +} + +async fn unsub_token(state: &AppState, uuid: &str) -> AppResult { + let secret = unsub_secret(state).await?; + let digest = Sha256::digest(format!("{secret}:{uuid}").as_bytes()); + Ok(digest.iter().take(16).map(|b| format!("{b:02x}")).collect()) +} + +struct Person { + uuid: String, + username: String, + email: String, +} + +async fn vars_for(state: &AppState, p: &Person) -> AppResult { + let brand = store::branding(state).await?.name; + let panel = state.cfg.public_url.clone().unwrap_or_default().trim_end_matches('/').to_string(); + let mut v = Vars::new(); + let level: Option<(i64, Option)> = sqlx::query_as("SELECT global_level, title FROM user_levels WHERE uuid = ?").bind(&p.uuid).fetch_optional(&state.db).await?; + let (lvl, title) = level.unwrap_or((1, None)); + let title = plain(&title.unwrap_or_default()); + let guild: Option<(String, String)> = sqlx::query_as("SELECT g.name, g.tag FROM guild_members m JOIN guilds g ON g.id = m.guild_id WHERE m.uuid = ?").bind(&p.uuid).fetch_optional(&state.db).await?; + let (gn, gt) = guild.unwrap_or_default(); + for (k, val) in [ + ("player", plain(&p.username)), ("uuid", p.uuid.clone()), ("email", p.email.clone()), ("level", lvl.to_string()), + ("rank_title_suffix", if title.is_empty() { String::new() } else { format!(" ({title})") }), ("rank_title", title), + ("guild", plain(&gn)), ("guild_tag", plain(>)), ("brand", brand), ("panel_url", panel.clone()), + ("unsubscribe_url", format!("{panel}/api/v1/email/unsubscribe?u={}&t={}", p.uuid, unsub_token(state, &p.uuid).await?)), + ] { + v.insert(k.into(), val); + } + Ok(v) +} + +struct Rendered { + subject: String, + text: String, + html: String, +} + +async fn build(state: &AppState, subject: &str, body: &str, important: bool, p: &Person) -> AppResult { + let vars = vars_for(state, p).await?; + let subject = fill(subject, &vars).replace(['\r', '\n'], " "); + let text = fill(body, &vars); + let brand = vars["brand"].clone(); + let accent = store::branding(state).await?.colors.accent; + let footer_text = if important { + format!("This message is about your {brand} account.") + } else { + format!("You are receiving this because you have an account on {brand}. Unsubscribe", esc(&vars["unsubscribe_url"])) + }; + let mut plain_text = text.clone(); + if !important { + plain_text.push_str(&format!("\n\n--\nUnsubscribe: {}", vars["unsubscribe_url"])); + } + Ok(Rendered { subject, text: plain_text, html: html(&text, &brand, &accent, &footer_text) }) +} + +// --------------------------------------------------------------------------- +// Audiences +// --------------------------------------------------------------------------- + +#[derive(Deserialize, Clone, Default)] +#[serde(default)] +pub struct Audience { + /// `all`, `group` or `players` + kind: String, + group_id: i64, + /// Player names, for `players` + names: Vec, +} + +async fn recipients(state: &AppState, a: &Audience, important: bool) -> AppResult> { + let optout = if important { "" } else { " AND u.email_optout = 0" }; + let base = format!("SELECT u.uuid, u.username, u.email FROM users u WHERE u.status = 'active' AND u.email IS NOT NULL AND u.email <> ''{optout}"); + let rows: Vec<(String, String, String)> = match a.kind.as_str() { + "all" => sqlx::query_as(&format!("{base} ORDER BY u.id")).fetch_all(&state.db).await?, + "group" => sqlx::query_as(&format!("{base} AND u.id IN (SELECT user_id FROM user_groups WHERE group_id = ?) ORDER BY u.id")).bind(a.group_id).fetch_all(&state.db).await?, + "players" => { + let mut out = Vec::new(); + for n in a.names.iter().take(500) { + let r: Option<(String, String, String)> = sqlx::query_as(&format!("{base} AND u.username = ? COLLATE NOCASE")).bind(n.trim()).fetch_optional(&state.db).await?; + out.extend(r); + } + out + } + _ => return Err(AppError::bad_request("choose who should get the email")), + }; + Ok(rows.into_iter().take(MAX_RECIPIENTS).map(|(uuid, username, email)| Person { uuid, username, email }).collect()) +} + +// --------------------------------------------------------------------------- +// Admin endpoints +// --------------------------------------------------------------------------- + +pub async fn get_all(_: AdminUser, State(state): State) -> AppResult> { + let conn = connections::settings(&state).await?; + let log: Vec<(i64, String, String, i64, i64, i64, Option, String, Option)> = + sqlx::query_as("SELECT id, subject, audience, total, sent, failed, last_error, started_at, finished_at FROM email_log ORDER BY id DESC LIMIT 20").fetch_all(&state.db).await?; + let groups: Vec<(i64, String)> = sqlx::query_as("SELECT id, name FROM groups ORDER BY name").fetch_all(&state.db).await?; + Ok(Json(json!({ + "configured": !conn.resend_api_key.is_empty() && !conn.sender_email.is_empty(), + "templates": templates(&state).await?, + "placeholders": PLACEHOLDERS, + "groups": groups.into_iter().map(|(id, name)| json!({"id": id, "name": name})).collect::>(), + "log": log.into_iter().map(|(id, subject, audience, total, sent, failed, error, started, finished)| json!({ + "id": id, "subject": subject, "audience": audience, "total": total, "sent": sent, "failed": failed, "last_error": error, "started_at": started, "finished_at": finished })).collect::>(), + }))) +} + +#[derive(Deserialize)] +pub struct TemplatesBody { + templates: Vec, +} + +pub async fn put_templates(_: AdminUser, State(state): State, Json(b): Json) -> AppResult> { + if b.templates.len() > 50 { + return Err(AppError::bad_request("at most 50 email templates")); + } + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for mut t in b.templates { + t.id = t.id.trim().to_string(); + if t.id.is_empty() || !t.id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') || !seen.insert(t.id.clone()) { + return Err(AppError::bad_request("each template needs its own short id (letters, numbers, - and _)")); + } + if t.subject.chars().count() > 200 || t.body.chars().count() > 20_000 || t.name.chars().count() > 80 { + return Err(AppError::bad_request("a template is too long")); + } + out.push(t); + } + store::kv_set(&state, "email_templates", &out).await?; + Ok(Json(json!({ "ok": true }))) +} + +#[derive(Deserialize)] +pub struct PreviewBody { + subject: String, + body: String, + #[serde(default)] + important: bool, +} + +async fn sample_person(state: &AppState, admin: &crate::auth::UserRow) -> Person { + let _ = state; + Person { uuid: admin.uuid.clone(), username: admin.username.clone(), email: admin.email.clone().unwrap_or_default() } +} + +pub async fn preview(admin: AdminUser, State(state): State, Json(b): Json) -> AppResult> { + let who = sample_person(&state, &admin.0).await; + let r = build(&state, &b.subject, &b.body, b.important, &who).await?; + Ok(Json(json!({ "subject": r.subject, "text": r.text, "html": r.html }))) +} + +#[derive(Deserialize)] +pub struct AudienceBody { + audience: Audience, + #[serde(default)] + important: bool, +} + +pub async fn audience(_: AdminUser, State(state): State, Json(b): Json) -> AppResult> { + let list = recipients(&state, &b.audience, b.important).await?; + Ok(Json(json!({ "count": list.len(), "sample": list.iter().take(5).map(|p| p.username.clone()).collect::>(), "limit": MAX_RECIPIENTS }))) +} + +#[derive(Deserialize)] +pub struct SendBody { + subject: String, + body: String, + #[serde(default)] + important: bool, + #[serde(default)] + audience: Audience, + /// Send one test copy to this address instead of to players. + #[serde(default)] + test_to: String, +} + +pub async fn send(admin: AdminUser, State(state): State, Json(b): Json) -> AppResult> { + if b.subject.trim().is_empty() || b.body.trim().is_empty() { + return Err(AppError::bad_request("write a subject and a message first")); + } + if !b.test_to.trim().is_empty() { + let me = sample_person(&state, &admin.0).await; + let r = build(&state, &format!("[Test] {}", b.subject), &b.body, b.important, &me).await?; + connections::send_mail(&state, b.test_to.trim(), &r.subject, &r.text, Some(&r.html)).await?; + return Ok(Json(json!({ "ok": true, "test": true }))); + } + let people = recipients(&state, &b.audience, b.important).await?; + if people.is_empty() { + return Err(AppError::bad_request("nobody to send to: no matching players have an email address (or they opted out)")); + } + let label = match b.audience.kind.as_str() { + "all" => "Everyone".to_string(), + "group" => sqlx::query_scalar::<_, String>("SELECT name FROM groups WHERE id = ?").bind(b.audience.group_id).fetch_optional(&state.db).await?.map(|n| format!("Group: {n}")).unwrap_or_else(|| "A group".into()), + _ => format!("{} chosen players", people.len()), + }; + let log_id: i64 = sqlx::query_scalar("INSERT INTO email_log (subject, audience, total, started_at) VALUES (?, ?, ?, ?) RETURNING id") + .bind(b.subject.chars().take(200).collect::()).bind(label).bind(people.len() as i64).bind(crate::db::now()).fetch_one(&state.db).await?; + let total = people.len(); + let bg = state.clone(); + tokio::spawn(async move { + for p in people { + let outcome = match build(&bg, &b.subject, &b.body, b.important, &p).await { + Ok(r) => connections::send_mail(&bg, &p.email, &r.subject, &r.text, Some(&r.html)).await, + Err(e) => Err(e), + }; + match outcome { + Ok(()) => { let _ = sqlx::query("UPDATE email_log SET sent = sent + 1 WHERE id = ?").bind(log_id).execute(&bg.db).await; } + Err(e) => { let _ = sqlx::query("UPDATE email_log SET failed = failed + 1, last_error = ? WHERE id = ?").bind(e.message.chars().take(200).collect::()).bind(log_id).execute(&bg.db).await; } + } + tokio::time::sleep(Duration::from_millis(150)).await; + } + let _ = sqlx::query("UPDATE email_log SET finished_at = ? WHERE id = ?").bind(crate::db::now()).bind(log_id).execute(&bg.db).await; + }); + Ok(Json(json!({ "ok": true, "queued": total, "log_id": log_id }))) +} + +// --------------------------------------------------------------------------- +// Public: unsubscribe +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +pub struct UnsubQuery { + u: String, + t: String, +} + +pub async fn unsubscribe(State(state): State, Query(q): Query) -> Html { + let page = |msg: &str| Html(format!("

{}

", esc(msg))); + let ok = matches!(unsub_token(&state, &q.u).await, Ok(t) if t == q.t); + if !ok { + return page("That link isn't valid."); + } + let _ = sqlx::query("UPDATE users SET email_optout = 1 WHERE uuid = ?").bind(&q.u).execute(&state.db).await; + page("You are unsubscribed. You will still get emails about your account.") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_becomes_safe_branded_html() { + let h = html("# Hi \n\nHello **world** [site](https://x.example/a?b=1&c=2) [button: Go](https://x.example)\n\n- one\n- two\n\n[bad](javascript:alert(1))", "My ", "#112233", "foot"); + assert!(h.contains("world")); + assert!(h.contains("href=\"https://x.example/a?b=1&c=2\"")); + assert!(h.contains("background:#112233;color:#ffffff"), "buttons use the brand colour"); + assert!(h.contains("
  • one
  • ")); + assert!(!h.contains("javascript:"), "only web links survive"); + assert!(h.contains("My <Server>")); + } +} diff --git a/panel/server/src/routes/mod.rs b/panel/server/src/routes/mod.rs index 4390c6c..5d2eadf 100644 --- a/panel/server/src/routes/mod.rs +++ b/panel/server/src/routes/mod.rs @@ -9,6 +9,7 @@ pub mod guild_bank; pub mod guilds; pub mod discord; pub mod discord_studio; +pub mod emails; pub mod integrations; pub mod landing; pub mod worldmap; @@ -64,6 +65,7 @@ pub fn api(state: &AppState) -> Router { .route("/guilds/{id}/invites", post(map::send_invite)) .route("/leaderboard", get(servers::global_leaderboard)) .route("/landing", get(landing::public_landing)) + .route("/email/unsubscribe", get(emails::unsubscribe)) .route("/launcher/authlib-injector.json", get(account::authlib_index)) .route("/launcher/authlib-injector.jar", get(account::authlib_jar)) // Leveling & Rewards @@ -176,6 +178,11 @@ pub fn api(state: &AppState) -> Router { .route("/rewards/{id}", put(leveling::admin_update_reward).delete(leveling::admin_delete_reward)) .route("/levels/rewards", get(leveling::list_rewards).post(leveling::admin_create_reward)) .route("/levels/rewards/{id}", put(leveling::admin_update_reward).delete(leveling::admin_delete_reward)) + .route("/email", get(emails::get_all)) + .route("/email/templates", put(emails::put_templates)) + .route("/email/preview", post(emails::preview)) + .route("/email/audience", post(emails::audience)) + .route("/email/send", post(emails::send)) .route("/discord/studio", get(discord_studio::get_all)) .route("/discord/studio/templates", put(discord_studio::put_templates)) .route("/discord/studio/live", put(discord_studio::put_live)) diff --git a/panel/server/tests/emails.rs b/panel/server/tests/emails.rs new file mode 100644 index 0000000..622572c --- /dev/null +++ b/panel/server/tests/emails.rs @@ -0,0 +1,59 @@ +mod common; +use common::*; + +#[tokio::test] +async fn emails_use_templates_audiences_and_opt_outs() { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + for (n, e) in [("Steve", "steve@example.com"), ("Alex", "alex@example.com"), ("Notch", "")] { + t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": n, "password": "password123", "email": e}))).await; + } + let (s, all) = t.call("GET", "/api/admin/email", Some(&admin), None).await; + assert_eq!(s, StatusCode::OK, "{all}"); + assert_eq!(all["configured"], false); + assert!(all["templates"].as_array().unwrap().len() >= 3, "starter templates are offered"); + + // Templates save, with sensible limits. + let bad = json!({"templates": [{"id": "bad id!", "name": "x", "subject": "s", "body": "b"}]}); + assert_eq!(t.call("PUT", "/api/admin/email/templates", Some(&admin), Some(bad)).await.0, StatusCode::BAD_REQUEST); + let ok = json!({"templates": [{"id": "news", "name": "News", "subject": "Hi {player}", "body": "Hello **{player}**, level {level}\n\n[button: Go]({panel_url})"}]}); + assert_eq!(t.call("PUT", "/api/admin/email/templates", Some(&admin), Some(ok)).await.0, StatusCode::OK); + + // Placeholders fill per player, and the HTML is branded and has an unsubscribe link. + let (s, p) = t.call("POST", "/api/admin/email/preview", Some(&admin), Some(json!({"subject": "Hi {player}", "body": "Hello **{player}**\n\n[button: Go]({panel_url})"}))).await; + assert_eq!(s, StatusCode::OK, "{p}"); + assert_eq!(p["subject"], "Hi admin"); + assert!(p["html"].as_str().unwrap().contains("admin") && p["html"].as_str().unwrap().contains("Unsubscribe")); + assert!(p["text"].as_str().unwrap().contains("/api/v1/email/unsubscribe?u=")); + + // Only active players with an email count. + let count = |a: Value| { let (t, admin) = (&t, admin.clone()); async move { t.call("POST", "/api/admin/email/audience", Some(&admin), Some(json!({"audience": a}))).await.1 } }; + assert_eq!(count(json!({"kind": "all"})).await["count"], 2, "Steve and Alex have an address; Notch and admin don't"); + assert_eq!(count(json!({"kind": "players", "names": ["steve", "nobody"]})).await["count"], 1); + + // Opting out through the link removes a player from marketing mail, but not from account notices. + let steve = t.uuid("Steve").await; + let token = { + let secret: String = sqlx::query_scalar("SELECT value FROM kv WHERE key = 'email_unsubscribe_secret'").fetch_one(&t.db).await.unwrap(); + let secret = secret.trim_matches('"').to_string(); + use sha2::{Digest, Sha256}; + Sha256::digest(format!("{secret}:{steve}").as_bytes()).iter().take(16).map(|b| format!("{b:02x}")).collect::() + }; + let (s, _) = t.fetch(&format!("/api/v1/email/unsubscribe?u={steve}&t=wrong")).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(count(json!({"kind": "all"})).await["count"], 2, "a wrong token changes nothing"); + t.fetch(&format!("/api/v1/email/unsubscribe?u={steve}&t={token}")).await; + assert_eq!(count(json!({"kind": "all"})).await["count"], 1); + let (_, important) = t.call("POST", "/api/admin/email/audience", Some(&admin), Some(json!({"audience": {"kind": "all"}, "important": true}))).await; + assert_eq!(important["count"], 2); + + // Without SMTP configured, a send is queued but each message fails with a clear reason in the log. + let (s, sent) = t.call("POST", "/api/admin/email/send", Some(&admin), Some(json!({"subject": "Hi", "body": "Hello", "audience": {"kind": "all"}}))).await; + assert_eq!(s, StatusCode::OK, "{sent}"); + for _ in 0..40 { + let (_, all) = t.call("GET", "/api/admin/email", Some(&admin), None).await; + if all["log"][0]["failed"] == 1 { assert!(all["log"][0]["last_error"].as_str().unwrap().contains("Resend")); return; } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + panic!("the send was never logged"); +} diff --git a/panel/web/src/App.svelte b/panel/web/src/App.svelte index 207b7b8..ad69499 100644 --- a/panel/web/src/App.svelte +++ b/panel/web/src/App.svelte @@ -19,6 +19,7 @@ import LevelingAdmin from './pages/LevelingAdmin.svelte'; import Progression from './pages/Progression.svelte'; import DiscordStudio from './pages/DiscordStudio.svelte'; + import Emails from './pages/Emails.svelte'; import GuildsAdmin from './pages/GuildsAdmin.svelte'; import { get } from './lib/api'; import { route } from './lib/router.svelte'; @@ -100,6 +101,8 @@ {:else if route.name === 'branding'} (brand = b)} /> + {:else if route.name === 'emails'} + {:else if route.name === 'discord'} {:else if route.name === 'landing-builder'} diff --git a/panel/web/src/components/Sidebar.svelte b/panel/web/src/components/Sidebar.svelte index ca39ddd..d441314 100644 --- a/panel/web/src/components/Sidebar.svelte +++ b/panel/web/src/components/Sidebar.svelte @@ -1,5 +1,5 @@ + +
    +
    +
    +

    Emails

    +

    Write reusable emails and send them to everyone, a group, or chosen players. Messages use your launcher colours.{#if data && !data.configured} Set up email under Settings first (sender and Resend key).{/if}

    +
    +
    + + {#if data && t} +
    + + +
    +
    + + +
    +
    Placeholders click to insert +
    {#each data.placeholders as p}{/each}
    + + +
    + + + +
    + +
    +

    Send

    +
    + + +
    +
    + + {#if kind === 'group'}{/if} + {#if kind === 'players'}{/if} +
    +

    {count ? `${count.count} recipient${count.count === 1 ? '' : 's'}${count.sample.length ? ` (${count.sample.join(', ')}${count.count > 5 ? '…' : ''})` : ''}` : '…'}

    + +
    +
    + +
    +

    Preview

    + {#if preview} +
    Subject{preview.subject}
    + + {:else}

    Loading…

    {/if} +
    +
    + +
    +

    Sent emails

    + {#if data.log.length} + + + + {#each data.log as l} + + {/each} + +
    WhenSubjectToSentFailed
    {timeAgo(l.started_at)}{l.subject}{l.audience}{l.sent}/{l.total}{#if !l.finished_at} …{/if}{l.failed}{#if l.last_error} {l.last_error}{/if}
    + {:else}

    Nothing sent yet.

    {/if} +
    + {:else}

    Loading…

    {/if} +
    + + +

    This sends {t?.subject} to {count?.count ?? 0} players. It can't be undone.

    +
    +
    + + From aae0a5a5ff9fb66ae2c78df324eb6d69408555ff Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 01:52:54 +0000 Subject: [PATCH 20/21] Public landing page live map; docs for Discord, live embeds, email --- README.md | 1 + docs/discord-and-email.md | 39 ++++++++++++++++ docs/map.md | 4 ++ panel/server/src/routes/mod.rs | 2 + panel/server/src/routes/worldmap.rs | 46 +++++++++++++++++++ panel/server/tests/public_map.rs | 52 ++++++++++++++++++++++ panel/web/src/components/LandingMap.svelte | 32 +++++++++++++ panel/web/src/lib/landingSchema.ts | 1 + panel/web/src/lib/types.ts | 2 +- panel/web/src/pages/PublicLanding.svelte | 6 +++ shared/map/MapViewer.svelte | 20 +++++---- 11 files changed, 195 insertions(+), 10 deletions(-) create mode 100644 docs/discord-and-email.md create mode 100644 panel/server/tests/public_map.rs create mode 100644 panel/web/src/components/LandingMap.svelte diff --git a/README.md b/README.md index c9193f9..8db3306 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ docker-compose.yml Panel deployment - [Admin guide](docs/admin-guide.md) — deploying, HTTPS, modpacks, players, backups - [Official server setup](docs/server-integration.md) — private authentication, supported versions, activity and UUID protection - [The SCOPENET Map](docs/map.md) — the built-in world map: terrain, guild land, shops and players +- [Discord, live embeds and email](docs/discord-and-email.md) — customisable announcements, self-updating status boards and emails to players - [Rewards, limits and permission nodes](docs/rewards-and-limits.md) — what quests can grant, and every setting you can change - [Architecture](docs/architecture.md) — how the pieces fit, API reference - [Development](docs/development.md) — running everything locally, tests diff --git a/docs/discord-and-email.md b/docs/discord-and-email.md new file mode 100644 index 0000000..847a6ed --- /dev/null +++ b/docs/discord-and-email.md @@ -0,0 +1,39 @@ +# Discord messages, live embeds and emails + +## Announcements (Admin panel → Discord → Announcements) + +Three messages are posted by the panel's webhook (Settings → Discord → webhook): **Achievement unlocked**, **New player** and **New guild**. Each one is fully editable, with a live preview built the way Discord will show it: + +- Message text above the box (mentions such as `<@&roleid>` work there), title and link, description, colour, a small picture on the right, a big picture at the bottom, author and footer (with icons), the time, and any number of side-by-side or full-width fields. +- The sender's name and picture. +- **Placeholders** such as `{player}`, `{avatar}`, `{achievement}`, `{achievement_description}`, `{xp}`, `{level}`, `{rank_title}`, `{guild_line}` (achievements), `{members_total}` (new players), `{guild}`, `{leader}`, `{members}`, `{claims}` (guilds) and `{server_name}`, `{time}` everywhere. Click a placeholder to insert it where you are typing. Unknown ones stay as typed so mistakes are easy to spot. +- Player names are cleaned so they can't ping anyone or break the formatting. + +Switch each message on or off, and press **Save & send a sample** to see it in your channel. + +## Live embeds (Discord → Live embeds) + +Four boards that the panel posts once and then **edits in place**, so the channel never fills up: + +| Embed | Shows | Rows are set by | +|---|---|---| +| **Server status** | Every server: online, players, TPS, version | the line template | +| **Active guilds** | Top guilds with level, members, land and leader | rows shown | +| **Player leaderboard** | Top players by playtime, level, kills or blocks | ranked by, rows shown | +| **Richest players** | Top balances of one server economy | economy of, rows shown | + +Every embed has the same editor as the announcements, plus a **line for each row** (for example `{rank}. **{player}** — {value}`) that fills `{rows}` in the description, an update interval (1 minute to 1 hour), and an optional **own webhook** so each board can live in its own channel. If someone deletes the message, the next update posts a fresh one; **Post as new message** forces it. + +## Emails (Admin panel → Emails) + +Set up the sender and Resend key under **Settings → Connections** first. Then: + +1. Write a **template**: subject and message with placeholders (`{player}`, `{level}`, `{rank_title}`, `{guild}`, `{brand}`, `{panel_url}`…). Simple formatting turns into a branded HTML email in your launcher colours: `# Heading`, `**bold**`, `- list`, `[link](https://…)`, `[button: Label](https://…)`. +2. Check the live **preview**, and **send a test** to yourself. +3. **Send** to everyone with an email, a group, or chosen players. The panel shows how many will receive it first, sends them one by one in the background, and keeps a log with failures and their reasons. + +Every email has an unsubscribe link; players who use it are skipped by later emails. Mark a message as an **account notice** to reach everyone regardless, without the unsubscribe link: use that only for things about their account. + +## Live map on the landing page + +In **Landing page**, add a **Live map** section. Choose the server (or leave it at the first server with a map), the height, and what visitors may see: guild land and places are on by default, **live player positions are off** (they let strangers find players). Visitors can pan and zoom the same map your players see in the launcher; removing the section closes the public map again. diff --git a/docs/map.md b/docs/map.md index 27bc0c4..224d446 100644 --- a/docs/map.md +++ b/docs/map.md @@ -27,6 +27,10 @@ Players with a linked server get a **Live Map** button on the Home screen. Pan b **Servers → your server** shows a map card with the number of tiles, how much space they use, when the server last sent something and the live map itself. **Redraw world** deletes the stored tiles; the server notices and draws everything again (useful after a world reset or a renderer update). +## On your public landing page + +Add a **Live map** section in the landing page builder to show the map to visitors, with only the layers you allow. See [discord-and-email.md](discord-and-email.md#live-map-on-the-landing-page). + ## How it works - The game server reads its region files (`region/*.mca`), works out the top block of every column, and writes 256 × 256 pixel tiles at one pixel per block. Zoomed-out levels are built from them, keeping real map colours so coastlines and roads stay sharp. diff --git a/panel/server/src/routes/mod.rs b/panel/server/src/routes/mod.rs index 5d2eadf..7972340 100644 --- a/panel/server/src/routes/mod.rs +++ b/panel/server/src/routes/mod.rs @@ -65,6 +65,8 @@ pub fn api(state: &AppState) -> Router { .route("/guilds/{id}/invites", post(map::send_invite)) .route("/leaderboard", get(servers::global_leaderboard)) .route("/landing", get(landing::public_landing)) + .route("/public/map/{id}", get(worldmap::public_info)) + .route("/public/map/{id}/overlay", get(worldmap::public_overlay)) .route("/email/unsubscribe", get(emails::unsubscribe)) .route("/launcher/authlib-injector.json", get(account::authlib_index)) .route("/launcher/authlib-injector.jar", get(account::authlib_jar)) diff --git a/panel/server/src/routes/worldmap.rs b/panel/server/src/routes/worldmap.rs index 99d064c..f4b2a23 100644 --- a/panel/server/src/routes/worldmap.rs +++ b/panel/server/src/routes/worldmap.rs @@ -171,3 +171,49 @@ pub async fn tile(State(state): State, Path((id, dim, z, x, file)): Pa } resp } + +// --------------------------------------------------------------------------- +// Public map (landing page) +// --------------------------------------------------------------------------- + +/// The landing page's map section decides what the public may see. Without an enabled map section, nothing is public. +async fn landing_map(state: &AppState, requested: i64) -> AppResult<(i64, Value)> { + let cfg = crate::routes::landing::get_config(state).await?; + let block = cfg.blocks.iter().find(|b| b.block_type == "map" && b.enabled).ok_or_else(|| AppError::not_found("no public map"))?; + let wanted = block.options.get("server_id").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))).unwrap_or(0); + let id: i64 = if wanted > 0 { + wanted + } else { + sqlx::query_scalar("SELECT COALESCE(MIN(id), 0) FROM game_servers WHERE live_map_enabled = 1").fetch_one(&state.db).await? + }; + if id == 0 || (requested != 0 && requested != id) { + return Err(AppError::not_found("no public map")); + } + let server = get_server(state, id).await?; + if !server.map_enabled { + return Err(AppError::not_found("no public map")); + } + let flag = |key: &str, default: bool| block.options.get(key).and_then(|v| v.as_bool().or_else(|| v.as_str().map(|s| s == "true"))).unwrap_or(default); + Ok((id, json!({ "claims": flag("show_claims", true), "pins": flag("show_pins", true), "players": flag("show_players", false) }))) +} + +/// `GET /api/v1/public/map/{server}`: tile key and dimensions for the landing page map (0 = the server the page chose). +pub async fn public_info(State(state): State, Path(requested): Path) -> AppResult> { + let (id, layers) = landing_map(&state, requested).await?; + let mut v = info(&state, id, true).await; + v["server_id"] = json!(id); + v["layers"] = layers; + Ok(Json(v)) +} + +pub async fn public_overlay(State(state): State, Path(requested): Path) -> AppResult> { + let (id, layers) = landing_map(&state, requested).await?; + let overlay = state.worldmap.overlay(id); + let on = |k: &str| layers[k].as_bool().unwrap_or(false); + Ok(Json(json!({ + "players": if on("players") { json!(state.worldmap.live_players(id)) } else { json!([]) }, + "claims": if on("claims") { overlay.get("claims").cloned().unwrap_or(json!([])) } else { json!([]) }, + "pins": if on("pins") { overlay.get("pins").cloned().unwrap_or(json!([])) } else { json!([]) }, + "updated": state.worldmap.stats(id).last_overlay_at, + }))) +} diff --git a/panel/server/tests/public_map.rs b/panel/server/tests/public_map.rs new file mode 100644 index 0000000..4fdd9f8 --- /dev/null +++ b/panel/server/tests/public_map.rs @@ -0,0 +1,52 @@ +mod common; +use common::*; + +fn tile(zoom: u8, x: i32, y: i32) -> Vec { + let png = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]; + let mut v = vec![zoom]; + v.extend(x.to_be_bytes()); + v.extend(y.to_be_bytes()); + v.extend((png.len() as u32).to_be_bytes()); + v.extend(png); + v +} + +#[tokio::test] +async fn the_landing_page_decides_what_the_public_sees_of_the_map() { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + let (_, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "SMP", "instance_id": "smp"}))).await; + let key = srv["token"].as_str().unwrap().to_string(); + assert_eq!(t.call_raw("POST", "/api/server/v1/map/tiles?dim=minecraft:overworld", Some(&key), tile(0, 0, 0)).await.0, StatusCode::OK); + t.call("POST", "/api/server/v1/map/players", Some(&key), Some(json!({"players": [{"uuid": "11111111-1111-1111-1111-111111111111", "name": "Steve", "dimension": "minecraft:overworld", "x": 1.0, "y": 64.0, "z": 2.0, "yaw": 0.0}]}))).await; + t.call("POST", "/api/server/v1/map/overlay", Some(&key), Some(json!({"claims": [{"id": "c"}], "pins": [{"id": "p"}]}))).await; + + // No map section on the landing page: nothing is public. + assert_eq!(t.call("GET", "/api/v1/public/map/0", None, None).await.0, StatusCode::NOT_FOUND); + + let landing = |opts: Value| json!({"blocks": [{"id": "m1", "type": "map", "enabled": true, "options": opts}]}); + assert_eq!(t.call("PUT", "/api/admin/landing", Some(&admin), Some(landing(json!({"show_players": false})))).await.0, StatusCode::OK); + let (s, info) = t.call("GET", "/api/v1/public/map/0", None, None).await; + assert_eq!(s, StatusCode::OK, "{info}"); + assert_eq!(info["ready"], true); + assert_eq!(info["layers"]["players"], false); + assert_eq!(info["layers"]["claims"], true); + // The tile key from the public info loads tiles without signing in. + let token = info["token"].as_str().unwrap(); + let (s, png) = t.fetch(&format!("/api/map/{}/overworld/0/0/0.png?t={token}", info["server_id"])).await; + assert_eq!((s, png.len()), (StatusCode::OK, 12)); + + // Players stay hidden unless the admin turns them on. + let (_, o) = t.call("GET", "/api/v1/public/map/0/overlay", None, None).await; + assert_eq!(o["players"].as_array().unwrap().len(), 0); + assert_eq!(o["claims"].as_array().unwrap().len(), 1); + t.call("PUT", "/api/admin/landing", Some(&admin), Some(landing(json!({"show_players": true, "show_pins": false})))).await; + let (_, o) = t.call("GET", "/api/v1/public/map/0/overlay", None, None).await; + assert_eq!(o["players"][0]["name"], "Steve"); + assert_eq!(o["pins"].as_array().unwrap().len(), 0); + + // Another server's map isn't exposed, and turning the section off closes it. + assert_eq!(t.call("GET", "/api/v1/public/map/999", None, None).await.0, StatusCode::NOT_FOUND); + t.call("PUT", "/api/admin/landing", Some(&admin), Some(json!({"blocks": [{"id": "m1", "type": "map", "enabled": false, "options": {}}]}))).await; + assert_eq!(t.call("GET", "/api/v1/public/map/0", None, None).await.0, StatusCode::NOT_FOUND); +} diff --git a/panel/web/src/components/LandingMap.svelte b/panel/web/src/components/LandingMap.svelte new file mode 100644 index 0000000..7534f1a --- /dev/null +++ b/panel/web/src/components/LandingMap.svelte @@ -0,0 +1,32 @@ + + +
    + +
    + + diff --git a/panel/web/src/lib/landingSchema.ts b/panel/web/src/lib/landingSchema.ts index 26dd9f1..b691ad5 100644 --- a/panel/web/src/lib/landingSchema.ts +++ b/panel/web/src/lib/landingSchema.ts @@ -44,6 +44,7 @@ export const BLOCK_SCHEMAS: Record = { divider: { type: 'divider', label: 'Divider', description: 'Space or rule between sections', group: 'Layout', defaults: { style: 'line', height: 48 }, fields: [choice('style', 'Style', ['line', 'space', 'glow']), field('height', 'Height', 'number')] }, testimonials: { type: 'testimonials', label: 'Testimonials', description: 'Player quotes and reviews', group: 'Content', defaults: { columns: '3', items: [] }, fields: [choice('columns', 'Columns', ['2', '3']), list('items', 'Quotes', [field('quote', 'Quote', 'textarea'), field('name', 'Player name'), field('role', 'Role')], { quote: '', name: '', role: '' })] }, video: { type: 'video', label: 'Video', description: 'Hosted video or YouTube embed', group: 'Media', defaults: { url: '', caption: '', poster: '' }, fields: [field('url', 'Video or YouTube URL', 'media'), field('poster', 'Poster image', 'media'), field('caption', 'Caption')] }, + map: { type: 'map', label: 'Live map', description: 'The SCOPENET Map of a server: terrain, guild land and shops', group: 'Network', defaults: { server_id: 0, height: 560, show_claims: true, show_pins: true, show_players: false }, fields: [field('server_id', 'Server id (0 = the first server with a map)', 'number'), field('height', 'Height (px)', 'number'), field('show_claims', 'Show guild land', 'toggle'), field('show_pins', 'Show spawn, warps, markets and shops', 'toggle'), field('show_players', 'Show players online (live positions!)', 'toggle')] }, buttons: { type: 'buttons', label: 'Button group', description: 'Custom links and action buttons', group: 'Conversion', defaults: { align: 'center', items: [] }, fields: [choice('align', 'Alignment', ['left', 'center']), list('items', 'Buttons', [field('label', 'Label'), field('url', 'URL', 'url'), choice('style', 'Style', ['primary', 'secondary'])], { label: 'Visit', url: '', style: 'primary' })] }, }; diff --git a/panel/web/src/lib/types.ts b/panel/web/src/lib/types.ts index 5a4541a..2dfe7c3 100644 --- a/panel/web/src/lib/types.ts +++ b/panel/web/src/lib/types.ts @@ -172,7 +172,7 @@ export interface FaqItem { answer: string; } -export type BlockType = 'hero' | 'download' | 'servers' | 'leaderboard' | 'stats' | 'instances' | 'news' | 'faq' | 'socials' | 'text' | 'image' | 'split' | 'features' | 'gallery' | 'cta' | 'divider' | 'testimonials' | 'video' | 'buttons'; +export type BlockType = 'hero' | 'download' | 'servers' | 'leaderboard' | 'stats' | 'instances' | 'news' | 'faq' | 'socials' | 'text' | 'image' | 'split' | 'features' | 'gallery' | 'cta' | 'divider' | 'testimonials' | 'video' | 'buttons' | 'map'; export interface LandingBlock { id: string; diff --git a/panel/web/src/pages/PublicLanding.svelte b/panel/web/src/pages/PublicLanding.svelte index 540a37d..5236309 100644 --- a/panel/web/src/pages/PublicLanding.svelte +++ b/panel/web/src/pages/PublicLanding.svelte @@ -6,6 +6,7 @@ } from '@lucide/svelte'; import { get, formatBytes } from '../lib/api'; import { session } from '../lib/session.svelte'; + import LandingMap from '../components/LandingMap.svelte'; import type { LandingConfig, HostedDownload, BlockType, FaqItem } from '../lib/types'; let config = $state(null); @@ -724,6 +725,11 @@ {#if block.options?.caption}

    {block.options.caption}

    {/if}
    + {:else if block.type === 'map'} +

    {block.title}

    {block.subtitle}

    + +
    + {:else if block.type === 'buttons'}

    {block.title}

    {block.subtitle}

    {#each block.options?.items ?? [] as item}{#if item.label && item.url}{item.label}{/if}{/each} diff --git a/shared/map/MapViewer.svelte b/shared/map/MapViewer.svelte index 90d3e29..0a41bd3 100644 --- a/shared/map/MapViewer.svelte +++ b/shared/map/MapViewer.svelte @@ -9,7 +9,7 @@ // click on a player does through the `player` snippet. let { info = null, overlay = null, tileUrl, avatarUrl, selfUuid = null, error = '', initialDimension = '', - player, toolbar, onplayer + player, toolbar, onplayer, available = { claims: true, pins: true, players: true } }: { info: MapInfo | null; overlay: MapOverlay | null; @@ -23,6 +23,8 @@ /** Extra buttons for the top bar. */ toolbar?: Snippet; onplayer?: (p: LivePlayer | null) => void; + /** Which layers exist for this viewer (the public map may hide some). */ + available?: MapLayers; } = $props(); let canvas: HTMLCanvasElement; @@ -97,23 +99,23 @@ {dims[0].label} {/if}
    - - {/if} + {#if available.pins} - {/if} + {#if available.players} + {/if}
    {#if toolbar}{@render toolbar()}{/if}
    - + {/if} {#if listOpen && everyone.length}
      {#each everyone as p (p.uuid)} From 7eea617225b9313d7b73235ba0c78d33340313a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 01:56:54 +0000 Subject: [PATCH 21/21] Panel: mobile overflow fixes --- panel/web/src/app.css | 2 ++ panel/web/src/pages/LevelingAdmin.svelte | 1 + panel/web/src/pages/QuestEditor.svelte | 1 + 3 files changed, 4 insertions(+) diff --git a/panel/web/src/app.css b/panel/web/src/app.css index 29e5059..20d60c4 100644 --- a/panel/web/src/app.css +++ b/panel/web/src/app.css @@ -315,4 +315,6 @@ pre { white-space: pre-wrap; } button, .btn, button.primary, button.ghost, button.danger { white-space: normal; } .page > header, .page header { flex-wrap: wrap; gap: 10px; } .table-wrap { overflow-x: auto; } + main { overflow-x: clip; } + .page { max-width: 100%; } } diff --git a/panel/web/src/pages/LevelingAdmin.svelte b/panel/web/src/pages/LevelingAdmin.svelte index 6d999c8..b2a660f 100644 --- a/panel/web/src/pages/LevelingAdmin.svelte +++ b/panel/web/src/pages/LevelingAdmin.svelte @@ -334,6 +334,7 @@ .reward-xp-needed { font-size: 0.75rem; color: #94a3b8; font-family: monospace; } .reward-actions { display: flex; align-items: center; gap: 6px; } + @media (max-width: 700px) { .reward-row { flex-wrap: wrap; } .reward-content { flex-basis: 60%; } } .side-col { display: flex; flex-direction: column; gap: 20px; } .side-card { background: var(--bg-2); border: 1px solid var(--line); border-radius: 14px; padding: 20px; display: flex; flex-direction: column; gap: 12px; } diff --git a/panel/web/src/pages/QuestEditor.svelte b/panel/web/src/pages/QuestEditor.svelte index 79faa2d..3d89bf9 100644 --- a/panel/web/src/pages/QuestEditor.svelte +++ b/panel/web/src/pages/QuestEditor.svelte @@ -399,6 +399,7 @@ header p { color: var(--muted); margin: 0; font-size: 0.95rem; } .limit-banner { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-radius: 12px; background: color-mix(in srgb, var(--accent) 9%, var(--bg-2)); border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent); color: var(--text); font-size: 0.9rem; } + @media (max-width: 700px) { .limit-banner { flex-wrap: wrap; } } .limit-banner:hover { border-color: color-mix(in srgb, var(--accent) 60%, transparent); } .limit-banner .edit { margin-left: auto; color: var(--accent-2); white-space: nowrap; } .pin-tag { display: inline-flex; align-items: center; gap: 3px; margin-left: 8px; padding: 1px 7px; border-radius: 99px; font-size: 0.68rem; font-weight: 600; background: color-mix(in srgb, #fcd34d 16%, transparent); color: #fcd34d; vertical-align: middle; }