Admin progression controls: quest limits/rotation, XP rates, level curve, manual XP adjustments

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
Claude committed 2026-09-30 17:25:12 +00:00
1 parent f2362def88
commit e3e25da616
10 files changed
+821 -43

No files matched your search

+12
View File
@@ -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<SqlitePool> {
+2
View File
@@ -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<yggdrasil::keys::Keys>) -> anyhow::Result<AppState> {
let secret = jwt_secret(&cfg)?;
progression::set_curve(&progression::load_pool(&db).await.map_err(|e| anyhow::anyhow!(e.message))?);
Ok(AppState {
db,
ygg,
+421
View File
@@ -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<Curve> = 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<Progression> {
let raw: Option<String> = 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<Progression> {
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<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 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<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)
.fetch_all(&mut *conn)
.await?;
if limit == 0 || eligible.len() <= limit {
return Ok(eligible.into_iter().map(|(id, _)| id).collect());
}
let mut current: Vec<String> = 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<bool> {
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<i64> {
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<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 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<XpChange> {
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());
}
}
}
+1 -24
View File
@@ -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;
+5
View File
@@ -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<AppState> {
.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));
+169
View File
@@ -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<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 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<Value> {
// 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<Value> {
Ok(json!({ "settings": p, "quest_pool": pool_counts(state).await?, "curve": curve_preview(p) }))
}
pub async fn get_settings(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Value>> {
let p = progression::load_pool(&state.db).await?;
Ok(Json(view(&state, &p).await?))
}
pub async fn put_settings(admin: AdminUser, State(state): State<AppState>, Json(next): Json<Progression>) -> AppResult<Json<Value>> {
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<AppState>, Query(q): Query<PlayerQuery>) -> AppResult<Json<Value>> {
let like = format!("%{}%", q.q.trim().replace('%', "").replace('_', ""));
let rows: Vec<(String, String, i64, i64, Option<String>)> = 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<Value> = 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<Value> {
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<String>) = 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<String>)> = 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<Value> = 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<AppState>, Path(uuid): Path<String>) -> AppResult<Json<Value>> {
Ok(Json(player_detail(&state, &uuid).await?))
}
#[derive(Deserialize)]
pub struct Adjust {
/// `global` or `server`.
scope: String,
server_id: Option<i64>,
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<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"));
}
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<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?)
}
_ => 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?))
}
+46 -6
View File
@@ -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<String>> = 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<String>,
#[serde(alias = "active")]
pub enabled: Option<bool>,
pub pinned: Option<bool>,
}
/// 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?;
+14 -13
View File
@@ -395,6 +395,7 @@ pub struct Sync {
pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>, Json(s): Json<Sync>) -> AppResult<Json<Value>> {
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<AppState>,
.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, ?)