feat: add global & server leveling, quests, achievements, guilds land-claiming, social & toggleable configs
This commit is contained in:
1 parent
4b79f69c20
commit
b4cef85b1f
40 files changed
+10261
-27
No files matched your search
@@ -395,6 +395,19 @@ pub struct GameEvent {
|
||||
at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Clone, Debug)]
|
||||
#[serde(default)]
|
||||
pub struct FeatureToggles {
|
||||
pub leveling_enabled: Option<bool>,
|
||||
pub global_xp_multiplier: Option<f64>,
|
||||
pub server_xp_multiplier: Option<f64>,
|
||||
pub quests_enabled: Option<bool>,
|
||||
pub achievements_enabled: Option<bool>,
|
||||
pub guilds_enabled: Option<bool>,
|
||||
pub land_claiming_enabled: Option<bool>,
|
||||
pub social_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct Sync {
|
||||
@@ -404,6 +417,7 @@ pub struct Sync {
|
||||
online: Vec<OnlinePlayer>,
|
||||
stats: Vec<StatDelta>,
|
||||
events: Vec<GameEvent>,
|
||||
features: Option<FeatureToggles>,
|
||||
}
|
||||
|
||||
/// Periodic heartbeat: who's online, TPS, stat deltas and notable events.
|
||||
@@ -490,8 +504,12 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
|
||||
.bind(&at_now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
update_progression_for_delta(&mut tx, server.id, &uuid, d, &at_now, s.features.as_ref()).await?;
|
||||
}
|
||||
|
||||
let achievements_on = s.features.as_ref().and_then(|f| f.achievements_enabled).unwrap_or(true);
|
||||
|
||||
for e in s.events.iter().filter(|_| fresh).take(MAX_EVENTS_PER_SYNC) {
|
||||
let kind = clip(&e.kind, 24).to_ascii_lowercase();
|
||||
if kind.is_empty() {
|
||||
@@ -506,11 +524,34 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
|
||||
.bind(server.id)
|
||||
.bind(e.uuid.as_deref().and_then(dashed))
|
||||
.bind(e.name.as_deref().map(|n| clip(n, 16)))
|
||||
.bind(kind)
|
||||
.bind(&kind)
|
||||
.bind(e.detail.as_deref().map(|d| clip(d, 256)))
|
||||
.bind(at)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Check if event unlocks an achievement (if achievements enabled)
|
||||
if achievements_on {
|
||||
if let Some(ref euuid) = e.uuid.as_deref().and_then(dashed) {
|
||||
let event_ach: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT id FROM achievements WHERE requirement_type = 'event' AND requirement_key = ?",
|
||||
)
|
||||
.bind(&kind)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for ach_id in event_ach {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(euuid)
|
||||
.bind(ach_id)
|
||||
.bind(&at_now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -533,6 +574,241 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
|
||||
Ok(Json(json!({ "ok": true, "kick": kick, "sync_interval_secs": SYNC_INTERVAL_SECS })))
|
||||
}
|
||||
|
||||
async fn update_progression_for_delta(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
server_id: i64,
|
||||
uuid: &str,
|
||||
d: &StatDelta,
|
||||
at_now: &str,
|
||||
features: Option<&FeatureToggles>,
|
||||
) -> 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);
|
||||
|
||||
// 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 delta_xp = (base_xp as f64 * global_mult).round() as i64;
|
||||
let server_delta_xp = (base_xp as f64 * server_mult).round() as i64;
|
||||
|
||||
if delta_xp > 0 {
|
||||
sqlx::query(
|
||||
"INSERT INTO user_levels (uuid, global_xp, global_level, updated_at)
|
||||
VALUES (?, ?, 1, ?)
|
||||
ON CONFLICT (uuid) DO UPDATE SET
|
||||
global_xp = global_xp + excluded.global_xp,
|
||||
updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(uuid)
|
||||
.bind(delta_xp)
|
||||
.bind(at_now)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
// Recompute global level
|
||||
let new_xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?")
|
||||
.bind(uuid)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
let (new_lvl, _, _, _) = crate::routes::leveling::level_from_xp(new_xp);
|
||||
sqlx::query("UPDATE user_levels SET global_level = ? WHERE uuid = ?")
|
||||
.bind(new_lvl)
|
||||
.bind(uuid)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if server_delta_xp > 0 {
|
||||
sqlx::query(
|
||||
"INSERT INTO server_levels (server_id, uuid, server_xp, server_level, updated_at)
|
||||
VALUES (?, ?, ?, 1, ?)
|
||||
ON CONFLICT (server_id, uuid) DO UPDATE SET
|
||||
server_xp = server_xp + excluded.server_xp,
|
||||
updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(server_id)
|
||||
.bind(uuid)
|
||||
.bind(server_delta_xp)
|
||||
.bind(at_now)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
// Recompute server level
|
||||
let new_s_xp: i64 = sqlx::query_scalar("SELECT server_xp FROM server_levels WHERE server_id = ? AND uuid = ?")
|
||||
.bind(server_id)
|
||||
.bind(uuid)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
let (new_s_lvl, _, _, _) = crate::routes::leveling::level_from_xp(new_s_xp);
|
||||
sqlx::query("UPDATE server_levels SET server_level = ? WHERE server_id = ? AND uuid = ?")
|
||||
.bind(new_s_lvl)
|
||||
.bind(server_id)
|
||||
.bind(uuid)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Advance active Daily & Weekly Quests (if quests enabled)
|
||||
if quests_on {
|
||||
let stat_deltas = [
|
||||
("playtime_secs", d.playtime_secs),
|
||||
("joins", d.joins),
|
||||
("player_kills", d.player_kills),
|
||||
("mob_kills", d.mob_kills),
|
||||
("blocks_broken", d.blocks_broken),
|
||||
("blocks_placed", d.blocks_placed),
|
||||
("messages", d.messages),
|
||||
];
|
||||
|
||||
let daily_key = crate::routes::quests::current_daily_key();
|
||||
let weekly_key = crate::routes::quests::current_weekly_key();
|
||||
|
||||
for (stat_name, amount) in stat_deltas {
|
||||
if amount <= 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Daily quests matching this stat
|
||||
let d_quests: Vec<(String, i64)> = sqlx::query_as(
|
||||
"SELECT id, target_count FROM quests WHERE enabled = 1 AND period = 'daily' AND target_stat = ?",
|
||||
)
|
||||
.bind(stat_name)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
|
||||
for (qid, target) in d_quests {
|
||||
sqlx::query(
|
||||
"INSERT INTO user_quests (user_uuid, quest_id, period_key, progress, completed, claimed, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?)
|
||||
ON CONFLICT (user_uuid, quest_id, period_key) DO UPDATE SET
|
||||
progress = progress + excluded.progress,
|
||||
completed = CASE WHEN (progress + excluded.progress) >= ? THEN 1 ELSE completed END,
|
||||
updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(uuid)
|
||||
.bind(&qid)
|
||||
.bind(&daily_key)
|
||||
.bind(amount)
|
||||
.bind(if amount >= target { 1 } else { 0 })
|
||||
.bind(at_now)
|
||||
.bind(target)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Weekly quests matching this stat
|
||||
let w_quests: Vec<(String, i64)> = sqlx::query_as(
|
||||
"SELECT id, target_count FROM quests WHERE enabled = 1 AND period = 'weekly' AND target_stat = ?",
|
||||
)
|
||||
.bind(stat_name)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
|
||||
for (qid, target) in w_quests {
|
||||
sqlx::query(
|
||||
"INSERT INTO user_quests (user_uuid, quest_id, period_key, progress, completed, claimed, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?)
|
||||
ON CONFLICT (user_uuid, quest_id, period_key) DO UPDATE SET
|
||||
progress = progress + excluded.progress,
|
||||
completed = CASE WHEN (progress + excluded.progress) >= ? THEN 1 ELSE completed END,
|
||||
updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(uuid)
|
||||
.bind(&qid)
|
||||
.bind(&weekly_key)
|
||||
.bind(amount)
|
||||
.bind(if amount >= target { 1 } else { 0 })
|
||||
.bind(at_now)
|
||||
.bind(target)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Milestone Achievements Check (if achievements enabled)
|
||||
if achievements_on {
|
||||
let total_stats: Option<(Option<i64>, Option<i64>, Option<i64>, Option<i64>, Option<i64>, Option<i64>)> = sqlx::query_as(
|
||||
"SELECT SUM(playtime_secs), SUM(player_kills), SUM(mob_kills), SUM(blocks_broken), SUM(blocks_placed), SUM(messages)
|
||||
FROM player_stats WHERE uuid = ?",
|
||||
)
|
||||
.bind(uuid)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
|
||||
if let Some((playtime, pkills, mkills, bbroken, bplaced, msgs)) = total_stats {
|
||||
let checks = [
|
||||
("blocks_broken", bbroken.unwrap_or(0)),
|
||||
("blocks_placed", bplaced.unwrap_or(0)),
|
||||
("player_kills", pkills.unwrap_or(0)),
|
||||
("mob_kills", mkills.unwrap_or(0)),
|
||||
("playtime_secs", playtime.unwrap_or(0)),
|
||||
("messages", msgs.unwrap_or(0)),
|
||||
];
|
||||
|
||||
for (key, val) in checks {
|
||||
let eligible: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT id FROM achievements WHERE requirement_type = 'stat' AND requirement_key = ? AND requirement_value <= ?",
|
||||
)
|
||||
.bind(key)
|
||||
.bind(val)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
|
||||
for ach_id in eligible {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(uuid)
|
||||
.bind(ach_id)
|
||||
.bind(at_now)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Level milestone achievements
|
||||
let global_level: Option<i64> = sqlx::query_scalar("SELECT global_level FROM user_levels WHERE uuid = ?")
|
||||
.bind(uuid)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
|
||||
if let Some(lvl) = global_level {
|
||||
let lvl_ach: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT id FROM achievements WHERE requirement_type = 'level' AND requirement_value <= ?",
|
||||
)
|
||||
.bind(lvl)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
|
||||
for ach_id in lvl_ach {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(uuid)
|
||||
.bind(ach_id)
|
||||
.bind(at_now)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user