Fix achievement icons in launcher, allow opening OAuth URLs, live map docs
- Shared achievement icon catalogue; launcher resolves icon/background/border ids - Tauri opener capability now allows http(s) URLs (fixes Discord sign-in) - Rewrite live map docs, admin guide progression section Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
17 files changed
+511
-151
No files matched your search
@@ -279,7 +279,8 @@ 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();
|
||||
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
|
||||
}
|
||||
@@ -315,7 +316,11 @@ impl LiveMap {
|
||||
}
|
||||
pub fn region_dir(&self, id: i64, dim: Dim) -> PathBuf {
|
||||
let w = self.world_dir(id);
|
||||
if dim.folder().is_empty() { w.join("region") } else { w.join(dim.folder()).join("region") }
|
||||
if dim.folder().is_empty() {
|
||||
w.join("region")
|
||||
} else {
|
||||
w.join(dim.folder()).join("region")
|
||||
}
|
||||
}
|
||||
fn cache_dir(&self, id: i64, dim: Dim) -> PathBuf {
|
||||
self.server_dir(id).join("cache").join(dim.slug())
|
||||
@@ -449,7 +454,11 @@ impl LiveMap {
|
||||
/// 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 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());
|
||||
@@ -514,7 +523,19 @@ impl LiveMap {
|
||||
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 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);
|
||||
|
||||
@@ -202,7 +202,8 @@ pub async fn save(pool: &sqlx::SqlitePool, p: &Progression) -> AppResult<()> {
|
||||
/// Recompute every stored level from its XP after the curve changed.
|
||||
pub async fn recalculate_levels(pool: &sqlx::SqlitePool) -> AppResult<usize> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let globals: Vec<(String, i64, i64)> = sqlx::query_as("SELECT uuid, global_xp, global_level FROM user_levels").fetch_all(&mut *tx).await?;
|
||||
let globals: Vec<(String, i64, i64)> =
|
||||
sqlx::query_as("SELECT uuid, global_xp, global_level FROM user_levels").fetch_all(&mut *tx).await?;
|
||||
let mut changed = 0;
|
||||
for (uuid, xp, level) in globals {
|
||||
let new = level_from_xp(xp).0;
|
||||
@@ -211,11 +212,17 @@ pub async fn recalculate_levels(pool: &sqlx::SqlitePool) -> AppResult<usize> {
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
let servers: Vec<(i64, String, i64, i64)> = sqlx::query_as("SELECT server_id, uuid, server_xp, server_level FROM server_levels").fetch_all(&mut *tx).await?;
|
||||
let servers: Vec<(i64, String, i64, i64)> =
|
||||
sqlx::query_as("SELECT server_id, uuid, server_xp, server_level FROM server_levels").fetch_all(&mut *tx).await?;
|
||||
for (server_id, uuid, xp, level) in servers {
|
||||
let new = level_from_xp(xp).0;
|
||||
if new != level {
|
||||
sqlx::query("UPDATE server_levels SET server_level = ? WHERE server_id = ? AND uuid = ?").bind(new).bind(server_id).bind(&uuid).execute(&mut *tx).await?;
|
||||
sqlx::query("UPDATE server_levels SET server_level = ? WHERE server_id = ? AND uuid = ?")
|
||||
.bind(new)
|
||||
.bind(server_id)
|
||||
.bind(&uuid)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
@@ -234,7 +241,13 @@ pub async fn recalculate_levels(pool: &sqlx::SqlitePool) -> AppResult<usize> {
|
||||
/// stays put for the whole period, even if admins add or edit quests; it is
|
||||
/// only topped up (raised limit, a disabled quest) or truncated (lowered
|
||||
/// limit). Pinned quests are always chosen first.
|
||||
pub async fn assigned_quest_ids(conn: &mut SqliteConnection, cfg: &Progression, uuid: &str, period: &str, key: &str) -> AppResult<Vec<String>> {
|
||||
pub async fn assigned_quest_ids(
|
||||
conn: &mut SqliteConnection,
|
||||
cfg: &Progression,
|
||||
uuid: &str,
|
||||
period: &str,
|
||||
key: &str,
|
||||
) -> AppResult<Vec<String>> {
|
||||
let limit = if period == "weekly" { cfg.weekly_quest_limit } else { cfg.daily_quest_limit } as usize;
|
||||
let eligible: Vec<(String, bool)> = sqlx::query_as("SELECT id, pinned FROM quests WHERE enabled = 1 AND period = ? ORDER BY id")
|
||||
.bind(period)
|
||||
@@ -262,20 +275,23 @@ pub async fn assigned_quest_ids(conn: &mut SqliteConnection, cfg: &Progression,
|
||||
.map(|(id, pinned)| (!pinned, hex::encode(Sha256::digest(format!("{seed}|{id}").as_bytes())), id))
|
||||
.collect();
|
||||
ranked.sort();
|
||||
let mut position: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM quest_assignments WHERE user_uuid = ? AND period_key = ?")
|
||||
.bind(uuid)
|
||||
.bind(key)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
for (_, _, id) in ranked.into_iter().take(limit - current.len()) {
|
||||
sqlx::query("INSERT OR REPLACE INTO quest_assignments (user_uuid, period, period_key, quest_id, position) VALUES (?, ?, ?, ?, ?)")
|
||||
let mut position: i64 =
|
||||
sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM quest_assignments WHERE user_uuid = ? AND period_key = ?")
|
||||
.bind(uuid)
|
||||
.bind(period)
|
||||
.bind(key)
|
||||
.bind(&id)
|
||||
.bind(position)
|
||||
.execute(&mut *conn)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
for (_, _, id) in ranked.into_iter().take(limit - current.len()) {
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO quest_assignments (user_uuid, period, period_key, quest_id, position) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(uuid)
|
||||
.bind(period)
|
||||
.bind(key)
|
||||
.bind(&id)
|
||||
.bind(position)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
position += 1;
|
||||
current.push(id);
|
||||
}
|
||||
@@ -284,7 +300,14 @@ pub async fn assigned_quest_ids(conn: &mut SqliteConnection, cfg: &Progression,
|
||||
Ok(current)
|
||||
}
|
||||
|
||||
pub async fn is_assigned(conn: &mut SqliteConnection, cfg: &Progression, uuid: &str, period: &str, key: &str, quest_id: &str) -> AppResult<bool> {
|
||||
pub async fn is_assigned(
|
||||
conn: &mut SqliteConnection,
|
||||
cfg: &Progression,
|
||||
uuid: &str,
|
||||
period: &str,
|
||||
key: &str,
|
||||
quest_id: &str,
|
||||
) -> AppResult<bool> {
|
||||
Ok(assigned_quest_ids(conn, cfg, uuid, period, key).await?.iter().any(|id| id == quest_id))
|
||||
}
|
||||
|
||||
@@ -329,7 +352,8 @@ pub fn target_xp(mode: Mode, current: i64, amount: i64) -> AppResult<i64> {
|
||||
}
|
||||
|
||||
pub async fn adjust_global(conn: &mut SqliteConnection, uuid: &str, mode: Mode, amount: i64, now: &str) -> AppResult<XpChange> {
|
||||
let old_xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?").bind(uuid).fetch_optional(&mut *conn).await?.unwrap_or(0);
|
||||
let old_xp: i64 =
|
||||
sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?").bind(uuid).fetch_optional(&mut *conn).await?.unwrap_or(0);
|
||||
let new_xp = target_xp(mode, old_xp, amount)?;
|
||||
let (old_level, new_level) = (level_from_xp(old_xp).0, level_from_xp(new_xp).0);
|
||||
sqlx::query(
|
||||
@@ -345,7 +369,14 @@ pub async fn adjust_global(conn: &mut SqliteConnection, uuid: &str, mode: Mode,
|
||||
Ok(XpChange { old_xp, new_xp, old_level, new_level })
|
||||
}
|
||||
|
||||
pub async fn adjust_server(conn: &mut SqliteConnection, server_id: i64, uuid: &str, mode: Mode, amount: i64, now: &str) -> AppResult<XpChange> {
|
||||
pub async fn adjust_server(
|
||||
conn: &mut SqliteConnection,
|
||||
server_id: i64,
|
||||
uuid: &str,
|
||||
mode: Mode,
|
||||
amount: i64,
|
||||
now: &str,
|
||||
) -> AppResult<XpChange> {
|
||||
let old_xp: i64 = sqlx::query_scalar("SELECT server_xp FROM server_levels WHERE server_id = ? AND uuid = ?")
|
||||
.bind(server_id)
|
||||
.bind(uuid)
|
||||
|
||||
@@ -60,15 +60,29 @@ pub async fn game_config_route(GameServer(server): GameServer) -> Json<Value> {
|
||||
}
|
||||
|
||||
/// Timestamp tables of the regions the panel already has, base64 per region.
|
||||
pub async fn game_manifest(GameServer(server): GameServer, State(state): State<AppState>, Query(q): Query<DimQuery>) -> AppResult<Json<Value>> {
|
||||
pub async fn game_manifest(
|
||||
GameServer(server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<DimQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_enabled(server.live_map_enabled)?;
|
||||
let dim = dim_of(&q.dim)?;
|
||||
let b64 = base64::engine::general_purpose::STANDARD;
|
||||
let regions: Vec<Value> = state.livemap.manifest(server.id, dim).into_iter().map(|(x, z, ts)| json!({ "x": x, "z": z, "timestamps": b64.encode(ts) })).collect();
|
||||
let regions: Vec<Value> = state
|
||||
.livemap
|
||||
.manifest(server.id, dim)
|
||||
.into_iter()
|
||||
.map(|(x, z, ts)| json!({ "x": x, "z": z, "timestamps": b64.encode(ts) }))
|
||||
.collect();
|
||||
Ok(Json(json!({ "dimension": dim.slug(), "regions": regions })))
|
||||
}
|
||||
|
||||
pub async fn game_chunks(GameServer(server): GameServer, State(state): State<AppState>, Query(q): Query<DimQuery>, body: Bytes) -> AppResult<Json<Value>> {
|
||||
pub async fn game_chunks(
|
||||
GameServer(server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<DimQuery>,
|
||||
body: Bytes,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_enabled(server.live_map_enabled)?;
|
||||
let dim = dim_of(&q.dim)?;
|
||||
let chunks = parse_chunks(&body).map_err(AppError::bad_request)?;
|
||||
@@ -102,7 +116,11 @@ fn sanitize(mut players: Vec<LivePlayer>) -> Vec<LivePlayer> {
|
||||
players
|
||||
}
|
||||
|
||||
pub async fn game_players(GameServer(server): GameServer, State(state): State<AppState>, Json(b): Json<PlayersBody>) -> AppResult<Json<Value>> {
|
||||
pub async fn game_players(
|
||||
GameServer(server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Json(b): Json<PlayersBody>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_enabled(server.live_map_enabled)?;
|
||||
state.livemap.set_players(server.id, sanitize(b.players));
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
@@ -140,7 +158,12 @@ async fn ws_loop(mut socket: WebSocket, state: AppState, id: i64) {
|
||||
}
|
||||
Message::Binary(b) => {
|
||||
let reply = match b.split_first() {
|
||||
Some((d, rest)) => match Dim::ALL.get(*d as usize).copied().ok_or("unknown dimension").and_then(|dim| parse_chunks(rest).map(|c| (dim, c))) {
|
||||
Some((d, rest)) => match Dim::ALL
|
||||
.get(*d as usize)
|
||||
.copied()
|
||||
.ok_or("unknown dimension")
|
||||
.and_then(|dim| parse_chunks(rest).map(|c| (dim, c)))
|
||||
{
|
||||
Ok((dim, chunks)) => match state.livemap.ingest_chunks(id, dim, chunks).await {
|
||||
Ok(n) => json!({ "type": "stored", "count": n }),
|
||||
Err(e) => json!({ "type": "error", "error": e.to_string() }),
|
||||
@@ -236,7 +259,12 @@ pub async fn admin_reset(_: AdminUser, State(state): State<AppState>, Path(id):
|
||||
/// 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> {
|
||||
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)?;
|
||||
|
||||
@@ -11,11 +11,10 @@ use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
async fn pool_counts(state: &AppState) -> AppResult<Value> {
|
||||
let rows: Vec<(String, i64, i64)> = sqlx::query_as(
|
||||
"SELECT period, COUNT(*), COALESCE(SUM(pinned), 0) FROM quests WHERE enabled = 1 GROUP BY period",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let rows: Vec<(String, i64, i64)> =
|
||||
sqlx::query_as("SELECT period, COUNT(*), COALESCE(SUM(pinned), 0) FROM quests WHERE enabled = 1 GROUP BY period")
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut out = json!({ "daily": { "enabled": 0, "pinned": 0 }, "weekly": { "enabled": 0, "pinned": 0 } });
|
||||
for (period, n, pinned) in rows {
|
||||
if period == "daily" || period == "weekly" {
|
||||
@@ -45,9 +44,11 @@ pub async fn put_settings(admin: AdminUser, State(state): State<AppState>, Json(
|
||||
let before = progression::load_pool(&state.db).await?;
|
||||
progression::save(&state.db, &next).await?;
|
||||
progression::set_curve(&next);
|
||||
let curve_changed = before.level_base != next.level_base || before.level_exponent != next.level_exponent || before.max_level != next.max_level;
|
||||
let curve_changed =
|
||||
before.level_base != next.level_base || before.level_exponent != next.level_exponent || before.max_level != next.max_level;
|
||||
let recalculated = if curve_changed { progression::recalculate_levels(&state.db).await? } else { 0 };
|
||||
crate::routes::activity::record(&state, &admin, "panel", "progression_settings", Some(&format!("recalculated {recalculated} levels"))).await?;
|
||||
crate::routes::activity::record(&state, &admin, "panel", "progression_settings", Some(&format!("recalculated {recalculated} levels")))
|
||||
.await?;
|
||||
let mut v = view(&state, &next).await?;
|
||||
v["recalculated"] = json!(recalculated);
|
||||
Ok(Json(v))
|
||||
@@ -139,7 +140,12 @@ pub struct Adjust {
|
||||
reason: String,
|
||||
}
|
||||
|
||||
pub async fn adjust_player(admin: AdminUser, State(state): State<AppState>, Path(uuid): Path<String>, Json(a): Json<Adjust>) -> AppResult<Json<Value>> {
|
||||
pub async fn adjust_player(
|
||||
admin: AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Path(uuid): Path<String>,
|
||||
Json(a): Json<Adjust>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE uuid = ?)").bind(&uuid).fetch_one(&state.db).await?;
|
||||
if !exists {
|
||||
return Err(AppError::not_found("player not found"));
|
||||
@@ -150,7 +156,8 @@ pub async fn adjust_player(admin: AdminUser, State(state): State<AppState>, Path
|
||||
"global" => ("global".to_string(), progression::adjust_global(&mut tx, &uuid, a.mode, a.amount, &now).await?),
|
||||
"server" => {
|
||||
let id = a.server_id.ok_or_else(|| AppError::bad_request("pick a server"))?;
|
||||
let name: Option<String> = sqlx::query_scalar("SELECT name FROM game_servers WHERE id = ?").bind(id).fetch_optional(&mut *tx).await?;
|
||||
let name: Option<String> =
|
||||
sqlx::query_scalar("SELECT name FROM game_servers WHERE id = ?").bind(id).fetch_optional(&mut *tx).await?;
|
||||
let name = name.ok_or_else(|| AppError::not_found("server not found"))?;
|
||||
(format!("server {name}"), progression::adjust_server(&mut tx, id, &uuid, a.mode, a.amount, &now).await?)
|
||||
}
|
||||
@@ -163,7 +170,14 @@ pub async fn adjust_player(admin: AdminUser, State(state): State<AppState>, Path
|
||||
tx.commit().await?;
|
||||
|
||||
let reason: String = a.reason.chars().filter(|c| !c.is_control()).take(120).collect();
|
||||
let detail = format!("{label} XP {} → {} (level {} → {}) for {uuid}{}", change.old_xp, change.new_xp, change.old_level, change.new_level, if reason.is_empty() { String::new() } else { format!(": {reason}") });
|
||||
let detail = format!(
|
||||
"{label} XP {} → {} (level {} → {}) for {uuid}{}",
|
||||
change.old_xp,
|
||||
change.new_xp,
|
||||
change.old_level,
|
||||
change.new_level,
|
||||
if reason.is_empty() { String::new() } else { format!(": {reason}") }
|
||||
);
|
||||
crate::routes::activity::record(&state, &admin, "panel", "xp_adjust", Some(&detail)).await?;
|
||||
Ok(Json(player_detail(&state, &uuid).await?))
|
||||
}
|
||||
@@ -33,7 +33,8 @@ async fn live_map_ingest_status_and_proxy() {
|
||||
let (_, hello) = t.call("POST", "/api/server/v1/hello", Some(&token), Some(json!({}))).await;
|
||||
assert_eq!(hello["live_map"]["enabled"], false);
|
||||
|
||||
let (s, v) = t.call("PUT", &format!("/api/admin/servers/{id}"), Some(&admin), Some(json!({"name": "Survival", "live_map_enabled": true}))).await;
|
||||
let (s, v) =
|
||||
t.call("PUT", &format!("/api/admin/servers/{id}"), Some(&admin), Some(json!({"name": "Survival", "live_map_enabled": true}))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_eq!(v["live_map_enabled"], true);
|
||||
let (_, hello) = t.call("POST", "/api/server/v1/hello", Some(&token), Some(json!({}))).await;
|
||||
|
||||
@@ -11,7 +11,13 @@ async fn player(t: &TestApp, admin: &str, name: &str) -> (String, String) {
|
||||
}
|
||||
|
||||
fn ids(v: &Value, period: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = v.as_array().unwrap().iter().filter(|q| q["quest"]["period"] == period).map(|q| q["quest"]["id"].as_str().unwrap().to_string()).collect();
|
||||
let mut out: Vec<String> = v
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|q| q["quest"]["period"] == period)
|
||||
.map(|q| q["quest"]["id"].as_str().unwrap().to_string())
|
||||
.collect();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user