//! 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, /// Economy and guild rules that apply on every server. pub rules: Rules, } /// Network-wide limits an admin can change without touching a server. 0 means "no limit" where it says so. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default)] pub struct Rules { /// Balance a player starts with on each server economy. pub starting_balance: f64, /// Chunks a new guild may claim. pub guild_base_claims: i64, /// Extra claimable chunks for every member beyond the leader. pub guild_claims_per_member: i64, /// Extra claimable chunks for every guild level above 1. pub guild_claims_per_level: i64, /// Most members a guild may have (0 = no limit). pub guild_max_members: i64, /// Most listings one player may have on a server's market (0 = no limit). pub market_max_listings: i64, } impl Default for Rules { fn default() -> Self { Self { starting_balance: 1000.0, guild_base_claims: 16, guild_claims_per_member: 0, guild_claims_per_level: 0, guild_max_members: 0, market_max_listings: 0 } } } 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(), rules: Rules::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 rules = &self.rules; if !rules.starting_balance.is_finite() || !(0.0..=1e9).contains(&rules.starting_balance) { return bad("Starting balance must be between 0 and 1,000,000,000."); } for (name, v, max) in [ ("Guild base claims", rules.guild_base_claims, 100_000), ("Claims per member", rules.guild_claims_per_member, 10_000), ("Claims per guild level", rules.guild_claims_per_level, 10_000), ("Guild member limit", rules.guild_max_members, 10_000), ("Market listing limit", rules.market_max_listings, 10_000), ] { if !(0..=max).contains(&v) { return Err(AppError::bad_request(format!("{name} must be between 0 and {max}."))); } } 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?; // The same rules as plain rows, so SQL can use them (guild claim limits are worked out in queries). for (key, value) in [ ("rule_claims_per_member", p.rules.guild_claims_per_member), ("rule_claims_per_level", p.rules.guild_claims_per_level), ("rule_guild_max_members", p.rules.guild_max_members), ] { sqlx::query("INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").bind(key).bind(value.to_string()).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()); } } }