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:
10 files changed
+821
-43
No files matched your search
@@ -558,6 +558,18 @@ const MIGRATIONS: &[&str] = &[
|
|||||||
r#"
|
r#"
|
||||||
ALTER TABLE game_servers ADD COLUMN live_map_enabled INTEGER NOT NULL DEFAULT 0;
|
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> {
|
pub async fn connect(data_dir: &Path) -> Result<SqlitePool> {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pub mod error;
|
|||||||
pub mod livemap;
|
pub mod livemap;
|
||||||
pub mod net;
|
pub mod net;
|
||||||
pub mod packs;
|
pub mod packs;
|
||||||
|
pub mod progression;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod seed;
|
pub mod seed;
|
||||||
pub mod state;
|
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).
|
/// 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> {
|
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)?;
|
let secret = jwt_secret(&cfg)?;
|
||||||
|
progression::set_curve(&progression::load_pool(&db).await.map_err(|e| anyhow::anyhow!(e.message))?);
|
||||||
Ok(AppState {
|
Ok(AppState {
|
||||||
db,
|
db,
|
||||||
ygg,
|
ygg,
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,30 +9,7 @@ use scopenet_shared::{ServerLevelInfo, UserLevelInfo};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
/// XP required to reach level L.
|
pub use crate::progression::{level_from_xp, xp_for_level};
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Award each configured entitlement once in the transaction which earned it.
|
/// Award each configured entitlement once in the transaction which earned it.
|
||||||
/// Achievement XP is credited automatically via the `achievement_xp` DB trigger;
|
/// Achievement XP is credited automatically via the `achievement_xp` DB trigger;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub mod landing;
|
|||||||
pub mod livemap;
|
pub mod livemap;
|
||||||
pub mod leveling;
|
pub mod leveling;
|
||||||
pub mod meta;
|
pub mod meta;
|
||||||
|
pub mod progression;
|
||||||
pub mod public;
|
pub mod public;
|
||||||
pub mod quests;
|
pub mod quests;
|
||||||
pub mod servers;
|
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("/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", get(achievements::admin_list_achievements).post(achievements::admin_create_achievement))
|
||||||
.route("/achievements/{id}", put(achievements::admin_update_achievement).delete(achievements::admin_delete_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", get(guilds::admin_list_guilds))
|
||||||
.route("/guilds/{id}", delete(guilds::admin_delete_guild))
|
.route("/guilds/{id}", delete(guilds::admin_delete_guild))
|
||||||
.layer(DefaultBodyLimit::max(upload_limit));
|
.layer(DefaultBodyLimit::max(upload_limit));
|
||||||
|
|||||||
@@ -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?))
|
||||||
|
}
|
||||||
@@ -17,6 +17,15 @@ pub fn current_weekly_key() -> String {
|
|||||||
chrono::Utc::now().format("%G-W%V").to_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 {
|
fn action_matches(target: &str, action: &str) -> bool {
|
||||||
let Some(target) = target.strip_prefix("action:") else { return false };
|
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)));
|
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?;
|
).fetch_all(&mut **tx).await?;
|
||||||
let daily = current_daily_key();
|
let daily = current_daily_key();
|
||||||
let weekly = current_weekly_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 {
|
for (id, period, target, required) in rows {
|
||||||
if !action_matches(&target, action) { continue; }
|
if !action_matches(&target, action) { continue; }
|
||||||
let key = if period == "weekly" { &weekly } else { &daily };
|
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(
|
sqlx::query(
|
||||||
"INSERT INTO user_quests (user_uuid, quest_id, period_key, progress, completed, claimed, updated_at)
|
"INSERT INTO user_quests (user_uuid, quest_id, period_key, progress, completed, claimed, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, 0, ?)
|
VALUES (?, ?, ?, ?, ?, 0, ?)
|
||||||
@@ -115,8 +133,16 @@ pub async fn get_my_quests(
|
|||||||
.fetch_all(&state.db)
|
.fetch_all(&state.db)
|
||||||
.await?;
|
.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
|
let list = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
.filter(|r| if r.3 == "weekly" { weekly_ids.contains(&r.0) } else { daily_ids.contains(&r.0) })
|
||||||
.map(
|
.map(
|
||||||
|(
|
|(
|
||||||
id,
|
id,
|
||||||
@@ -134,6 +160,7 @@ pub async fn get_my_quests(
|
|||||||
claimed_opt,
|
claimed_opt,
|
||||||
period_key_opt,
|
period_key_opt,
|
||||||
)| {
|
)| {
|
||||||
|
let quest_period_is_weekly = period == "weekly";
|
||||||
let progress = progress_opt.unwrap_or(0);
|
let progress = progress_opt.unwrap_or(0);
|
||||||
let completed = completed_opt.unwrap_or(progress >= target);
|
let completed = completed_opt.unwrap_or(progress >= target);
|
||||||
let claimed = claimed_opt.unwrap_or(false);
|
let claimed = claimed_opt.unwrap_or(false);
|
||||||
@@ -166,7 +193,7 @@ pub async fn get_my_quests(
|
|||||||
completed,
|
completed,
|
||||||
claimed,
|
claimed,
|
||||||
period_key: pkey,
|
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()
|
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(
|
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 = ?",
|
"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)]
|
#[serde(flatten)]
|
||||||
pub quest: Quest,
|
pub quest: Quest,
|
||||||
pub total_completions: i64,
|
pub total_completions: i64,
|
||||||
|
/// Always handed out first when a player's quests are limited.
|
||||||
|
pub pinned: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Admin list all quests.
|
/// Admin list all quests.
|
||||||
@@ -302,10 +336,11 @@ pub async fn admin_list_quests(
|
|||||||
String,
|
String,
|
||||||
bool,
|
bool,
|
||||||
i64,
|
i64,
|
||||||
|
bool,
|
||||||
)> = sqlx::query_as(
|
)> = sqlx::query_as(
|
||||||
"SELECT q.id, q.title, q.description, q.period, q.category, q.target_stat,
|
"SELECT q.id, q.title, q.description, q.period, q.category, q.target_stat,
|
||||||
q.target_count, q.xp_reward, q.icon, q.enabled,
|
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
|
FROM quests q
|
||||||
LEFT JOIN user_quests uq ON uq.quest_id = q.id AND uq.completed = 1
|
LEFT JOIN user_quests uq ON uq.quest_id = q.id AND uq.completed = 1
|
||||||
GROUP BY q.id
|
GROUP BY q.id
|
||||||
@@ -317,7 +352,7 @@ pub async fn admin_list_quests(
|
|||||||
let list = rows
|
let list = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(
|
.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 {
|
AdminQuestRow {
|
||||||
quest: Quest {
|
quest: Quest {
|
||||||
id,
|
id,
|
||||||
@@ -335,6 +370,7 @@ pub async fn admin_list_quests(
|
|||||||
active: Some(enabled),
|
active: Some(enabled),
|
||||||
},
|
},
|
||||||
total_completions: completions,
|
total_completions: completions,
|
||||||
|
pinned,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -358,6 +394,7 @@ pub struct QuestPayload {
|
|||||||
pub icon: Option<String>,
|
pub icon: Option<String>,
|
||||||
#[serde(alias = "active")]
|
#[serde(alias = "active")]
|
||||||
pub enabled: Option<bool>,
|
pub enabled: Option<bool>,
|
||||||
|
pub pinned: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Admin create quest.
|
/// Admin create quest.
|
||||||
@@ -375,8 +412,8 @@ pub async fn admin_create_quest(
|
|||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO quests (id, title, description, period, category, target_stat, target_count, xp_reward, icon, enabled, created_at)
|
"INSERT INTO quests (id, title, description, period, category, target_stat, target_count, xp_reward, icon, enabled, created_at, pinned)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(payload.title)
|
.bind(payload.title)
|
||||||
@@ -389,6 +426,7 @@ pub async fn admin_create_quest(
|
|||||||
.bind(payload.icon.unwrap_or_else(|| "⛏️".into()))
|
.bind(payload.icon.unwrap_or_else(|| "⛏️".into()))
|
||||||
.bind(payload.enabled.unwrap_or(true))
|
.bind(payload.enabled.unwrap_or(true))
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
|
.bind(payload.pinned.unwrap_or(false))
|
||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -415,7 +453,8 @@ pub async fn admin_update_quest(
|
|||||||
target_count = ?,
|
target_count = ?,
|
||||||
xp_reward = ?,
|
xp_reward = ?,
|
||||||
icon = COALESCE(?, icon),
|
icon = COALESCE(?, icon),
|
||||||
enabled = COALESCE(?, enabled)
|
enabled = COALESCE(?, enabled),
|
||||||
|
pinned = COALESCE(?, pinned)
|
||||||
WHERE id = ?",
|
WHERE id = ?",
|
||||||
)
|
)
|
||||||
.bind(payload.title)
|
.bind(payload.title)
|
||||||
@@ -427,6 +466,7 @@ pub async fn admin_update_quest(
|
|||||||
.bind(payload.xp_reward)
|
.bind(payload.xp_reward)
|
||||||
.bind(payload.icon)
|
.bind(payload.icon)
|
||||||
.bind(payload.enabled)
|
.bind(payload.enabled)
|
||||||
|
.bind(payload.pinned)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -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>> {
|
pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>, Json(s): Json<Sync>) -> AppResult<Json<Value>> {
|
||||||
let at_now = now();
|
let at_now = now();
|
||||||
let mut tx = state.db.begin().await?;
|
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 leveling_on = s.features.as_ref().and_then(|f| f.leveling_enabled).unwrap_or(true);
|
||||||
let mut xp_before = std::collections::HashMap::new();
|
let mut xp_before = std::collections::HashMap::new();
|
||||||
if !leveling_on {
|
if !leveling_on {
|
||||||
@@ -486,7 +487,7 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
|
|||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.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);
|
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,
|
d: &StatDelta,
|
||||||
at_now: &str,
|
at_now: &str,
|
||||||
features: Option<&FeatureToggles>,
|
features: Option<&FeatureToggles>,
|
||||||
|
cfg: &crate::progression::Progression,
|
||||||
) -> AppResult<()> {
|
) -> AppResult<()> {
|
||||||
let leveling_on = features.and_then(|f| f.leveling_enabled).unwrap_or(true);
|
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 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 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);
|
// Each game server's own multiplier stacks with the panel-wide one.
|
||||||
let server_mult = features.and_then(|f| f.server_xp_multiplier).unwrap_or(1.5).max(0.0);
|
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)
|
// 1. Calculate XP gains: Global XP and Server XP (only if leveling enabled)
|
||||||
if leveling_on {
|
if leveling_on {
|
||||||
let base_xp = (d.playtime_secs / 6)
|
let base_xp = cfg.base_xp(d.playtime_secs, d.player_kills, d.mob_kills, d.blocks_broken, d.blocks_placed, d.messages);
|
||||||
+ (d.player_kills * 50)
|
|
||||||
+ (d.mob_kills * 15)
|
|
||||||
+ (d.blocks_broken / 5)
|
|
||||||
+ (d.blocks_placed / 5)
|
|
||||||
+ (d.messages * 2);
|
|
||||||
|
|
||||||
let delta_xp = (base_xp as f64 * global_mult).round() as i64;
|
let delta_xp = (base_xp * global_mult).round() as i64;
|
||||||
let server_delta_xp = (base_xp as f64 * server_mult).round() as i64;
|
let server_delta_xp = (base_xp * server_mult).round() as i64;
|
||||||
|
|
||||||
if delta_xp > 0 {
|
if delta_xp > 0 {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -679,6 +677,9 @@ async fn update_progression_for_delta(
|
|||||||
|
|
||||||
let daily_key = crate::routes::quests::current_daily_key();
|
let daily_key = crate::routes::quests::current_daily_key();
|
||||||
let weekly_key = crate::routes::quests::current_weekly_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 {
|
for (stat_name, amount) in stat_deltas {
|
||||||
if amount <= 0 {
|
if amount <= 0 {
|
||||||
@@ -693,7 +694,7 @@ async fn update_progression_for_delta(
|
|||||||
.fetch_all(&mut **tx)
|
.fetch_all(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
for (qid, target) in d_quests {
|
for (qid, target) in d_quests.into_iter().filter(|(id, _)| daily_ids.contains(id)) {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO user_quests (user_uuid, quest_id, period_key, progress, completed, claimed, updated_at)
|
"INSERT INTO user_quests (user_uuid, quest_id, period_key, progress, completed, claimed, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, 0, ?)
|
VALUES (?, ?, ?, ?, ?, 0, ?)
|
||||||
@@ -721,7 +722,7 @@ async fn update_progression_for_delta(
|
|||||||
.fetch_all(&mut **tx)
|
.fetch_all(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
for (qid, target) in w_quests {
|
for (qid, target) in w_quests.into_iter().filter(|(id, _)| weekly_ids.contains(id)) {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO user_quests (user_uuid, quest_id, period_key, progress, completed, claimed, updated_at)
|
"INSERT INTO user_quests (user_uuid, quest_id, period_key, progress, completed, claimed, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, 0, ?)
|
VALUES (?, ?, ?, ?, ?, 0, ?)
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ async fn leveling_and_quests_progression() {
|
|||||||
let admin = t.login("admin", "supersecret").await;
|
let admin = t.login("admin", "supersecret").await;
|
||||||
let (alex_token, _, alex_uuid, _) = setup_users(&t).await;
|
let (alex_token, _, alex_uuid, _) = setup_users(&t).await;
|
||||||
let (_server_id, server_token) = create_test_server(&t, &admin).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
|
// 1. Initial level check
|
||||||
let (s, lvl) = t.call("GET", "/api/v1/leveling/me", Some(&alex_token), None).await;
|
let (s, lvl) = t.call("GET", "/api/v1/leveling/me", Some(&alex_token), None).await;
|
||||||
|
|||||||
@@ -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<String> {
|
||||||
|
let mut out: Vec<String> = v.as_array().unwrap().iter().filter(|q| q["quest"]["period"] == period).map(|q| q["quest"]["id"].as_str().unwrap().to_string()).collect();
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user