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

+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, ?)