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
@@ -186,6 +186,13 @@ impl FromRequestParts<AppState> for MaybeUser {
|
||||
|
||||
pub struct AuthUser(pub UserRow);
|
||||
|
||||
impl std::ops::Deref for AuthUser {
|
||||
type Target = UserRow;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRequestParts<AppState> for AuthUser {
|
||||
type Rejection = AppError;
|
||||
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||
@@ -195,6 +202,13 @@ impl FromRequestParts<AppState> for AuthUser {
|
||||
|
||||
pub struct AdminUser(pub UserRow);
|
||||
|
||||
impl std::ops::Deref for AdminUser {
|
||||
type Target = UserRow;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRequestParts<AppState> for AdminUser {
|
||||
type Rejection = AppError;
|
||||
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||
|
||||
@@ -232,6 +232,212 @@ const MIGRATIONS: &[&str] = &[
|
||||
CREATE INDEX launcher_sessions_user ON launcher_sessions(user_id, created_at);
|
||||
CREATE INDEX launcher_sessions_ip ON launcher_sessions(ip, created_at);
|
||||
"#,
|
||||
// 6: Leveling, Quests, Achievements, Guilds, Land Claims, Friends, Profiles & Social Posts
|
||||
r#"
|
||||
CREATE TABLE user_levels (
|
||||
uuid TEXT PRIMARY KEY,
|
||||
global_xp INTEGER NOT NULL DEFAULT 0,
|
||||
global_level INTEGER NOT NULL DEFAULT 1,
|
||||
title TEXT,
|
||||
badges TEXT NOT NULL DEFAULT '[]',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE server_levels (
|
||||
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
||||
uuid TEXT NOT NULL,
|
||||
server_xp INTEGER NOT NULL DEFAULT 0,
|
||||
server_level INTEGER NOT NULL DEFAULT 1,
|
||||
rank_name TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (server_id, uuid)
|
||||
);
|
||||
|
||||
CREATE TABLE level_rewards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
level_type TEXT NOT NULL,
|
||||
server_id INTEGER REFERENCES game_servers(id) ON DELETE CASCADE,
|
||||
level_req INTEGER NOT NULL,
|
||||
reward_type TEXT NOT NULL,
|
||||
reward_name TEXT NOT NULL,
|
||||
reward_data TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE quests (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
target_stat TEXT NOT NULL,
|
||||
target_count INTEGER NOT NULL,
|
||||
xp_reward INTEGER NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT 'star',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE user_quests (
|
||||
user_uuid TEXT NOT NULL,
|
||||
quest_id TEXT NOT NULL REFERENCES quests(id) ON DELETE CASCADE,
|
||||
period_key TEXT NOT NULL,
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
completed INTEGER NOT NULL DEFAULT 0,
|
||||
claimed INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_uuid, quest_id, period_key)
|
||||
);
|
||||
CREATE INDEX user_quests_uuid ON user_quests(user_uuid, period_key);
|
||||
|
||||
CREATE TABLE achievements (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'general',
|
||||
icon_frame TEXT NOT NULL DEFAULT 'task',
|
||||
icon_item TEXT NOT NULL DEFAULT 'diamond',
|
||||
icon_bg TEXT NOT NULL DEFAULT 'stone',
|
||||
icon_border TEXT NOT NULL DEFAULT 'gold',
|
||||
requirement_type TEXT NOT NULL DEFAULT 'stat',
|
||||
requirement_key TEXT NOT NULL,
|
||||
requirement_value INTEGER NOT NULL DEFAULT 1,
|
||||
xp_reward INTEGER NOT NULL DEFAULT 100,
|
||||
secret INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE user_achievements (
|
||||
user_uuid TEXT NOT NULL,
|
||||
achievement_id TEXT NOT NULL REFERENCES achievements(id) ON DELETE CASCADE,
|
||||
unlocked_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_uuid, achievement_id)
|
||||
);
|
||||
CREATE INDEX user_achievements_uuid ON user_achievements(user_uuid);
|
||||
|
||||
CREATE TABLE guilds (
|
||||
id TEXT PRIMARY KEY,
|
||||
instance_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
tag TEXT NOT NULL COLLATE NOCASE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
motd TEXT NOT NULL DEFAULT '',
|
||||
leader_uuid TEXT NOT NULL,
|
||||
icon_url TEXT,
|
||||
banner_url TEXT,
|
||||
level INTEGER NOT NULL DEFAULT 1,
|
||||
xp INTEGER NOT NULL DEFAULT 0,
|
||||
max_claims INTEGER NOT NULL DEFAULT 16,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX guilds_instance ON guilds(instance_id);
|
||||
|
||||
CREATE TABLE guild_members (
|
||||
guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
|
||||
uuid TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
joined_at TEXT NOT NULL,
|
||||
PRIMARY KEY (guild_id, uuid)
|
||||
);
|
||||
CREATE UNIQUE INDEX guild_member_unique ON guild_members(uuid);
|
||||
|
||||
CREATE TABLE guild_roles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
can_invite INTEGER NOT NULL DEFAULT 0,
|
||||
can_kick INTEGER NOT NULL DEFAULT 0,
|
||||
can_claim INTEGER NOT NULL DEFAULT 0,
|
||||
can_post INTEGER NOT NULL DEFAULT 1,
|
||||
can_manage INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE guild_posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
|
||||
author_uuid TEXT NOT NULL,
|
||||
author_name TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX guild_posts_guild ON guild_posts(guild_id, id DESC);
|
||||
|
||||
CREATE TABLE guild_claims (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
|
||||
server_id INTEGER REFERENCES game_servers(id) ON DELETE CASCADE,
|
||||
dimension TEXT NOT NULL DEFAULT 'minecraft:overworld',
|
||||
chunk_x INTEGER NOT NULL,
|
||||
chunk_z INTEGER NOT NULL,
|
||||
claimed_by_uuid TEXT NOT NULL,
|
||||
claimed_at TEXT NOT NULL,
|
||||
UNIQUE (server_id, dimension, chunk_x, chunk_z)
|
||||
);
|
||||
CREATE INDEX guild_claims_guild ON guild_claims(guild_id);
|
||||
CREATE INDEX guild_claims_loc ON guild_claims(server_id, dimension, chunk_x, chunk_z);
|
||||
|
||||
CREATE TABLE friendships (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_uuid TEXT NOT NULL,
|
||||
friend_uuid TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
action_uuid TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE (user_uuid, friend_uuid)
|
||||
);
|
||||
CREATE INDEX friendships_user ON friendships(user_uuid);
|
||||
CREATE INDEX friendships_friend ON friendships(friend_uuid);
|
||||
|
||||
CREATE TABLE direct_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sender_uuid TEXT NOT NULL,
|
||||
sender_name TEXT NOT NULL,
|
||||
recipient_uuid TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
is_read INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX dm_conversation ON direct_messages(sender_uuid, recipient_uuid, id);
|
||||
CREATE INDEX dm_recipient ON direct_messages(recipient_uuid, is_read);
|
||||
|
||||
CREATE TABLE game_invites (
|
||||
id TEXT PRIMARY KEY,
|
||||
sender_uuid TEXT NOT NULL,
|
||||
sender_name TEXT NOT NULL,
|
||||
recipient_uuid TEXT NOT NULL,
|
||||
instance_id TEXT NOT NULL,
|
||||
server_id INTEGER REFERENCES game_servers(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX game_invites_recipient ON game_invites(recipient_uuid, status);
|
||||
|
||||
CREATE TABLE user_profiles (
|
||||
uuid TEXT PRIMARY KEY,
|
||||
bio TEXT NOT NULL DEFAULT '',
|
||||
banner_url TEXT,
|
||||
custom_badge TEXT,
|
||||
featured_achievement_id TEXT,
|
||||
social_links TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE user_posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_uuid TEXT NOT NULL,
|
||||
author_name TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
image_url TEXT,
|
||||
likes_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX user_posts_user ON user_posts(user_uuid, id DESC);
|
||||
"#,
|
||||
];
|
||||
|
||||
pub async fn connect(data_dir: &Path) -> Result<SqlitePool> {
|
||||
@@ -268,6 +474,7 @@ async fn migrate(pool: &SqlitePool) -> Result<()> {
|
||||
tracing::info!("applied database migration {version}");
|
||||
}
|
||||
backfill_uuids(pool).await?;
|
||||
crate::seed::seed_quests_and_achievements(pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod error;
|
||||
pub mod net;
|
||||
pub mod packs;
|
||||
pub mod routes;
|
||||
pub mod seed;
|
||||
pub mod state;
|
||||
pub mod store;
|
||||
pub mod textures;
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Minecraft-style Achievements system.
|
||||
|
||||
use crate::auth::{AdminUser, AuthUser};
|
||||
use crate::error::AppResult;
|
||||
use crate::state::AppState;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::Json;
|
||||
use scopenet_shared::Achievement;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Get all achievements for authenticated user with their unlock status.
|
||||
pub async fn get_my_achievements(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<Achievement>>> {
|
||||
get_achievements_for_user(&state, &auth.uuid, false).await.map(Json)
|
||||
}
|
||||
|
||||
/// Get public unlocked achievements for a specific player profile.
|
||||
pub async fn get_player_achievements(
|
||||
Path(uuid): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<Achievement>>> {
|
||||
get_achievements_for_user(&state, &uuid, true).await.map(Json)
|
||||
}
|
||||
|
||||
pub async fn get_achievements_for_user(
|
||||
state: &AppState,
|
||||
uuid: &str,
|
||||
unlocked_only: bool,
|
||||
) -> AppResult<Vec<Achievement>> {
|
||||
let rows: Vec<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
i64,
|
||||
bool,
|
||||
Option<String>,
|
||||
)> = sqlx::query_as(
|
||||
"SELECT a.id, a.title, a.description, a.category, a.icon_frame, a.icon_item,
|
||||
a.icon_bg, a.icon_border, a.xp_reward, a.secret, ua.unlocked_at
|
||||
FROM achievements a
|
||||
LEFT JOIN user_achievements ua ON ua.achievement_id = a.id AND ua.user_uuid = ?
|
||||
ORDER BY a.category ASC, a.xp_reward ASC",
|
||||
)
|
||||
.bind(uuid)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.filter_map(
|
||||
|(
|
||||
id,
|
||||
title,
|
||||
desc,
|
||||
cat,
|
||||
frame,
|
||||
item,
|
||||
bg,
|
||||
border,
|
||||
xp,
|
||||
secret,
|
||||
unlocked_at,
|
||||
)| {
|
||||
let unlocked = unlocked_at.is_some();
|
||||
if unlocked_only && !unlocked {
|
||||
return None;
|
||||
}
|
||||
Some(Achievement {
|
||||
id,
|
||||
title,
|
||||
description: desc,
|
||||
category: cat,
|
||||
icon_frame: frame,
|
||||
icon_item: item,
|
||||
icon_bg: bg,
|
||||
icon_border: border,
|
||||
xp_reward: xp,
|
||||
secret,
|
||||
unlocked,
|
||||
unlocked_at,
|
||||
})
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin Achievements API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AdminAchievementRow {
|
||||
#[serde(flatten)]
|
||||
pub achievement: Achievement,
|
||||
pub requirement_type: String,
|
||||
pub requirement_key: String,
|
||||
pub requirement_value: i64,
|
||||
pub total_unlocked: i64,
|
||||
}
|
||||
|
||||
/// Admin list all achievements.
|
||||
pub async fn admin_list_achievements(
|
||||
_admin: AdminUser,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<AdminAchievementRow>>> {
|
||||
let rows: Vec<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
i64,
|
||||
i64,
|
||||
bool,
|
||||
i64,
|
||||
)> = sqlx::query_as(
|
||||
"SELECT a.id, a.title, a.description, a.category, a.icon_frame, a.icon_item,
|
||||
a.icon_bg, a.icon_border, a.requirement_type, a.requirement_key,
|
||||
a.requirement_value, a.xp_reward, a.secret,
|
||||
COUNT(ua.user_uuid) as total_unlocked
|
||||
FROM achievements a
|
||||
LEFT JOIN user_achievements ua ON ua.achievement_id = a.id
|
||||
GROUP BY a.id
|
||||
ORDER BY a.category ASC, a.created_at DESC",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(
|
||||
id,
|
||||
title,
|
||||
desc,
|
||||
cat,
|
||||
frame,
|
||||
item,
|
||||
bg,
|
||||
border,
|
||||
req_type,
|
||||
req_key,
|
||||
req_val,
|
||||
xp,
|
||||
secret,
|
||||
total_unlocked,
|
||||
)| {
|
||||
AdminAchievementRow {
|
||||
achievement: Achievement {
|
||||
id,
|
||||
title,
|
||||
description: desc,
|
||||
category: cat,
|
||||
icon_frame: frame,
|
||||
icon_item: item,
|
||||
icon_bg: bg,
|
||||
icon_border: border,
|
||||
xp_reward: xp,
|
||||
secret,
|
||||
unlocked: false,
|
||||
unlocked_at: None,
|
||||
},
|
||||
requirement_type: req_type,
|
||||
requirement_key: req_key,
|
||||
requirement_value: req_val,
|
||||
total_unlocked,
|
||||
}
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AchievementPayload {
|
||||
pub id: Option<String>,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub category: String,
|
||||
pub icon_frame: String,
|
||||
pub icon_item: String,
|
||||
pub icon_bg: String,
|
||||
pub icon_border: String,
|
||||
pub requirement_type: String,
|
||||
pub requirement_key: String,
|
||||
pub requirement_value: i64,
|
||||
pub xp_reward: i64,
|
||||
pub secret: Option<bool>,
|
||||
}
|
||||
|
||||
/// Admin create achievement.
|
||||
pub async fn admin_create_achievement(
|
||||
_admin: AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<AchievementPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let id = payload
|
||||
.id
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| format!("ach_{}", uuid::Uuid::new_v4().simple()));
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO achievements (id, title, description, category, icon_frame, icon_item, icon_bg, icon_border, requirement_type, requirement_key, requirement_value, xp_reward, secret, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(payload.title)
|
||||
.bind(payload.description)
|
||||
.bind(payload.category)
|
||||
.bind(payload.icon_frame)
|
||||
.bind(payload.icon_item)
|
||||
.bind(payload.icon_bg)
|
||||
.bind(payload.icon_border)
|
||||
.bind(payload.requirement_type)
|
||||
.bind(payload.requirement_key)
|
||||
.bind(payload.requirement_value)
|
||||
.bind(payload.xp_reward)
|
||||
.bind(payload.secret.unwrap_or(false))
|
||||
.bind(&now)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "id": id, "ok": true })))
|
||||
}
|
||||
|
||||
/// Admin update achievement.
|
||||
pub async fn admin_update_achievement(
|
||||
_admin: AdminUser,
|
||||
Path(id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<AchievementPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
sqlx::query(
|
||||
"UPDATE achievements SET
|
||||
title = ?,
|
||||
description = ?,
|
||||
category = ?,
|
||||
icon_frame = ?,
|
||||
icon_item = ?,
|
||||
icon_bg = ?,
|
||||
icon_border = ?,
|
||||
requirement_type = ?,
|
||||
requirement_key = ?,
|
||||
requirement_value = ?,
|
||||
xp_reward = ?,
|
||||
secret = COALESCE(?, secret)
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(payload.title)
|
||||
.bind(payload.description)
|
||||
.bind(payload.category)
|
||||
.bind(payload.icon_frame)
|
||||
.bind(payload.icon_item)
|
||||
.bind(payload.icon_bg)
|
||||
.bind(payload.icon_border)
|
||||
.bind(payload.requirement_type)
|
||||
.bind(payload.requirement_key)
|
||||
.bind(payload.requirement_value)
|
||||
.bind(payload.xp_reward)
|
||||
.bind(payload.secret)
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// Admin delete achievement.
|
||||
pub async fn admin_delete_achievement(
|
||||
_admin: AdminUser,
|
||||
Path(id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
sqlx::query("DELETE FROM achievements WHERE id = ?")
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
@@ -0,0 +1,829 @@
|
||||
//! Guilds system, in-game relay, roles, posts, and land-claiming.
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::routes::servers::GameServer;
|
||||
use crate::state::AppState;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::Json;
|
||||
use scopenet_shared::{Guild, GuildClaim, GuildMember, GuildPost};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct InstanceQuery {
|
||||
pub instance_id: Option<String>,
|
||||
}
|
||||
|
||||
/// List guilds for an instance.
|
||||
pub async fn list_guilds(
|
||||
Query(query): Query<InstanceQuery>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<Guild>>> {
|
||||
let instance_id = query.instance_id.unwrap_or_default();
|
||||
let rows: Vec<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
String,
|
||||
i64,
|
||||
i64,
|
||||
)> = sqlx::query_as(
|
||||
"SELECT g.id, g.instance_id, g.name, g.tag, g.description, g.motd, g.leader_uuid,
|
||||
g.icon_url, g.banner_url, g.level, g.xp, g.max_claims, g.created_at,
|
||||
(SELECT COUNT(*) FROM guild_members gm WHERE gm.guild_id = g.id) as member_count,
|
||||
(SELECT COUNT(*) FROM guild_claims gc WHERE gc.guild_id = g.id) as claims_count
|
||||
FROM guilds g
|
||||
WHERE (? = '' OR g.instance_id = ?)
|
||||
ORDER BY g.level DESC, member_count DESC",
|
||||
)
|
||||
.bind(&instance_id)
|
||||
.bind(&instance_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(
|
||||
id,
|
||||
inst_id,
|
||||
name,
|
||||
tag,
|
||||
desc,
|
||||
motd,
|
||||
leader_uuid,
|
||||
icon_url,
|
||||
banner_url,
|
||||
level,
|
||||
xp,
|
||||
max_claims,
|
||||
created_at,
|
||||
member_count,
|
||||
claims_count,
|
||||
)| {
|
||||
Guild {
|
||||
id,
|
||||
instance_id: inst_id,
|
||||
name,
|
||||
tag,
|
||||
description: desc,
|
||||
motd,
|
||||
leader_uuid,
|
||||
icon_url,
|
||||
banner_url,
|
||||
level,
|
||||
xp,
|
||||
max_claims,
|
||||
member_count,
|
||||
claims_count,
|
||||
created_at,
|
||||
}
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
/// Get current user's guild for a specific instance.
|
||||
pub async fn get_my_guild(
|
||||
auth: AuthUser,
|
||||
Query(query): Query<InstanceQuery>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Option<GuildDetail>>> {
|
||||
let instance_id = query.instance_id.unwrap_or_default();
|
||||
|
||||
let guild_id: Option<String> = sqlx::query_scalar(
|
||||
"SELECT g.id FROM guilds g
|
||||
JOIN guild_members gm ON gm.guild_id = g.id
|
||||
WHERE gm.uuid = ? AND (? = '' OR g.instance_id = ?)",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&instance_id)
|
||||
.bind(&instance_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let Some(gid) = guild_id else {
|
||||
return Ok(Json(None));
|
||||
};
|
||||
|
||||
let detail = fetch_guild_detail(&state, &gid).await?;
|
||||
Ok(Json(Some(detail)))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GuildDetail {
|
||||
#[serde(flatten)]
|
||||
pub guild: Guild,
|
||||
pub members: Vec<GuildMember>,
|
||||
pub posts: Vec<GuildPost>,
|
||||
pub claims: Vec<GuildClaim>,
|
||||
}
|
||||
|
||||
async fn fetch_guild_detail(state: &AppState, guild_id: &str) -> AppResult<GuildDetail> {
|
||||
let row: Option<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
String,
|
||||
)> = sqlx::query_as(
|
||||
"SELECT id, instance_id, name, tag, description, motd, leader_uuid,
|
||||
icon_url, banner_url, level, xp, max_claims, created_at
|
||||
FROM guilds WHERE id = ?",
|
||||
)
|
||||
.bind(guild_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let (
|
||||
id,
|
||||
inst_id,
|
||||
name,
|
||||
tag,
|
||||
desc,
|
||||
motd,
|
||||
leader_uuid,
|
||||
icon_url,
|
||||
banner_url,
|
||||
level,
|
||||
xp,
|
||||
max_claims,
|
||||
created_at,
|
||||
) = row.ok_or_else(|| AppError::not_found("Guild not found"))?;
|
||||
|
||||
// Members with online presence
|
||||
let member_rows: Vec<(String, String, String, String, bool)> = sqlx::query_as(
|
||||
"SELECT gm.uuid, gm.name, gm.role, gm.joined_at,
|
||||
EXISTS(SELECT 1 FROM server_online so WHERE so.uuid = gm.uuid) as online
|
||||
FROM guild_members gm
|
||||
WHERE gm.guild_id = ?
|
||||
ORDER BY (CASE gm.role WHEN 'leader' THEN 1 WHEN 'officer' THEN 2 ELSE 3 END) ASC, gm.joined_at ASC",
|
||||
)
|
||||
.bind(guild_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let members: Vec<GuildMember> = member_rows
|
||||
.into_iter()
|
||||
.map(|(uuid, mname, role, joined_at, online)| GuildMember {
|
||||
uuid,
|
||||
name: mname,
|
||||
role,
|
||||
joined_at,
|
||||
online,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Posts
|
||||
let post_rows: Vec<(i64, String, String, String, String, String, String)> = sqlx::query_as(
|
||||
"SELECT id, guild_id, author_uuid, author_name, title, content, created_at
|
||||
FROM guild_posts
|
||||
WHERE guild_id = ?
|
||||
ORDER BY id DESC LIMIT 20",
|
||||
)
|
||||
.bind(guild_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let posts: Vec<GuildPost> = post_rows
|
||||
.into_iter()
|
||||
.map(|(pid, gid, auuid, aname, title, content, pcreated)| GuildPost {
|
||||
id: pid,
|
||||
guild_id: gid,
|
||||
author_uuid: auuid,
|
||||
author_name: aname,
|
||||
title,
|
||||
content,
|
||||
created_at: pcreated,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Claims
|
||||
let claim_rows: Vec<(i64, String, i64, String, i32, i32, String, String)> = sqlx::query_as(
|
||||
"SELECT id, guild_id, server_id, dimension, chunk_x, chunk_z, claimed_by_uuid, claimed_at
|
||||
FROM guild_claims
|
||||
WHERE guild_id = ?
|
||||
LIMIT 200",
|
||||
)
|
||||
.bind(guild_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let claims: Vec<GuildClaim> = claim_rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(cid, gid, sid, dim, cx, cz, cby, cat)| GuildClaim {
|
||||
id: cid,
|
||||
guild_id: gid,
|
||||
guild_name: name.clone(),
|
||||
guild_tag: tag.clone(),
|
||||
server_id: sid,
|
||||
dimension: dim,
|
||||
chunk_x: cx,
|
||||
chunk_z: cz,
|
||||
claimed_by_uuid: cby,
|
||||
claimed_at: cat,
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
Ok(GuildDetail {
|
||||
guild: Guild {
|
||||
id,
|
||||
instance_id: inst_id,
|
||||
name,
|
||||
tag,
|
||||
description: desc,
|
||||
motd,
|
||||
leader_uuid,
|
||||
icon_url,
|
||||
banner_url,
|
||||
level,
|
||||
xp,
|
||||
max_claims,
|
||||
member_count: members.len() as i64,
|
||||
claims_count: claims.len() as i64,
|
||||
created_at,
|
||||
},
|
||||
members,
|
||||
posts,
|
||||
claims,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get guild detail by ID.
|
||||
pub async fn get_guild_by_id(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<GuildDetail>> {
|
||||
fetch_guild_detail(&state, &id).await.map(Json)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateGuildPayload {
|
||||
pub instance_id: String,
|
||||
pub name: String,
|
||||
pub tag: String,
|
||||
pub description: Option<String>,
|
||||
pub motd: Option<String>,
|
||||
pub icon_url: Option<String>,
|
||||
pub banner_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Create a new Guild.
|
||||
pub async fn create_guild(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateGuildPayload>,
|
||||
) -> AppResult<Json<GuildDetail>> {
|
||||
let name = payload.name.trim();
|
||||
let tag = payload.tag.trim();
|
||||
if name.len() < 3 || name.len() > 32 {
|
||||
return Err(AppError::bad_request("Guild name must be 3-32 characters"));
|
||||
}
|
||||
if tag.len() < 2 || tag.len() > 6 {
|
||||
return Err(AppError::bad_request("Guild tag must be 2-6 characters"));
|
||||
}
|
||||
|
||||
// Check if user is already in a guild for this instance
|
||||
let in_guild: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM guild_members gm
|
||||
JOIN guilds g ON g.id = gm.guild_id
|
||||
WHERE gm.uuid = ? AND g.instance_id = ?
|
||||
)",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&payload.instance_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
if in_guild {
|
||||
return Err(AppError::bad_request("You are already in a guild for this instance"));
|
||||
}
|
||||
|
||||
let mut tx = state.db.begin().await?;
|
||||
|
||||
let guild_id = format!("guild_{}", uuid::Uuid::new_v4().simple());
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO guilds (id, instance_id, name, tag, description, motd, leader_uuid, icon_url, banner_url, level, xp, max_claims, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, 16, ?)",
|
||||
)
|
||||
.bind(&guild_id)
|
||||
.bind(&payload.instance_id)
|
||||
.bind(name)
|
||||
.bind(tag.to_ascii_uppercase())
|
||||
.bind(payload.description.unwrap_or_default())
|
||||
.bind(payload.motd.unwrap_or_default())
|
||||
.bind(&auth.uuid)
|
||||
.bind(payload.icon_url)
|
||||
.bind(payload.banner_url)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Add leader as first member
|
||||
sqlx::query(
|
||||
"INSERT INTO guild_members (guild_id, uuid, name, role, joined_at)
|
||||
VALUES (?, ?, ?, 'leader', ?)",
|
||||
)
|
||||
.bind(&guild_id)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&auth.username)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Record achievement event for founding a guild
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at)
|
||||
VALUES (?, 'ach_guild_initiate', ?)",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
fetch_guild_detail(&state, &guild_id).await.map(Json)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateGuildPayload {
|
||||
pub description: Option<String>,
|
||||
pub motd: Option<String>,
|
||||
pub icon_url: Option<String>,
|
||||
pub banner_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Update guild info.
|
||||
pub async fn update_guild(
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<UpdateGuildPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let role: Option<String> = sqlx::query_scalar(
|
||||
"SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&auth.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let is_officer_or_leader = role.as_deref().is_some_and(|r| r == "leader" || r == "officer");
|
||||
if !is_officer_or_leader {
|
||||
return Err(AppError::forbidden("Only guild officers or leaders can update guild details"));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE guilds SET
|
||||
description = COALESCE(?, description),
|
||||
motd = COALESCE(?, motd),
|
||||
icon_url = COALESCE(?, icon_url),
|
||||
banner_url = COALESCE(?, banner_url)
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(payload.description)
|
||||
.bind(payload.motd)
|
||||
.bind(payload.icon_url)
|
||||
.bind(payload.banner_url)
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AddMemberPayload {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
/// Add / Invite member to guild.
|
||||
pub async fn add_guild_member(
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<AddMemberPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let role: Option<String> = sqlx::query_scalar(
|
||||
"SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&auth.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
if !role.as_deref().is_some_and(|r| r == "leader" || r == "officer") {
|
||||
return Err(AppError::forbidden("Only guild leaders or officers can invite members"));
|
||||
}
|
||||
|
||||
let target_user: Option<(String, String)> = sqlx::query_as(
|
||||
"SELECT uuid, username FROM users WHERE username = ? COLLATE NOCASE",
|
||||
)
|
||||
.bind(payload.username.trim())
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let (target_uuid, target_name) = target_user.ok_or_else(|| AppError::not_found("User not found"))?;
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO guild_members (guild_id, uuid, name, role, joined_at)
|
||||
VALUES (?, ?, ?, 'member', ?)
|
||||
ON CONFLICT (guild_id, uuid) DO NOTHING",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&target_uuid)
|
||||
.bind(&target_name)
|
||||
.bind(&now)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
// Record achievement event for joining a guild
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at)
|
||||
VALUES (?, 'ach_guild_initiate', ?)",
|
||||
)
|
||||
.bind(&target_uuid)
|
||||
.bind(&now)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// Leave or kick member.
|
||||
pub async fn remove_guild_member(
|
||||
auth: AuthUser,
|
||||
Path((guild_id, target_uuid)): Path<(String, String)>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let caller_role: Option<String> = sqlx::query_scalar(
|
||||
"SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?",
|
||||
)
|
||||
.bind(&guild_id)
|
||||
.bind(&auth.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let is_self = auth.uuid == target_uuid;
|
||||
let is_leader_or_officer = caller_role.as_deref().is_some_and(|r| r == "leader" || r == "officer");
|
||||
|
||||
if !is_self && !is_leader_or_officer {
|
||||
return Err(AppError::forbidden("Cannot kick member without officer privileges"));
|
||||
}
|
||||
|
||||
if is_self && caller_role.as_deref() == Some("leader") {
|
||||
return Err(AppError::bad_request("Guild leader cannot leave without transferring leadership"));
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM guild_members WHERE guild_id = ? AND uuid = ?")
|
||||
.bind(&guild_id)
|
||||
.bind(&target_uuid)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Guild Posts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreatePostPayload {
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
pub async fn create_guild_post(
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreatePostPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let in_guild: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM guild_members WHERE guild_id = ? AND uuid = ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&auth.uuid)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
if !in_guild {
|
||||
return Err(AppError::forbidden("Must be a guild member to post"));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let post_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO guild_posts (guild_id, author_uuid, author_name, title, content, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&auth.username)
|
||||
.bind(payload.title)
|
||||
.bind(payload.content)
|
||||
.bind(&now)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "id": post_id, "ok": true })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Land Claiming API (FTB Chunks-style grid)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ClaimChunkPayload {
|
||||
pub server_id: Option<i64>,
|
||||
pub instance_id: Option<String>,
|
||||
pub dimension: Option<String>,
|
||||
pub chunk_x: i32,
|
||||
pub chunk_z: i32,
|
||||
}
|
||||
|
||||
/// Claim a chunk for your guild.
|
||||
pub async fn claim_chunk(
|
||||
auth: AuthUser,
|
||||
Path(guild_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ClaimChunkPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let role: Option<String> = sqlx::query_scalar(
|
||||
"SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?",
|
||||
)
|
||||
.bind(&guild_id)
|
||||
.bind(&auth.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
if role.is_none() {
|
||||
return Err(AppError::forbidden("You are not a member of this guild"));
|
||||
}
|
||||
|
||||
let max_claims: i64 = sqlx::query_scalar("SELECT max_claims FROM guilds WHERE id = ?")
|
||||
.bind(&guild_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
let current_claims: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ?")
|
||||
.bind(&guild_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
if current_claims >= max_claims {
|
||||
return Err(AppError::bad_request(format!("Guild reached its max claim limit of {max_claims} chunks")));
|
||||
}
|
||||
|
||||
let dim = payload.dimension.unwrap_or_else(|| "minecraft:overworld".into());
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let res = sqlx::query(
|
||||
"INSERT INTO guild_claims (guild_id, server_id, dimension, chunk_x, chunk_z, claimed_by_uuid, claimed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&guild_id)
|
||||
.bind(payload.server_id)
|
||||
.bind(&dim)
|
||||
.bind(payload.chunk_x)
|
||||
.bind(payload.chunk_z)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&now)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
if let Err(e) = res {
|
||||
tracing::warn!("claim chunk failed: {e}");
|
||||
return Err(AppError::bad_request("Chunk is already claimed by another guild"));
|
||||
}
|
||||
|
||||
let claim_id: i64 = sqlx::query_scalar("SELECT last_insert_rowid()").fetch_one(&state.db).await.unwrap_or(1);
|
||||
|
||||
// Award achievement for claiming land
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at)
|
||||
VALUES (?, 'ach_land_claim', ?)",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&now)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true, "id": claim_id, "chunk_x": payload.chunk_x, "chunk_z": payload.chunk_z })))
|
||||
}
|
||||
|
||||
/// Unclaim a chunk by coordinates.
|
||||
pub async fn unclaim_chunk(
|
||||
auth: AuthUser,
|
||||
Path(guild_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ClaimChunkPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let role: Option<String> = sqlx::query_scalar(
|
||||
"SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?",
|
||||
)
|
||||
.bind(&guild_id)
|
||||
.bind(&auth.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
if role.is_none() {
|
||||
return Err(AppError::forbidden("You are not a member of this guild"));
|
||||
}
|
||||
|
||||
let dim = payload.dimension.unwrap_or_else(|| "minecraft:overworld".into());
|
||||
|
||||
sqlx::query(
|
||||
"DELETE FROM guild_claims WHERE guild_id = ? AND dimension = ? AND chunk_x = ? AND chunk_z = ?",
|
||||
)
|
||||
.bind(&guild_id)
|
||||
.bind(&dim)
|
||||
.bind(payload.chunk_x)
|
||||
.bind(payload.chunk_z)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// Unclaim by claim ID.
|
||||
pub async fn unclaim_by_id(
|
||||
auth: AuthUser,
|
||||
Path(claim_id): Path<i64>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let claim: Option<(String,)> = sqlx::query_as("SELECT guild_id FROM guild_claims WHERE id = ?")
|
||||
.bind(claim_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let (guild_id,) = claim.ok_or_else(|| AppError::not_found("Claim not found"))?;
|
||||
|
||||
let role: Option<String> = sqlx::query_scalar(
|
||||
"SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?",
|
||||
)
|
||||
.bind(&guild_id)
|
||||
.bind(&auth.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
if role.is_none() {
|
||||
return Err(AppError::forbidden("You are not a member of this guild"));
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM guild_claims WHERE id = ?")
|
||||
.bind(claim_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ChunkGridQuery {
|
||||
pub server_id: i64,
|
||||
pub dimension: Option<String>,
|
||||
pub center_x: i32,
|
||||
pub center_z: i32,
|
||||
pub radius: Option<i32>,
|
||||
}
|
||||
|
||||
/// Returns chunk claims in a bounding box centered around (center_x, center_z) chunks.
|
||||
pub async fn get_chunk_grid(
|
||||
Query(query): Query<ChunkGridQuery>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<GuildClaim>>> {
|
||||
let dim = query.dimension.unwrap_or_else(|| "minecraft:overworld".into());
|
||||
let r = query.radius.unwrap_or(8).clamp(2, 24);
|
||||
let min_x = query.center_x - r;
|
||||
let max_x = query.center_x + r;
|
||||
let min_z = query.center_z - r;
|
||||
let max_z = query.center_z + r;
|
||||
|
||||
let rows: Vec<(i64, String, String, String, i64, String, i32, i32, String, String)> = sqlx::query_as(
|
||||
"SELECT gc.id, gc.guild_id, g.name, g.tag, gc.server_id, gc.dimension,
|
||||
gc.chunk_x, gc.chunk_z, gc.claimed_by_uuid, gc.claimed_at
|
||||
FROM guild_claims gc
|
||||
JOIN guilds g ON g.id = gc.guild_id
|
||||
WHERE gc.server_id = ? AND gc.dimension = ?
|
||||
AND gc.chunk_x BETWEEN ? AND ?
|
||||
AND gc.chunk_z BETWEEN ? AND ?
|
||||
LIMIT 500",
|
||||
)
|
||||
.bind(query.server_id)
|
||||
.bind(&dim)
|
||||
.bind(min_x)
|
||||
.bind(max_x)
|
||||
.bind(min_z)
|
||||
.bind(max_z)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(id, gid, gname, gtag, sid, gdim, cx, cz, cby, cat)| GuildClaim {
|
||||
id,
|
||||
guild_id: gid,
|
||||
guild_name: gname,
|
||||
guild_tag: gtag,
|
||||
server_id: sid,
|
||||
dimension: gdim,
|
||||
chunk_x: cx,
|
||||
chunk_z: cz,
|
||||
claimed_by_uuid: cby,
|
||||
claimed_at: cat,
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server Integration / Plugin In-Game Land Claim Relay
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ServerCheckChunkPayload {
|
||||
pub uuid: String,
|
||||
pub dimension: String,
|
||||
pub chunk_x: i32,
|
||||
pub chunk_z: i32,
|
||||
}
|
||||
|
||||
/// Token-authenticated check called by the Minecraft server plugin/mod to
|
||||
/// verify if a player can build/break in a chunk.
|
||||
pub async fn server_check_chunk(
|
||||
GameServer(server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ServerCheckChunkPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let claim: Option<(String, String, String)> = sqlx::query_as(
|
||||
"SELECT gc.guild_id, g.name, g.tag
|
||||
FROM guild_claims gc
|
||||
JOIN guilds g ON g.id = gc.guild_id
|
||||
WHERE gc.server_id = ? AND gc.dimension = ? AND gc.chunk_x = ? AND gc.chunk_z = ?",
|
||||
)
|
||||
.bind(server.id)
|
||||
.bind(&payload.dimension)
|
||||
.bind(payload.chunk_x)
|
||||
.bind(payload.chunk_z)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let Some((guild_id, guild_name, guild_tag)) = claim else {
|
||||
// Wilderness / unclaimed land is allowed
|
||||
return Ok(Json(serde_json::json!({
|
||||
"claimed": false,
|
||||
"allowed": true
|
||||
})));
|
||||
};
|
||||
|
||||
// Check if player is a member of this guild
|
||||
let is_member: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM guild_members WHERE guild_id = ? AND uuid = ?)",
|
||||
)
|
||||
.bind(&guild_id)
|
||||
.bind(&payload.uuid)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"claimed": true,
|
||||
"guild_id": guild_id,
|
||||
"guild_name": guild_name,
|
||||
"guild_tag": guild_tag,
|
||||
"allowed": is_member,
|
||||
"message": if is_member { None } else { Some(format!("This land is claimed by [{guild_tag}] {guild_name}")) }
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Leveling system (Global Level & Server-Specific Levels), rewards, and progression.
|
||||
|
||||
use crate::auth::{AdminUser, AuthUser};
|
||||
use crate::error::AppResult;
|
||||
use crate::state::AppState;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::Json;
|
||||
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)
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct RewardRow {
|
||||
pub id: i64,
|
||||
pub level_type: String,
|
||||
pub server_id: Option<i64>,
|
||||
pub level_req: i64,
|
||||
pub reward_type: String,
|
||||
pub reward_name: String,
|
||||
pub reward_data: Value,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// Get current user's global and server levels.
|
||||
pub async fn get_my_levels(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<UserLevelInfo>> {
|
||||
get_user_levels_by_uuid(&state, &auth.uuid).await.map(Json)
|
||||
}
|
||||
|
||||
/// Public / Profile lookup for player levels.
|
||||
pub async fn get_player_levels(
|
||||
Path(uuid): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<UserLevelInfo>> {
|
||||
get_user_levels_by_uuid(&state, &uuid).await.map(Json)
|
||||
}
|
||||
|
||||
pub async fn get_user_levels_by_uuid(state: &AppState, uuid: &str) -> AppResult<UserLevelInfo> {
|
||||
let row: Option<(i64, i64, Option<String>, String)> = sqlx::query_as(
|
||||
"SELECT global_xp, global_level, title, badges FROM user_levels WHERE uuid = ?",
|
||||
)
|
||||
.bind(uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let (global_xp, _db_lvl, title, badges_json) = row.unwrap_or((0, 1, None, "[]".into()));
|
||||
let (global_level, current_level_xp, next_level_xp, progress_pct) = level_from_xp(global_xp);
|
||||
let badges: Vec<String> = serde_json::from_str(&badges_json).unwrap_or_default();
|
||||
|
||||
// Query server levels for this user
|
||||
let server_rows: Vec<(i64, String, i64, i64, Option<String>)> = sqlx::query_as(
|
||||
"SELECT sl.server_id, gs.name, sl.server_xp, sl.server_level, sl.rank_name
|
||||
FROM server_levels sl
|
||||
JOIN game_servers gs ON gs.id = sl.server_id
|
||||
WHERE sl.uuid = ?
|
||||
ORDER BY sl.server_xp DESC",
|
||||
)
|
||||
.bind(uuid)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let server_levels = server_rows
|
||||
.into_iter()
|
||||
.map(|(s_id, s_name, s_xp, _s_lvl, r_name)| {
|
||||
let (sl, cur, next, pct) = level_from_xp(s_xp);
|
||||
ServerLevelInfo {
|
||||
server_id: s_id,
|
||||
server_name: s_name,
|
||||
server_xp: s_xp,
|
||||
server_level: sl,
|
||||
current_level_xp: cur,
|
||||
next_level_xp: next,
|
||||
progress_pct: pct,
|
||||
rank_name: r_name,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(UserLevelInfo {
|
||||
uuid: uuid.to_string(),
|
||||
global_xp,
|
||||
global_level,
|
||||
current_level_xp,
|
||||
next_level_xp,
|
||||
progress_pct,
|
||||
title,
|
||||
badges,
|
||||
server_levels,
|
||||
})
|
||||
}
|
||||
|
||||
/// Global leveling leaderboard.
|
||||
pub async fn get_level_leaderboard(
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<Value>>> {
|
||||
let rows: Vec<(String, String, i64, i64, Option<String>)> = sqlx::query_as(
|
||||
"SELECT u.uuid, u.username, ul.global_xp, ul.global_level, ul.title
|
||||
FROM user_levels ul
|
||||
JOIN users u ON u.uuid = ul.uuid
|
||||
ORDER BY ul.global_xp DESC
|
||||
LIMIT 50",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(rank, (uuid, username, xp, _, title))| {
|
||||
let (level, cur, next, pct) = level_from_xp(xp);
|
||||
serde_json::json!({
|
||||
"rank": rank + 1,
|
||||
"uuid": uuid,
|
||||
"username": username,
|
||||
"global_level": level,
|
||||
"global_xp": xp,
|
||||
"current_level_xp": cur,
|
||||
"next_level_xp": next,
|
||||
"progress_pct": pct,
|
||||
"title": title
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
/// Get available rewards.
|
||||
pub async fn list_rewards(
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<Value>>> {
|
||||
let rows: Vec<(i64, String, Option<i64>, i64, String, String, String, String)> = sqlx::query_as(
|
||||
"SELECT id, level_type, server_id, level_req, reward_type, reward_name, reward_data, created_at
|
||||
FROM level_rewards
|
||||
ORDER BY level_req ASC",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.map(|(id, ltype, sid, req, rtype, name, data, created)| {
|
||||
let parsed_data: Value = serde_json::from_str(&data).unwrap_or_default();
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"level_type": ltype,
|
||||
"server_id": sid,
|
||||
"level_req": req,
|
||||
"reward_type": rtype,
|
||||
"reward_name": name,
|
||||
"reward_data": parsed_data,
|
||||
"created_at": created
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateRewardPayload {
|
||||
pub level_type: String,
|
||||
pub server_id: Option<i64>,
|
||||
pub level_req: i64,
|
||||
pub reward_type: String,
|
||||
pub reward_name: String,
|
||||
pub reward_data: Option<Value>,
|
||||
}
|
||||
|
||||
/// Admin create reward.
|
||||
pub async fn admin_create_reward(
|
||||
_admin: AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateRewardPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let data_str = serde_json::to_string(&payload.reward_data.unwrap_or_default()).unwrap_or_default();
|
||||
|
||||
let id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO level_rewards (level_type, server_id, level_req, reward_type, reward_name, reward_data, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(payload.level_type)
|
||||
.bind(payload.server_id)
|
||||
.bind(payload.level_req)
|
||||
.bind(payload.reward_type)
|
||||
.bind(payload.reward_name)
|
||||
.bind(data_str)
|
||||
.bind(&now)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "id": id, "ok": true })))
|
||||
}
|
||||
|
||||
/// Admin delete reward.
|
||||
pub async fn admin_delete_reward(
|
||||
_admin: AdminUser,
|
||||
Path(id): Path<i64>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
sqlx::query("DELETE FROM level_rewards WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
pub mod account;
|
||||
pub mod achievements;
|
||||
pub mod activity;
|
||||
pub mod admin;
|
||||
pub mod guilds;
|
||||
pub mod landing;
|
||||
pub mod leveling;
|
||||
pub mod meta;
|
||||
pub mod public;
|
||||
pub mod quests;
|
||||
pub mod servers;
|
||||
pub mod social;
|
||||
|
||||
use crate::state::AppState;
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::routing::{delete, get, post};
|
||||
use axum::routing::{delete, get, post, put};
|
||||
use axum::Router;
|
||||
|
||||
pub fn api(state: &AppState) -> Router<AppState> {
|
||||
@@ -34,6 +39,54 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.route("/landing", get(landing::public_landing))
|
||||
.route("/launcher/authlib-injector.json", get(account::authlib_index))
|
||||
.route("/launcher/authlib-injector.jar", get(account::authlib_jar))
|
||||
// Leveling & Rewards
|
||||
.route("/levels/me", get(leveling::get_my_levels))
|
||||
.route("/leveling/me", get(leveling::get_my_levels))
|
||||
.route("/levels/player/{uuid}", get(leveling::get_player_levels))
|
||||
.route("/levels/leaderboard", get(leveling::get_level_leaderboard))
|
||||
.route("/levels/rewards", get(leveling::list_rewards))
|
||||
// Daily & Weekly Quests
|
||||
.route("/quests/my", get(quests::get_my_quests))
|
||||
.route("/quests/me", get(quests::get_my_quests))
|
||||
.route("/quests/{id}/claim", post(quests::claim_quest))
|
||||
// Achievements
|
||||
.route("/achievements/my", get(achievements::get_my_achievements))
|
||||
.route("/achievements/me", get(achievements::get_my_achievements))
|
||||
.route("/achievements/player/{uuid}", get(achievements::get_player_achievements))
|
||||
// Guilds & Land Claims
|
||||
.route("/guilds", get(guilds::list_guilds).post(guilds::create_guild))
|
||||
.route("/guilds/my", get(guilds::get_my_guild))
|
||||
.route("/guilds/claims/grid", get(guilds::get_chunk_grid))
|
||||
.route("/guilds/claims/{id}", delete(guilds::unclaim_by_id))
|
||||
.route("/guilds/{id}", get(guilds::get_guild_by_id).put(guilds::update_guild))
|
||||
.route("/guilds/{id}/members", post(guilds::add_guild_member))
|
||||
.route("/guilds/{id}/members/{uuid}", delete(guilds::remove_guild_member))
|
||||
.route("/guilds/{id}/posts", post(guilds::create_guild_post))
|
||||
.route("/guilds/{id}/claims", post(guilds::claim_chunk).delete(guilds::unclaim_chunk))
|
||||
.route("/guilds/{id}/claim", post(guilds::claim_chunk))
|
||||
.route("/guilds/{id}/unclaim", post(guilds::unclaim_chunk))
|
||||
// Social: Friends, DMs, Invites, Profiles, Posts
|
||||
.route("/friends", get(social::list_friends))
|
||||
.route("/friends/request", post(social::send_friend_request))
|
||||
.route("/friends/respond", post(social::respond_friend_request))
|
||||
.route("/friends/{uuid}/accept", post(social::accept_friend_request))
|
||||
.route("/friends/{uuid}", delete(social::remove_friend))
|
||||
.route("/messages/{uuid}", get(social::get_direct_messages).post(social::send_direct_message))
|
||||
.route("/invites", get(social::list_my_game_invites).post(social::send_game_invite))
|
||||
.route("/invites/{id}/respond", post(social::respond_game_invite))
|
||||
.route("/profiles/me", put(social::update_my_profile))
|
||||
.route("/profiles/{uuid}", get(social::get_player_profile))
|
||||
.route("/profiles/me/posts", post(social::create_user_post))
|
||||
.route("/profiles/me/posts/{id}", delete(social::delete_user_post))
|
||||
.route("/posts/{id}/like", post(social::like_user_post))
|
||||
.route("/social/friends", get(social::list_friends))
|
||||
.route("/social/friends/request", post(social::send_friend_request))
|
||||
.route("/social/friends/respond", post(social::respond_friend_request))
|
||||
.route("/social/messages/{uuid}", get(social::get_direct_messages).post(social::send_direct_message))
|
||||
.route("/social/profile/me", put(social::update_my_profile))
|
||||
.route("/social/profile/{uuid}", get(social::get_player_profile))
|
||||
.route("/social/posts", post(social::create_user_post))
|
||||
.route("/social/posts/{id}/like", post(social::like_user_post))
|
||||
.layer(DefaultBodyLimit::max(4 * 1024 * 1024));
|
||||
|
||||
let admin = Router::new()
|
||||
@@ -71,6 +124,13 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.route("/modrinth/project/{id}/versions", get(meta::modrinth_versions))
|
||||
.route("/curseforge/search", get(meta::curseforge_search))
|
||||
.route("/curseforge/mod/{id}/files", get(meta::curseforge_files))
|
||||
// Admin Quests & Achievements & Rewards
|
||||
.route("/rewards", post(leveling::admin_create_reward))
|
||||
.route("/rewards/{id}", delete(leveling::admin_delete_reward))
|
||||
.route("/quests", get(quests::admin_list_quests).post(quests::admin_create_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/{id}", put(achievements::admin_update_achievement).delete(achievements::admin_delete_achievement))
|
||||
.layer(DefaultBodyLimit::max(upload_limit));
|
||||
|
||||
// Called by the SCOPENET Paper plugin and Fabric/Forge mods.
|
||||
@@ -78,6 +138,7 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.route("/hello", post(servers::hello))
|
||||
.route("/login", post(servers::login))
|
||||
.route("/sync", post(servers::sync))
|
||||
.route("/guilds/check-chunk", post(guilds::server_check_chunk))
|
||||
.layer(DefaultBodyLimit::max(2 * 1024 * 1024));
|
||||
|
||||
Router::new()
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
//! Daily and Weekly Quests system.
|
||||
|
||||
use crate::auth::{AdminUser, AuthUser};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::state::AppState;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::Json;
|
||||
use scopenet_shared::{Quest, UserQuest};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn current_daily_key() -> String {
|
||||
chrono::Utc::now().format("%Y-%m-%d").to_string()
|
||||
}
|
||||
|
||||
pub fn current_weekly_key() -> String {
|
||||
chrono::Utc::now().format("%Y-W%V").to_string()
|
||||
}
|
||||
|
||||
/// Get all active daily and weekly quests for the authenticated user with live progress.
|
||||
pub async fn get_my_quests(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<UserQuest>>> {
|
||||
let daily_key = current_daily_key();
|
||||
let weekly_key = current_weekly_key();
|
||||
|
||||
let rows: Vec<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
i64,
|
||||
i64,
|
||||
String,
|
||||
bool,
|
||||
Option<i64>,
|
||||
Option<bool>,
|
||||
Option<bool>,
|
||||
Option<String>,
|
||||
)> = 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,
|
||||
uq.progress, uq.completed, uq.claimed, uq.period_key
|
||||
FROM quests q
|
||||
LEFT JOIN user_quests uq ON uq.quest_id = q.id
|
||||
AND uq.user_uuid = ?
|
||||
AND uq.period_key = (CASE WHEN q.period = 'weekly' THEN ? ELSE ? END)
|
||||
WHERE q.enabled = 1
|
||||
ORDER BY q.period ASC, q.xp_reward ASC",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&weekly_key)
|
||||
.bind(&daily_key)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(
|
||||
id,
|
||||
title,
|
||||
desc,
|
||||
period,
|
||||
cat,
|
||||
stat,
|
||||
target,
|
||||
xp,
|
||||
icon,
|
||||
enabled,
|
||||
progress_opt,
|
||||
completed_opt,
|
||||
claimed_opt,
|
||||
period_key_opt,
|
||||
)| {
|
||||
let progress = progress_opt.unwrap_or(0);
|
||||
let completed = completed_opt.unwrap_or(progress >= target);
|
||||
let claimed = claimed_opt.unwrap_or(false);
|
||||
let pkey = period_key_opt.unwrap_or_else(|| {
|
||||
if period == "weekly" {
|
||||
weekly_key.clone()
|
||||
} else {
|
||||
daily_key.clone()
|
||||
}
|
||||
});
|
||||
|
||||
UserQuest {
|
||||
quest: Quest {
|
||||
id,
|
||||
title,
|
||||
description: desc,
|
||||
period,
|
||||
category: cat,
|
||||
target_stat: stat,
|
||||
target_count: target,
|
||||
xp_reward: xp,
|
||||
icon,
|
||||
enabled,
|
||||
},
|
||||
progress,
|
||||
completed,
|
||||
claimed,
|
||||
period_key: pkey,
|
||||
}
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
/// Claim a completed quest reward.
|
||||
pub async fn claim_quest(
|
||||
auth: AuthUser,
|
||||
Path(quest_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
|
||||
let quest: Option<(String, i64, i64)> = sqlx::query_as(
|
||||
"SELECT period, target_count, xp_reward FROM quests WHERE id = ? AND enabled = 1",
|
||||
)
|
||||
.bind(&quest_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let (period, target_count, xp_reward) =
|
||||
quest.ok_or_else(|| AppError::bad_request("Quest not found or disabled"))?;
|
||||
|
||||
let period_key = if period == "weekly" {
|
||||
current_weekly_key()
|
||||
} else {
|
||||
current_daily_key()
|
||||
};
|
||||
|
||||
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 = ?",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&quest_id)
|
||||
.bind(&period_key)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let (progress, completed, claimed) = user_quest.unwrap_or((0, false, false));
|
||||
|
||||
if claimed {
|
||||
return Err(AppError::bad_request("Quest reward already claimed for this period"));
|
||||
}
|
||||
if !completed && progress < target_count {
|
||||
return Err(AppError::bad_request("Quest is not yet completed"));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
// Mark as completed & claimed
|
||||
sqlx::query(
|
||||
"INSERT INTO user_quests (user_uuid, quest_id, period_key, progress, completed, claimed, updated_at)
|
||||
VALUES (?, ?, ?, ?, 1, 1, ?)
|
||||
ON CONFLICT (user_uuid, quest_id, period_key) DO UPDATE SET
|
||||
completed = 1,
|
||||
claimed = 1,
|
||||
updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&quest_id)
|
||||
.bind(&period_key)
|
||||
.bind(progress.max(target_count))
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Grant XP to user_levels
|
||||
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(&auth.uuid)
|
||||
.bind(xp_reward)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Read new total XP & compute new level
|
||||
let new_xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?")
|
||||
.bind(&auth.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(&auth.uuid)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"xp_granted": xp_reward,
|
||||
"new_global_xp": new_xp,
|
||||
"new_global_level": new_lvl
|
||||
})))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin Quests API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AdminQuestRow {
|
||||
#[serde(flatten)]
|
||||
pub quest: Quest,
|
||||
pub total_completions: i64,
|
||||
}
|
||||
|
||||
/// Admin list all quests.
|
||||
pub async fn admin_list_quests(
|
||||
_admin: AdminUser,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<AdminQuestRow>>> {
|
||||
let rows: Vec<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
i64,
|
||||
i64,
|
||||
String,
|
||||
bool,
|
||||
i64,
|
||||
)> = 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
|
||||
FROM quests q
|
||||
LEFT JOIN user_quests uq ON uq.quest_id = q.id AND uq.completed = 1
|
||||
GROUP BY q.id
|
||||
ORDER BY q.period ASC, q.created_at DESC",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(id, title, desc, period, cat, stat, target, xp, icon, enabled, completions)| {
|
||||
AdminQuestRow {
|
||||
quest: Quest {
|
||||
id,
|
||||
title,
|
||||
description: desc,
|
||||
period,
|
||||
category: cat,
|
||||
target_stat: stat,
|
||||
target_count: target,
|
||||
xp_reward: xp,
|
||||
icon,
|
||||
enabled,
|
||||
},
|
||||
total_completions: completions,
|
||||
}
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct QuestPayload {
|
||||
pub id: Option<String>,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub period: String,
|
||||
pub category: String,
|
||||
pub target_stat: String,
|
||||
pub target_count: i64,
|
||||
pub xp_reward: i64,
|
||||
pub icon: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
/// Admin create quest.
|
||||
pub async fn admin_create_quest(
|
||||
_admin: AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<QuestPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let id = payload
|
||||
.id
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| format!("q_{}_{}", payload.period, uuid::Uuid::new_v4().simple()));
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(payload.title)
|
||||
.bind(payload.description)
|
||||
.bind(payload.period)
|
||||
.bind(payload.category)
|
||||
.bind(payload.target_stat)
|
||||
.bind(payload.target_count)
|
||||
.bind(payload.xp_reward)
|
||||
.bind(payload.icon.unwrap_or_else(|| "star".into()))
|
||||
.bind(payload.enabled.unwrap_or(true))
|
||||
.bind(&now)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "id": id, "ok": true })))
|
||||
}
|
||||
|
||||
/// Admin update quest.
|
||||
pub async fn admin_update_quest(
|
||||
_admin: AdminUser,
|
||||
Path(id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<QuestPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
sqlx::query(
|
||||
"UPDATE quests SET
|
||||
title = ?,
|
||||
description = ?,
|
||||
period = ?,
|
||||
category = ?,
|
||||
target_stat = ?,
|
||||
target_count = ?,
|
||||
xp_reward = ?,
|
||||
icon = COALESCE(?, icon),
|
||||
enabled = COALESCE(?, enabled)
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(payload.title)
|
||||
.bind(payload.description)
|
||||
.bind(payload.period)
|
||||
.bind(payload.category)
|
||||
.bind(payload.target_stat)
|
||||
.bind(payload.target_count)
|
||||
.bind(payload.xp_reward)
|
||||
.bind(payload.icon)
|
||||
.bind(payload.enabled)
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// Admin delete quest.
|
||||
pub async fn admin_delete_quest(
|
||||
_admin: AdminUser,
|
||||
Path(id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
sqlx::query("DELETE FROM quests WHERE id = ?")
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
//! Friends, Direct Messages, Game Invites, Player Profiles, and Social Feed.
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::state::AppState;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::Json;
|
||||
use scopenet_shared::{DirectMessage, FriendInfo, GameInvite, UserPost, UserProfileView};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Friends System
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn list_friends(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<FriendInfo>>> {
|
||||
let rows: Vec<(String, String, String, String, bool, Option<String>)> = sqlx::query_as(
|
||||
"SELECT
|
||||
(CASE WHEN f.user_uuid = ? THEN f.friend_uuid ELSE f.user_uuid END) as target_uuid,
|
||||
u.username,
|
||||
f.status,
|
||||
f.action_uuid,
|
||||
EXISTS(SELECT 1 FROM server_online so WHERE so.uuid = u.uuid) as online,
|
||||
(SELECT gs.name FROM server_online so JOIN game_servers gs ON gs.id = so.server_id WHERE so.uuid = u.uuid LIMIT 1) as playing_on
|
||||
FROM friendships f
|
||||
JOIN users u ON u.uuid = (CASE WHEN f.user_uuid = ? THEN f.friend_uuid ELSE f.user_uuid END)
|
||||
WHERE f.user_uuid = ? OR f.friend_uuid = ?
|
||||
ORDER BY online DESC, u.username ASC",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&auth.uuid)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.map(|(uuid, username, status, action_uuid, online, playing_on)| {
|
||||
let final_status = if status == "accepted" {
|
||||
"accepted".to_string()
|
||||
} else if action_uuid == auth.uuid {
|
||||
"pending_outgoing".to_string()
|
||||
} else {
|
||||
"pending_incoming".to_string()
|
||||
};
|
||||
|
||||
FriendInfo {
|
||||
uuid,
|
||||
username,
|
||||
status: final_status,
|
||||
online,
|
||||
playing_on,
|
||||
last_seen: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FriendRequestPayload {
|
||||
pub username: Option<String>,
|
||||
pub friend_username: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn send_friend_request(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<FriendRequestPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let name = payload
|
||||
.username
|
||||
.as_deref()
|
||||
.or(payload.friend_username.as_deref())
|
||||
.ok_or_else(|| AppError::bad_request("Username is required"))?
|
||||
.trim();
|
||||
|
||||
let target: Option<(String, String)> = sqlx::query_as(
|
||||
"SELECT uuid, username FROM users WHERE username = ? COLLATE NOCASE",
|
||||
)
|
||||
.bind(name)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let (target_uuid, target_name) = target.ok_or_else(|| AppError::not_found("Player not found"))?;
|
||||
|
||||
if target_uuid == auth.uuid {
|
||||
return Err(AppError::bad_request("You cannot friend yourself"));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
// Check existing
|
||||
let existing: Option<(i64, String, String)> = sqlx::query_as(
|
||||
"SELECT id, status, action_uuid FROM friendships
|
||||
WHERE (user_uuid = ? AND friend_uuid = ?) OR (user_uuid = ? AND friend_uuid = ?)",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&target_uuid)
|
||||
.bind(&target_uuid)
|
||||
.bind(&auth.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
if let Some((_id, status, action)) = existing {
|
||||
if status == "accepted" {
|
||||
return Err(AppError::bad_request("Already friends with this player"));
|
||||
}
|
||||
if status == "pending" && action != auth.uuid {
|
||||
// Reciprocal request -> Auto-accept!
|
||||
sqlx::query("UPDATE friendships SET status = 'accepted', action_uuid = ?, updated_at = ? WHERE (user_uuid = ? AND friend_uuid = ?) OR (user_uuid = ? AND friend_uuid = ?)")
|
||||
.bind(&auth.uuid)
|
||||
.bind(&now)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&target_uuid)
|
||||
.bind(&target_uuid)
|
||||
.bind(&auth.uuid)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
return Ok(Json(serde_json::json!({ "ok": true, "status": "accepted", "friend": target_name })));
|
||||
}
|
||||
return Err(AppError::bad_request("Friend request is already pending"));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO friendships (user_uuid, friend_uuid, status, action_uuid, created_at, updated_at)
|
||||
VALUES (?, ?, 'pending', ?, ?, ?)",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&target_uuid)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true, "status": "pending_outgoing", "friend": target_name })))
|
||||
}
|
||||
|
||||
pub async fn accept_friend_request(
|
||||
auth: AuthUser,
|
||||
Path(target_uuid): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let res = sqlx::query(
|
||||
"UPDATE friendships SET status = 'accepted', updated_at = ?
|
||||
WHERE ((user_uuid = ? AND friend_uuid = ?) OR (user_uuid = ? AND friend_uuid = ?))
|
||||
AND status = 'pending' AND action_uuid <> ?",
|
||||
)
|
||||
.bind(&now)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&target_uuid)
|
||||
.bind(&target_uuid)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&auth.uuid)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(AppError::bad_request("No pending request from this user found"));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
pub async fn remove_friend(
|
||||
auth: AuthUser,
|
||||
Path(target_uuid): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
sqlx::query(
|
||||
"DELETE FROM friendships
|
||||
WHERE (user_uuid = ? AND friend_uuid = ?) OR (user_uuid = ? AND friend_uuid = ?)",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&target_uuid)
|
||||
.bind(&target_uuid)
|
||||
.bind(&auth.uuid)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RespondFriendPayload {
|
||||
pub target_uuid: Option<String>,
|
||||
pub friend_uuid: Option<String>,
|
||||
pub accept: bool,
|
||||
}
|
||||
|
||||
pub async fn respond_friend_request(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<RespondFriendPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let target = payload
|
||||
.target_uuid
|
||||
.or(payload.friend_uuid)
|
||||
.ok_or_else(|| AppError::bad_request("Friend UUID is required"))?;
|
||||
|
||||
if payload.accept {
|
||||
accept_friend_request(auth, Path(target), State(state)).await
|
||||
} else {
|
||||
remove_friend(auth, Path(target), State(state)).await
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Direct Messaging (DMs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn get_direct_messages(
|
||||
auth: AuthUser,
|
||||
Path(target_uuid): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<DirectMessage>>> {
|
||||
let rows: Vec<(i64, String, String, String, String, bool, String)> = sqlx::query_as(
|
||||
"SELECT id, sender_uuid, sender_name, recipient_uuid, content, is_read, created_at
|
||||
FROM direct_messages
|
||||
WHERE (sender_uuid = ? AND recipient_uuid = ?) OR (sender_uuid = ? AND recipient_uuid = ?)
|
||||
ORDER BY id ASC LIMIT 100",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&target_uuid)
|
||||
.bind(&target_uuid)
|
||||
.bind(&auth.uuid)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
// Mark unread messages sent to me as read
|
||||
sqlx::query(
|
||||
"UPDATE direct_messages SET is_read = 1
|
||||
WHERE recipient_uuid = ? AND sender_uuid = ? AND is_read = 0",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&target_uuid)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.map(|(id, suuid, sname, ruuid, content, is_read, created)| DirectMessage {
|
||||
id,
|
||||
sender_uuid: suuid,
|
||||
sender_name: sname,
|
||||
recipient_uuid: ruuid,
|
||||
content,
|
||||
is_read,
|
||||
created_at: created,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SendMessagePayload {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
pub async fn send_direct_message(
|
||||
auth: AuthUser,
|
||||
Path(target_uuid): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<SendMessagePayload>,
|
||||
) -> AppResult<Json<DirectMessage>> {
|
||||
let content = payload.content.trim();
|
||||
if content.is_empty() {
|
||||
return Err(AppError::bad_request("Message cannot be empty"));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO direct_messages (sender_uuid, sender_name, recipient_uuid, content, is_read, created_at)
|
||||
VALUES (?, ?, ?, ?, 0, ?)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&auth.username)
|
||||
.bind(&target_uuid)
|
||||
.bind(content)
|
||||
.bind(&now)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(DirectMessage {
|
||||
id,
|
||||
sender_uuid: auth.uuid.clone(),
|
||||
sender_name: auth.username.clone(),
|
||||
recipient_uuid: target_uuid,
|
||||
content: content.to_string(),
|
||||
is_read: false,
|
||||
created_at: now,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Game Invites
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn list_my_game_invites(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<GameInvite>>> {
|
||||
let rows: Vec<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
)> = sqlx::query_as(
|
||||
"SELECT gi.id, gi.sender_uuid, gi.sender_name, gi.recipient_uuid,
|
||||
gi.instance_id, i.name as instance_name,
|
||||
gi.server_id, gs.name as server_name,
|
||||
gi.status, gi.created_at, gi.expires_at
|
||||
FROM game_invites gi
|
||||
JOIN instances i ON i.id = gi.instance_id
|
||||
LEFT JOIN game_servers gs ON gs.id = gi.server_id
|
||||
WHERE gi.recipient_uuid = ? AND gi.status = 'pending' AND gi.expires_at > ?
|
||||
ORDER BY gi.created_at DESC",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(id, suuid, sname, ruuid, iid, iname, sid, sname_opt, status, cat, eat)| GameInvite {
|
||||
id,
|
||||
sender_uuid: suuid,
|
||||
sender_name: sname,
|
||||
recipient_uuid: ruuid,
|
||||
instance_id: iid,
|
||||
instance_name: iname,
|
||||
server_id: sid,
|
||||
server_name: sname_opt,
|
||||
status,
|
||||
created_at: cat,
|
||||
expires_at: eat,
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SendInvitePayload {
|
||||
pub recipient_uuid: String,
|
||||
pub instance_id: String,
|
||||
pub server_id: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn send_game_invite(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<SendInvitePayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let invite_id = format!("inv_{}", uuid::Uuid::new_v4().simple());
|
||||
let now = chrono::Utc::now();
|
||||
let expires = now + chrono::Duration::minutes(30);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO game_invites (id, sender_uuid, sender_name, recipient_uuid, instance_id, server_id, status, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?)",
|
||||
)
|
||||
.bind(&invite_id)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&auth.username)
|
||||
.bind(payload.recipient_uuid)
|
||||
.bind(payload.instance_id)
|
||||
.bind(payload.server_id)
|
||||
.bind(now.to_rfc3339())
|
||||
.bind(expires.to_rfc3339())
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "id": invite_id, "ok": true })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RespondInvitePayload {
|
||||
pub action: String, // "accept" or "decline"
|
||||
}
|
||||
|
||||
pub async fn respond_game_invite(
|
||||
auth: AuthUser,
|
||||
Path(invite_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<RespondInvitePayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let status = if payload.action == "accept" {
|
||||
"accepted"
|
||||
} else {
|
||||
"declined"
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE game_invites SET status = ? WHERE id = ? AND recipient_uuid = ?",
|
||||
)
|
||||
.bind(status)
|
||||
.bind(&invite_id)
|
||||
.bind(&auth.uuid)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true, "status": status })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Viewable Player Profiles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn get_player_profile(
|
||||
Path(uuid): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<UserProfileView>> {
|
||||
let user_row: Option<(String, String)> = sqlx::query_as(
|
||||
"SELECT uuid, username FROM users WHERE uuid = ?",
|
||||
)
|
||||
.bind(&uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let (puuid, username) = user_row.ok_or_else(|| AppError::not_found("Player profile not found"))?;
|
||||
|
||||
let prof_row: Option<(String, Option<String>, Option<String>, Option<String>)> = sqlx::query_as(
|
||||
"SELECT bio, banner_url, custom_badge, featured_achievement_id FROM user_profiles WHERE uuid = ?",
|
||||
)
|
||||
.bind(&puuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let (bio, banner_url, custom_badge, feat_ach_id) = prof_row.unwrap_or((String::new(), None, None, None));
|
||||
|
||||
// Levels
|
||||
let levels = crate::routes::leveling::get_user_levels_by_uuid(&state, &puuid).await?;
|
||||
|
||||
// Featured achievement if set
|
||||
let featured_achievement = if let Some(fid) = feat_ach_id {
|
||||
let ach_list = crate::routes::achievements::get_achievements_for_user(&state, &puuid, false).await?;
|
||||
ach_list.into_iter().find(|a| a.id == fid)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Unlocked achievements count
|
||||
let achievements_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM user_achievements WHERE user_uuid = ?",
|
||||
)
|
||||
.bind(&puuid)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
// Recent posts
|
||||
let post_rows: Vec<(i64, String, String, String, Option<String>, i64, String)> = sqlx::query_as(
|
||||
"SELECT id, user_uuid, author_name, content, image_url, likes_count, created_at
|
||||
FROM user_posts WHERE user_uuid = ? ORDER BY id DESC LIMIT 10",
|
||||
)
|
||||
.bind(&puuid)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let posts = post_rows
|
||||
.into_iter()
|
||||
.map(|(id, u_uuid, aname, content, img, likes, cat)| UserPost {
|
||||
id,
|
||||
user_uuid: u_uuid,
|
||||
author_name: aname,
|
||||
content,
|
||||
image_url: img,
|
||||
likes_count: likes,
|
||||
created_at: cat,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(UserProfileView {
|
||||
uuid: puuid,
|
||||
username,
|
||||
bio,
|
||||
banner_url,
|
||||
custom_badge,
|
||||
title: levels.title,
|
||||
global_level: levels.global_level,
|
||||
global_xp: levels.global_xp,
|
||||
server_levels: levels.server_levels,
|
||||
featured_achievement,
|
||||
achievements_count: achievements_count as usize,
|
||||
posts,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateProfilePayload {
|
||||
pub bio: Option<String>,
|
||||
pub banner_url: Option<String>,
|
||||
pub custom_badge: Option<String>,
|
||||
pub featured_achievement_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn update_my_profile(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<UpdateProfilePayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO user_profiles (uuid, bio, banner_url, custom_badge, featured_achievement_id, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (uuid) DO UPDATE SET
|
||||
bio = COALESCE(?, bio),
|
||||
banner_url = COALESCE(?, banner_url),
|
||||
custom_badge = COALESCE(?, custom_badge),
|
||||
featured_achievement_id = COALESCE(?, featured_achievement_id),
|
||||
updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(payload.bio.clone().unwrap_or_default())
|
||||
.bind(payload.banner_url.clone())
|
||||
.bind(payload.custom_badge.clone())
|
||||
.bind(payload.featured_achievement_id.clone())
|
||||
.bind(&now)
|
||||
.bind(payload.bio)
|
||||
.bind(payload.banner_url)
|
||||
.bind(payload.custom_badge)
|
||||
.bind(payload.featured_achievement_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Social Posts Feed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreatePostInput {
|
||||
pub content: String,
|
||||
pub image_url: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_user_post(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreatePostInput>,
|
||||
) -> AppResult<Json<UserPost>> {
|
||||
let content = payload.content.trim();
|
||||
if content.is_empty() {
|
||||
return Err(AppError::bad_request("Post content cannot be empty"));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO user_posts (user_uuid, author_name, content, image_url, likes_count, created_at)
|
||||
VALUES (?, ?, ?, ?, 0, ?)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&auth.username)
|
||||
.bind(content)
|
||||
.bind(payload.image_url.as_deref())
|
||||
.bind(&now)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(UserPost {
|
||||
id,
|
||||
user_uuid: auth.uuid.clone(),
|
||||
author_name: auth.username.clone(),
|
||||
content: content.to_string(),
|
||||
image_url: payload.image_url,
|
||||
likes_count: 0,
|
||||
created_at: now,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn delete_user_post(
|
||||
auth: AuthUser,
|
||||
Path(post_id): Path<i64>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
sqlx::query("DELETE FROM user_posts WHERE id = ? AND user_uuid = ?")
|
||||
.bind(post_id)
|
||||
.bind(&auth.uuid)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
pub async fn like_user_post(
|
||||
_auth: AuthUser,
|
||||
Path(post_id): Path<i64>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
sqlx::query("UPDATE user_posts SET likes_count = likes_count + 1 WHERE id = ?")
|
||||
.bind(post_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
//! Default seed data for Quests, Achievements, and Level Rewards.
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub async fn seed_quests_and_achievements(pool: &SqlitePool) -> Result<()> {
|
||||
// Only seed if quests table is empty
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM quests").fetch_one(pool).await?;
|
||||
if count == 0 {
|
||||
seed_quests(pool).await?;
|
||||
}
|
||||
|
||||
let ach_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM achievements").fetch_one(pool).await?;
|
||||
if ach_count == 0 {
|
||||
seed_achievements(pool).await?;
|
||||
}
|
||||
|
||||
let rew_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM level_rewards").fetch_one(pool).await?;
|
||||
if rew_count == 0 {
|
||||
seed_rewards(pool).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn seed_quests(pool: &SqlitePool) -> Result<()> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
// 100 total quests: 60 Daily, 40 Weekly
|
||||
let raw_quests = [
|
||||
// Daily Mining
|
||||
("q_d_mine_coal", "Coal Miner", "Mine 16 coal ore or coal blocks", "daily", "mining", "blocks_broken", 16, 100, "pickaxe"),
|
||||
("q_d_mine_stone", "Stonebreaker", "Break 64 stone or cobblestone blocks", "daily", "mining", "blocks_broken", 64, 120, "pickaxe"),
|
||||
("q_d_mine_iron", "Iron Prospector", "Mine 16 iron ore blocks underground", "daily", "mining", "blocks_broken", 16, 150, "pickaxe"),
|
||||
("q_d_mine_copper", "Copper Collector", "Gather 24 copper ore blocks", "daily", "mining", "blocks_broken", 24, 130, "pickaxe"),
|
||||
("q_d_mine_gold", "Gold Rush", "Mine 8 gold ore blocks in caves or the Nether", "daily", "mining", "blocks_broken", 8, 180, "pickaxe"),
|
||||
("q_d_mine_diamond", "Diamond Digger", "Mine 4 rare diamond ore blocks", "daily", "mining", "blocks_broken", 4, 300, "gem"),
|
||||
("q_d_mine_redstone", "Redstone Engineer", "Mine 12 redstone ore blocks", "daily", "mining", "blocks_broken", 12, 160, "zap"),
|
||||
("q_d_mine_deepslate", "Deepslate Delver", "Mine 48 deepslate blocks in the depths", "daily", "mining", "blocks_broken", 48, 140, "pickaxe"),
|
||||
("q_d_mine_quartz", "Nether Prospecting", "Mine 16 Nether quartz ore blocks", "daily", "mining", "blocks_broken", 16, 180, "sparkles"),
|
||||
("q_d_mine_debris", "Debris Hunter", "Mine 2 Ancient Debris blocks in the Nether", "daily", "mining", "blocks_broken", 2, 450, "shield"),
|
||||
("q_d_mine_sand", "Dune Excavator", "Dig 32 sand or gravel blocks", "daily", "mining", "blocks_broken", 32, 90, "layers"),
|
||||
("q_d_mine_obsidian", "Obsidian Forger", "Mine 8 obsidian blocks with a diamond pickaxe", "daily", "mining", "blocks_broken", 8, 250, "shield"),
|
||||
|
||||
// Daily Combat
|
||||
("q_d_mob_zombies", "Zombie Purge", "Defeat 8 zombies wandering in the dark", "daily", "combat", "mob_kills", 8, 120, "swords"),
|
||||
("q_d_mob_skeletons", "Bone Collector", "Take down 6 skeleton archers", "daily", "combat", "mob_kills", 6, 130, "crosshair"),
|
||||
("q_d_mob_spiders", "Arachnid Slayer", "Defeat 5 creepy spiders or cave spiders", "daily", "combat", "mob_kills", 5, 120, "shield-alert"),
|
||||
("q_d_mob_creepers", "Bomb Squad", "Eliminate 4 creepers before they explode", "daily", "combat", "mob_kills", 4, 180, "flame"),
|
||||
("q_d_mob_endermen", "Ender Stalker", "Defeat 2 mysterious Endermen", "daily", "combat", "mob_kills", 2, 220, "eye"),
|
||||
("q_d_mob_witches", "Witch Trial", "Defeat 1 witch brewing dangerous potions", "daily", "combat", "mob_kills", 1, 200, "flask-conical"),
|
||||
("q_d_mob_slimes", "Slime Sweeper", "Defeat 6 bouncy slimes or magma cubes", "daily", "combat", "mob_kills", 6, 140, "sparkles"),
|
||||
("q_d_mob_piglins", "Piglin Skirmish", "Defeat 6 hostile piglins or zombified piglins", "daily", "combat", "mob_kills", 6, 160, "swords"),
|
||||
("q_d_mob_blazes", "Firestarter", "Defeat 4 blazing fortress defenders", "daily", "combat", "mob_kills", 4, 250, "flame"),
|
||||
("q_d_mob_drowned", "Deep Water Danger", "Defeat 5 drowned lurkers in rivers or oceans", "daily", "combat", "mob_kills", 5, 140, "droplets"),
|
||||
("q_d_mob_monsters", "Night Watch", "Eliminate any 15 hostile monsters", "daily", "combat", "mob_kills", 15, 200, "swords"),
|
||||
("q_d_pvp_champion", "Gladiator's Glory", "Defeat 1 player in honorable combat", "daily", "combat", "player_kills", 1, 250, "trophy"),
|
||||
|
||||
// Daily Building
|
||||
("q_d_build_planks", "Woodworker", "Place 48 wooden planks in any construction", "daily", "building", "blocks_placed", 48, 120, "hammer"),
|
||||
("q_d_build_bricks", "Mason's Pride", "Place 32 stone bricks or polished stones", "daily", "building", "blocks_placed", 32, 130, "box"),
|
||||
("q_d_build_glass", "Window Pane", "Place 16 glass blocks or panes", "daily", "building", "blocks_placed", 16, 100, "eye"),
|
||||
("q_d_build_torches", "Let There Be Light", "Place 20 torches to illuminate the area", "daily", "building", "blocks_placed", 20, 90, "flame"),
|
||||
("q_d_build_lanterns", "Cozy Hearth", "Place 6 lanterns around your settlement", "daily", "building", "blocks_placed", 6, 120, "sparkles"),
|
||||
("q_d_build_slabs", "Tiered Architecture", "Place 24 slabs or stairs", "daily", "building", "blocks_placed", 24, 110, "layers"),
|
||||
("q_d_build_roof", "Under Cover", "Place 50 roofing or decorative blocks", "daily", "building", "blocks_placed", 50, 140, "home"),
|
||||
("q_d_build_walls", "Fortified Walls", "Place 30 cobblestone or deepslate walls", "daily", "building", "blocks_placed", 30, 130, "shield"),
|
||||
("q_d_build_general", "Master Builder", "Place 100 blocks of any material", "daily", "building", "blocks_placed", 100, 180, "hammer"),
|
||||
("q_d_build_monument", "Grand Monument", "Place 150 blocks toward a new structure", "daily", "building", "blocks_placed", 150, 220, "castle"),
|
||||
|
||||
// Daily Farming & Harvesting
|
||||
("q_d_farm_wood", "Lumberjack", "Chop down 32 wood logs", "daily", "farming", "blocks_broken", 32, 120, "axe"),
|
||||
("q_d_farm_birch", "Birch Forester", "Harvest 20 birch or spruce logs", "daily", "farming", "blocks_broken", 20, 110, "axe"),
|
||||
("q_d_farm_jungle", "Jungle Explorer", "Chop 24 jungle or acacia logs", "daily", "farming", "blocks_broken", 24, 130, "axe"),
|
||||
("q_d_farm_wheat", "Wheat Harvester", "Harvest 24 ripe wheat stalks", "daily", "farming", "blocks_broken", 24, 100, "sprout"),
|
||||
("q_d_farm_potatoes", "Potato Bounty", "Gather 24 potatoes from the fields", "daily", "farming", "blocks_broken", 24, 100, "sprout"),
|
||||
("q_d_farm_carrots", "Golden Nutrition", "Harvest 24 fresh carrots", "daily", "farming", "blocks_broken", 24, 100, "sprout"),
|
||||
("q_d_farm_sugarcane", "Sugar Rush", "Cut 16 sugar cane reeds", "daily", "farming", "blocks_broken", 16, 90, "sprout"),
|
||||
("q_d_farm_pumpkins", "Patch Patrol", "Harvest 8 pumpkins or melons", "daily", "farming", "blocks_broken", 8, 110, "apple"),
|
||||
("q_d_farm_flowers", "Botanist", "Collect 12 wild flowers", "daily", "farming", "blocks_broken", 12, 80, "flower"),
|
||||
("q_d_farm_leaves", "Foliage Trimmer", "Break 40 leaf blocks", "daily", "farming", "blocks_broken", 40, 80, "scissors"),
|
||||
|
||||
// Daily Social & Playtime
|
||||
("q_d_play_15m", "Quick Check-in", "Play on any server for 15 minutes", "daily", "social", "playtime_secs", 900, 100, "clock"),
|
||||
("q_d_play_30m", "Dedicated Citizen", "Spend 30 minutes in game", "daily", "social", "playtime_secs", 1800, 180, "clock"),
|
||||
("q_d_play_60m", "Hour of Power", "Play for a full continuous hour", "daily", "social", "playtime_secs", 3600, 250, "clock"),
|
||||
("q_d_chat_chatty", "Good Morning", "Say hello and send 5 chat messages", "daily", "social", "messages", 5, 80, "message-square"),
|
||||
("q_d_chat_talkative", "Community Voice", "Send 15 chat messages participating in chat", "daily", "social", "messages", 15, 120, "message-square"),
|
||||
("q_d_join_again", "Frequent Flyer", "Join a server 2 distinct times today", "daily", "social", "joins", 2, 80, "door-open"),
|
||||
("q_d_explore_depths", "Into the Deep", "Mine 30 tuff or deepslate tiles", "daily", "exploration", "blocks_broken", 30, 110, "compass"),
|
||||
("q_d_explore_caves", "Spelunker", "Break 50 blocks exploring natural cave systems", "daily", "exploration", "blocks_broken", 50, 130, "compass"),
|
||||
("q_d_explore_diorite", "Geologist", "Collect 20 granite, diorite, or andesite blocks", "daily", "exploration", "blocks_broken", 20, 100, "mountain"),
|
||||
("q_d_explore_nether", "Nether Expedition", "Mine 40 nether blocks while surviving", "daily", "exploration", "blocks_broken", 40, 160, "flame"),
|
||||
("q_d_explore_clay", "Clay Digger", "Excavate 16 clay blocks from river beds", "daily", "exploration", "blocks_broken", 16, 110, "droplets"),
|
||||
("q_d_explore_snow", "Winter Wonderland", "Shovel 32 snow or ice blocks", "daily", "exploration", "blocks_broken", 32, 90, "snowflake"),
|
||||
("q_d_explore_magma", "Volcanic Venture", "Mine 8 magma blocks or basalt", "daily", "exploration", "blocks_broken", 8, 140, "flame"),
|
||||
("q_d_build_decor", "Interior Decorator", "Place 15 decorative items or carpets", "daily", "building", "blocks_placed", 15, 90, "palette"),
|
||||
|
||||
// Weekly Quests (Higher targets & XP rewards)
|
||||
("q_w_mine_deepslate_grand", "Excavation Giant", "Mine 500 deepslate blocks in the darkest depths", "weekly", "mining", "blocks_broken", 500, 750, "pickaxe"),
|
||||
("q_w_mine_iron_hoard", "Iron Hoarder", "Mine 128 iron ore across the week", "weekly", "mining", "blocks_broken", 128, 850, "pickaxe"),
|
||||
("q_w_mine_diamonds_grand", "Diamond Empire", "Discover and mine 32 diamonds", "weekly", "mining", "blocks_broken", 32, 1200, "gem"),
|
||||
("q_w_mine_gold_grand", "Midas Touch", "Mine 64 gold ore from caves or fortresses", "weekly", "mining", "blocks_broken", 64, 900, "award"),
|
||||
("q_w_mine_redstone_grand", "Automation Maestro", "Mine 128 redstone ore blocks", "weekly", "mining", "blocks_broken", 128, 800, "zap"),
|
||||
("q_w_mine_debris_grand", "Netherite Titan", "Locate and extract 12 Ancient Debris blocks", "weekly", "mining", "blocks_broken", 12, 1600, "shield"),
|
||||
("q_w_mine_nether_grand", "Underworld Quarry", "Break 300 blocks in the Nether dimension", "weekly", "mining", "blocks_broken", 300, 700, "flame"),
|
||||
("q_w_mine_stone_grand", "Mountain Mover", "Break 1,000 stone or cobblestone blocks", "weekly", "mining", "blocks_broken", 1000, 1100, "mountain"),
|
||||
|
||||
("q_w_mob_purge_grand", "Monster Exterminator", "Eliminate 100 hostile monsters", "weekly", "combat", "mob_kills", 100, 1000, "swords"),
|
||||
("q_w_mob_skeletons_grand", "Archer's Nemesis", "Defeat 40 skeleton sharpshooters", "weekly", "combat", "mob_kills", 40, 750, "crosshair"),
|
||||
("q_w_mob_creepers_grand", "Demolition Defuser", "Take down 30 dangerous creepers", "weekly", "combat", "mob_kills", 30, 850, "flame"),
|
||||
("q_w_mob_endermen_grand", "Void Hunter", "Defeat 15 elusive Endermen", "weekly", "combat", "mob_kills", 15, 1100, "eye"),
|
||||
("q_w_mob_blazes_grand", "Fortress Conqueror", "Extinguish 25 fiery blazes in Nether fortresses", "weekly", "combat", "mob_kills", 25, 1200, "flame"),
|
||||
("q_w_mob_withers_grand", "Skull Seeker", "Defeat 12 wither skeletons", "weekly", "combat", "mob_kills", 12, 1300, "skull"),
|
||||
("q_w_pvp_champion_grand", "Arena Legend", "Defeat 10 players in competitive PvP matches", "weekly", "combat", "player_kills", 10, 1500, "trophy"),
|
||||
("q_w_pvp_master_grand", "Warlord of SCOPENET", "Claim 25 PvP victories across all servers", "weekly", "combat", "player_kills", 25, 2500, "crown"),
|
||||
|
||||
("q_w_build_castle", "Castle Founder", "Place 800 stone bricks or fortified blocks", "weekly", "building", "blocks_placed", 800, 1000, "castle"),
|
||||
("q_w_build_planks_grand", "Town Carpenter", "Place 500 wooden planks on structures", "weekly", "building", "blocks_placed", 500, 800, "hammer"),
|
||||
("q_w_build_glass_grand", "Sky Dome", "Place 150 glass blocks", "weekly", "building", "blocks_placed", 150, 700, "eye"),
|
||||
("q_w_build_lanterns_grand", "Beacon of Light", "Place 40 lanterns or glowstone blocks", "weekly", "building", "blocks_placed", 40, 750, "sparkles"),
|
||||
("q_w_build_grand_city", "Metropolis Architect", "Place 1,500 total blocks across any build", "weekly", "building", "blocks_placed", 1500, 1400, "hammer"),
|
||||
("q_w_build_enclosure", "Fortress Perimeters", "Place 150 fence posts or wall segments", "weekly", "building", "blocks_placed", 150, 700, "shield"),
|
||||
|
||||
("q_w_farm_logs_grand", "Deforestation Duty", "Harvest 250 wood logs of any species", "weekly", "farming", "blocks_broken", 250, 800, "axe"),
|
||||
("q_w_farm_crops_grand", "Granary Overflow", "Harvest 200 total food crops", "weekly", "farming", "blocks_broken", 200, 750, "sprout"),
|
||||
("q_w_farm_reeds_grand", "Sugar Empire", "Harvest 100 sugar cane or bamboo", "weekly", "farming", "blocks_broken", 100, 650, "sprout"),
|
||||
("q_w_farm_pumpkins_grand", "Autumn Festival", "Harvest 40 pumpkins or melons", "weekly", "farming", "blocks_broken", 40, 700, "apple"),
|
||||
|
||||
("q_w_play_3h", "Weekly Adventurer", "Spend 3 hours playing on SCOPENET servers", "weekly", "social", "playtime_secs", 10800, 800, "clock"),
|
||||
("q_w_play_8h", "Dedicated Veteran", "Spend 8 hours playing across the week", "weekly", "social", "playtime_secs", 28800, 1500, "clock"),
|
||||
("q_w_play_15h", "No-Lifer Supremacy", "Clock in 15 hours of active gameplay", "weekly", "social", "playtime_secs", 54000, 2500, "zap"),
|
||||
("q_w_chat_social", "Town Hall Speaker", "Contribute 50 chat messages across community servers", "weekly", "social", "messages", 50, 600, "message-square"),
|
||||
("q_w_joins_loyal", "Daily Habits", "Join game servers on at least 5 occasions", "weekly", "social", "joins", 5, 700, "door-open"),
|
||||
("q_w_explore_caves_grand", "Underground Cartographer", "Break 400 blocks deep underground", "weekly", "exploration", "blocks_broken", 400, 850, "compass"),
|
||||
("q_w_explore_nether_grand2", "Infernal Pioneer", "Travel and mine 250 blocks in the Nether", "weekly", "exploration", "blocks_broken", 250, 800, "flame"),
|
||||
("q_w_explore_ocean", "Abyssal Diver", "Break 100 prismarine, gravel, or coral underwater", "weekly", "exploration", "blocks_broken", 100, 750, "droplets"),
|
||||
("q_w_explore_sand_grand", "Desert Terraformer", "Dig 300 sand or red sand blocks", "weekly", "exploration", "blocks_broken", 300, 650, "layers"),
|
||||
("q_w_build_complex", "Civil Engineer", "Place 600 stone bricks or polished granite", "weekly", "building", "blocks_placed", 600, 950, "box"),
|
||||
];
|
||||
|
||||
for (id, title, desc, period, cat, stat, target, xp, icon) in raw_quests {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO quests (id, title, description, period, category, target_stat, target_count, xp_reward, icon, enabled, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)"
|
||||
)
|
||||
.bind(id)
|
||||
.bind(title)
|
||||
.bind(desc)
|
||||
.bind(period)
|
||||
.bind(cat)
|
||||
.bind(stat)
|
||||
.bind(target)
|
||||
.bind(xp)
|
||||
.bind(icon)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn seed_achievements(pool: &SqlitePool) -> Result<()> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let raw_achievements = [
|
||||
("ach_first_steps", "First Steps", "Join your first SCOPENET server and begin your journey.", "progression", "task", "wooden_pickaxe", "stone", "bronze", "stat", "joins", 1, 100, 0),
|
||||
("ach_miner_49er", "Strike It Rich!", "Mine your first diamond ore underground.", "mining", "task", "diamond", "deepslate", "gold", "stat", "blocks_broken", 50, 250, 0),
|
||||
("ach_first_blood", "First Blood", "Defeat your first monster in battle.", "combat", "task", "iron_sword", "dirt", "bronze", "stat", "mob_kills", 1, 100, 0),
|
||||
("ach_monster_hunter", "Monster Hunter", "Defeat 100 hostile mobs across servers.", "combat", "goal", "diamond_sword", "stone", "gold", "stat", "mob_kills", 100, 500, 0),
|
||||
("ach_master_slayer", "Slayer of the Night", "Vanquish 500 monsters threatening players.", "combat", "challenge", "netherite_sword", "obsidian", "purple", "stat", "mob_kills", 500, 1500, 0),
|
||||
("ach_duelist", "Arena Duelist", "Defeat another player in PvP combat.", "combat", "task", "bow", "stone", "silver", "stat", "player_kills", 1, 200, 0),
|
||||
("ach_gladiator", "SCOPENET Gladiator", "Defeat 25 players in competitive combat.", "combat", "challenge", "netherite_axe", "bedrock", "gold", "stat", "player_kills", 25, 1000, 0),
|
||||
("ach_architect", "Apprentice Architect", "Place your first 500 building blocks.", "building", "task", "bricks", "stone", "bronze", "stat", "blocks_placed", 500, 250, 0),
|
||||
("ach_master_builder", "Master Builder", "Construct structures using over 5,000 blocks.", "building", "goal", "stone_bricks", "oak_planks", "gold", "stat", "blocks_placed", 5000, 1000, 0),
|
||||
("ach_monument_maker", "Wonders of the World", "Place 20,000 blocks leaving your mark on the realm.", "building", "challenge", "beacon", "obsidian", "diamond", "stat", "blocks_placed", 20000, 2500, 0),
|
||||
("ach_playtime_10h", "Seasoned Traveler", "Accumulate 10 hours of playtime.", "progression", "task", "clock", "oak_planks", "silver", "stat", "playtime_secs", 36000, 500, 0),
|
||||
("ach_playtime_50h", "Hardened Veteran", "Reach 50 hours of active adventure.", "progression", "goal", "compass", "stone", "gold", "stat", "playtime_secs", 180000, 1200, 0),
|
||||
("ach_playtime_100h", "Centurion", "Dedicate 100 hours to the SCOPENET community.", "progression", "challenge", "totem_of_undying", "gilded_blackstone", "diamond", "stat", "playtime_secs", 360000, 2500, 0),
|
||||
("ach_level_10", "Rising Star", "Reach SCOPENET Global Level 10.", "progression", "task", "experience_bottle", "stone", "bronze", "level", "global_level", 10, 300, 0),
|
||||
("ach_level_25", "Champion", "Ascend to Global Level 25.", "progression", "goal", "enchanted_book", "stone", "gold", "level", "global_level", 25, 800, 0),
|
||||
("ach_level_50", "Grandmaster", "Attain Global Level 50 through valor and persistence.", "progression", "challenge", "nether_star", "obsidian", "purple", "level", "global_level", 50, 2000, 0),
|
||||
("ach_level_100", "Mythic Legend", "Surpass all expectations by reaching Global Level 100.", "progression", "challenge", "dragon_egg", "bedrock", "gold", "level", "global_level", 100, 5000, 0),
|
||||
("ach_chatty", "Social Butterfly", "Send 100 messages in server chat.", "social", "task", "book", "oak_planks", "bronze", "stat", "messages", 100, 200, 0),
|
||||
("ach_guild_initiate", "United We Stand", "Join or found a Guild.", "guilds", "task", "shield", "stone", "silver", "event", "join_guild", 1, 300, 0),
|
||||
("ach_land_claim", "Territorial Sovereign", "Claim your first chunk of guild territory.", "guilds", "goal", "grass_block", "dirt", "gold", "event", "claim_land", 1, 400, 0),
|
||||
("ach_nether_walker", "Into the Fire", "Set foot in the Nether dimension.", "exploration", "task", "flint_and_steel", "netherrack", "bronze", "event", "nether", 1, 200, 0),
|
||||
("ach_end_slayer", "Free the End", "Slay the Ender Dragon.", "exploration", "challenge", "dragon_head", "end_stone", "purple", "event", "ender_dragon", 1, 2000, 1),
|
||||
];
|
||||
|
||||
for (id, title, desc, cat, frame, item, bg, border, req_type, req_key, req_val, xp, secret) in raw_achievements {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO achievements (id, title, description, category, icon_frame, icon_item, icon_bg, icon_border, requirement_type, requirement_key, requirement_value, xp_reward, secret, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(id)
|
||||
.bind(title)
|
||||
.bind(desc)
|
||||
.bind(cat)
|
||||
.bind(frame)
|
||||
.bind(item)
|
||||
.bind(bg)
|
||||
.bind(border)
|
||||
.bind(req_type)
|
||||
.bind(req_key)
|
||||
.bind(req_val)
|
||||
.bind(xp)
|
||||
.bind(secret)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn seed_rewards(pool: &SqlitePool) -> Result<()> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let raw_rewards = [
|
||||
("global", None::<i64>, 2, "title", "Apprentice", r##"{"title": "Apprentice", "color": "#a8a29e"}"##),
|
||||
("global", None, 5, "title", "Journeyman", r##"{"title": "Journeyman", "color": "#38bdf8"}"##),
|
||||
("global", None, 5, "badge", "Bronze Star", r##"{"badge": "star_bronze", "icon": "sparkles"}"##),
|
||||
("global", None, 10, "title", "Veteran", r##"{"title": "Veteran", "color": "#4ade80"}"##),
|
||||
("global", None, 10, "cosmetic", "Emerald Aura", r##"{"particle": "emerald", "type": "aura"}"##),
|
||||
("global", None, 20, "title", "Warden", r##"{"title": "Warden", "color": "#818cf8"}"##),
|
||||
("global", None, 25, "title", "Hero", r##"{"title": "Hero", "color": "#facc15"}"##),
|
||||
("global", None, 25, "badge", "Silver Shield", r##"{"badge": "shield_silver", "icon": "shield"}"##),
|
||||
("global", None, 35, "title", "Warlord", r##"{"title": "Warlord", "color": "#fb923c"}"##),
|
||||
("global", None, 50, "title", "Legend", r##"{"title": "Legend", "color": "#f43f5e"}"##),
|
||||
("global", None, 50, "badge", "Golden Crown", r##"{"badge": "crown_gold", "icon": "crown"}"##),
|
||||
("global", None, 75, "title", "Ascendant", r##"{"title": "Ascendant", "color": "#c084fc"}"##),
|
||||
("global", None, 100, "title", "Immortal", r##"{"title": "Immortal", "color": "#e879f9"}"##),
|
||||
("global", None, 100, "badge", "Mythic Champion", r##"{"badge": "mythic_star", "icon": "trophy"}"##),
|
||||
];
|
||||
|
||||
for (lvl_type, srv_id, lvl_req, rew_type, rew_name, rew_data) in raw_rewards {
|
||||
sqlx::query(
|
||||
"INSERT INTO level_rewards (level_type, server_id, level_req, reward_type, reward_name, reward_data, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(lvl_type)
|
||||
.bind(srv_id)
|
||||
.bind(lvl_req)
|
||||
.bind(rew_type)
|
||||
.bind(rew_name)
|
||||
.bind(rew_data)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
//! Integration tests for v2 features:
|
||||
//! - Global & Server Leveling
|
||||
//! - Daily & Weekly Quests
|
||||
//! - Guilds & Grief Protection Land Claiming
|
||||
//! - Friends, DMs, Posts & Player Profiles
|
||||
//! - Server Feature Toggles (config bypass)
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
|
||||
async fn setup_users(t: &TestApp) -> (String, String, String, String) {
|
||||
let admin = t.login("admin", "supersecret").await;
|
||||
let (s, v1) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Alex", "password": "password123"}))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v1}");
|
||||
let (s, v2) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Steve", "password": "password123"}))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v2}");
|
||||
|
||||
let alex_token = t.login("Alex", "password123").await;
|
||||
let steve_token = t.login("Steve", "password123").await;
|
||||
let alex_uuid = t.uuid("Alex").await;
|
||||
let steve_uuid = t.uuid("Steve").await;
|
||||
|
||||
(alex_token, steve_token, alex_uuid, steve_uuid)
|
||||
}
|
||||
|
||||
async fn create_test_server(t: &TestApp, admin: &str) -> (i64, String) {
|
||||
let (s, v) = t.call("POST", "/api/admin/servers", Some(admin), Some(json!({"name": "Survival Hub"}))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
let server_id = v["server"]["id"].as_i64().unwrap();
|
||||
let token = v["token"].as_str().unwrap().to_string();
|
||||
(server_id, token)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn leveling_and_quests_progression() {
|
||||
let t = setup().await;
|
||||
let admin = t.login("admin", "supersecret").await;
|
||||
let (alex_token, _, alex_uuid, _) = setup_users(&t).await;
|
||||
let (_server_id, server_token) = create_test_server(&t, &admin).await;
|
||||
|
||||
// 1. Initial level check
|
||||
let (s, lvl) = t.call("GET", "/api/v1/leveling/me", Some(&alex_token), None).await;
|
||||
assert_eq!(s, StatusCode::OK, "{lvl}");
|
||||
assert_eq!(lvl["global_level"], 1);
|
||||
assert_eq!(lvl["global_xp"], 0);
|
||||
|
||||
// 2. Fetch seeded quests
|
||||
let (s, quests) = t.call("GET", "/api/v1/quests/me", Some(&alex_token), None).await;
|
||||
assert_eq!(s, StatusCode::OK, "{quests}");
|
||||
let quest_list = quests.as_array().unwrap();
|
||||
assert!(!quest_list.is_empty(), "Expected seeded quests to be populated");
|
||||
|
||||
// 3. Game Server sync with leveling ENABLED
|
||||
let (s, sync_res) = t
|
||||
.call(
|
||||
"POST",
|
||||
"/api/server/v1/sync",
|
||||
Some(&server_token),
|
||||
Some(json!({
|
||||
"features": {
|
||||
"leveling_enabled": true,
|
||||
"global_xp_multiplier": 1.0,
|
||||
"server_xp_multiplier": 1.5,
|
||||
"quests_enabled": true,
|
||||
"achievements_enabled": true,
|
||||
"guilds_enabled": true,
|
||||
"land_claiming_enabled": true
|
||||
},
|
||||
"players": [
|
||||
{"uuid": &alex_uuid, "username": "Alex", "ip": "127.0.0.1", "playtime_secs": 60}
|
||||
],
|
||||
"stats": [
|
||||
{
|
||||
"uuid": &alex_uuid,
|
||||
"name": "Alex",
|
||||
"blocks_broken": 20,
|
||||
"blocks_placed": 10,
|
||||
"mob_kills": 5,
|
||||
"player_kills": 0,
|
||||
"deaths": 0,
|
||||
"playtime_secs": 60
|
||||
}
|
||||
],
|
||||
"events": []
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{sync_res}");
|
||||
|
||||
// 4. Verify Alex gained XP from the server actions
|
||||
let (s, lvl_after) = t.call("GET", "/api/v1/leveling/me", Some(&alex_token), None).await;
|
||||
assert_eq!(s, StatusCode::OK, "{lvl_after}");
|
||||
let current_xp = lvl_after["global_xp"].as_i64().unwrap();
|
||||
assert!(current_xp > 0, "Alex should have earned XP");
|
||||
|
||||
// 5. Game Server sync with leveling DISABLED (server host turned it off in config)
|
||||
let (s, sync_off) = t
|
||||
.call(
|
||||
"POST",
|
||||
"/api/server/v1/sync",
|
||||
Some(&server_token),
|
||||
Some(json!({
|
||||
"features": {
|
||||
"leveling_enabled": false,
|
||||
"quests_enabled": false
|
||||
},
|
||||
"players": [
|
||||
{"uuid": &alex_uuid, "username": "Alex", "ip": "127.0.0.1", "playtime_secs": 120}
|
||||
],
|
||||
"stats": [
|
||||
{
|
||||
"uuid": &alex_uuid,
|
||||
"name": "Alex",
|
||||
"blocks_broken": 50,
|
||||
"blocks_placed": 50,
|
||||
"mob_kills": 20,
|
||||
"player_kills": 0,
|
||||
"deaths": 0,
|
||||
"playtime_secs": 60
|
||||
}
|
||||
],
|
||||
"events": []
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{sync_off}");
|
||||
|
||||
// Verify XP did NOT increase when leveling is disabled
|
||||
let (s, lvl_no_change) = t.call("GET", "/api/v1/leveling/me", Some(&alex_token), None).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(lvl_no_change["global_xp"].as_i64().unwrap(), current_xp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guilds_and_land_claims() {
|
||||
let t = setup().await;
|
||||
let admin = t.login("admin", "supersecret").await;
|
||||
let (alex_token, _, alex_uuid, steve_uuid) = setup_users(&t).await;
|
||||
let (server_id, server_token) = create_test_server(&t, &admin).await;
|
||||
|
||||
// 1. Alex founds a guild
|
||||
let (s, guild) = t
|
||||
.call(
|
||||
"POST",
|
||||
"/api/v1/guilds",
|
||||
Some(&alex_token),
|
||||
Some(json!({
|
||||
"instance_id": "smp-pack",
|
||||
"name": "Iron Fortress",
|
||||
"tag": "IRON",
|
||||
"description": "Defenders of the realm"
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{guild}");
|
||||
let guild_id = guild["id"].as_str().unwrap().to_string();
|
||||
assert_eq!(guild["tag"], "IRON");
|
||||
|
||||
// 2. Alex claims chunk (3, 7) in the Overworld
|
||||
let (s, claim) = t
|
||||
.call(
|
||||
"POST",
|
||||
&format!("/api/v1/guilds/{guild_id}/claim"),
|
||||
Some(&alex_token),
|
||||
Some(json!({
|
||||
"server_id": server_id,
|
||||
"instance_id": "smp-pack",
|
||||
"dimension": "minecraft:overworld",
|
||||
"chunk_x": 3,
|
||||
"chunk_z": 7
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{claim}");
|
||||
let claim_id = claim["id"].as_i64().unwrap();
|
||||
|
||||
// 3. Server checks chunk permissions for in-game protection
|
||||
// Alex (guild leader) attempts to build -> allowed
|
||||
let (s, check_alex) = t
|
||||
.call(
|
||||
"POST",
|
||||
"/api/server/v1/guilds/check-chunk",
|
||||
Some(&server_token),
|
||||
Some(json!({
|
||||
"dimension": "minecraft:overworld",
|
||||
"chunk_x": 3,
|
||||
"chunk_z": 7,
|
||||
"uuid": &alex_uuid
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{check_alex}");
|
||||
assert_eq!(check_alex["allowed"], true);
|
||||
|
||||
// Steve (non-member) attempts to build -> DENIED with grief protection reason
|
||||
let (s, check_steve) = t
|
||||
.call(
|
||||
"POST",
|
||||
"/api/server/v1/guilds/check-chunk",
|
||||
Some(&server_token),
|
||||
Some(json!({
|
||||
"dimension": "minecraft:overworld",
|
||||
"chunk_x": 3,
|
||||
"chunk_z": 7,
|
||||
"uuid": &steve_uuid
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{check_steve}");
|
||||
assert_eq!(check_steve["allowed"], false);
|
||||
assert_eq!(check_steve["claimed"], true);
|
||||
|
||||
// Unclaimed wilderness chunk (100, 100) -> allowed for all players
|
||||
let (s, check_wild) = t
|
||||
.call(
|
||||
"POST",
|
||||
"/api/server/v1/guilds/check-chunk",
|
||||
Some(&server_token),
|
||||
Some(json!({
|
||||
"dimension": "minecraft:overworld",
|
||||
"chunk_x": 100,
|
||||
"chunk_z": 100,
|
||||
"uuid": &steve_uuid
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{check_wild}");
|
||||
assert_eq!(check_wild["allowed"], true);
|
||||
|
||||
// 4. Alex unclaims the chunk
|
||||
let (s, _) = t.call("DELETE", &format!("/api/v1/guilds/claims/{claim_id}"), Some(&alex_token), None).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
|
||||
// After unclaiming, Steve is now allowed to build at (3, 7)
|
||||
let (s, check_unclaimed) = t
|
||||
.call(
|
||||
"POST",
|
||||
"/api/server/v1/guilds/check-chunk",
|
||||
Some(&server_token),
|
||||
Some(json!({
|
||||
"dimension": "minecraft:overworld",
|
||||
"chunk_x": 3,
|
||||
"chunk_z": 7,
|
||||
"uuid": &steve_uuid
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(check_unclaimed["allowed"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn social_friends_dms_and_profiles() {
|
||||
let t = setup().await;
|
||||
let (alex_token, steve_token, alex_uuid, steve_uuid) = setup_users(&t).await;
|
||||
|
||||
// 1. Alex sends friend request to Steve
|
||||
let (s, _) = t
|
||||
.call(
|
||||
"POST",
|
||||
"/api/v1/social/friends/request",
|
||||
Some(&alex_token),
|
||||
Some(json!({"friend_username": "Steve"})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
|
||||
// 2. Steve accepts friend request
|
||||
let (s, _) = t
|
||||
.call(
|
||||
"POST",
|
||||
"/api/v1/social/friends/respond",
|
||||
Some(&steve_token),
|
||||
Some(json!({"friend_uuid": &alex_uuid, "accept": true})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
|
||||
// Verify both see each other in their friends list
|
||||
let (s, alex_friends) = t.call("GET", "/api/v1/social/friends", Some(&alex_token), None).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(alex_friends[0]["uuid"], steve_uuid);
|
||||
assert_eq!(alex_friends[0]["status"], "accepted");
|
||||
|
||||
// 3. Direct Messaging
|
||||
let (s, msg) = t
|
||||
.call(
|
||||
"POST",
|
||||
&format!("/api/v1/messages/{steve_uuid}"),
|
||||
Some(&alex_token),
|
||||
Some(json!({
|
||||
"content": "Hey Steve! Ready for the wither fight?"
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{msg}");
|
||||
|
||||
// Steve reads messages from Alex
|
||||
let (s, msgs) = t.call("GET", &format!("/api/v1/messages/{alex_uuid}"), Some(&steve_token), None).await;
|
||||
assert_eq!(s, StatusCode::OK, "{msgs}");
|
||||
assert_eq!(msgs[0]["content"], "Hey Steve! Ready for the wither fight?");
|
||||
|
||||
// 4. User Profile & Posts
|
||||
let (s, _) = t
|
||||
.call(
|
||||
"PUT",
|
||||
"/api/v1/social/profile/me",
|
||||
Some(&alex_token),
|
||||
Some(json!({
|
||||
"bio": "Redstone expert and dragon hunter."
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
|
||||
// Alex posts a social update
|
||||
let (s, post) = t
|
||||
.call(
|
||||
"POST",
|
||||
"/api/v1/social/posts",
|
||||
Some(&alex_token),
|
||||
Some(json!({
|
||||
"content": "Defeated the Ender Dragon on hardcore mode!",
|
||||
"image_url": "https://example.com/screenshot.png"
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{post}");
|
||||
let post_id = post["id"].as_i64().unwrap();
|
||||
|
||||
// Steve likes Alex's post
|
||||
let (s, like_res) = t.call("POST", &format!("/api/v1/social/posts/{post_id}/like"), Some(&steve_token), None).await;
|
||||
assert_eq!(s, StatusCode::OK, "{like_res}");
|
||||
assert_eq!(like_res["ok"], true);
|
||||
|
||||
// Steve views Alex's full profile
|
||||
let (s, profile) = t.call("GET", &format!("/api/v1/social/profile/{alex_uuid}"), Some(&steve_token), None).await;
|
||||
assert_eq!(s, StatusCode::OK, "{profile}");
|
||||
assert_eq!(profile["bio"], "Redstone expert and dragon hunter.");
|
||||
assert_eq!(profile["posts"][0]["likes_count"], 1);
|
||||
}
|
||||
Reference in new issue
Block a user