diff --git a/panel/server/src/db.rs b/panel/server/src/db.rs index 93fa0ad..51db138 100644 --- a/panel/server/src/db.rs +++ b/panel/server/src/db.rs @@ -558,6 +558,18 @@ const MIGRATIONS: &[&str] = &[ r#" ALTER TABLE game_servers ADD COLUMN live_map_enabled INTEGER NOT NULL DEFAULT 0; "#, + r#" + ALTER TABLE quests ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0; + CREATE TABLE quest_assignments ( + user_uuid TEXT NOT NULL, + period TEXT NOT NULL, + period_key TEXT NOT NULL, + quest_id TEXT NOT NULL REFERENCES quests(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + PRIMARY KEY (user_uuid, period_key, quest_id) + ); + CREATE INDEX quest_assignments_user ON quest_assignments(user_uuid, period_key, position); + "#, ]; pub async fn connect(data_dir: &Path) -> Result { diff --git a/panel/server/src/lib.rs b/panel/server/src/lib.rs index 056ccf9..50b3de8 100644 --- a/panel/server/src/lib.rs +++ b/panel/server/src/lib.rs @@ -10,6 +10,7 @@ pub mod error; pub mod livemap; pub mod net; pub mod packs; +pub mod progression; pub mod routes; pub mod seed; pub mod state; @@ -54,6 +55,7 @@ pub async fn build_state(cfg: config::Config, db: sqlx::SqlitePool) -> anyhow::R /// Like [`build_state`] with a given auth-server key (tests reuse one key). pub async fn build_state_with_keys(cfg: config::Config, db: sqlx::SqlitePool, ygg: Arc) -> anyhow::Result { let secret = jwt_secret(&cfg)?; + progression::set_curve(&progression::load_pool(&db).await.map_err(|e| anyhow::anyhow!(e.message))?); Ok(AppState { db, ygg, diff --git a/panel/server/src/progression.rs b/panel/server/src/progression.rs new file mode 100644 index 0000000..052697d --- /dev/null +++ b/panel/server/src/progression.rs @@ -0,0 +1,421 @@ +//! Admin-tunable progression: quest limits and rotation, XP rates, the level +//! curve and manual XP/level adjustments. + +use crate::error::{AppError, AppResult}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use sqlx::SqliteConnection; +use std::sync::RwLock; + +/// Hard ceiling so a bad curve can never make level lookups spin. +pub const LEVEL_CEILING: i64 = 10_000; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct XpRates { + /// XP per hour spent online. + pub playtime_per_hour: f64, + pub player_kill: f64, + pub mob_kill: f64, + pub block_broken: f64, + pub block_placed: f64, + pub message: f64, +} + +impl Default for XpRates { + // Matches the original hard-coded formula: 1 XP per 6 s, 50/15 per kill, + // 1 XP per 5 blocks, 2 per chat message. + fn default() -> Self { + Self { playtime_per_hour: 600.0, player_kill: 50.0, mob_kill: 15.0, block_broken: 0.2, block_placed: 0.2, message: 2.0 } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct Progression { + /// Daily quests each player is given per day. 0 hands out every enabled quest. + pub daily_quest_limit: u32, + /// Weekly quests each player is given per week. 0 hands out every enabled quest. + pub weekly_quest_limit: u32, + /// `per_player` (each player draws their own set) or `shared` (everyone gets the same set). + pub quest_rotation: String, + /// XP needed for level L is `round(level_base * L^level_exponent)`. + pub level_base: f64, + pub level_exponent: f64, + /// Highest reachable level. 0 means no cap. + pub max_level: i64, + /// Panel-side multipliers, applied on top of each game server's own. + pub global_xp_multiplier: f64, + pub server_xp_multiplier: f64, + pub xp_rates: XpRates, +} + +impl Default for Progression { + fn default() -> Self { + Self { + daily_quest_limit: 5, + weekly_quest_limit: 3, + quest_rotation: "per_player".into(), + level_base: 100.0, + level_exponent: 1.5, + max_level: 0, + global_xp_multiplier: 1.0, + server_xp_multiplier: 1.0, + xp_rates: XpRates::default(), + } + } +} + +impl Progression { + pub fn validate(&self) -> AppResult<()> { + let bad = |m: &str| Err(AppError::bad_request(m.to_string())); + if self.daily_quest_limit > 100 || self.weekly_quest_limit > 100 { + return bad("Quest limits can be at most 100 (use 0 to give out every quest)."); + } + if !matches!(self.quest_rotation.as_str(), "per_player" | "shared") { + return bad("Quest rotation must be per_player or shared."); + } + if !self.level_base.is_finite() || !(10.0..=100_000.0).contains(&self.level_base) { + return bad("Level base must be between 10 and 100,000."); + } + if !self.level_exponent.is_finite() || !(1.0..=3.0).contains(&self.level_exponent) { + return bad("Level exponent must be between 1 and 3."); + } + if !(0..=LEVEL_CEILING).contains(&self.max_level) || (self.max_level == 1) { + return bad("Max level must be 0 (no cap) or between 2 and 10,000."); + } + for (name, v) in [("Global XP multiplier", self.global_xp_multiplier), ("Server XP multiplier", self.server_xp_multiplier)] { + if !v.is_finite() || !(0.0..=100.0).contains(&v) { + return Err(AppError::bad_request(format!("{name} must be between 0 and 100."))); + } + } + let r = &self.xp_rates; + for (name, v) in [ + ("Playtime XP", r.playtime_per_hour), + ("Player kill XP", r.player_kill), + ("Mob kill XP", r.mob_kill), + ("Block broken XP", r.block_broken), + ("Block placed XP", r.block_placed), + ("Chat message XP", r.message), + ] { + if !v.is_finite() || !(0.0..=100_000.0).contains(&v) { + return Err(AppError::bad_request(format!("{name} must be between 0 and 100,000."))); + } + } + Ok(()) + } + + /// Base XP for one batch of stat deltas, before multipliers. + pub fn base_xp(&self, playtime_secs: i64, player_kills: i64, mob_kills: i64, broken: i64, placed: i64, messages: i64) -> f64 { + let r = &self.xp_rates; + playtime_secs as f64 / 3600.0 * r.playtime_per_hour + + player_kills as f64 * r.player_kill + + mob_kills as f64 * r.mob_kill + + broken as f64 * r.block_broken + + placed as f64 * r.block_placed + + messages as f64 * r.message + } +} + +// --------------------------------------------------------------------------- +// The level curve is process-wide: levels are looked up from many places that +// have no access to the app state. It is loaded at startup and whenever an +// admin saves new settings. +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy)] +struct Curve { + base: f64, + exponent: f64, + max_level: i64, +} + +static CURVE: RwLock = RwLock::new(Curve { base: 100.0, exponent: 1.5, max_level: 0 }); + +pub fn set_curve(p: &Progression) { + *CURVE.write().unwrap() = Curve { base: p.level_base, exponent: p.level_exponent, max_level: p.max_level }; +} + +fn curve() -> Curve { + *CURVE.read().unwrap() +} + +pub fn max_level() -> i64 { + match curve().max_level { + 0 => LEVEL_CEILING, + m => m.min(LEVEL_CEILING), + } +} + +/// XP required to reach level `lvl`. +pub fn xp_for_level(lvl: i64) -> i64 { + if lvl <= 1 { + return 0; + } + let c = curve(); + (c.base * (lvl as f64).powf(c.exponent)).round() as i64 +} + +/// `(level, xp into the level, xp the level spans, progress %)` for a total. +pub fn level_from_xp(xp: i64) -> (i64, i64, i64, f64) { + let cap = max_level(); + let mut lvl = 1; + if xp > 0 { + // Jump close to the answer first so huge totals stay cheap. + let c = curve(); + let guess = ((xp as f64 / c.base).powf(1.0 / c.exponent)).floor() as i64; + lvl = (guess - 2).clamp(1, cap); + while lvl > 1 && xp < xp_for_level(lvl) { + lvl -= 1; + } + while lvl < cap && xp >= xp_for_level(lvl + 1) { + lvl += 1; + } + } + let curr_req = xp_for_level(lvl); + let progress = (xp - curr_req).max(0); + if lvl >= cap { + return (lvl, progress, progress.max(1), 100.0); + } + let span = (xp_for_level(lvl + 1) - curr_req).max(1); + (lvl, progress, span, (progress as f64 / span as f64 * 100.0).clamp(0.0, 100.0)) +} + +pub async fn load(conn: &mut SqliteConnection) -> AppResult { + let raw: Option = sqlx::query_scalar("SELECT value FROM kv WHERE key = 'progression'").fetch_optional(&mut *conn).await?; + Ok(raw.and_then(|r| serde_json::from_str(&r).ok()).unwrap_or_default()) +} + +pub async fn load_pool(pool: &sqlx::SqlitePool) -> AppResult { + let mut conn = pool.acquire().await?; + load(&mut conn).await +} + +pub async fn save(pool: &sqlx::SqlitePool, p: &Progression) -> AppResult<()> { + sqlx::query("INSERT INTO kv (key, value) VALUES ('progression', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value") + .bind(serde_json::to_string(p)?) + .execute(pool) + .await?; + Ok(()) +} + +/// Recompute every stored level from its XP after the curve changed. +pub async fn recalculate_levels(pool: &sqlx::SqlitePool) -> AppResult { + 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 mut changed = 0; + for (uuid, xp, level) in globals { + let new = level_from_xp(xp).0; + if new != level { + sqlx::query("UPDATE user_levels SET global_level = ? WHERE uuid = ?").bind(new).bind(&uuid).execute(&mut *tx).await?; + 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?; + 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?; + changed += 1; + } + } + tx.commit().await?; + Ok(changed) +} + +// --------------------------------------------------------------------------- +// Quest assignment +// --------------------------------------------------------------------------- + +/// The quests one player is given for a period. `period` is `daily` or +/// `weekly` and `key` the current period key. +/// +/// With a limit, the chosen set is stored the first time it is needed so it +/// 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> { + 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) + .fetch_all(&mut *conn) + .await?; + if limit == 0 || eligible.len() <= limit { + return Ok(eligible.into_iter().map(|(id, _)| id).collect()); + } + + let mut current: Vec = sqlx::query_scalar( + "SELECT qa.quest_id FROM quest_assignments qa JOIN quests q ON q.id = qa.quest_id + WHERE qa.user_uuid = ? AND qa.period_key = ? AND q.enabled = 1 AND q.period = ? ORDER BY qa.position", + ) + .bind(uuid) + .bind(key) + .bind(period) + .fetch_all(&mut *conn) + .await?; + + if current.len() < limit { + let seed = if cfg.quest_rotation == "shared" { format!("{period}|{key}") } else { format!("{period}|{key}|{uuid}") }; + let mut ranked: Vec<(bool, String, String)> = eligible + .into_iter() + .filter(|(id, _)| !current.contains(id)) + .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 (?, ?, ?, ?, ?)") + .bind(uuid) + .bind(period) + .bind(key) + .bind(&id) + .bind(position) + .execute(&mut *conn) + .await?; + position += 1; + current.push(id); + } + } + current.truncate(limit); + Ok(current) +} + +pub async fn is_assigned(conn: &mut SqliteConnection, cfg: &Progression, uuid: &str, period: &str, key: &str, quest_id: &str) -> AppResult { + Ok(assigned_quest_ids(conn, cfg, uuid, period, key).await?.iter().any(|id| id == quest_id)) +} + +// --------------------------------------------------------------------------- +// Manual XP changes +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Mode { + Add, + Remove, + SetXp, + SetLevel, + Reset, +} + +pub struct XpChange { + pub old_xp: i64, + pub new_xp: i64, + pub old_level: i64, + pub new_level: i64, +} + +pub fn target_xp(mode: Mode, current: i64, amount: i64) -> AppResult { + const MAX_XP: i64 = 2_000_000_000; + if !(0..=MAX_XP).contains(&amount) { + return Err(AppError::bad_request("Amount must be between 0 and 2,000,000,000.")); + } + Ok(match mode { + Mode::Add => (current + amount).min(MAX_XP), + Mode::Remove => (current - amount).max(0), + Mode::SetXp => amount, + Mode::SetLevel => { + if amount < 1 || amount > max_level() { + return Err(AppError::bad_request(format!("Level must be between 1 and {}.", max_level()))); + } + xp_for_level(amount) + } + Mode::Reset => 0, + }) +} + +pub async fn adjust_global(conn: &mut SqliteConnection, uuid: &str, mode: Mode, amount: i64, now: &str) -> AppResult { + 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( + "INSERT INTO user_levels (uuid, global_xp, global_level, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT(uuid) DO UPDATE SET global_xp = excluded.global_xp, global_level = excluded.global_level, updated_at = excluded.updated_at", + ) + .bind(uuid) + .bind(new_xp) + .bind(new_level) + .bind(now) + .execute(&mut *conn) + .await?; + 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 { + let old_xp: i64 = sqlx::query_scalar("SELECT server_xp FROM server_levels WHERE server_id = ? AND uuid = ?") + .bind(server_id) + .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( + "INSERT INTO server_levels (server_id, uuid, server_xp, server_level, updated_at) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(server_id, uuid) DO UPDATE SET server_xp = excluded.server_xp, server_level = excluded.server_level, updated_at = excluded.updated_at", + ) + .bind(server_id) + .bind(uuid) + .bind(new_xp) + .bind(new_level) + .bind(now) + .execute(&mut *conn) + .await?; + Ok(XpChange { old_xp, new_xp, old_level, new_level }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_the_original_curve_and_rates() { + let p = Progression::default(); + assert_eq!(xp_for_level(1), 0); + assert_eq!(xp_for_level(2), 283); + assert_eq!(xp_for_level(10), 3162); + // 10 minutes online, 2 mob kills, 10 blocks broken: 100 + 30 + 2. + assert_eq!(p.base_xp(600, 0, 2, 10, 0, 0).round() as i64, 132); + p.validate().unwrap(); + } + + #[test] + fn levels_follow_xp_both_ways() { + for xp in [0, 1, 282, 283, 284, 3161, 3162, 1_000_000, 50_000_000] { + let (lvl, into, span, pct) = level_from_xp(xp); + assert!(xp_for_level(lvl) <= xp.max(0) && (xp < xp_for_level(lvl + 1) || lvl >= LEVEL_CEILING), "xp {xp} -> level {lvl}"); + assert_eq!(into, xp - xp_for_level(lvl)); + assert!(span >= 1 && (0.0..=100.0).contains(&pct)); + } + } + + #[test] + fn modes_compute_targets() { + assert_eq!(target_xp(Mode::Add, 100, 50).unwrap(), 150); + assert_eq!(target_xp(Mode::Remove, 100, 500).unwrap(), 0); + assert_eq!(target_xp(Mode::SetXp, 100, 7).unwrap(), 7); + assert_eq!(target_xp(Mode::SetLevel, 100, 10).unwrap(), 3162); + assert_eq!(target_xp(Mode::Reset, 100, 0).unwrap(), 0); + assert!(target_xp(Mode::Add, 0, -1).is_err()); + assert!(target_xp(Mode::SetLevel, 0, 0).is_err()); + } + + #[test] + fn rejects_bad_settings() { + let ok = Progression::default(); + for bad in [ + Progression { daily_quest_limit: 101, ..ok.clone() }, + Progression { quest_rotation: "x".into(), ..ok.clone() }, + Progression { level_exponent: 0.5, ..ok.clone() }, + Progression { level_base: f64::NAN, ..ok.clone() }, + Progression { max_level: 1, ..ok.clone() }, + Progression { global_xp_multiplier: -1.0, ..ok.clone() }, + ] { + assert!(bad.validate().is_err()); + } + } +} diff --git a/panel/server/src/routes/leveling.rs b/panel/server/src/routes/leveling.rs index c1b90f0..16e9942 100644 --- a/panel/server/src/routes/leveling.rs +++ b/panel/server/src/routes/leveling.rs @@ -9,30 +9,7 @@ use scopenet_shared::{ServerLevelInfo, UserLevelInfo}; use serde::{Deserialize, Serialize}; use serde_json::Value; -/// XP required to reach level L. -pub fn xp_for_level(lvl: i64) -> i64 { - if lvl <= 1 { - return 0; - } - ((lvl as f64).powf(1.5) * 100.0).round() as i64 -} - -/// Computes (level, current_level_xp, next_level_span, progress_pct) from total XP. -pub fn level_from_xp(xp: i64) -> (i64, i64, i64, f64) { - if xp <= 0 { - return (1, 0, 100, 0.0); - } - let mut lvl = 1; - while xp >= xp_for_level(lvl + 1) { - lvl += 1; - } - let curr_req = xp_for_level(lvl); - let next_req = xp_for_level(lvl + 1); - let span = (next_req - curr_req).max(1); - let progress_in_lvl = (xp - curr_req).max(0); - let pct = (progress_in_lvl as f64 / span as f64 * 100.0).clamp(0.0, 100.0); - (lvl, progress_in_lvl, span, pct) -} +pub use crate::progression::{level_from_xp, xp_for_level}; /// Award each configured entitlement once in the transaction which earned it. /// Achievement XP is credited automatically via the `achievement_xp` DB trigger; diff --git a/panel/server/src/routes/mod.rs b/panel/server/src/routes/mod.rs index b874bd9..3054b3f 100644 --- a/panel/server/src/routes/mod.rs +++ b/panel/server/src/routes/mod.rs @@ -9,6 +9,7 @@ pub mod landing; pub mod livemap; pub mod leveling; pub mod meta; +pub mod progression; pub mod public; pub mod quests; pub mod servers; @@ -159,6 +160,10 @@ pub fn api(state: &AppState) -> Router { .route("/quests/{id}", put(quests::admin_update_quest).delete(quests::admin_delete_quest)) .route("/achievements", get(achievements::admin_list_achievements).post(achievements::admin_create_achievement)) .route("/achievements/{id}", put(achievements::admin_update_achievement).delete(achievements::admin_delete_achievement)) + .route("/progression", get(progression::get_settings).put(progression::put_settings)) + .route("/progression/players", get(progression::list_players)) + .route("/progression/players/{uuid}", get(progression::get_player)) + .route("/progression/players/{uuid}/adjust", post(progression::adjust_player)) .route("/guilds", get(guilds::admin_list_guilds)) .route("/guilds/{id}", delete(guilds::admin_delete_guild)) .layer(DefaultBodyLimit::max(upload_limit)); diff --git a/panel/server/src/routes/progression.rs b/panel/server/src/routes/progression.rs new file mode 100644 index 0000000..dcd6d59 --- /dev/null +++ b/panel/server/src/routes/progression.rs @@ -0,0 +1,169 @@ +//! Admin control of progression: quest limits, XP rates, the level curve and +//! manual per-player XP/level changes. + +use crate::auth::AdminUser; +use crate::error::{AppError, AppResult}; +use crate::progression::{self, Mode, Progression}; +use crate::state::AppState; +use axum::extract::{Path, Query, State}; +use axum::Json; +use serde::Deserialize; +use serde_json::{json, Value}; + +async fn pool_counts(state: &AppState) -> AppResult { + 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" { + out[period] = json!({ "enabled": n, "pinned": pinned }); + } + } + Ok(out) +} + +fn curve_preview(p: &Progression) -> Vec { + // The preview is computed from the settings being shown, not the live curve. + let cap = if p.max_level == 0 { 50 } else { p.max_level.min(50) }; + (2..=cap).map(|l| json!({ "level": l, "xp": (p.level_base * (l as f64).powf(p.level_exponent)).round() as i64 })).collect() +} + +async fn view(state: &AppState, p: &Progression) -> AppResult { + Ok(json!({ "settings": p, "quest_pool": pool_counts(state).await?, "curve": curve_preview(p) })) +} + +pub async fn get_settings(_: AdminUser, State(state): State) -> AppResult> { + let p = progression::load_pool(&state.db).await?; + Ok(Json(view(&state, &p).await?)) +} + +pub async fn put_settings(admin: AdminUser, State(state): State, Json(next): Json) -> AppResult> { + next.validate()?; + 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 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?; + let mut v = view(&state, &next).await?; + v["recalculated"] = json!(recalculated); + Ok(Json(v)) +} + +#[derive(Deserialize)] +pub struct PlayerQuery { + #[serde(default)] + q: String, + #[serde(default = "default_limit")] + limit: i64, + #[serde(default)] + offset: i64, +} +fn default_limit() -> i64 { + 40 +} + +/// Players with their global level, searchable by name. +pub async fn list_players(_: AdminUser, State(state): State, Query(q): Query) -> AppResult> { + let like = format!("%{}%", q.q.trim().replace('%', "").replace('_', "")); + let rows: Vec<(String, String, i64, i64, Option)> = sqlx::query_as( + "SELECT u.uuid, u.username, COALESCE(ul.global_xp, 0), COALESCE(ul.global_level, 1), ul.title + FROM users u LEFT JOIN user_levels ul ON ul.uuid = u.uuid + WHERE u.username LIKE ? ORDER BY COALESCE(ul.global_xp, 0) DESC, u.username COLLATE NOCASE LIMIT ? OFFSET ?", + ) + .bind(&like) + .bind(q.limit.clamp(1, 100)) + .bind(q.offset.max(0)) + .fetch_all(&state.db) + .await?; + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username LIKE ?").bind(&like).fetch_one(&state.db).await?; + let players: Vec = rows + .into_iter() + .map(|(uuid, name, xp, _, title)| { + let (level, into, span, pct) = progression::level_from_xp(xp); + json!({ "uuid": uuid, "name": name, "global_xp": xp, "global_level": level, "level_xp": into, "level_span": span, "progress_pct": pct, "title": title }) + }) + .collect(); + Ok(Json(json!({ "players": players, "total": total }))) +} + +async fn player_detail(state: &AppState, uuid: &str) -> AppResult { + let user: Option<(String,)> = sqlx::query_as("SELECT username FROM users WHERE uuid = ?").bind(uuid).fetch_optional(&state.db).await?; + let (name,) = user.ok_or_else(|| AppError::not_found("player not found"))?; + let (xp, title): (i64, Option) = sqlx::query_as("SELECT global_xp, title FROM user_levels WHERE uuid = ?") + .bind(uuid) + .fetch_optional(&state.db) + .await? + .unwrap_or((0, None)); + let (level, into, span, pct) = progression::level_from_xp(xp); + let servers: Vec<(i64, String, i64, Option)> = sqlx::query_as( + "SELECT s.id, s.name, COALESCE(sl.server_xp, 0), sl.rank_name + FROM game_servers s LEFT JOIN server_levels sl ON sl.server_id = s.id AND sl.uuid = ? + ORDER BY s.name COLLATE NOCASE", + ) + .bind(uuid) + .fetch_all(&state.db) + .await?; + let servers: Vec = servers + .into_iter() + .map(|(id, sname, sxp, rank)| { + let (sl, sinto, sspan, spct) = progression::level_from_xp(sxp); + json!({ "server_id": id, "server_name": sname, "xp": sxp, "level": sl, "level_xp": sinto, "level_span": sspan, "progress_pct": spct, "rank_name": rank }) + }) + .collect(); + Ok(json!({ + "uuid": uuid, "name": name, "title": title, + "global": { "xp": xp, "level": level, "level_xp": into, "level_span": span, "progress_pct": pct }, + "servers": servers, + "max_level": progression::max_level(), + })) +} + +pub async fn get_player(_: AdminUser, State(state): State, Path(uuid): Path) -> AppResult> { + Ok(Json(player_detail(&state, &uuid).await?)) +} + +#[derive(Deserialize)] +pub struct Adjust { + /// `global` or `server`. + scope: String, + server_id: Option, + mode: Mode, + /// XP for add/remove/set_xp, the level for set_level. Ignored for reset. + #[serde(default)] + amount: i64, + #[serde(default)] + reason: String, +} + +pub async fn adjust_player(admin: AdminUser, State(state): State, Path(uuid): Path, Json(a): Json) -> AppResult> { + 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")); + } + let now = chrono::Utc::now().to_rfc3339(); + let mut tx = state.db.begin().await?; + let (label, change) = match a.scope.as_str() { + "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 = 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?) + } + _ => 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?; + } + 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}") }); + crate::routes::activity::record(&state, &admin, "panel", "xp_adjust", Some(&detail)).await?; + Ok(Json(player_detail(&state, &uuid).await?)) +} diff --git a/panel/server/src/routes/quests.rs b/panel/server/src/routes/quests.rs index 8042fda..0afe827 100644 --- a/panel/server/src/routes/quests.rs +++ b/panel/server/src/routes/quests.rs @@ -17,6 +17,15 @@ pub fn current_weekly_key() -> String { chrono::Utc::now().format("%G-W%V").to_string() } +/// When the current daily and weekly periods end (UTC midnight / next Monday). +fn period_ends() -> (String, String) { + use chrono::{Datelike, Duration, Utc}; + let today = Utc::now().date_naive(); + let midnight = |d: chrono::NaiveDate| d.and_hms_opt(0, 0, 0).unwrap().and_utc().to_rfc3339(); + let days_to_monday = 7 - today.weekday().num_days_from_monday() as i64; + (midnight(today + Duration::days(1)), midnight(today + Duration::days(days_to_monday))) +} + fn action_matches(target: &str, action: &str) -> bool { let Some(target) = target.strip_prefix("action:") else { return false }; let (target_action, target_dimension) = target.split_once('@').map_or((target, None), |(a, d)| (a, Some(d))); @@ -58,9 +67,18 @@ pub async fn advance_action_quests( ).fetch_all(&mut **tx).await?; let daily = current_daily_key(); let weekly = current_weekly_key(); + let cfg = crate::progression::load(&mut **tx).await?; + let mut assigned: std::collections::HashMap<&str, Vec> = std::collections::HashMap::new(); for (id, period, target, required) in rows { if !action_matches(&target, action) { continue; } let key = if period == "weekly" { &weekly } else { &daily }; + // Only quests this player was given for the period make progress. + let period_name = if period == "weekly" { "weekly" } else { "daily" }; + if !assigned.contains_key(period_name) { + let ids = crate::progression::assigned_quest_ids(&mut **tx, &cfg, uuid, period_name, key).await?; + assigned.insert(period_name, ids); + } + if !assigned[period_name].contains(&id) { continue; } sqlx::query( "INSERT INTO user_quests (user_uuid, quest_id, period_key, progress, completed, claimed, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?) @@ -115,8 +133,16 @@ pub async fn get_my_quests( .fetch_all(&state.db) .await?; + let cfg = crate::progression::load_pool(&state.db).await?; + let mut conn = state.db.acquire().await?; + let daily_ids = crate::progression::assigned_quest_ids(&mut conn, &cfg, &auth.uuid, "daily", &daily_key).await?; + let weekly_ids = crate::progression::assigned_quest_ids(&mut conn, &cfg, &auth.uuid, "weekly", &weekly_key).await?; + drop(conn); + let (daily_ends, weekly_ends) = period_ends(); + let list = rows .into_iter() + .filter(|r| if r.3 == "weekly" { weekly_ids.contains(&r.0) } else { daily_ids.contains(&r.0) }) .map( |( id, @@ -134,6 +160,7 @@ pub async fn get_my_quests( claimed_opt, period_key_opt, )| { + let quest_period_is_weekly = period == "weekly"; let progress = progress_opt.unwrap_or(0); let completed = completed_opt.unwrap_or(progress >= target); let claimed = claimed_opt.unwrap_or(false); @@ -166,7 +193,7 @@ pub async fn get_my_quests( completed, claimed, period_key: pkey, - expires_at: Some(chrono::Utc::now().to_rfc3339()), + expires_at: Some(if quest_period_is_weekly { weekly_ends.clone() } else { daily_ends.clone() }), } }, ) @@ -199,6 +226,11 @@ pub async fn claim_quest( current_daily_key() }; + let cfg = crate::progression::load(&mut tx).await?; + if !crate::progression::is_assigned(&mut tx, &cfg, &auth.uuid, &period, &period_key, &quest_id).await? { + return Err(AppError::bad_request("That quest isn't one of yours this period")); + } + let user_quest: Option<(i64, bool, bool)> = sqlx::query_as( "SELECT progress, completed, claimed FROM user_quests WHERE user_uuid = ? AND quest_id = ? AND period_key = ?", ) @@ -283,6 +315,8 @@ pub struct AdminQuestRow { #[serde(flatten)] pub quest: Quest, pub total_completions: i64, + /// Always handed out first when a player's quests are limited. + pub pinned: bool, } /// Admin list all quests. @@ -302,10 +336,11 @@ pub async fn admin_list_quests( String, bool, i64, + bool, )> = sqlx::query_as( "SELECT q.id, q.title, q.description, q.period, q.category, q.target_stat, q.target_count, q.xp_reward, q.icon, q.enabled, - COUNT(uq.user_uuid) as total_completions + COUNT(uq.user_uuid) as total_completions, q.pinned FROM quests q LEFT JOIN user_quests uq ON uq.quest_id = q.id AND uq.completed = 1 GROUP BY q.id @@ -317,7 +352,7 @@ pub async fn admin_list_quests( let list = rows .into_iter() .map( - |(id, title, desc, period, cat, stat, target, xp, icon, enabled, completions)| { + |(id, title, desc, period, cat, stat, target, xp, icon, enabled, completions, pinned)| { AdminQuestRow { quest: Quest { id, @@ -335,6 +370,7 @@ pub async fn admin_list_quests( active: Some(enabled), }, total_completions: completions, + pinned, } }, ) @@ -358,6 +394,7 @@ pub struct QuestPayload { pub icon: Option, #[serde(alias = "active")] pub enabled: Option, + pub pinned: Option, } /// Admin create quest. @@ -375,8 +412,8 @@ pub async fn admin_create_quest( let now = chrono::Utc::now().to_rfc3339(); sqlx::query( - "INSERT INTO quests (id, title, description, period, category, target_stat, target_count, xp_reward, icon, enabled, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO quests (id, title, description, period, category, target_stat, target_count, xp_reward, icon, enabled, created_at, pinned) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&id) .bind(payload.title) @@ -389,6 +426,7 @@ pub async fn admin_create_quest( .bind(payload.icon.unwrap_or_else(|| "⛏️".into())) .bind(payload.enabled.unwrap_or(true)) .bind(&now) + .bind(payload.pinned.unwrap_or(false)) .execute(&state.db) .await?; @@ -415,7 +453,8 @@ pub async fn admin_update_quest( target_count = ?, xp_reward = ?, icon = COALESCE(?, icon), - enabled = COALESCE(?, enabled) + enabled = COALESCE(?, enabled), + pinned = COALESCE(?, pinned) WHERE id = ?", ) .bind(payload.title) @@ -427,6 +466,7 @@ pub async fn admin_update_quest( .bind(payload.xp_reward) .bind(payload.icon) .bind(payload.enabled) + .bind(payload.pinned) .bind(&id) .execute(&state.db) .await?; diff --git a/panel/server/src/routes/servers.rs b/panel/server/src/routes/servers.rs index 3a466e6..2b7aaf2 100644 --- a/panel/server/src/routes/servers.rs +++ b/panel/server/src/routes/servers.rs @@ -395,6 +395,7 @@ pub struct Sync { pub async fn sync(GameServer(server): GameServer, State(state): State, Json(s): Json) -> AppResult> { let at_now = now(); let mut tx = state.db.begin().await?; + let progression = crate::progression::load(&mut tx).await?; let leveling_on = s.features.as_ref().and_then(|f| f.leveling_enabled).unwrap_or(true); let mut xp_before = std::collections::HashMap::new(); if !leveling_on { @@ -486,7 +487,7 @@ pub async fn sync(GameServer(server): GameServer, State(state): State, .execute(&mut *tx) .await?; - update_progression_for_delta(&mut tx, server.id, &uuid, d, &at_now, s.features.as_ref()).await?; + update_progression_for_delta(&mut tx, server.id, &uuid, d, &at_now, s.features.as_ref(), &progression).await?; } let achievements_on = s.features.as_ref().and_then(|f| f.achievements_enabled).unwrap_or(true); @@ -587,25 +588,22 @@ async fn update_progression_for_delta( d: &StatDelta, at_now: &str, features: Option<&FeatureToggles>, + cfg: &crate::progression::Progression, ) -> AppResult<()> { let leveling_on = features.and_then(|f| f.leveling_enabled).unwrap_or(true); let quests_on = features.and_then(|f| f.quests_enabled).unwrap_or(true); let achievements_on = features.and_then(|f| f.achievements_enabled).unwrap_or(true); - let global_mult = features.and_then(|f| f.global_xp_multiplier).unwrap_or(1.0).max(0.0); - let server_mult = features.and_then(|f| f.server_xp_multiplier).unwrap_or(1.5).max(0.0); + // Each game server's own multiplier stacks with the panel-wide one. + let global_mult = features.and_then(|f| f.global_xp_multiplier).unwrap_or(1.0).max(0.0) * cfg.global_xp_multiplier; + let server_mult = features.and_then(|f| f.server_xp_multiplier).unwrap_or(1.5).max(0.0) * cfg.server_xp_multiplier; // 1. Calculate XP gains: Global XP and Server XP (only if leveling enabled) if leveling_on { - let base_xp = (d.playtime_secs / 6) - + (d.player_kills * 50) - + (d.mob_kills * 15) - + (d.blocks_broken / 5) - + (d.blocks_placed / 5) - + (d.messages * 2); + let base_xp = cfg.base_xp(d.playtime_secs, d.player_kills, d.mob_kills, d.blocks_broken, d.blocks_placed, d.messages); - let delta_xp = (base_xp as f64 * global_mult).round() as i64; - let server_delta_xp = (base_xp as f64 * server_mult).round() as i64; + let delta_xp = (base_xp * global_mult).round() as i64; + let server_delta_xp = (base_xp * server_mult).round() as i64; if delta_xp > 0 { sqlx::query( @@ -679,6 +677,9 @@ async fn update_progression_for_delta( let daily_key = crate::routes::quests::current_daily_key(); let weekly_key = crate::routes::quests::current_weekly_key(); + // Only the quests this player was given for the period make progress. + let daily_ids = crate::progression::assigned_quest_ids(&mut **tx, cfg, uuid, "daily", &daily_key).await?; + let weekly_ids = crate::progression::assigned_quest_ids(&mut **tx, cfg, uuid, "weekly", &weekly_key).await?; for (stat_name, amount) in stat_deltas { if amount <= 0 { @@ -693,7 +694,7 @@ async fn update_progression_for_delta( .fetch_all(&mut **tx) .await?; - for (qid, target) in d_quests { + for (qid, target) in d_quests.into_iter().filter(|(id, _)| daily_ids.contains(id)) { sqlx::query( "INSERT INTO user_quests (user_uuid, quest_id, period_key, progress, completed, claimed, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?) @@ -721,7 +722,7 @@ async fn update_progression_for_delta( .fetch_all(&mut **tx) .await?; - for (qid, target) in w_quests { + for (qid, target) in w_quests.into_iter().filter(|(id, _)| weekly_ids.contains(id)) { sqlx::query( "INSERT INTO user_quests (user_uuid, quest_id, period_key, progress, completed, claimed, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?) diff --git a/panel/server/tests/features_v2.rs b/panel/server/tests/features_v2.rs index 4bc0046..22dccce 100644 --- a/panel/server/tests/features_v2.rs +++ b/panel/server/tests/features_v2.rs @@ -37,6 +37,9 @@ async fn leveling_and_quests_progression() { let admin = t.login("admin", "supersecret").await; let (alex_token, _, alex_uuid, _) = setup_users(&t).await; let (_server_id, server_token) = create_test_server(&t, &admin).await; + // Hand every quest to every player so progress can be checked per quest. + let (s, v) = t.call("PUT", "/api/admin/progression", Some(&admin), Some(json!({"daily_quest_limit": 0, "weekly_quest_limit": 0}))).await; + assert_eq!(s, StatusCode::OK, "{v}"); // 1. Initial level check let (s, lvl) = t.call("GET", "/api/v1/leveling/me", Some(&alex_token), None).await; diff --git a/panel/server/tests/progression.rs b/panel/server/tests/progression.rs new file mode 100644 index 0000000..9fc9aaf --- /dev/null +++ b/panel/server/tests/progression.rs @@ -0,0 +1,148 @@ +//! Admin progression controls: quest limits/rotation, level curve, manual XP. +//! One test on purpose: the level curve is process-wide state. + +mod common; +use common::*; + +async fn player(t: &TestApp, admin: &str, name: &str) -> (String, String) { + let (s, v) = t.call("POST", "/api/admin/users", Some(admin), Some(json!({"username": name, "password": "password123"}))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + (t.login(name, "password123").await, t.uuid(name).await) +} + +fn ids(v: &Value, period: &str) -> Vec { + let mut out: Vec = 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 +} + +#[tokio::test] +async fn admins_control_quest_limits_levels_and_xp() { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + let (alex, _) = player(&t, &admin, "Alex").await; + let (steve, steve_uuid) = player(&t, &admin, "Steve").await; + + // Defaults: 5 daily + 3 weekly quests per player out of the seeded pool. + let (s, cfg) = t.call("GET", "/api/admin/progression", Some(&admin), None).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(cfg["settings"]["daily_quest_limit"], 5); + let weekly_pool = cfg["quest_pool"]["weekly"]["enabled"].as_u64().unwrap() as usize; + assert!(cfg["quest_pool"]["daily"]["enabled"].as_u64().unwrap() > 20 && weekly_pool > 20); + let (s, _) = t.call("GET", "/api/admin/progression", Some(&steve), None).await; + assert_eq!(s, StatusCode::FORBIDDEN); + + let (_, mine) = t.call("GET", "/api/v1/quests/my", Some(&steve), None).await; + assert_eq!(ids(&mine, "daily").len(), 5); + assert_eq!(ids(&mine, "weekly").len(), 3); + // Stable on repeat, different between players. + let (_, again) = t.call("GET", "/api/v1/quests/my", Some(&steve), None).await; + assert_eq!(ids(&mine, "daily"), ids(&again, "daily")); + let (_, theirs) = t.call("GET", "/api/v1/quests/my", Some(&alex), None).await; + assert_ne!(ids(&mine, "daily"), ids(&theirs, "daily")); + + // A quest that wasn't handed out can't be claimed. + let given = ids(&mine, "daily"); + let other = ids(&theirs, "daily").into_iter().find(|id| !given.contains(id)).unwrap(); + let (s, e) = t.call("POST", &format!("/api/v1/quests/{other}/claim"), Some(&steve), None).await; + assert_eq!(s, StatusCode::BAD_REQUEST, "{e}"); + + // Lower the limit: the first picks stay. Raise it: they are kept and topped up. + let mut settings = cfg["settings"].clone(); + settings["daily_quest_limit"] = json!(2); + let (s, v) = t.call("PUT", "/api/admin/progression", Some(&admin), Some(settings.clone())).await; + assert_eq!(s, StatusCode::OK, "{v}"); + let (_, fewer) = t.call("GET", "/api/v1/quests/my", Some(&steve), None).await; + let fewer_ids = ids(&fewer, "daily"); + assert_eq!(fewer_ids.len(), 2); + assert!(fewer_ids.iter().all(|id| given.contains(id))); + settings["daily_quest_limit"] = json!(7); + t.call("PUT", "/api/admin/progression", Some(&admin), Some(settings.clone())).await; + let (_, more) = t.call("GET", "/api/v1/quests/my", Some(&steve), None).await; + assert_eq!(ids(&more, "daily").len(), 7); + assert!(fewer_ids.iter().all(|id| ids(&more, "daily").contains(id))); + + // 0 hands out everything; "shared" gives all players the same set. + settings["weekly_quest_limit"] = json!(0); + settings["daily_quest_limit"] = json!(4); + settings["quest_rotation"] = json!("shared"); + t.call("PUT", "/api/admin/progression", Some(&admin), Some(settings.clone())).await; + let (_, all_weekly) = t.call("GET", "/api/v1/quests/my", Some(&steve), None).await; + assert_eq!(ids(&all_weekly, "weekly").len(), weekly_pool); + // Players who already drew today's set keep it; new players share one set. + let (mia, _) = player(&t, &admin, "Mia").await; + let (zoe, _) = player(&t, &admin, "Zoe").await; + let (_, a) = t.call("GET", "/api/v1/quests/my", Some(&mia), None).await; + let (_, b) = t.call("GET", "/api/v1/quests/my", Some(&zoe), None).await; + assert_eq!(ids(&a, "daily").len(), 4); + assert_eq!(ids(&a, "daily"), ids(&b, "daily"), "shared rotation is the same for everyone"); + + // Pinned quests are picked first. + let pin = ids(&theirs, "daily").into_iter().find(|id| !ids(&a, "daily").contains(id)).unwrap(); + let (s, q) = t.call("GET", "/api/admin/quests", Some(&admin), None).await; + assert_eq!(s, StatusCode::OK); + let found = q.as_array().unwrap().iter().find(|x| x["id"] == pin.as_str()).unwrap(); + let quest = json!({ + "title": found["title"], "description": found["description"], "period": found["period"], "category": found["category"], + "target_stat": found["target_stat"], "target_count": found["target_count"], "xp_reward": found["xp_reward"], "pinned": true + }); + let (s, e) = t.call("PUT", &format!("/api/admin/quests/{pin}"), Some(&admin), Some(quest)).await; + assert_eq!(s, StatusCode::OK, "{e}"); + let (_, pinned) = t.call("GET", "/api/v1/quests/my", Some(&admin), None).await; + assert!(ids(&pinned, "daily").contains(&pin)); + + // Validation. + settings["daily_quest_limit"] = json!(500); + let (s, _) = t.call("PUT", "/api/admin/progression", Some(&admin), Some(settings.clone())).await; + assert_eq!(s, StatusCode::BAD_REQUEST); + + // Manual XP. + let adjust = |body: Value| { + let uri = format!("/api/admin/progression/players/{steve_uuid}/adjust"); + let (t, admin) = (&t, admin.clone()); + async move { t.call("POST", &uri, Some(&admin), Some(body)).await } + }; + let (s, v) = adjust(json!({"scope": "global", "mode": "add", "amount": 1000, "reason": "event prize"})).await; + assert_eq!(s, StatusCode::OK, "{v}"); + assert_eq!(v["global"]["xp"], 1000); + assert_eq!(v["global"]["level"], 4); // 100 * 4^1.5 = 800 <= 1000 < 100 * 5^1.5 = 1118 + let (_, v) = adjust(json!({"scope": "global", "mode": "set_level", "level": 10, "amount": 10})).await; + assert_eq!(v["global"]["xp"], 3162); + assert_eq!(v["global"]["level"], 10); + let (_, v) = adjust(json!({"scope": "global", "mode": "remove", "amount": 5000})).await; + assert_eq!(v["global"]["xp"], 0); + let (s, _) = adjust(json!({"scope": "global", "mode": "set_level", "amount": 0})).await; + assert_eq!(s, StatusCode::BAD_REQUEST); + + let (_, servers) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "Survival"}))).await; + let sid = servers["server"]["id"].as_i64().unwrap(); + let (s, v) = adjust(json!({"scope": "server", "server_id": sid, "mode": "set_xp", "amount": 300})).await; + assert_eq!(s, StatusCode::OK, "{v}"); + assert_eq!(v["servers"][0]["xp"], 300); + assert_eq!(v["servers"][0]["level"], 2); + let (_, me) = t.call("GET", "/api/v1/levels/me", Some(&steve), None).await; + assert_eq!(me["server_levels"][0]["server_xp"], 300); + + let (_, list) = t.call("GET", "/api/admin/progression/players?q=ste", Some(&admin), None).await; + assert_eq!(list["players"][0]["name"], "Steve"); + let (_, log) = t.call("GET", "/api/admin/activity?source=panel", Some(&admin), None).await; + assert!(log.as_array().unwrap().iter().any(|e| e["kind"] == "xp_adjust" && e["detail"].as_str().unwrap().contains("event prize"))); + + // A steeper curve recalculates stored levels: 1000 XP is level 4 at base + // 100 but only level 2 at base 500 (500 * 2^1.5 = 1414 > 1000 -> level 1). + adjust(json!({"scope": "global", "mode": "set_xp", "amount": 1000})).await; + settings["daily_quest_limit"] = json!(5); + settings["level_base"] = json!(500.0); + let (s, v) = t.call("PUT", "/api/admin/progression", Some(&admin), Some(settings.clone())).await; + assert_eq!(s, StatusCode::OK, "{v}"); + assert!(v["recalculated"].as_u64().unwrap() >= 1); + let (_, me) = t.call("GET", "/api/v1/levels/me", Some(&steve), None).await; + assert_eq!(me["global_level"], 1); + // And a cap. + settings["level_base"] = json!(100.0); + settings["max_level"] = json!(3); + t.call("PUT", "/api/admin/progression", Some(&admin), Some(settings.clone())).await; + let (_, me) = t.call("GET", "/api/v1/levels/me", Some(&steve), None).await; + assert_eq!(me["global_level"], 3); + assert_eq!(me["progress_pct"], 100.0); +}