Rework BlueMap claiming and expand ranks guilds chat and warps
This commit is contained in:
1 parent
434ed5338a
commit
6a2944a88e
68 files changed
+1453
-2500
No files matched your search
@@ -14,12 +14,6 @@ pub struct Config {
|
||||
pub max_upload_mb: usize,
|
||||
pub public_url: Option<String>,
|
||||
pub trusted_proxies: Vec<std::net::IpAddr>,
|
||||
/// Vantage generator binary (defaults to `vantage` on PATH or /opt/vantage).
|
||||
pub vantage_bin: Option<PathBuf>,
|
||||
/// Minecraft client assets directory handed to Vantage.
|
||||
pub vantage_assets: Option<PathBuf>,
|
||||
/// Extra arguments appended to `vantage server`.
|
||||
pub vantage_args: Vec<String>,
|
||||
}
|
||||
|
||||
fn var(name: &str) -> Option<String> {
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -707,6 +707,18 @@ const MIGRATIONS: &[&str] = &[
|
||||
);
|
||||
CREATE INDEX guild_invites_target ON guild_invites(target_uuid, status);
|
||||
"#,
|
||||
// Player initiated guild applications.
|
||||
r#"
|
||||
CREATE TABLE guild_join_requests (
|
||||
guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
|
||||
uuid TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (guild_id, uuid)
|
||||
);
|
||||
CREATE INDEX guild_join_requests_player ON guild_join_requests(uuid);
|
||||
"#,
|
||||
];
|
||||
|
||||
pub async fn connect(data_dir: &Path) -> Result<SqlitePool> {
|
||||
|
||||
@@ -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())),
|
||||
livemap: Arc::new(livemap::LiveMap::new(&cfg.data_dir)),
|
||||
cfg: Arc::new(cfg),
|
||||
})
|
||||
}
|
||||
|
||||
+5
-277
@@ -1,20 +1,10 @@
|
||||
//! 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 `<data>/livemap/<server>/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.
|
||||
|
||||
//! Live player positions and the legacy world mirror received from game servers.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::collections::HashMap;
|
||||
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;
|
||||
@@ -244,17 +234,6 @@ struct Snapshot {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidecar supervisor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Sidecar {
|
||||
child: tokio::process::Child,
|
||||
port: u16,
|
||||
token: String,
|
||||
last_used: Instant,
|
||||
log: std::sync::Arc<Mutex<VecDeque<String>>>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Serialize)]
|
||||
pub struct ServerStats {
|
||||
pub chunks_received: u64,
|
||||
@@ -264,50 +243,19 @@ pub struct ServerStats {
|
||||
|
||||
pub struct LiveMap {
|
||||
root: PathBuf,
|
||||
bin: Option<PathBuf>,
|
||||
assets: Option<PathBuf>,
|
||||
extra_args: Vec<String>,
|
||||
players: Mutex<HashMap<i64, Snapshot>>,
|
||||
stats: Mutex<HashMap<i64, ServerStats>>,
|
||||
sidecars: tokio::sync::Mutex<HashMap<(i64, Dim), Sidecar>>,
|
||||
last_error: Mutex<HashMap<(i64, Dim), String>>,
|
||||
write_lock: tokio::sync::Mutex<()>,
|
||||
players_written: Mutex<HashMap<i64, Instant>>,
|
||||
}
|
||||
|
||||
fn find_bin(configured: Option<&Path>) -> Option<PathBuf> {
|
||||
let candidates: Vec<PathBuf> = match configured {
|
||||
Some(p) => vec![p.to_path_buf()],
|
||||
None => {
|
||||
let mut v: Vec<PathBuf> =
|
||||
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<PathBuf>, assets: Option<PathBuf>, extra_args: Vec<String>) -> Self {
|
||||
let assets = assets.or_else(|| {
|
||||
let p = data_dir.join("livemap").join("assets");
|
||||
p.is_dir().then_some(p)
|
||||
});
|
||||
pub fn new(data_dir: &Path) -> Self {
|
||||
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())
|
||||
}
|
||||
@@ -322,23 +270,6 @@ impl LiveMap {
|
||||
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<String> {
|
||||
self.bin.as_ref().map(|p| p.display().to_string())
|
||||
}
|
||||
pub fn assets_path(&self) -> Option<String> {
|
||||
self.assets.as_ref().map(|p| p.display().to_string())
|
||||
}
|
||||
|
||||
// ---- ingest ----
|
||||
|
||||
/// Write a batch of chunks into the server's mirror. Returns how many
|
||||
@@ -394,20 +325,9 @@ impl LiveMap {
|
||||
}
|
||||
|
||||
pub fn set_players(&self, id: i64, players: Vec<LivePlayer>) {
|
||||
self.players.lock().unwrap().insert(id, Snapshot { at: Instant::now(), players: players.clone() });
|
||||
self.players.lock().unwrap().insert(id, Snapshot { at: Instant::now(), players });
|
||||
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() {
|
||||
@@ -419,11 +339,6 @@ impl LiveMap {
|
||||
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()
|
||||
}
|
||||
@@ -446,157 +361,8 @@ impl LiveMap {
|
||||
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<String> {
|
||||
self.sidecars.lock().await.keys().filter(|(s, _)| *s == id).map(|(_, d)| d.slug().to_string()).collect()
|
||||
}
|
||||
|
||||
pub fn last_error(&self, id: i64) -> Option<String> {
|
||||
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<Mutex<VecDeque<String>>> = 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::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<Vec<_>>()
|
||||
.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<Self>) {
|
||||
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.
|
||||
/// Clear the legacy world mirror and current player positions.
|
||||
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);
|
||||
@@ -606,44 +372,6 @@ impl LiveMap {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tail<R: tokio::io::AsyncRead + Unpin + Send + 'static>(r: R, log: std::sync::Arc<Mutex<VecDeque<String>>>) {
|
||||
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<Dim>) -> 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<String> {
|
||||
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::*;
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -65,7 +65,7 @@ pub async fn player_info(GameServer(server): GameServer, State(state): State<App
|
||||
quests.insert(period.into(), json!({ "total": ids.len(), "completed": done, "claimed": claimed }));
|
||||
}
|
||||
drop(conn); // give the connection back before the pool is used again
|
||||
let rank: Option<(String, String, String)> = sqlx::query_as("SELECT primary_group, display, prefix FROM player_ranks WHERE server_id = ? AND uuid = ?").bind(server.id).bind(&uuid).fetch_optional(&state.db).await?;
|
||||
let rank: Option<(String, String, String, String)> = sqlx::query_as("SELECT primary_group, display, prefix, suffix FROM player_ranks WHERE server_id = ? AND uuid = ?").bind(server.id).bind(&uuid).fetch_optional(&state.db).await?;
|
||||
let online: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM server_online WHERE uuid = ?)").bind(&uuid).fetch_one(&state.db).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
@@ -87,7 +87,7 @@ pub async fn player_info(GameServer(server): GameServer, State(state): State<App
|
||||
"friends": friends,
|
||||
"achievements": achievements,
|
||||
"quests": quests,
|
||||
"rank": rank.map(|(primary, display, prefix)| json!({ "group": primary, "display": display, "prefix": prefix })),
|
||||
"rank": rank.map(|(primary, display, prefix, suffix)| json!({ "group": primary, "display": display, "prefix": prefix, "suffix": suffix })),
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! sync, an invite link for launchers, and optional webhook announcements.
|
||||
//!
|
||||
//! Everything here is opt-in and needs the bot token and server ID saved in
|
||||
//! Settings. Sync only ever *adds* membership, in the direction the admin picks.
|
||||
//! Settings. Group mappings only add membership in the chosen direction;
|
||||
//! managed level-title roles are replaced when a player's rank changes.
|
||||
|
||||
use crate::auth::{AdminUser, AuthUser};
|
||||
use crate::error::{AppError, AppResult};
|
||||
@@ -22,6 +23,8 @@ const API: &str = "https://discord.com/api/v10";
|
||||
pub struct DiscordSettings {
|
||||
/// `off`, `discord_to_panel`, `panel_to_discord` or `both`.
|
||||
pub role_sync: String,
|
||||
/// Discord role used until the first mapped rank title is earned.
|
||||
pub base_role_id: String,
|
||||
/// Shown to players in the launcher.
|
||||
pub invite_url: String,
|
||||
/// Announcements go here. Empty turns them off.
|
||||
@@ -33,7 +36,7 @@ pub struct DiscordSettings {
|
||||
|
||||
impl Default for DiscordSettings {
|
||||
fn default() -> Self {
|
||||
Self { role_sync: "off".into(), invite_url: String::new(), webhook_url: String::new(), notify_achievements: true, notify_guilds: true, notify_members: false }
|
||||
Self { role_sync: "off".into(), base_role_id: String::new(), invite_url: String::new(), webhook_url: String::new(), notify_achievements: true, notify_guilds: true, notify_members: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +57,7 @@ pub fn valid_invite(url: &str) -> bool {
|
||||
|
||||
fn view(s: &DiscordSettings) -> Value {
|
||||
json!({
|
||||
"role_sync": s.role_sync, "invite_url": s.invite_url,
|
||||
"role_sync": s.role_sync, "base_role_id": s.base_role_id, "invite_url": s.invite_url,
|
||||
"webhook_set": !s.webhook_url.is_empty(),
|
||||
"notify_achievements": s.notify_achievements, "notify_guilds": s.notify_guilds, "notify_members": s.notify_members,
|
||||
})
|
||||
@@ -72,6 +75,8 @@ pub async fn get_settings(_: AdminUser, State(state): State<AppState>) -> AppRes
|
||||
#[derive(Deserialize)]
|
||||
pub struct SettingsInput {
|
||||
role_sync: String,
|
||||
#[serde(default)]
|
||||
base_role_id: String,
|
||||
invite_url: String,
|
||||
/// Omitted or empty keeps the saved webhook; `"-"` clears it.
|
||||
#[serde(default)]
|
||||
@@ -85,6 +90,9 @@ pub async fn put_settings(_: AdminUser, State(state): State<AppState>, Json(i):
|
||||
if !["off", "discord_to_panel", "panel_to_discord", "both"].contains(&i.role_sync.as_str()) {
|
||||
return Err(AppError::bad_request("unknown role sync mode"));
|
||||
}
|
||||
if !i.base_role_id.is_empty() && (!i.base_role_id.bytes().all(|b| b.is_ascii_digit()) || i.base_role_id.len() > 24) {
|
||||
return Err(AppError::bad_request("Base Discord role ID must contain digits only"));
|
||||
}
|
||||
let old = load(&state).await?;
|
||||
let webhook = match i.webhook_url.trim() {
|
||||
"" => old.webhook_url.clone(),
|
||||
@@ -100,6 +108,7 @@ pub async fn put_settings(_: AdminUser, State(state): State<AppState>, Json(i):
|
||||
}
|
||||
let next = DiscordSettings {
|
||||
role_sync: i.role_sync,
|
||||
base_role_id: i.base_role_id,
|
||||
invite_url: invite,
|
||||
webhook_url: webhook,
|
||||
notify_achievements: i.notify_achievements,
|
||||
@@ -197,6 +206,7 @@ pub struct SyncReport {
|
||||
pub not_in_server: u32,
|
||||
pub groups_added: u32,
|
||||
pub roles_added: u32,
|
||||
pub roles_removed: u32,
|
||||
pub failed: u32,
|
||||
}
|
||||
|
||||
@@ -212,11 +222,13 @@ pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
|
||||
return Err(AppError::bad_request("save a Discord bot token and server ID in Settings first"));
|
||||
}
|
||||
let mapping: Vec<(i64, String)> = sqlx::query_as("SELECT id, discord_role FROM groups WHERE discord_role <> ''").fetch_all(&state.db).await?;
|
||||
if mapping.is_empty() {
|
||||
let rank_roles: Vec<(i64, String)> = sqlx::query_as("SELECT level_req, json_extract(reward_data, '$.discord_role_id') FROM level_rewards WHERE level_type='global' AND reward_type='title' AND json_extract(reward_data, '$.discord_role_id') IS NOT NULL AND json_extract(reward_data, '$.discord_role_id') <> '' ORDER BY level_req DESC")
|
||||
.fetch_all(&state.db).await?;
|
||||
if mapping.is_empty() && rank_roles.is_empty() && settings.base_role_id.is_empty() {
|
||||
return Ok(report);
|
||||
}
|
||||
let linked: Vec<(i64, String)> = sqlx::query_as("SELECT user_id, provider_id FROM account_connections WHERE provider = 'discord'").fetch_all(&state.db).await?;
|
||||
for (user_id, discord_id) in linked {
|
||||
let linked: Vec<(i64, String, String)> = sqlx::query_as("SELECT c.user_id, c.provider_id, u.uuid FROM account_connections c JOIN users u ON u.id=c.user_id WHERE c.provider = 'discord'").fetch_all(&state.db).await?;
|
||||
for (user_id, discord_id, uuid) in linked {
|
||||
report.checked += 1;
|
||||
let resp = bot_request(state, reqwest::Method::GET, format!("{API}/guilds/{}/members/{discord_id}", c.discord_guild_id), &c.discord_bot_token).send().await;
|
||||
let resp = match resp {
|
||||
@@ -235,6 +247,7 @@ pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
|
||||
report.failed += 1;
|
||||
break;
|
||||
}
|
||||
if !resp.status().is_success() { report.failed += 1; continue; }
|
||||
let Ok(member) = resp.json::<Value>().await else {
|
||||
report.failed += 1;
|
||||
continue;
|
||||
@@ -242,11 +255,17 @@ pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
|
||||
let roles: Vec<String> = member["roles"].as_array().map(|a| a.iter().filter_map(|r| r.as_str().map(String::from)).collect()).unwrap_or_default();
|
||||
let groups: Vec<i64> = sqlx::query_scalar("SELECT group_id FROM user_groups WHERE user_id = ?").bind(user_id).fetch_all(&state.db).await?;
|
||||
let p = plan(&settings.role_sync, &mapping, &roles, &groups);
|
||||
let xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid=?").bind(&uuid).fetch_optional(&state.db).await?.unwrap_or(0);
|
||||
let level = crate::progression::level_from_xp(xp).0;
|
||||
let chosen = rank_roles.iter().find(|(required,_)| *required <= level).map(|(_,role)| role.clone())
|
||||
.or_else(|| (!settings.base_role_id.is_empty()).then(|| settings.base_role_id.clone()));
|
||||
for g in p.add_groups {
|
||||
sqlx::query("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)").bind(user_id).bind(g).execute(&state.db).await?;
|
||||
report.groups_added += 1;
|
||||
}
|
||||
for role in p.add_roles {
|
||||
// A level title takes precedence over mapped panel groups on Discord.
|
||||
let wanted_roles = if let Some(role) = &chosen { if roles.contains(role) { Vec::new() } else { vec![role.clone()] } } else { p.add_roles };
|
||||
for role in wanted_roles {
|
||||
let done = bot_request(state, reqwest::Method::PUT, format!("{API}/guilds/{}/members/{discord_id}/roles/{role}", c.discord_guild_id), &c.discord_bot_token)
|
||||
.header("Content-Length", "0")
|
||||
.send()
|
||||
@@ -257,6 +276,16 @@ pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
if let Some(chosen) = chosen {
|
||||
let mut managed: Vec<String> = rank_roles.iter().map(|(_, role)| role.clone()).collect();
|
||||
if !settings.base_role_id.is_empty() { managed.push(settings.base_role_id.clone()); }
|
||||
managed.sort(); managed.dedup();
|
||||
for role in managed.into_iter().filter(|r| r != &chosen && roles.contains(r)) {
|
||||
let done = bot_request(state, reqwest::Method::DELETE, format!("{API}/guilds/{}/members/{discord_id}/roles/{role}", c.discord_guild_id), &c.discord_bot_token).send().await;
|
||||
match done { Ok(r) if r.status().is_success() => report.roles_removed += 1, _ => report.failed += 1 }
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||
}
|
||||
Ok(report)
|
||||
|
||||
@@ -437,13 +437,7 @@ pub async fn update_guild(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<UpdateGuildPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?")
|
||||
.bind(&id)
|
||||
.bind(&auth.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let is_officer_or_leader = role.as_deref().is_some_and(|r| r == "leader" || r == "officer");
|
||||
let is_officer_or_leader = guild_can(&state, &id, &auth.uuid, "manage").await?;
|
||||
if !is_officer_or_leader {
|
||||
return Err(AppError::forbidden("Only guild officers or leaders can update guild details"));
|
||||
}
|
||||
@@ -472,6 +466,132 @@ pub struct AddMemberPayload {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct JoinRequestPayload { #[serde(default)] pub message: String }
|
||||
|
||||
pub async fn request_join(auth: AuthUser, Path(id): Path<String>, State(state): State<AppState>, Json(payload): Json<JoinRequestPayload>) -> AppResult<Json<Value>> {
|
||||
let instance: Option<String> = sqlx::query_scalar("SELECT instance_id FROM guilds WHERE id = ?").bind(&id).fetch_optional(&state.db).await?;
|
||||
let instance = instance.ok_or_else(|| AppError::not_found("guild not found"))?;
|
||||
let in_guild: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ?)")
|
||||
.bind(&auth.uuid).bind(&instance).fetch_one(&state.db).await?;
|
||||
if in_guild { return Err(AppError::bad_request("Leave your current guild before requesting to join another")); }
|
||||
let name: String = sqlx::query_scalar("SELECT username FROM users WHERE id = ?").bind(auth.id).fetch_one(&state.db).await?;
|
||||
sqlx::query("INSERT INTO guild_join_requests(guild_id,uuid,name,message,created_at) VALUES(?,?,?,?,?) ON CONFLICT(guild_id,uuid) DO UPDATE SET message=excluded.message,created_at=excluded.created_at")
|
||||
.bind(&id).bind(&auth.uuid).bind(&name).bind(payload.message.trim().chars().take(300).collect::<String>()).bind(crate::db::now()).execute(&state.db).await?;
|
||||
Ok(Json(json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn list_join_requests(auth: AuthUser, Path(id): Path<String>, State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
if !guild_can(&state, &id, &auth.uuid, "invite").await? { return Err(AppError::forbidden("Your guild role cannot review requests")); }
|
||||
let rows: Vec<(String,String,String,String)> = sqlx::query_as("SELECT uuid,name,message,created_at FROM guild_join_requests WHERE guild_id=? ORDER BY created_at DESC")
|
||||
.bind(&id).fetch_all(&state.db).await?;
|
||||
Ok(Json(json!(rows.into_iter().map(|(uuid,name,message,created_at)| json!({"uuid":uuid,"name":name,"message":message,"created_at":created_at})).collect::<Vec<_>>())))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct JoinDecision { pub accept: bool }
|
||||
|
||||
pub async fn respond_join_request(auth: AuthUser, Path((id, uuid)): Path<(String,String)>, State(state): State<AppState>, Json(decision): Json<JoinDecision>) -> AppResult<Json<Value>> {
|
||||
if !guild_can(&state, &id, &auth.uuid, "invite").await? { return Err(AppError::forbidden("Your guild role cannot review requests")); }
|
||||
let mut tx = state.db.begin().await?;
|
||||
let name: Option<String> = sqlx::query_scalar("SELECT name FROM guild_join_requests WHERE guild_id=? AND uuid=?")
|
||||
.bind(&id).bind(&uuid).fetch_optional(&mut *tx).await?;
|
||||
let name = name.ok_or_else(|| AppError::not_found("request not found"))?;
|
||||
if decision.accept {
|
||||
sqlx::query("INSERT INTO guild_members(guild_id,uuid,name,role,joined_at) VALUES(?,?,?,'member',?)")
|
||||
.bind(&id).bind(&uuid).bind(&name).bind(crate::db::now()).execute(&mut *tx).await.map_err(|_| AppError::bad_request("Player has already joined a guild"))?;
|
||||
sqlx::query("DELETE FROM guild_join_requests WHERE uuid=?").bind(&uuid).execute(&mut *tx).await?;
|
||||
} else {
|
||||
sqlx::query("DELETE FROM guild_join_requests WHERE guild_id=? AND uuid=?").bind(&id).bind(&uuid).execute(&mut *tx).await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(Json(json!({"ok":true})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GuildRoleInput {
|
||||
pub name: String,
|
||||
#[serde(default)] pub priority: i64,
|
||||
#[serde(default)] pub can_invite: bool,
|
||||
#[serde(default)] pub can_kick: bool,
|
||||
#[serde(default)] pub can_claim: bool,
|
||||
#[serde(default)] pub can_post: bool,
|
||||
#[serde(default)] pub can_manage: bool,
|
||||
}
|
||||
|
||||
async fn guild_can(state: &AppState, guild_id: &str, uuid: &str, action: &str) -> AppResult<bool> {
|
||||
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id=? AND uuid=?")
|
||||
.bind(guild_id).bind(uuid).fetch_optional(&state.db).await?;
|
||||
let Some(role) = role else { return Ok(false); };
|
||||
if role == "leader" || role == "officer" { return Ok(true); }
|
||||
if role == "member" { return Ok(matches!(action, "claim" | "post")); }
|
||||
let column = match action { "invite" => "can_invite", "kick" => "can_kick", "claim" => "can_claim", "post" => "can_post", "manage" => "can_manage", _ => return Ok(false) };
|
||||
let allowed: Option<i64> = sqlx::query_scalar(&format!("SELECT {column} FROM guild_roles WHERE guild_id=? AND name=?"))
|
||||
.bind(guild_id).bind(&role).fetch_optional(&state.db).await?;
|
||||
Ok(allowed.unwrap_or(0) != 0)
|
||||
}
|
||||
|
||||
async fn require_guild_leader(state: &AppState, guild_id: &str, uuid: &str) -> AppResult<()> {
|
||||
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id=? AND uuid=?")
|
||||
.bind(guild_id).bind(uuid).fetch_optional(&state.db).await?;
|
||||
if role.as_deref() != Some("leader") { return Err(AppError::forbidden("Only the guild leader can manage roles")); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_guild_roles(auth: AuthUser, Path(id): Path<String>, State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let member: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_members WHERE guild_id=? AND uuid=?)")
|
||||
.bind(&id).bind(&auth.uuid).fetch_one(&state.db).await?;
|
||||
if !member { return Err(AppError::forbidden("Guild membership is required")); }
|
||||
let roles: Vec<(i64,String,i64,i64,i64,i64,i64,i64)> = sqlx::query_as("SELECT id,name,priority,can_invite,can_kick,can_claim,can_post,can_manage FROM guild_roles WHERE guild_id=? ORDER BY priority DESC,name")
|
||||
.bind(&id).fetch_all(&state.db).await?;
|
||||
Ok(Json(json!(roles.into_iter().map(|(id,name,priority,invite,kick,claim,post,manage)| json!({"id":id,"name":name,"priority":priority,"can_invite":invite!=0,"can_kick":kick!=0,"can_claim":claim!=0,"can_post":post!=0,"can_manage":manage!=0})).collect::<Vec<_>>())))
|
||||
}
|
||||
|
||||
pub async fn create_guild_role(auth: AuthUser, Path(id): Path<String>, State(state): State<AppState>, Json(role): Json<GuildRoleInput>) -> AppResult<Json<Value>> {
|
||||
require_guild_leader(&state, &id, &auth.uuid).await?;
|
||||
let name = role.name.trim();
|
||||
if name.len() < 2 || name.len() > 24 || !name.chars().all(|c| c.is_alphanumeric() || c == ' ' || c == '-' || c == '_')
|
||||
|| ["leader","officer","member"].iter().any(|r| r.eq_ignore_ascii_case(name)) {
|
||||
return Err(AppError::bad_request("Role names must be 2–24 letters, numbers, spaces, hyphens or underscores, and cannot be a built-in role"));
|
||||
}
|
||||
let duplicate: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_roles WHERE guild_id=? AND name=? COLLATE NOCASE)")
|
||||
.bind(&id).bind(name).fetch_one(&state.db).await?;
|
||||
if duplicate { return Err(AppError::conflict("A role with this name already exists")); }
|
||||
let inserted = sqlx::query("INSERT INTO guild_roles(guild_id,name,priority,can_invite,can_kick,can_claim,can_post,can_manage) VALUES(?,?,?,?,?,?,?,?)")
|
||||
.bind(&id).bind(name).bind(role.priority.clamp(0,100)).bind(role.can_invite).bind(role.can_kick).bind(role.can_claim).bind(role.can_post).bind(role.can_manage)
|
||||
.execute(&state.db).await?;
|
||||
Ok(Json(json!({"ok":true,"id":inserted.last_insert_rowid()})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AssignRole { pub role: String }
|
||||
|
||||
pub async fn assign_guild_role(auth: AuthUser, Path((id, uuid)): Path<(String,String)>, State(state): State<AppState>, Json(input): Json<AssignRole>) -> AppResult<Json<Value>> {
|
||||
require_guild_leader(&state, &id, &auth.uuid).await?;
|
||||
if input.role == "leader" { return Err(AppError::bad_request("Use leadership transfer to assign the leader role")); }
|
||||
if input.role != "member" && input.role != "officer" {
|
||||
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_roles WHERE guild_id=? AND name=?)")
|
||||
.bind(&id).bind(&input.role).fetch_one(&state.db).await?;
|
||||
if !exists { return Err(AppError::bad_request("Role does not belong to this guild")); }
|
||||
}
|
||||
let done = sqlx::query("UPDATE guild_members SET role=? WHERE guild_id=? AND uuid=? AND role<>'leader'")
|
||||
.bind(&input.role).bind(&id).bind(&uuid).execute(&state.db).await?;
|
||||
if done.rows_affected() == 0 { return Err(AppError::not_found("member not found or is guild leader")); }
|
||||
Ok(Json(json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn delete_guild_role(auth: AuthUser, Path((id, role_id)): Path<(String,i64)>, State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
require_guild_leader(&state, &id, &auth.uuid).await?;
|
||||
let name: Option<String> = sqlx::query_scalar("SELECT name FROM guild_roles WHERE guild_id=? AND id=?")
|
||||
.bind(&id).bind(role_id).fetch_optional(&state.db).await?;
|
||||
let name = name.ok_or_else(|| AppError::not_found("role not found"))?;
|
||||
let mut tx = state.db.begin().await?;
|
||||
sqlx::query("UPDATE guild_members SET role='member' WHERE guild_id=? AND role=?").bind(&id).bind(&name).execute(&mut *tx).await?;
|
||||
sqlx::query("DELETE FROM guild_roles WHERE guild_id=? AND id=?").bind(&id).bind(role_id).execute(&mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
Ok(Json(json!({"ok":true})))
|
||||
}
|
||||
|
||||
/// Add / Invite member to guild.
|
||||
pub async fn add_guild_member(
|
||||
auth: AuthUser,
|
||||
@@ -479,13 +599,7 @@ pub async fn add_guild_member(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<AddMemberPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?")
|
||||
.bind(&id)
|
||||
.bind(&auth.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
if !role.as_deref().is_some_and(|r| r == "leader" || r == "officer") {
|
||||
if !guild_can(&state, &id, &auth.uuid, "invite").await? {
|
||||
return Err(AppError::forbidden("Only guild leaders or officers can invite members"));
|
||||
}
|
||||
|
||||
@@ -536,7 +650,7 @@ pub async fn remove_guild_member(
|
||||
.await?;
|
||||
|
||||
let is_self = auth.uuid == target_uuid;
|
||||
let is_leader_or_officer = caller_role.as_deref().is_some_and(|r| r == "leader" || r == "officer");
|
||||
let is_leader_or_officer = guild_can(&state, &guild_id, &auth.uuid, "kick").await?;
|
||||
|
||||
if !is_self && !is_leader_or_officer {
|
||||
return Err(AppError::forbidden("Cannot kick member without officer privileges"));
|
||||
@@ -573,8 +687,8 @@ pub async fn create_guild_post(
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
if !in_guild {
|
||||
return Err(AppError::forbidden("Must be a guild member to post"));
|
||||
if !in_guild || !guild_can(&state, &id, &auth.uuid, "post").await? {
|
||||
return Err(AppError::forbidden("Your guild role cannot post"));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
@@ -622,8 +736,8 @@ pub async fn claim_chunk(
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
if role.is_none() {
|
||||
return Err(AppError::forbidden("You are not a member of this guild"));
|
||||
if role.is_none() || !guild_can(&state, &guild_id, &auth.uuid, "claim").await? {
|
||||
return Err(AppError::forbidden("Your guild role cannot claim land"));
|
||||
}
|
||||
|
||||
let max_claims: i64 = sqlx::query_scalar("SELECT max_claims FROM guilds WHERE id = ?").bind(&guild_id).fetch_one(&state.db).await?;
|
||||
@@ -694,8 +808,8 @@ pub async fn unclaim_chunk(
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
if role.is_none() {
|
||||
return Err(AppError::forbidden("You are not a member of this guild"));
|
||||
if role.is_none() || !guild_can(&state, &guild_id, &auth.uuid, "claim").await? {
|
||||
return Err(AppError::forbidden("Your guild role cannot unclaim land"));
|
||||
}
|
||||
|
||||
let dim = payload.dimension.unwrap_or_else(|| "minecraft:overworld".into());
|
||||
@@ -724,8 +838,8 @@ pub async fn unclaim_by_id(auth: AuthUser, Path(claim_id): Path<i64>, State(stat
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
if role.is_none() {
|
||||
return Err(AppError::forbidden("You are not a member of this guild"));
|
||||
if role.is_none() || !guild_can(&state, &guild_id, &auth.uuid, "claim").await? {
|
||||
return Err(AppError::forbidden("Your guild role cannot unclaim land"));
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM guild_claims WHERE id = ?").bind(claim_id).execute(&state.db).await?;
|
||||
@@ -1033,6 +1147,7 @@ pub async fn server_claim_chunk(
|
||||
let Some((guild_id, _role)) = member else {
|
||||
return Err(AppError::bad_request("You must be in a guild to claim land. Create one with /guild create <name> <tag>"));
|
||||
};
|
||||
if !guild_can(&state, &guild_id, &payload.uuid, "claim").await? { return Err(AppError::forbidden("Your guild role cannot claim land")); }
|
||||
|
||||
let max_claims: i64 =
|
||||
sqlx::query_scalar("SELECT max_claims FROM guilds WHERE id = ?").bind(&guild_id).fetch_one(&state.db).await.unwrap_or(16);
|
||||
@@ -1103,7 +1218,7 @@ pub async fn server_unclaim_chunk(
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
if member.is_none() {
|
||||
if member.is_none() || !guild_can(&state, &guild_id, &payload.uuid, "claim").await? {
|
||||
return Err(AppError::forbidden("You cannot unclaim land belonging to another guild"));
|
||||
}
|
||||
|
||||
|
||||
@@ -130,8 +130,11 @@ async fn apply_luckperms(conn: &mut SqliteConnection, server_id: i64, data: &Val
|
||||
let settings = load_settings(conn).await?;
|
||||
let players: Vec<LpPlayer> = serde_json::from_value(data.get("players").cloned().unwrap_or(json!([]))).unwrap_or_default();
|
||||
let mapped: Vec<(i64, String)> = sqlx::query_as("SELECT id, luckperms_group FROM groups WHERE luckperms_group <> ''").fetch_all(&mut *conn).await?;
|
||||
let level_groups: Vec<(i64, String)> = sqlx::query_as("SELECT level_req, json_extract(reward_data, '$.luckperms_group') FROM level_rewards WHERE level_type='global' AND reward_type='title' AND json_extract(reward_data, '$.luckperms_group') IS NOT NULL AND json_extract(reward_data, '$.luckperms_group') <> '' ORDER BY level_req DESC")
|
||||
.fetch_all(&mut *conn).await?;
|
||||
let now = crate::db::now();
|
||||
let mut assign = Vec::new();
|
||||
let mut level_assign = Vec::new();
|
||||
for p in players.into_iter().take(2000) {
|
||||
let Some(uuid) = crate::yggdrasil::dashed(&p.uuid) else { continue };
|
||||
let groups: Vec<String> = p.groups.iter().map(|g| clean(g, 64).to_lowercase()).filter(|g| !g.is_empty()).take(64).collect();
|
||||
@@ -155,6 +158,20 @@ async fn apply_luckperms(conn: &mut SqliteConnection, server_id: i64, data: &Val
|
||||
.bind(&now)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
if !level_groups.is_empty() {
|
||||
let xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid=?").bind(&uuid).fetch_optional(&mut *conn).await?.unwrap_or(0);
|
||||
let level = crate::progression::level_from_xp(xp).0;
|
||||
let target = level_groups.iter().find(|(required, _)| *required <= level).map(|(_, group)| group.to_lowercase());
|
||||
let mut add = Vec::new();
|
||||
let mut remove = Vec::new();
|
||||
for (_, group) in &level_groups {
|
||||
let key = group.to_lowercase();
|
||||
if target.as_deref() == Some(key.as_str()) {
|
||||
if !groups.contains(&key) { add.push(key); }
|
||||
} else if groups.contains(&key) { remove.push(key); }
|
||||
}
|
||||
if !add.is_empty() || !remove.is_empty() { level_assign.push(json!({"uuid":uuid,"add":add,"remove":remove})); }
|
||||
}
|
||||
if settings.luckperms_sync == "off" || mapped.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -173,7 +190,7 @@ async fn apply_luckperms(conn: &mut SqliteConnection, server_id: i64, data: &Val
|
||||
assign.push(json!({ "uuid": uuid, "add": plan.game_add, "remove": plan.game_remove }));
|
||||
}
|
||||
}
|
||||
Ok(json!({ "mode": settings.luckperms_sync, "assign": assign }))
|
||||
Ok(json!({ "mode": settings.luckperms_sync, "assign": assign, "level_assign": level_assign }))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Leveling system (Global Level & Server-Specific Levels), rewards, and progression.
|
||||
|
||||
use crate::auth::{AdminUser, AuthUser};
|
||||
use crate::error::AppResult;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::state::AppState;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::Json;
|
||||
@@ -51,6 +51,9 @@ pub async fn grant_rewards(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, uuid: &
|
||||
_ => {} // Item/cosmetic entitlements are recorded in granted_rewards.
|
||||
}
|
||||
}
|
||||
let active_title: Option<String> = 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(&mut **tx).await?;
|
||||
sqlx::query("UPDATE user_levels SET title=? WHERE uuid=?").bind(active_title).bind(uuid).execute(&mut **tx).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -278,6 +281,33 @@ pub struct RewardPayload {
|
||||
pub reward_data: Option<Value>,
|
||||
}
|
||||
|
||||
fn validate_rank_mapping(data: &serde_json::Map<String, Value>) -> AppResult<()> {
|
||||
if data.get("discord_role_id").is_some_and(|v| !v.is_string() && !v.is_null())
|
||||
|| data.get("luckperms_group").is_some_and(|v| !v.is_string() && !v.is_null()) {
|
||||
return Err(AppError::bad_request("Rank role mappings must be text"));
|
||||
}
|
||||
let discord = data.get("discord_role_id").and_then(Value::as_str).unwrap_or("");
|
||||
if !discord.is_empty() && (discord.len() > 24 || !discord.bytes().all(|b| b.is_ascii_digit())) {
|
||||
return Err(AppError::bad_request("Discord role ID must contain digits only"));
|
||||
}
|
||||
let group = data.get("luckperms_group").and_then(Value::as_str).unwrap_or("");
|
||||
if !group.is_empty() && (group.len() > 64 || !group.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.')) {
|
||||
return Err(AppError::bad_request("LuckPerms group must contain letters, digits, _, - or ."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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?;
|
||||
for (uuid, xp) in players {
|
||||
let level = level_from_xp(xp).0;
|
||||
let title: Option<String> = 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?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Admin create reward.
|
||||
pub async fn admin_create_reward(
|
||||
_admin: AdminUser,
|
||||
@@ -300,6 +330,7 @@ pub async fn admin_create_reward(
|
||||
data_obj.insert("icon".into(), Value::String(ico));
|
||||
}
|
||||
data_obj.insert("title".into(), Value::String(reward_name.clone()));
|
||||
validate_rank_mapping(&data_obj)?;
|
||||
let data_str = serde_json::to_string(&data_obj).unwrap_or_default();
|
||||
|
||||
let id: i64 = sqlx::query_scalar(
|
||||
@@ -317,6 +348,8 @@ pub async fn admin_create_reward(
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
refresh_global_titles(&state).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "id": id, "ok": true })))
|
||||
}
|
||||
|
||||
@@ -341,6 +374,7 @@ pub async fn admin_update_reward(
|
||||
data_obj.insert("icon".into(), Value::String(ico));
|
||||
}
|
||||
data_obj.insert("title".into(), Value::String(reward_name.clone()));
|
||||
validate_rank_mapping(&data_obj)?;
|
||||
let data_str = serde_json::to_string(&data_obj).unwrap_or_default();
|
||||
|
||||
sqlx::query(
|
||||
@@ -359,6 +393,8 @@ pub async fn admin_update_reward(
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
refresh_global_titles(&state).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "id": id, "ok": true })))
|
||||
}
|
||||
|
||||
@@ -372,5 +408,6 @@ pub async fn admin_delete_reward(
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
refresh_global_titles(&state).await?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
//! Live map endpoints: ingest from game servers, the authenticated Vantage
|
||||
//! proxy for launchers/admin, and admin status.
|
||||
//! Live player positions and legacy world-mirror ingest from game servers.
|
||||
|
||||
use super::servers::{get_server, GameServer};
|
||||
use crate::auth::{AdminUser, AuthUser};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::livemap::{parse_chunks, Dim, LivePlayer};
|
||||
use crate::state::AppState;
|
||||
use axum::body::{Body, Bytes};
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::response::Response;
|
||||
use axum::Json;
|
||||
use base64::Engine;
|
||||
use serde::Deserialize;
|
||||
@@ -199,7 +197,6 @@ fn label(d: Dim) -> &'static str {
|
||||
|
||||
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<Value> = summary["dimensions"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
@@ -209,43 +206,35 @@ async fn info(state: &AppState, id: i64, enabled: bool) -> Value {
|
||||
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."
|
||||
"Live player positions are off for this server."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
json!({
|
||||
"enabled": enabled,
|
||||
"ready": enabled && generator && assets && has_world,
|
||||
"ready": enabled,
|
||||
"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<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
|
||||
/// A signed-in player's own live position for the territory map.
|
||||
pub async fn viewer_info(AuthUser(auth): AuthUser, State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
|
||||
let server = get_server(&state, id).await?;
|
||||
Ok(Json(info(&state, id, server.live_map_enabled).await))
|
||||
let mut result = info(&state, id, server.live_map_enabled).await;
|
||||
result["me"] = serde_json::to_value(state.livemap.live_players(id).into_iter()
|
||||
.find(|player| player.uuid.eq_ignore_ascii_case(&auth.uuid)))?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn admin_status(_: AdminUser, State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
|
||||
let server = get_server(&state, id).await?;
|
||||
let mut v = info(&state, id, server.live_map_enabled).await;
|
||||
let (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))
|
||||
}
|
||||
@@ -255,42 +244,3 @@ pub async fn admin_reset(_: AdminUser, State(state): State<AppState>, Path(id):
|
||||
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<AppState>,
|
||||
Path((id, dim, path)): Path<(i64, String, String)>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Response> {
|
||||
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()))?)
|
||||
}
|
||||
@@ -146,8 +146,14 @@ pub async fn create_invite(state: &AppState, inviter_uuid: &str, inviter_name: &
|
||||
}
|
||||
let mut conn = state.db.acquire().await?;
|
||||
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?").bind(guild_id).bind(inviter_uuid).fetch_optional(&mut *conn).await?;
|
||||
if !role.as_deref().is_some_and(|r| r == "leader" || r == "officer") {
|
||||
return Err(AppError::forbidden("Only guild leaders and officers can invite players"));
|
||||
let allowed = match role.as_deref() {
|
||||
Some("leader" | "officer") => true,
|
||||
Some(custom) => sqlx::query_scalar::<_, i64>("SELECT can_invite FROM guild_roles WHERE guild_id=? AND name=?")
|
||||
.bind(guild_id).bind(custom).fetch_optional(&mut *conn).await?.unwrap_or(0) != 0,
|
||||
None => false,
|
||||
};
|
||||
if !allowed {
|
||||
return Err(AppError::forbidden("Your guild role cannot invite players"));
|
||||
}
|
||||
let target: Option<String> = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ? AND status = 'active'").bind(target_uuid).fetch_optional(&mut *conn).await?;
|
||||
let target_name = target.ok_or_else(|| AppError::not_found("Player not found"))?;
|
||||
|
||||
@@ -84,6 +84,11 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.route("/guilds/claims/{id}", delete(guilds::unclaim_by_id))
|
||||
.route("/guilds/{id}", get(guilds::get_guild_by_id).put(guilds::update_guild))
|
||||
.route("/guilds/{id}/members", get(guilds::get_guild_members).post(guilds::add_guild_member))
|
||||
.route("/guilds/{id}/members/{uuid}/role", put(guilds::assign_guild_role))
|
||||
.route("/guilds/{id}/roles", get(guilds::list_guild_roles).post(guilds::create_guild_role))
|
||||
.route("/guilds/{id}/roles/{role_id}", delete(guilds::delete_guild_role))
|
||||
.route("/guilds/{id}/requests", get(guilds::list_join_requests).post(guilds::request_join))
|
||||
.route("/guilds/{id}/requests/{uuid}/respond", post(guilds::respond_join_request))
|
||||
.route("/guilds/{id}/members/{uuid}", delete(guilds::remove_guild_member))
|
||||
.route("/guilds/{id}/posts", get(guilds::get_guild_posts).post(guilds::create_guild_post))
|
||||
.route("/guilds/{id}/wallet", get(guilds::guild_wallet))
|
||||
@@ -236,7 +241,6 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.layer(DefaultBodyLimit::max(10 * 1024 * 1024));
|
||||
|
||||
Router::new()
|
||||
.route("/api/livemap/{id}/{dim}/{*path}", get(livemap::proxy))
|
||||
.nest("/api/v1", launcher)
|
||||
.nest("/api/server/v1", game.merge(livemap_game))
|
||||
.nest("/api/admin", admin)
|
||||
|
||||
@@ -164,9 +164,7 @@ pub async fn adjust_player(
|
||||
_ => return Err(AppError::bad_request("scope must be global or server")),
|
||||
};
|
||||
// Level rewards (titles, badges) are earned, so they follow upward changes.
|
||||
if change.new_xp > change.old_xp {
|
||||
crate::routes::leveling::grant_rewards(&mut tx, &uuid, &now).await?;
|
||||
}
|
||||
crate::routes::leveling::grant_rewards(&mut tx, &uuid, &now).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
let reason: String = a.reason.chars().filter(|c| !c.is_control()).take(120).collect();
|
||||
|
||||
@@ -61,6 +61,7 @@ fn clip(s: &str, max: usize) -> String {
|
||||
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
|
||||
pub struct ServerRow {
|
||||
pub instance_id: String,
|
||||
#[serde(skip)] // retained only for reading the legacy database column
|
||||
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.
|
||||
@@ -867,8 +868,6 @@ pub struct ServerInput {
|
||||
#[serde(default)]
|
||||
require_launcher: bool,
|
||||
#[serde(default)]
|
||||
map_url: String,
|
||||
#[serde(default)]
|
||||
live_map_enabled: bool,
|
||||
#[serde(default)]
|
||||
economy_group: String,
|
||||
@@ -908,18 +907,6 @@ impl ServerInput {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -946,7 +933,7 @@ pub async fn create(_: AdminUser, State(state): State<AppState>, Json(input): Js
|
||||
.bind(input.require_launcher)
|
||||
.bind(now())
|
||||
.bind(&input.instance_id)
|
||||
.bind(input.map_url.trim())
|
||||
.bind("")
|
||||
.bind(input.live_map_enabled)
|
||||
.bind(input.economy_group.trim())
|
||||
.bind(input.map_address.trim())
|
||||
@@ -970,7 +957,7 @@ pub async fn update(
|
||||
.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("")
|
||||
.bind(input.live_map_enabled)
|
||||
.bind(input.economy_group.trim())
|
||||
.bind(input.map_address.trim())
|
||||
@@ -1168,7 +1155,6 @@ pub async fn public_servers(State(state): State<AppState>) -> AppResult<Json<Val
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"instance_id": s.instance_id,
|
||||
"map_url": s.map_url,
|
||||
"map_address": s.map_address,
|
||||
"live_map": s.live_map_enabled,
|
||||
"online": is_online,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
mod common;
|
||||
use common::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn leader_approves_join_request_and_role_controls_claims() {
|
||||
let t = setup().await;
|
||||
let admin = t.login("admin", "supersecret").await;
|
||||
for name in ["Leader", "Applicant"] {
|
||||
let (status, body) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username":name,"password":"password123"}))).await;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
}
|
||||
let leader = t.login("Leader", "password123").await;
|
||||
let applicant = t.login("Applicant", "password123").await;
|
||||
let applicant_uuid = t.uuid("Applicant").await;
|
||||
let (_, server) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name":"SMP","instance_id":"smp"}))).await;
|
||||
let server_id = server["server"]["id"].as_i64().unwrap();
|
||||
let (status, guild) = t.call("POST", "/api/v1/guilds", Some(&leader), Some(json!({"instance_id":"smp","name":"Pimps","tag":"PIMP"}))).await;
|
||||
assert_eq!(status, StatusCode::OK, "{guild}");
|
||||
let id = guild["id"].as_str().unwrap();
|
||||
let path = format!("/api/v1/guilds/{id}");
|
||||
|
||||
assert_eq!(t.call("POST", &format!("{path}/requests"), Some(&applicant), Some(json!({"message":"Let me in"}))).await.0, StatusCode::OK);
|
||||
assert_eq!(t.call("GET", &format!("{path}/requests"), Some(&applicant), None).await.0, StatusCode::FORBIDDEN);
|
||||
let (_, requests) = t.call("GET", &format!("{path}/requests"), Some(&leader), None).await;
|
||||
assert_eq!(requests[0]["message"], "Let me in");
|
||||
assert_eq!(t.call("POST", &format!("{path}/requests/{applicant_uuid}/respond"), Some(&leader), Some(json!({"accept":true}))).await.0, StatusCode::OK);
|
||||
|
||||
let (status, role) = t.call("POST", &format!("{path}/roles"), Some(&leader), Some(json!({"name":"Builder","can_claim":false,"can_post":true}))).await;
|
||||
assert_eq!(status, StatusCode::OK, "{role}");
|
||||
assert_eq!(t.call("PUT", &format!("{path}/members/{applicant_uuid}/role"), Some(&leader), Some(json!({"role":"Builder"}))).await.0, StatusCode::OK);
|
||||
let claim = json!({"server_id":server_id,"dimension":"minecraft:overworld","chunk_x":1,"chunk_z":1});
|
||||
assert_eq!(t.call("POST", &format!("{path}/claim"), Some(&applicant), Some(claim)).await.0, StatusCode::FORBIDDEN);
|
||||
let role_id = role["id"].as_i64().unwrap();
|
||||
assert_eq!(t.call("DELETE", &format!("{path}/roles/{role_id}"), Some(&leader), None).await.0, StatusCode::OK);
|
||||
let (_, members) = t.call("GET", &format!("{path}/members"), Some(&leader), None).await;
|
||||
assert_eq!(members.as_array().unwrap().iter().find(|m| m["uuid"] == applicant_uuid).unwrap()["role"], "member");
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Central live map: ingest from game servers, status for admins, viewer proxy.
|
||||
//! Live position and world-mirror ingest from game servers.
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
@@ -20,7 +20,7 @@ async fn post_bytes(t: &TestApp, uri: &str, token: &str, body: Vec<u8>) -> (Stat
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_map_ingest_status_and_proxy() {
|
||||
async fn live_map_ingest_and_player_positions() {
|
||||
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;
|
||||
@@ -82,29 +82,12 @@ async fn live_map_ingest_status_and_proxy() {
|
||||
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);
|
||||
assert_eq!(info["ready"], true);
|
||||
|
||||
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;
|
||||
@@ -114,70 +97,3 @@ async fn live_map_ingest_status_and_proxy() {
|
||||
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());
|
||||
}
|
||||
Generated
+2
-97
@@ -1,16 +1,12 @@
|
||||
{
|
||||
"name": "scopenet-panel-web",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "scopenet-panel-web",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"@thoughts-on-things/vantage-mc": "^0.15.1",
|
||||
"three": "^0.180.0"
|
||||
},
|
||||
"version": "0.5.0",
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
@@ -20,20 +16,12 @@
|
||||
"@lucide/svelte": "^1.48.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||
"@tsconfig/svelte": "^5.0.8",
|
||||
"@types/three": "^0.180.0",
|
||||
"svelte": "^5.57.1",
|
||||
"svelte-check": "^4.7.6",
|
||||
"typescript": "~5.9.0",
|
||||
"vite": "^8.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@dimforge/rapier3d-compat": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@fontsource-variable/inter": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
|
||||
@@ -456,25 +444,6 @@
|
||||
"vite": "^8.0.0-beta.7 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@thoughts-on-things/vantage-mc": {
|
||||
"version": "0.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@thoughts-on-things/vantage-mc/-/vantage-mc-0.15.1.tgz",
|
||||
"integrity": "sha512-muhg9950CgOa8keoCUV+utDHUKhkckwWGVn5O3HFMrgr7OrPo3VpEsBYVSUVL/RjmQDiqEaC19FPLGMTH97jpg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18",
|
||||
"three": ">=0.160"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@tsconfig/svelte": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz",
|
||||
@@ -482,13 +451,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -496,43 +458,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/stats.js": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/three": {
|
||||
"version": "0.180.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
"@types/stats.js": "*",
|
||||
"@types/webxr": "*",
|
||||
"@webgpu/types": "*",
|
||||
"fflate": "~0.8.2",
|
||||
"meshoptimizer": "~0.22.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/webxr": {
|
||||
"version": "0.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@webgpu/types": {
|
||||
"version": "0.1.74",
|
||||
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz",
|
||||
"integrity": "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
||||
@@ -662,13 +587,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -972,13 +890,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "0.22.0",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
|
||||
"integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mri": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
@@ -1204,12 +1115,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.180.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
|
||||
@@ -18,14 +18,9 @@
|
||||
"@lucide/svelte": "^1.48.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||
"@tsconfig/svelte": "^5.0.8",
|
||||
"@types/three": "^0.180.0",
|
||||
"svelte": "^5.57.1",
|
||||
"svelte-check": "^4.7.6",
|
||||
"typescript": "~5.9.0",
|
||||
"vite": "^8.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@thoughts-on-things/vantage-mc": "^0.15.1",
|
||||
"three": "^0.180.0"
|
||||
}
|
||||
}
|
||||
@@ -1,192 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { Map as MapIcon, RefreshCw, Trash2, CircleCheck, CircleAlert, Users, Boxes, Cpu, Package, LoaderCircle } from '@lucide/svelte';
|
||||
import { del, formatBytes, get, timeAgo } from '../lib/api';
|
||||
import { session } from '../lib/session.svelte';
|
||||
import { toast, toastError } from '../lib/toast.svelte';
|
||||
import Modal from './Modal.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { MapPin, RefreshCw, Users } from '@lucide/svelte';
|
||||
import { get, timeAgo } from '../lib/api';
|
||||
import { toastError } from '../lib/toast.svelte';
|
||||
import type { LiveMapStatus } from '../lib/types';
|
||||
|
||||
let { id, enabled }: { id: number; enabled: boolean } = $props();
|
||||
|
||||
let { id, enabled, address = '' }: { id: number; enabled: boolean; address?: string } = $props();
|
||||
let status = $state<LiveMapStatus | null>(null);
|
||||
let dim = $state('overworld');
|
||||
let host = $state<HTMLDivElement>();
|
||||
let viewer: any = null;
|
||||
let loading = $state(false);
|
||||
let viewError = $state('');
|
||||
let resetOpen = $state(false);
|
||||
let loadedKey = '';
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
status = await get<LiveMapStatus>(`/api/admin/servers/${id}/livemap`);
|
||||
if (!status.dimensions.some((d) => d.slug === dim)) dim = status.dimensions.find((d) => d.available)?.slug ?? 'overworld';
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
}
|
||||
try { status = await get<LiveMapStatus>(`/api/admin/servers/${id}/livemap`); }
|
||||
catch (e) { toastError(e); }
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void id;
|
||||
void enabled;
|
||||
onMount(() => {
|
||||
refresh();
|
||||
const t = setInterval(refresh, 10000);
|
||||
return () => clearInterval(t);
|
||||
const timer = setInterval(refresh, 10_000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
|
||||
function closeViewer() {
|
||||
viewer?.dispose();
|
||||
viewer = null;
|
||||
loadedKey = '';
|
||||
}
|
||||
|
||||
async function openViewer() {
|
||||
if (!host || !status?.ready) return;
|
||||
const key = `${id}:${dim}`;
|
||||
if (loadedKey === key && viewer) return;
|
||||
closeViewer();
|
||||
loading = true;
|
||||
viewError = '';
|
||||
try {
|
||||
const [{ VantageViewer }, { worldFromHttp }] = await Promise.all([
|
||||
import('@thoughts-on-things/vantage-mc/three'),
|
||||
import('@thoughts-on-things/vantage-mc/core'),
|
||||
]);
|
||||
const url = `${location.origin}${status.base}/${dim}/v1/worlds/default/manifest.json`;
|
||||
const world = await worldFromHttp(url, { accessToken: session.token ?? undefined });
|
||||
const v = new VantageViewer(host, { view: 'top', players: { offline: false } });
|
||||
try {
|
||||
await v.load({ world, view: 'top' });
|
||||
} catch (e) {
|
||||
v.dispose();
|
||||
throw e;
|
||||
}
|
||||
viewer = v;
|
||||
loadedKey = key;
|
||||
} catch (e) {
|
||||
viewError = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Reload whenever the dimension changes or the map becomes ready.
|
||||
$effect(() => {
|
||||
void dim;
|
||||
if (status?.ready && host) openViewer();
|
||||
});
|
||||
onDestroy(closeViewer);
|
||||
|
||||
async function reset() {
|
||||
try {
|
||||
await del(`/api/admin/servers/${id}/livemap`);
|
||||
closeViewer();
|
||||
resetOpen = false;
|
||||
toast('Live map data cleared — the server will re-upload its world');
|
||||
refresh();
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
}
|
||||
}
|
||||
|
||||
const totalRegions = $derived(status?.world.dimensions.reduce((n, d) => n + d.regions, 0) ?? 0);
|
||||
const totalBytes = $derived(status?.world.dimensions.reduce((n, d) => n + d.bytes, 0) ?? 0);
|
||||
</script>
|
||||
|
||||
<section class="card live">
|
||||
<div class="section-title">
|
||||
<h2><MapIcon size={17} /> Live Map</h2>
|
||||
<span class="pill" class:ok={status?.ready} class:off={!enabled}>{!enabled ? 'Off' : status?.ready ? 'Live' : 'Setting up'}</span>
|
||||
<button class="ghost icon" aria-label="Refresh" onclick={refresh}><RefreshCw size={15} /></button>
|
||||
<section class="card relay">
|
||||
<div class="heading">
|
||||
<h2><MapPin size={17} /> Live player positions</h2>
|
||||
<span class="pill" class:ok={enabled}>{enabled ? 'On' : 'Off'}</span>
|
||||
<button class="ghost icon" aria-label="Refresh positions" onclick={refresh}><RefreshCw size={15} /></button>
|
||||
</div>
|
||||
|
||||
<p class="muted small">{address ? 'The launcher uses this server’s BlueMap for 2D terrain and these positions to center each player’s claim map.' : 'Add a BlueMap address to display 2D terrain in the launcher.'}</p>
|
||||
{#if !enabled}
|
||||
<p class="muted small">Turn on <strong>Live Map</strong> in this server's settings. The SCOPENET plugin/mod will then send chunks and player positions here, and launchers get a Live Map button.</p>
|
||||
<p class="muted small">Turn on live player positions in this server’s settings to let players center the map on themselves.</p>
|
||||
{:else if status}
|
||||
<div class="checks">
|
||||
<div class="check" class:good={status.generator.installed}>
|
||||
{#if status.generator.installed}<CircleCheck size={16} />{:else}<CircleAlert size={16} />{/if}
|
||||
<span><Cpu size={13} /> Vantage generator</span>
|
||||
<small>{status.generator.installed ? status.generator.path : 'Not installed on the panel'}</small>
|
||||
</div>
|
||||
<div class="check" class:good={status.assets.configured}>
|
||||
{#if status.assets.configured}<CircleCheck size={16} />{:else}<CircleAlert size={16} />{/if}
|
||||
<span><Package size={13} /> Minecraft assets</span>
|
||||
<small>{status.assets.configured ? status.assets.path : 'Set SCOPENET_VANTAGE_ASSETS'}</small>
|
||||
</div>
|
||||
<div class="check" class:good={status.world.level_dat && totalRegions > 0}>
|
||||
{#if status.world.level_dat && totalRegions > 0}<CircleCheck size={16} />{:else}<CircleAlert size={16} />{/if}
|
||||
<span><Boxes size={13} /> World data</span>
|
||||
<small>{totalRegions ? `${totalRegions} regions · ${formatBytes(totalBytes)} · ${status.stats.chunks_received.toLocaleString()} chunks this session` : 'Waiting for the server to upload'}</small>
|
||||
</div>
|
||||
<div class="check" class:good={status.players > 0}>
|
||||
{#if status.players > 0}<CircleCheck size={16} />{:else}<CircleAlert size={16} />{/if}
|
||||
<span><Users size={13} /> Player positions</span>
|
||||
<small>{status.stats.last_players_at ? `${status.players} live · updated ${timeAgo(status.stats.last_players_at)}` : 'Nothing received yet'}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !status.generator.installed || !status.assets.configured}
|
||||
<p class="hint-box small">
|
||||
The panel stores what the server sends, but rendering needs the Vantage generator and Minecraft client assets.
|
||||
Install them as described in <code>docs/vantage-setup.md</code> — until then launchers show a “map is being set up” message.
|
||||
</p>
|
||||
{/if}
|
||||
{#if status.last_error}<p class="err small">Generator: {status.last_error}</p>{/if}
|
||||
|
||||
{#if status.ready}
|
||||
<div class="tabs">
|
||||
{#each status.dimensions as d (d.slug)}
|
||||
<button class="tab" class:on={dim === d.slug} disabled={!d.available} onclick={() => (dim = d.slug)}>{d.label}</button>
|
||||
<p class="summary"><Users size={15} /> {status.players} online {status.players === 1 ? 'position' : 'positions'}
|
||||
{#if status.stats.last_players_at}<span class="muted small">· updated {timeAgo(status.stats.last_players_at)}</span>{/if}
|
||||
</p>
|
||||
{#if status.roster.length}
|
||||
<div class="roster">
|
||||
{#each status.roster as player (player.uuid)}
|
||||
<span>{player.name} <small>{player.dimension.replace('minecraft:', '')} · {Math.floor(player.x)}, {Math.floor(player.z)}</small></span>
|
||||
{/each}
|
||||
<span class="grow"></span>
|
||||
<button class="ghost" onclick={() => (resetOpen = true)}><Trash2 size={14} /> Reset map data</button>
|
||||
</div>
|
||||
<div class="stage">
|
||||
<div class="viewer" bind:this={host}></div>
|
||||
{#if loading}<div class="overlay"><LoaderCircle class="spin" size={22} /> Rendering terrain…</div>{/if}
|
||||
{#if viewError}<div class="overlay err">Map unavailable: {viewError}</div>{/if}
|
||||
</div>
|
||||
{:else if status.message}
|
||||
<p class="muted small">{status.message}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="skeleton"></div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<Modal bind:open={resetOpen} title="Reset map data?">
|
||||
<p class="muted">Deletes the mirrored world and rendered tiles for this server. The game server uploads everything again, which can take a while for big worlds.</p>
|
||||
{#snippet footer()}
|
||||
<button class="ghost" onclick={() => (resetOpen = false)}>Cancel</button>
|
||||
<button class="danger" onclick={reset}><Trash2 size={16} /> Reset</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.live { display: flex; flex-direction: column; gap: 14px; margin-bottom: 16px; }
|
||||
h2 { display: flex; align-items: center; gap: 8px; }
|
||||
.pill { margin-left: auto; padding: 3px 11px; border-radius: 99px; font-size: 0.75rem; font-weight: 600; background: color-mix(in srgb, #fcd34d 16%, transparent); color: #fcd34d; }
|
||||
.pill.ok { background: color-mix(in srgb, #6ee7b7 16%, transparent); color: #6ee7b7; }
|
||||
.pill.off { background: var(--bg-2); color: var(--muted); }
|
||||
.checks { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 10px; }
|
||||
.check { display: grid; grid-template-columns: 18px 1fr; gap: 2px 10px; align-items: center; padding: 11px 13px; border-radius: 12px; background: var(--bg-2); color: #fcd34d; }
|
||||
.check.good { color: #6ee7b7; }
|
||||
.check span { color: var(--text); font-size: 0.85rem; font-weight: 560; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.check span :global(svg) { color: var(--muted); }
|
||||
.check small { grid-column: 2; color: var(--muted); font-size: 0.75rem; overflow-wrap: anywhere; }
|
||||
.hint-box { padding: 11px 14px; border-radius: 12px; background: color-mix(in srgb, var(--accent) 8%, var(--bg-2)); color: var(--text-2); line-height: 1.5; }
|
||||
.err { color: #fda4af; }
|
||||
.tabs { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||
.tab { padding: 7px 14px; border-radius: 99px; font-size: 0.85rem; background: var(--bg-2); }
|
||||
.tab.on { border-color: color-mix(in srgb, var(--accent) 60%, transparent); background: var(--accent-soft); }
|
||||
.tab:disabled { opacity: 0.4; }
|
||||
.grow { flex: 1; }
|
||||
.stage { position: relative; border-radius: 14px; overflow: hidden; background: #102026; height: min(62vh, 620px); min-height: 380px; }
|
||||
.viewer { position: absolute; inset: 0; }
|
||||
.viewer :global(canvas) { display: block; }
|
||||
.overlay { position: absolute; inset: 0; display: grid; place-items: center; grid-auto-flow: column; gap: 10px; justify-content: center; color: #e5e7eb; background: rgba(16, 32, 38, 0.72); pointer-events: none; }
|
||||
.overlay.err { color: #fda4af; text-align: center; padding: 24px; }
|
||||
.skeleton { height: 120px; border-radius: 12px; background: var(--bg-2); }
|
||||
.relay { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.heading, .heading h2, .summary { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.heading h2 { margin: 0 auto 0 0; font-size: 1rem; }
|
||||
.summary { margin: 0; }
|
||||
.roster { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.roster > span { display: flex; flex-direction: column; gap: 2px; padding: 0.5rem 0.7rem; border: 1px solid var(--line); border-radius: 8px; font-size: 0.83rem; }
|
||||
.roster small { color: var(--muted); }
|
||||
</style>
|
||||
@@ -21,14 +21,10 @@
|
||||
<label class="field">Map address (BlueMap)<input type="url" bind:value={draft.map_address} placeholder="https://map.example.com" autocomplete="off" /><span class="tiny muted">Where this server's BlueMap is served. Players get a <strong>Live Map</strong> button in the launcher that shows it inside the app, exactly as in a browser. Install BlueMap on the server, then leave this empty to hide the button.</span></label>
|
||||
<Toggle
|
||||
bind:checked={draft.live_map_enabled}
|
||||
label="Live Map"
|
||||
help="The SCOPENET plugin/mod sends this server's chunks and player positions to the panel, which hosts a Vantage 3D map. Launchers show a Live Map button — nothing to run next to the server."
|
||||
label="Live player positions"
|
||||
help="The server plugin/mod sends live player positions to the panel so the launcher can center the BlueMap territory view on the player."
|
||||
/>
|
||||
<label class="field">Shared economy group<input bind:value={draft.economy_group} maxlength="32" placeholder="Leave empty for a separate economy" /><span class="tiny muted">Servers with the same group name share player balances and guild banks, so one wallet follows players across your network. Existing per-server balances aren't merged.</span></label>
|
||||
<details class="adv">
|
||||
<summary>Use an external Vantage map instead</summary>
|
||||
<label class="field">Vantage manifest URL<input type="url" bind:value={draft.map_url} placeholder="https://maps.example.com/world.json" /><span class="tiny muted">Optional and only used when Live Map above is off. The URL is visible to launcher users.</span></label>
|
||||
</details>
|
||||
<div class="field">
|
||||
<span class="lbl">Who can join</span>
|
||||
<div class="opts">
|
||||
@@ -68,8 +64,5 @@
|
||||
.opt.on :global(svg) { color: var(--accent-2); }
|
||||
.gpick { padding: 6px 12px; border-radius: 99px; font-size: 0.85rem; background: var(--bg-2); }
|
||||
.gpick.on { border-color: var(--c); background: color-mix(in srgb, var(--c) 18%, transparent); }
|
||||
.adv { border: 1px dashed var(--line, rgba(255, 255, 255, 0.12)); border-radius: 10px; padding: 10px 12px; }
|
||||
.adv summary { cursor: pointer; font-size: 0.85rem; color: var(--muted); }
|
||||
.adv[open] summary { margin-bottom: 10px; }
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||
</style>
|
||||
@@ -93,7 +93,6 @@ export interface AuthServerInfo { public_url: string; yggdrasil_url: string; pub
|
||||
|
||||
export interface GameServer {
|
||||
instance_id: string;
|
||||
map_url: string;
|
||||
live_map_enabled: boolean;
|
||||
map_address: string;
|
||||
economy_group: string;
|
||||
@@ -149,19 +148,14 @@ export interface LiveMapStatus {
|
||||
enabled: boolean;
|
||||
ready: boolean;
|
||||
message: string;
|
||||
base: string;
|
||||
dimensions: { id: string; slug: string; label: string; available: boolean }[];
|
||||
players: number;
|
||||
generator: { installed: boolean; path: string | null };
|
||||
assets: { configured: boolean; path: string | null };
|
||||
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 };
|
||||
running: string[];
|
||||
last_error: string | null;
|
||||
roster: { uuid: string; name: string; dimension: string; x: number; y: number; z: number }[];
|
||||
}
|
||||
|
||||
export type ServerDraft = { instance_id?: string; name: string; map_url?: 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; live_map_enabled: boolean; economy_group?: string; map_address?: string; access: 'all' | 'members' | 'groups'; allowed_groups: string[]; require_launcher: boolean };
|
||||
|
||||
export interface HostedDownload {
|
||||
platform: 'windows' | 'mac' | 'linux';
|
||||
@@ -216,6 +210,8 @@ export interface LevelReward {
|
||||
reward_value: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
discord_role_id?: string;
|
||||
luckperms_group?: string;
|
||||
}
|
||||
|
||||
export interface UserLevelInfo {
|
||||
|
||||
@@ -8,6 +8,16 @@
|
||||
let stats = $state<any>(null);
|
||||
let instances = $state<any[]>([]);
|
||||
let branding = $state<any>(null);
|
||||
let showGettingStarted = $state(localStorage.getItem('scopenet_hide_getting_started') !== 'true');
|
||||
|
||||
function hideGettingStarted() {
|
||||
showGettingStarted = false;
|
||||
localStorage.setItem('scopenet_hide_getting_started', 'true');
|
||||
}
|
||||
function showGuide() {
|
||||
showGettingStarted = true;
|
||||
localStorage.removeItem('scopenet_hide_getting_started');
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
get('/api/admin/stats').then((s) => (stats = s));
|
||||
@@ -38,6 +48,7 @@
|
||||
{#if stats?.launcher_download_url}
|
||||
<a class="btn primary" href={stats.launcher_download_url} target="_blank" rel="noreferrer"><Download size={16} /> Launcher download</a>
|
||||
{/if}
|
||||
{#if !showGettingStarted}<button class="ghost" onclick={showGuide}>Show getting started</button>{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid stats">
|
||||
@@ -69,8 +80,8 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-title"><h2>Getting started</h2></div>
|
||||
{#if showGettingStarted}<section class="card">
|
||||
<div class="section-title"><h2>Getting started</h2><button class="hint" onclick={hideGettingStarted} aria-label="Hide Getting started">Hide</button></div>
|
||||
<div class="steps">
|
||||
{#each steps as s}
|
||||
<a class="step" class:done={s.done} href={s.href}>
|
||||
@@ -80,7 +91,7 @@
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
</section>{/if}
|
||||
|
||||
{#if stats?.live?.servers?.length}
|
||||
<section class="card servers">
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
reward_value: x.reward_value ?? x.reward_name ?? '',
|
||||
description: x.description ?? '',
|
||||
icon: x.icon ?? '🎁',
|
||||
discord_role_id: x.reward_data?.discord_role_id ?? '',
|
||||
luckperms_group: x.reward_data?.luckperms_group ?? '',
|
||||
})).sort((a, b) => a.level - b.level);
|
||||
leaderboard = lb || [];
|
||||
const p = await get<{ settings: { level_base: number; level_exponent: number; max_level: number } }>('/api/admin/progression');
|
||||
@@ -77,10 +79,10 @@
|
||||
saving = true;
|
||||
try {
|
||||
if (draft.id) {
|
||||
await put(`/api/admin/levels/rewards/${draft.id}`, draft);
|
||||
await put(`/api/admin/levels/rewards/${draft.id}`, { ...draft, reward_data: { discord_role_id: draft.discord_role_id ?? '', luckperms_group: draft.luckperms_group ?? '' } });
|
||||
toast('Reward updated');
|
||||
} else {
|
||||
await post('/api/admin/levels/rewards', draft);
|
||||
await post('/api/admin/levels/rewards', { ...draft, reward_data: { discord_role_id: draft.discord_role_id ?? '', luckperms_group: draft.luckperms_group ?? '' } });
|
||||
toast('Reward created');
|
||||
}
|
||||
draftOpen = false;
|
||||
@@ -209,7 +211,7 @@
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="r-lvl">Target Global Level</label>
|
||||
<input id="r-lvl" type="number" min="2" max="500" bind:value={draft.level} required />
|
||||
<input id="r-lvl" type="number" min="1" max="500" bind:value={draft.level} required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="r-type">Reward Type</label>
|
||||
@@ -236,6 +238,12 @@
|
||||
<label for="r-desc">Description</label>
|
||||
<textarea id="r-desc" rows="3" bind:value={draft.description} required placeholder="Describe what the player unlocks..."></textarea>
|
||||
</div>
|
||||
{#if draft.reward_type === 'title'}
|
||||
<div class="grid-2">
|
||||
<div class="field"><label>Discord role ID<input bind:value={draft.discord_role_id} inputmode="numeric" placeholder="Optional role for this rank" /></label></div>
|
||||
<div class="field"><label>LuckPerms group<input bind:value={draft.luckperms_group} placeholder="Optional in-game group" /></label></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="ghost" onclick={() => (draftOpen = false)}>Cancel</button>
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
|
||||
function openEdit() {
|
||||
if (!s) return;
|
||||
draft = { instance_id: s.instance_id, name: s.name, map_url: s.map_url, live_map_enabled: s.live_map_enabled, economy_group: s.economy_group, map_address: s.map_address, access: s.access, allowed_groups: [...s.allowed_groups], require_launcher: s.require_launcher };
|
||||
draft = { instance_id: s.instance_id, name: s.name, live_map_enabled: s.live_map_enabled, economy_group: s.economy_group, map_address: s.map_address, access: s.access, allowed_groups: [...s.allowed_groups], require_launcher: s.require_launcher };
|
||||
editOpen = true;
|
||||
}
|
||||
async function saveEdit() {
|
||||
@@ -150,7 +150,7 @@
|
||||
<div class="card tile"><span class="lbl"><Clock size={14} /> Total playtime</span><span class="val">{duration(d.totals.playtime_secs)}</span></div>
|
||||
</div>
|
||||
|
||||
<LiveMapPanel id={s.id} enabled={s.live_map_enabled} />
|
||||
<LiveMapPanel id={s.id} enabled={s.live_map_enabled} address={s.map_address} />
|
||||
|
||||
<IntegrationsPanel id={s.id} />
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
async function saveDisc() {
|
||||
savingDisc = true;
|
||||
try {
|
||||
disc = await put('/api/admin/discord', { role_sync: disc.role_sync, invite_url: disc.invite_url, webhook_url: discWebhook, notify_achievements: disc.notify_achievements, notify_guilds: disc.notify_guilds, notify_members: disc.notify_members });
|
||||
disc = await put('/api/admin/discord', { role_sync: disc.role_sync, base_role_id: disc.base_role_id, invite_url: disc.invite_url, webhook_url: discWebhook, notify_achievements: disc.notify_achievements, notify_guilds: disc.notify_guilds, notify_members: disc.notify_members });
|
||||
discWebhook = '';
|
||||
toast('Discord community settings saved');
|
||||
loadDisc();
|
||||
@@ -203,7 +203,7 @@
|
||||
{#if disc}
|
||||
<section class="card col">
|
||||
<div class="section-title"><KeyRound size={18} /><h2>Discord community</h2></div>
|
||||
<p class="help">{disc.linked_accounts} linked account{disc.linked_accounts === 1 ? '' : 's'}. Role sync needs the bot token and server ID above{disc.bot_ready ? '' : ' (not saved yet)'}. Sync only ever <em>adds</em>: it never removes a role or group. Map roles to groups under Players → Groups.</p>
|
||||
<p class="help">{disc.linked_accounts} linked account{disc.linked_accounts === 1 ? '' : 's'}. Role sync needs the bot token and server ID above{disc.bot_ready ? '' : ' (not saved yet)'}. Map group roles under Players → Groups and title roles under Leveling. The highest earned title role takes priority; obsolete title roles are removed.</p>
|
||||
<label class="field">Role sync
|
||||
<select bind:value={disc.role_sync}>
|
||||
<option value="off">Off</option>
|
||||
@@ -212,6 +212,7 @@
|
||||
<option value="both">Both directions</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">Base level Discord role ID<input bind:value={disc.base_role_id} inputmode="numeric" placeholder="Optional role for new players" /></label>
|
||||
<label class="field">Invite link shown in the launcher<input bind:value={disc.invite_url} placeholder="https://discord.gg/yourcode" autocomplete="off" /></label>
|
||||
<label class="field">Announcements webhook<input type="password" bind:value={discWebhook} placeholder={disc.webhook_set ? 'Saved — type to replace, or - to remove' : 'https://discord.com/api/webhooks/…'} autocomplete="off" /></label>
|
||||
<Toggle bind:checked={disc.notify_achievements} label="Announce achievements" />
|
||||
|
||||
Reference in new issue
Block a user