From badb5fccc0cf8e1800562d08e6c406f3fd166e35 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 18:06:18 +0000 Subject: [PATCH] Panel: plugin integrations (LuckPerms ranks + group mapping, reports), server event queue, developer API endpoints --- panel/server/src/db.rs | 79 ++++++ panel/server/src/purge.rs | 2 + panel/server/src/routes/admin.rs | 6 +- panel/server/src/routes/dev_api.rs | 231 +++++++++++++++++ panel/server/src/routes/economy.rs | 5 + panel/server/src/routes/integrations.rs | 321 ++++++++++++++++++++++++ panel/server/src/routes/mod.rs | 11 + panel/server/src/routes/servers.rs | 7 +- panel/server/tests/dev_api.rs | 86 +++++++ panel/server/tests/integrations.rs | 160 ++++++++++++ 10 files changed, 906 insertions(+), 2 deletions(-) create mode 100644 panel/server/src/routes/dev_api.rs create mode 100644 panel/server/src/routes/integrations.rs create mode 100644 panel/server/tests/dev_api.rs create mode 100644 panel/server/tests/integrations.rs diff --git a/panel/server/src/db.rs b/panel/server/src/db.rs index 8122e0c..a258437 100644 --- a/panel/server/src/db.rs +++ b/panel/server/src/db.rs @@ -575,6 +575,85 @@ const MIGRATIONS: &[&str] = &[ ALTER TABLE server_market ADD COLUMN seller_guild_id TEXT; ALTER TABLE guild_wallet_transactions ADD COLUMN note TEXT NOT NULL DEFAULT ''; "#, + // Plugin integrations (LuckPerms ranks, WorldGuard, Spark, CoreProtect…) and + // events queued for game servers (level-ups, guild joins, achievements). + r#" + ALTER TABLE groups ADD COLUMN luckperms_group TEXT NOT NULL DEFAULT ''; + CREATE TABLE server_integrations ( + server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE, + name TEXT NOT NULL, + version TEXT NOT NULL DEFAULT '', + data TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL, + PRIMARY KEY (server_id, name) + ); + CREATE TABLE player_ranks ( + server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE, + uuid TEXT NOT NULL, + primary_group TEXT NOT NULL DEFAULT '', + display TEXT NOT NULL DEFAULT '', + prefix TEXT NOT NULL DEFAULT '', + suffix TEXT NOT NULL DEFAULT '', + groups TEXT NOT NULL DEFAULT '[]', + permissions TEXT NOT NULL DEFAULT '[]', + weight INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + PRIMARY KEY (server_id, uuid) + ); + CREATE INDEX player_ranks_uuid ON player_ranks(uuid); + CREATE TABLE server_notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + uuid TEXT NOT NULL, + payload TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL + ); + CREATE INDEX server_notifications_server ON server_notifications(server_id, id); + "#, + // Every code path that changes a level, unlocks an achievement or moves a + // member queues the matching event for the servers the player is on. + r#" + CREATE TRIGGER notify_global_level AFTER UPDATE OF global_level ON user_levels WHEN NEW.global_level > OLD.global_level BEGIN + INSERT INTO server_notifications (server_id, kind, uuid, payload, created_at) + SELECT so.server_id, 'level_up', NEW.uuid, + json_object('scope', 'global', 'level', NEW.global_level, 'previous', OLD.global_level, 'xp', NEW.global_xp), + strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + FROM server_online so WHERE so.uuid = NEW.uuid; + END; + CREATE TRIGGER notify_server_level AFTER UPDATE OF server_level ON server_levels WHEN NEW.server_level > OLD.server_level BEGIN + INSERT INTO server_notifications (server_id, kind, uuid, payload, created_at) + SELECT NEW.server_id, 'level_up', NEW.uuid, + json_object('scope', 'server', 'level', NEW.server_level, 'previous', OLD.server_level, 'xp', NEW.server_xp), + strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE EXISTS (SELECT 1 FROM server_online so WHERE so.server_id = NEW.server_id AND so.uuid = NEW.uuid); + END; + CREATE TRIGGER notify_achievement AFTER INSERT ON user_achievements BEGIN + INSERT INTO server_notifications (server_id, kind, uuid, payload, created_at) + SELECT so.server_id, 'achievement', NEW.user_uuid, + json_object('id', NEW.achievement_id, + 'title', (SELECT title FROM achievements WHERE id = NEW.achievement_id), + 'xp', (SELECT xp_reward FROM achievements WHERE id = NEW.achievement_id)), + strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + FROM server_online so WHERE so.uuid = NEW.user_uuid; + END; + CREATE TRIGGER notify_guild_join AFTER INSERT ON guild_members BEGIN + INSERT INTO server_notifications (server_id, kind, uuid, payload, created_at) + SELECT so.server_id, 'guild_join', NEW.uuid, + json_object('guild_id', NEW.guild_id, 'guild', (SELECT name FROM guilds WHERE id = NEW.guild_id), + 'tag', (SELECT tag FROM guilds WHERE id = NEW.guild_id), 'role', NEW.role), + strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + FROM server_online so WHERE so.uuid = NEW.uuid; + END; + CREATE TRIGGER notify_guild_leave AFTER DELETE ON guild_members BEGIN + INSERT INTO server_notifications (server_id, kind, uuid, payload, created_at) + SELECT so.server_id, 'guild_leave', OLD.uuid, + json_object('guild_id', OLD.guild_id, 'guild', (SELECT name FROM guilds WHERE id = OLD.guild_id), + 'tag', (SELECT tag FROM guilds WHERE id = OLD.guild_id)), + strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + FROM server_online so WHERE so.uuid = OLD.uuid; + END; + "#, // Bumped by any change to claims, guilds or membership so game servers can // poll a single number instead of asking about every block. r#" diff --git a/panel/server/src/purge.rs b/panel/server/src/purge.rs index 36b134d..0427822 100644 --- a/panel/server/src/purge.rs +++ b/panel/server/src/purge.rs @@ -63,6 +63,8 @@ pub async fn purge_user(state: &AppState, user: &UserRow) -> AppResult String { "#7c5cff".into() @@ -256,7 +260,7 @@ fn default_color() -> String { pub async fn list_groups(_: AdminUser, State(state): State) -> AppResult>> { Ok(Json( sqlx::query_as( - "SELECT g.id, g.name, g.color, (SELECT COUNT(*) FROM user_groups ug WHERE ug.group_id = g.id) AS members FROM groups g ORDER BY g.name", + "SELECT g.id, g.name, g.color, (SELECT COUNT(*) FROM user_groups ug WHERE ug.group_id = g.id) AS members, g.luckperms_group FROM groups g ORDER BY g.name", ) .fetch_all(&state.db) .await?, diff --git a/panel/server/src/routes/dev_api.rs b/panel/server/src/routes/dev_api.rs new file mode 100644 index 0000000..823a271 --- /dev/null +++ b/panel/server/src/routes/dev_api.rs @@ -0,0 +1,231 @@ +//! Data and actions for in-game plugins: what the SCOPENET Developer API, the +//! PlaceholderAPI expansion and other plugins read and change for a player. + +use crate::error::{AppError, AppResult}; +use crate::routes::economy::{begin_operation, finish_operation}; +use crate::routes::servers::GameServer; +use crate::state::AppState; +use axum::extract::State; +use axum::Json; +use serde::Deserialize; +use serde_json::{json, Value}; + +#[derive(Deserialize)] +pub struct Who { + pub uuid: String, +} + +fn dashed_or_err(uuid: &str) -> AppResult { + crate::yggdrasil::dashed(uuid).ok_or_else(|| AppError::bad_request("invalid player UUID")) +} + +/// Everything a placeholder or `getPlayer()` needs in one round trip. +pub async fn player_info(GameServer(server): GameServer, State(state): State, Json(p): Json) -> AppResult> { + let uuid = dashed_or_err(&p.uuid)?; + let user: Option<(String, String, String)> = sqlx::query_as("SELECT username, role, created_at FROM users WHERE uuid = ?").bind(&uuid).fetch_optional(&state.db).await?; + let Some((name, role, joined)) = user else { + return Ok(Json(json!({ "exists": false, "uuid": uuid }))); + }; + let levels = crate::routes::leveling::get_user_levels_by_uuid(&state, &uuid).await?; + let here = levels.server_levels.iter().find(|s| s.server_id == server.id); + let guild: Option<(String, String, String, String, i64)> = sqlx::query_as( + "SELECT g.id, g.name, g.tag, gm.role, (SELECT COUNT(*) FROM guild_claims gc WHERE gc.guild_id = g.id) + FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ?", + ) + .bind(&uuid) + .bind(&server.instance_id) + .fetch_optional(&state.db) + .await?; + let balance = crate::routes::economy::balance_for(&state, server.id, &uuid).await?; + let (playtime, kills, deaths): (Option, Option, Option) = + sqlx::query_as("SELECT SUM(playtime_secs), SUM(player_kills + mob_kills), SUM(deaths) FROM player_stats WHERE uuid = ?").bind(&uuid).fetch_one(&state.db).await?; + let (server_playtime,): (Option,) = sqlx::query_as("SELECT SUM(playtime_secs) FROM player_stats WHERE uuid = ? AND server_id = ?").bind(&uuid).bind(server.id).fetch_one(&state.db).await?; + let friends: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM friendships WHERE status = 'accepted' AND (user_uuid = ?1 OR friend_uuid = ?1)").bind(&uuid).fetch_one(&state.db).await?; + let achievements: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_achievements WHERE user_uuid = ?").bind(&uuid).fetch_one(&state.db).await?; + + // Quest progress for this period: how many of the player's quests are done. + let cfg = crate::progression::load_pool(&state.db).await?; + let mut conn = state.db.acquire().await?; + let mut quests = serde_json::Map::new(); + for (period, key) in [("daily", crate::routes::quests::current_daily_key()), ("weekly", crate::routes::quests::current_weekly_key())] { + let ids = crate::progression::assigned_quest_ids(&mut conn, &cfg, &uuid, period, &key).await?; + let (done, claimed): (i64, i64) = if ids.is_empty() { + (0, 0) + } else { + sqlx::query_as( + "SELECT COALESCE(SUM(completed), 0), COALESCE(SUM(claimed), 0) FROM user_quests + WHERE user_uuid = ? AND period_key = ? AND quest_id IN (SELECT value FROM json_each(?))", + ) + .bind(&uuid) + .bind(&key) + .bind(serde_json::to_string(&ids)?) + .fetch_one(&mut *conn) + .await? + }; + quests.insert(period.into(), json!({ "total": ids.len(), "completed": done, "claimed": claimed })); + } + drop(conn); // give the connection back before the pool is used again + let rank: Option<(String, String, String)> = sqlx::query_as("SELECT primary_group, display, prefix FROM player_ranks WHERE server_id = ? AND uuid = ?").bind(server.id).bind(&uuid).fetch_optional(&state.db).await?; + let online: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM server_online WHERE uuid = ?)").bind(&uuid).fetch_one(&state.db).await?; + + Ok(Json(json!({ + "exists": true, + "uuid": uuid, + "name": name, + "role": role, + "joined": joined, + "online": online, + "global": { "level": levels.global_level, "xp": levels.global_xp, "next_level_xp": levels.next_level_xp, "current_level_xp": levels.current_level_xp, "progress_pct": levels.progress_pct, "title": levels.title, "rank": levels.rank, "badges": levels.badges }, + "server": here.map(|s| json!({ "level": s.server_level, "xp": s.server_xp, "next_level_xp": s.next_level_xp, "current_level_xp": s.current_level_xp, "progress_pct": s.progress_pct, "rank_name": s.rank_name })) + .unwrap_or(json!({ "level": 1, "xp": 0, "next_level_xp": 0, "current_level_xp": 0, "progress_pct": 0.0, "rank_name": null })), + "guild": guild.map(|(id, name, tag, role, claims)| json!({ "id": id, "name": name, "tag": tag, "role": role, "claims": claims })), + "balance": balance, + "playtime_secs": playtime.unwrap_or(0), + "server_playtime_secs": server_playtime.unwrap_or(0), + "kills": kills.unwrap_or(0), + "deaths": deaths.unwrap_or(0), + "friends": friends, + "achievements": achievements, + "quests": quests, + "rank": rank.map(|(primary, display, prefix)| json!({ "group": primary, "display": display, "prefix": prefix })), + }))) +} + +#[derive(Deserialize)] +pub struct XpPayload { + pub operation_id: String, + pub uuid: String, + /// `global` or `server` (the calling server). + #[serde(default = "global")] + pub scope: String, + /// Positive grants XP, negative takes it away. + pub amount: i64, + #[serde(default)] + pub reason: String, +} +fn global() -> String { + "global".into() +} + +/// `addXP()`: grant (or remove) XP on behalf of a plugin. Idempotent on `operation_id`. +pub async fn add_xp(GameServer(server): GameServer, State(state): State, Json(p): Json) -> AppResult> { + let uuid = dashed_or_err(&p.uuid)?; + if p.amount == 0 || p.amount.abs() > 10_000_000 { + return Err(AppError::bad_request("amount must be between -10,000,000 and 10,000,000 and not zero")); + } + let (mut tx, previous) = begin_operation(&state, server.id, &p.operation_id).await?; + if let Some(previous) = previous { + return Ok(Json(previous)); + } + let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE uuid = ?)").bind(&uuid).fetch_one(&mut *tx).await?; + if !exists { + return Err(AppError::not_found("player not found")); + } + let now = chrono::Utc::now().to_rfc3339(); + let (mode, amount) = if p.amount > 0 { (crate::progression::Mode::Add, p.amount) } else { (crate::progression::Mode::Remove, -p.amount) }; + let change = match p.scope.as_str() { + "global" => crate::progression::adjust_global(&mut tx, &uuid, mode, amount, &now).await?, + "server" => crate::progression::adjust_server(&mut tx, server.id, &uuid, mode, amount, &now).await?, + _ => return Err(AppError::bad_request("scope must be global or server")), + }; + if change.new_xp > change.old_xp { + crate::routes::leveling::grant_rewards(&mut tx, &uuid, &now).await?; + } + let detail = format!("{} XP {} → {} (level {} → {}) via {}: {}", p.scope, change.old_xp, change.new_xp, change.old_level, change.new_level, server.name, p.reason.chars().filter(|c| !c.is_control()).take(100).collect::()); + sqlx::query("INSERT INTO server_events (server_id, uuid, name, kind, detail, created_at) VALUES (?, ?, NULL, 'xp', ?, ?)").bind(server.id).bind(&uuid).bind(detail).bind(crate::db::now()).execute(&mut *tx).await?; + finish_operation(tx, server.id, &p.operation_id, json!({ "ok": true, "scope": p.scope, "xp": change.new_xp, "level": change.new_level, "previous_level": change.old_level })).await +} + +#[derive(Deserialize)] +pub struct ObjectivePayload { + pub operation_id: String, + pub uuid: String, + /// A quest id (`q_d_mine_coal`), or an action such as `block_broken:DIAMOND_ORE`. + pub objective: String, + /// Progress to add. `0` completes the quest outright. + #[serde(default)] + pub amount: i64, +} + +/// `completeQuestObjective()`: move a player's quest forward from another plugin. +pub async fn quest_objective(GameServer(server): GameServer, State(state): State, Json(p): Json) -> AppResult> { + let uuid = dashed_or_err(&p.uuid)?; + if !(0..=1_000_000).contains(&p.amount) || p.objective.is_empty() || p.objective.len() > 120 { + return Err(AppError::bad_request("invalid objective")); + } + let (mut tx, previous) = begin_operation(&state, server.id, &p.operation_id).await?; + if let Some(previous) = previous { + return Ok(Json(previous)); + } + let now = crate::db::now(); + let quest: Option<(String, i64)> = sqlx::query_as("SELECT period, target_count FROM quests WHERE id = ? AND enabled = 1").bind(&p.objective).fetch_optional(&mut *tx).await?; + let advanced = if let Some((period, target)) = quest { + let cfg = crate::progression::load(&mut tx).await?; + let key = if period == "weekly" { crate::routes::quests::current_weekly_key() } else { crate::routes::quests::current_daily_key() }; + if !crate::progression::is_assigned(&mut tx, &cfg, &uuid, &period, &key, &p.objective).await? { + return Err(AppError::bad_request("that quest isn't one of the player's quests this period")); + } + let add = if p.amount == 0 { target } else { p.amount }; + 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(&p.objective).bind(&key).bind(add).bind(add >= target).bind(&now).bind(target) + .execute(&mut *tx) + .await?; + true + } else { + // Not a quest id: treat it as an action ("block_broken:STONE") like the plugin's own events. + let before: i64 = sqlx::query_scalar("SELECT COALESCE(SUM(progress), 0) FROM user_quests WHERE user_uuid = ?").bind(&uuid).fetch_one(&mut *tx).await?; + crate::routes::quests::advance_action_quests(&mut tx, &uuid, &p.objective, p.amount.max(1), &now).await?; + let after: i64 = sqlx::query_scalar("SELECT COALESCE(SUM(progress), 0) FROM user_quests WHERE user_uuid = ?").bind(&uuid).fetch_one(&mut *tx).await?; + after > before + }; + finish_operation(tx, server.id, &p.operation_id, json!({ "ok": true, "advanced": advanced })).await +} + +/// `getFriends()`: accepted friends with where they are playing. +pub async fn friends(GameServer(_): GameServer, State(state): State, Json(p): Json) -> AppResult> { + let uuid = dashed_or_err(&p.uuid)?; + let rows: Vec<(String, String, bool, Option)> = sqlx::query_as( + "SELECT u.uuid, u.username, EXISTS(SELECT 1 FROM server_online so WHERE so.uuid = u.uuid), + (SELECT gs.name FROM server_online so JOIN game_servers gs ON gs.id = so.server_id WHERE so.uuid = u.uuid LIMIT 1) + FROM friendships f JOIN users u ON u.uuid = (CASE WHEN f.user_uuid = ?1 THEN f.friend_uuid ELSE f.user_uuid END) + WHERE f.status = 'accepted' AND (f.user_uuid = ?1 OR f.friend_uuid = ?1) ORDER BY 3 DESC, u.username COLLATE NOCASE LIMIT 500", + ) + .bind(&uuid) + .fetch_all(&state.db) + .await?; + Ok(Json(json!({ "friends": rows.into_iter().map(|(uuid, name, online, playing_on)| json!({ "uuid": uuid, "name": name, "online": online, "playing_on": playing_on })).collect::>() }))) +} + +#[derive(Deserialize)] +pub struct GuildQuery { + /// Look a guild up by id, tag or name — or by one of its members. + #[serde(default)] + pub guild: String, + #[serde(default)] + pub member: String, +} + +/// `getGuild()`. +pub async fn guild(GameServer(server): GameServer, State(state): State, Json(q): Json) -> AppResult> { + let id: Option = if !q.member.is_empty() { + let uuid = dashed_or_err(&q.member)?; + sqlx::query_scalar("SELECT g.id FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ?").bind(uuid).bind(&server.instance_id).fetch_optional(&state.db).await? + } else { + sqlx::query_scalar("SELECT id FROM guilds WHERE instance_id = ?2 AND (id = ?1 OR tag = ?1 COLLATE NOCASE OR name = ?1 COLLATE NOCASE)").bind(q.guild.trim()).bind(&server.instance_id).fetch_optional(&state.db).await? + }; + let Some(id) = id else { return Ok(Json(json!({ "exists": false }))) }; + let (name, tag, desc, leader, created): (String, String, String, String, String) = + sqlx::query_as("SELECT name, tag, description, leader_uuid, created_at FROM guilds WHERE id = ?").bind(&id).fetch_one(&state.db).await?; + let members: Vec<(String, String, String)> = sqlx::query_as("SELECT uuid, name, role FROM guild_members WHERE guild_id = ? ORDER BY joined_at").bind(&id).fetch_all(&state.db).await?; + let claims: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ? AND server_id = ?").bind(&id).bind(server.id).fetch_one(&state.db).await?; + let balance: f64 = sqlx::query_scalar("SELECT COALESCE((SELECT balance FROM guild_wallets WHERE guild_id = ? AND server_id = ?), 0)").bind(&id).bind(server.id).fetch_one(&state.db).await?; + Ok(Json(json!({ + "exists": true, "id": id, "name": name, "tag": tag, "description": desc, "leader": leader, "created": created, + "claims": claims, "balance": balance, + "members": members.into_iter().map(|(uuid, name, role)| json!({ "uuid": uuid, "name": name, "role": role })).collect::>(), + }))) +} diff --git a/panel/server/src/routes/economy.rs b/panel/server/src/routes/economy.rs index bb5e968..90eccc0 100644 --- a/panel/server/src/routes/economy.rs +++ b/panel/server/src/routes/economy.rs @@ -213,6 +213,11 @@ pub struct ServerBalanceQuery { pub username: Option, } +/// A player's balance on a server (0 until they have an account there). +pub async fn balance_for(state: &AppState, server_id: i64, uuid: &str) -> AppResult { + Ok(sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?").bind(server_id).bind(uuid).fetch_optional(&state.db).await?.unwrap_or(0.0)) +} + pub async fn server_get_balance( GameServer(server): GameServer, State(state): State, diff --git a/panel/server/src/routes/integrations.rs b/panel/server/src/routes/integrations.rs new file mode 100644 index 0000000..0ad3102 --- /dev/null +++ b/panel/server/src/routes/integrations.rs @@ -0,0 +1,321 @@ +//! Plugin integrations: what game servers report about the plugins they run +//! (LuckPerms ranks, WorldGuard regions, Spark performance, CoreProtect +//! activity…), the LuckPerms ↔ panel group mapping, and the queue of events +//! (level-ups, guild joins, achievements) handed back to game servers. + +use crate::auth::AdminUser; +use crate::error::{AppError, AppResult}; +use crate::routes::servers::{get_server, GameServer}; +use crate::state::AppState; +use axum::extract::{Path, State}; +use axum::Json; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sqlx::SqliteConnection; +use std::collections::HashSet; + +const MAX_REPORT_BYTES: usize = 1024 * 1024; +const KNOWN: &[&str] = &["luckperms", "placeholderapi", "vault", "coreprotect", "worldguard", "spark", "compat"]; + +// --------------------------------------------------------------------------- +// Settings +// --------------------------------------------------------------------------- + +/// How LuckPerms groups and panel groups follow each other. LuckPerms stays +/// authoritative for Minecraft permissions; this only moves *membership* of +/// groups that an admin has explicitly mapped. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct IntegrationSettings { + /// `off` (show ranks only), `game_to_panel`, `panel_to_game` or `both` (adds in both directions, never removes). + pub luckperms_sync: String, +} + +impl Default for IntegrationSettings { + fn default() -> Self { + Self { luckperms_sync: "off".into() } + } +} + +pub async fn load_settings(conn: &mut SqliteConnection) -> AppResult { + let raw: Option = sqlx::query_scalar("SELECT value FROM kv WHERE key = 'integrations'").fetch_optional(&mut *conn).await?; + Ok(raw.and_then(|r| serde_json::from_str(&r).ok()).unwrap_or_default()) +} + +pub async fn get_settings(_: AdminUser, State(state): State) -> AppResult> { + let mut conn = state.db.acquire().await?; + Ok(Json(json!({ "settings": load_settings(&mut conn).await? }))) +} + +pub async fn put_settings(_: AdminUser, State(state): State, Json(s): Json) -> AppResult> { + if !matches!(s.luckperms_sync.as_str(), "off" | "game_to_panel" | "panel_to_game" | "both") { + return Err(AppError::bad_request("luckperms_sync must be off, game_to_panel, panel_to_game or both")); + } + crate::store::kv_set(&state, "integrations", &s).await?; + Ok(Json(json!({ "settings": s }))) +} + +#[derive(Deserialize)] +pub struct GroupMapping { + /// The LuckPerms group this panel group follows. Empty clears the mapping. + pub luckperms_group: String, +} + +pub async fn set_group_mapping(_: AdminUser, State(state): State, Path(id): Path, Json(m): Json) -> AppResult> { + let lp = m.luckperms_group.trim(); + if lp.len() > 64 || !lp.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')) { + return Err(AppError::bad_request("LuckPerms group names are letters, digits, _ - and .")); + } + let done = sqlx::query("UPDATE groups SET luckperms_group = ? WHERE id = ?").bind(lp.to_lowercase()).bind(id).execute(&state.db).await?; + if done.rows_affected() == 0 { + return Err(AppError::not_found("group not found")); + } + Ok(Json(json!({ "ok": true }))) +} + +// --------------------------------------------------------------------------- +// LuckPerms planning (pure, so it can be tested without a database) +// --------------------------------------------------------------------------- + +#[derive(Debug, Default, PartialEq)] +pub struct Plan { + pub panel_add: Vec, + pub panel_remove: Vec, + pub game_add: Vec, + pub game_remove: Vec, +} + +/// `mapped`: (panel group id, LuckPerms group). `member_of`: the panel groups the player is in. +pub fn plan(mode: &str, lp_groups: &HashSet, mapped: &[(i64, String)], member_of: &HashSet) -> Plan { + let mut p = Plan::default(); + let (to_panel, to_game, remove) = match mode { + "game_to_panel" => (true, false, true), + "panel_to_game" => (false, true, true), + "both" => (true, true, false), + _ => return p, + }; + for (gid, lp) in mapped { + let in_game = lp_groups.contains(lp); + let in_panel = member_of.contains(gid); + match (in_game, in_panel) { + (true, false) if to_panel => p.panel_add.push(*gid), + (false, true) if to_panel && remove && mode == "game_to_panel" => p.panel_remove.push(*gid), + (false, true) if to_game => p.game_add.push(lp.clone()), + (true, false) if to_game && remove && mode == "panel_to_game" => p.game_remove.push(lp.clone()), + _ => {} + } + } + p +} + +#[derive(Deserialize, Default)] +#[serde(default)] +struct LpPlayer { + uuid: String, + primary: String, + display: String, + prefix: String, + suffix: String, + weight: i64, + groups: Vec, + permissions: Vec, +} + +fn clean(s: &str, max: usize) -> String { + s.chars().filter(|c| !c.is_control() || *c == '\u{a7}').take(max).collect() +} + +/// Store reported ranks and work out group changes for the players in the report. +async fn apply_luckperms(conn: &mut SqliteConnection, server_id: i64, data: &Value) -> AppResult { + let settings = load_settings(conn).await?; + let players: Vec = serde_json::from_value(data.get("players").cloned().unwrap_or(json!([]))).unwrap_or_default(); + let mapped: Vec<(i64, String)> = sqlx::query_as("SELECT id, luckperms_group FROM groups WHERE luckperms_group <> ''").fetch_all(&mut *conn).await?; + let now = crate::db::now(); + let mut assign = Vec::new(); + for p in players.into_iter().take(2000) { + let Some(uuid) = crate::yggdrasil::dashed(&p.uuid) else { continue }; + let groups: Vec = p.groups.iter().map(|g| clean(g, 64).to_lowercase()).filter(|g| !g.is_empty()).take(64).collect(); + let perms: Vec = p.permissions.iter().map(|x| clean(x, 100)).take(40).collect(); + sqlx::query( + "INSERT INTO player_ranks (server_id, uuid, primary_group, display, prefix, suffix, groups, permissions, weight, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (server_id, uuid) DO UPDATE SET primary_group = excluded.primary_group, display = excluded.display, + prefix = excluded.prefix, suffix = excluded.suffix, groups = excluded.groups, permissions = excluded.permissions, + weight = excluded.weight, updated_at = excluded.updated_at", + ) + .bind(server_id) + .bind(&uuid) + .bind(clean(&p.primary, 64).to_lowercase()) + .bind(clean(&p.display, 64)) + .bind(clean(&p.prefix, 64)) + .bind(clean(&p.suffix, 64)) + .bind(serde_json::to_string(&groups)?) + .bind(serde_json::to_string(&perms)?) + .bind(p.weight.clamp(-1_000_000, 1_000_000)) + .bind(&now) + .execute(&mut *conn) + .await?; + if settings.luckperms_sync == "off" || mapped.is_empty() { + continue; + } + let user_id: Option = sqlx::query_scalar("SELECT id FROM users WHERE uuid = ? AND status = 'active'").bind(&uuid).fetch_optional(&mut *conn).await?; + let Some(user_id) = user_id else { continue }; + let member_of: HashSet = sqlx::query_scalar("SELECT group_id FROM user_groups WHERE user_id = ?").bind(user_id).fetch_all(&mut *conn).await?.into_iter().collect(); + let lp_set: HashSet = groups.iter().cloned().collect(); + let plan = plan(&settings.luckperms_sync, &lp_set, &mapped, &member_of); + for gid in &plan.panel_add { + sqlx::query("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)").bind(user_id).bind(gid).execute(&mut *conn).await?; + } + for gid in &plan.panel_remove { + sqlx::query("DELETE FROM user_groups WHERE user_id = ? AND group_id = ?").bind(user_id).bind(gid).execute(&mut *conn).await?; + } + if !plan.game_add.is_empty() || !plan.game_remove.is_empty() { + assign.push(json!({ "uuid": uuid, "add": plan.game_add, "remove": plan.game_remove })); + } + } + Ok(json!({ "mode": settings.luckperms_sync, "assign": assign })) +} + +// --------------------------------------------------------------------------- +// Game server reports +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +pub struct Report { + pub name: String, + #[serde(default)] + pub version: String, + #[serde(default)] + pub data: Value, +} + +pub async fn server_report(GameServer(server): GameServer, State(state): State, Json(r): Json) -> AppResult> { + let name = r.name.trim().to_lowercase(); + if !KNOWN.contains(&name.as_str()) { + return Err(AppError::bad_request("unknown integration")); + } + let raw = serde_json::to_string(&r.data)?; + if raw.len() > MAX_REPORT_BYTES { + return Err(AppError::bad_request("report is too large")); + } + let mut tx = state.db.begin().await?; + let config = if name == "luckperms" { apply_luckperms(&mut tx, server.id, &r.data).await? } else { json!({}) }; + // Player-specific rank rows are the stored form of the LuckPerms report; keep only the group list. + let stored = if name == "luckperms" { + json!({ "groups": r.data.get("groups").cloned().unwrap_or(json!([])), "players_reported": r.data.get("players").and_then(|p| p.as_array()).map_or(0, |a| a.len()) }).to_string() + } else { + raw + }; + sqlx::query( + "INSERT INTO server_integrations (server_id, name, version, data, updated_at) VALUES (?, ?, ?, ?, ?) + ON CONFLICT (server_id, name) DO UPDATE SET version = excluded.version, data = excluded.data, updated_at = excluded.updated_at", + ) + .bind(server.id) + .bind(&name) + .bind(clean(&r.version, 40)) + .bind(stored) + .bind(crate::db::now()) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(Json(json!({ "ok": true, "config": config }))) +} + +// --------------------------------------------------------------------------- +// Admin view +// --------------------------------------------------------------------------- + +pub async fn admin_server(_: AdminUser, State(state): State, Path(id): Path) -> AppResult> { + get_server(&state, id).await?; + let rows: Vec<(String, String, String, String)> = + sqlx::query_as("SELECT name, version, data, updated_at FROM server_integrations WHERE server_id = ? ORDER BY name").bind(id).fetch_all(&state.db).await?; + let list: Vec = rows + .into_iter() + .map(|(name, version, data, updated_at)| json!({ "name": name, "version": version, "data": serde_json::from_str::(&data).unwrap_or(Value::Null), "updated_at": updated_at })) + .collect(); + let mut conn = state.db.acquire().await?; + let settings = load_settings(&mut conn).await?; + let ranks: Vec<(String, String, String, String, String, String, String, i64)> = sqlx::query_as( + "SELECT r.uuid, COALESCE(u.username, r.uuid), r.primary_group, r.display, r.prefix, r.groups, r.permissions, r.weight + FROM player_ranks r LEFT JOIN users u ON u.uuid = r.uuid WHERE r.server_id = ? ORDER BY r.weight DESC, 2 LIMIT 200", + ) + .bind(id) + .fetch_all(&mut *conn) + .await?; + Ok(Json(json!({ + "integrations": list, + "settings": settings, + "ranks": ranks.into_iter().map(|(uuid, name, primary, display, prefix, groups, perms, weight)| json!({ + "uuid": uuid, "name": name, "primary": primary, "display": display, "prefix": prefix, "weight": weight, + "groups": serde_json::from_str::(&groups).unwrap_or(json!([])), + "permissions": serde_json::from_str::(&perms).unwrap_or(json!([])), + })).collect::>(), + }))) +} + +// --------------------------------------------------------------------------- +// Notifications: events the panel hands to game servers with their next sync +// --------------------------------------------------------------------------- + +/// Queue an event for every game server the player is online on (or for one +/// specific server). Plugins turn these into Bukkit events. +pub async fn notify(conn: &mut SqliteConnection, server_id: Option, kind: &str, uuid: &str, payload: Value) -> AppResult<()> { + let servers: Vec = match server_id { + Some(id) => vec![id], + None => sqlx::query_scalar("SELECT server_id FROM server_online WHERE uuid = ?").bind(uuid).fetch_all(&mut *conn).await?, + }; + for id in servers { + sqlx::query("INSERT INTO server_notifications (server_id, kind, uuid, payload, created_at) VALUES (?, ?, ?, ?, ?)") + .bind(id) + .bind(kind) + .bind(uuid) + .bind(payload.to_string()) + .bind(crate::db::now()) + .execute(&mut *conn) + .await?; + } + Ok(()) +} + +/// Take (and delete) what is waiting for this server. Oldest first, bounded. +pub async fn take_notifications(conn: &mut SqliteConnection, server_id: i64) -> AppResult> { + let rows: Vec<(i64, String, String, String, String)> = + sqlx::query_as("SELECT id, kind, uuid, payload, created_at FROM server_notifications WHERE server_id = ? ORDER BY id LIMIT 100").bind(server_id).fetch_all(&mut *conn).await?; + if let Some(last) = rows.last().map(|r| r.0) { + sqlx::query("DELETE FROM server_notifications WHERE server_id = ? AND id <= ?").bind(server_id).bind(last).execute(&mut *conn).await?; + } + // Stale entries (a server that was offline for days) aren't worth replaying. + sqlx::query("DELETE FROM server_notifications WHERE created_at < ?").bind((chrono::Utc::now() - chrono::Duration::hours(6)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true)).execute(&mut *conn).await?; + Ok(rows.into_iter().map(|(_, kind, uuid, payload, at)| json!({ "kind": kind, "uuid": uuid, "data": serde_json::from_str::(&payload).unwrap_or(Value::Null), "at": at })).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn set(v: &[&str]) -> HashSet { + v.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn sync_modes_only_move_mapped_groups() { + let mapped = vec![(1, "vip".to_string()), (2, "mod".to_string())]; + let none: HashSet = HashSet::new(); + let in_vip: HashSet = [1].into(); + + assert_eq!(plan("off", &set(&["vip"]), &mapped, &none), Plan::default()); + // LuckPerms is followed, in both directions, for mapped groups only. + let p = plan("game_to_panel", &set(&["vip", "default"]), &mapped, &none); + assert_eq!((p.panel_add, p.panel_remove), (vec![1], vec![])); + let p = plan("game_to_panel", &set(&["default"]), &mapped, &in_vip); + assert_eq!((p.panel_add, p.panel_remove), (vec![], vec![1])); + // The panel leads the other way round. + let p = plan("panel_to_game", &set(&[]), &mapped, &in_vip); + assert_eq!((p.game_add, p.game_remove), (vec!["vip".to_string()], vec![])); + let p = plan("panel_to_game", &set(&["mod"]), &mapped, &none); + assert_eq!(p.game_remove, vec!["mod".to_string()]); + // "both" only ever adds. + let p = plan("both", &set(&["mod"]), &mapped, &in_vip); + assert_eq!((p.panel_add, p.panel_remove, p.game_add, p.game_remove), (vec![2], vec![], vec!["vip".to_string()], vec![])); + } +} diff --git a/panel/server/src/routes/mod.rs b/panel/server/src/routes/mod.rs index 8682078..e425b30 100644 --- a/panel/server/src/routes/mod.rs +++ b/panel/server/src/routes/mod.rs @@ -3,9 +3,11 @@ pub mod achievements; pub mod activity; pub mod admin; pub mod connections; +pub mod dev_api; pub mod economy; pub mod guild_bank; pub mod guilds; +pub mod integrations; pub mod landing; pub mod livemap; pub mod leveling; @@ -165,6 +167,9 @@ pub fn api(state: &AppState) -> Router { .route("/progression/players", get(progression::list_players)) .route("/progression/players/{uuid}", get(progression::get_player)) .route("/progression/players/{uuid}/adjust", post(progression::adjust_player)) + .route("/integrations/settings", get(integrations::get_settings).put(integrations::put_settings)) + .route("/groups/{id}/luckperms", put(integrations::set_group_mapping)) + .route("/servers/{id}/integrations", get(integrations::admin_server)) .route("/guilds", get(guilds::admin_list_guilds)) .route("/guilds/{id}", delete(guilds::admin_delete_guild)) .layer(DefaultBodyLimit::max(upload_limit)); @@ -182,6 +187,12 @@ pub fn api(state: &AppState) -> Router { .route("/guilds/player", post(guilds::server_get_player_guild)) .route("/guilds/create", post(guilds::server_create_guild)) .route("/guilds/leave", post(guilds::server_guild_leave)) + .route("/integrations/report", post(integrations::server_report)) + .route("/player/info", post(dev_api::player_info)) + .route("/player/xp", post(dev_api::add_xp)) + .route("/player/quest-objective", post(dev_api::quest_objective)) + .route("/player/friends", post(dev_api::friends)) + .route("/guild/info", post(dev_api::guild)) .route("/guilds/bank", post(guild_bank::server_bank_info)) .route("/guilds/bank/transfer", post(guild_bank::server_bank_transfer)) .route("/guilds/bank/credit", post(guild_bank::server_bank_credit)) diff --git a/panel/server/src/routes/servers.rs b/panel/server/src/routes/servers.rs index 2b7aaf2..71aed53 100644 --- a/panel/server/src/routes/servers.rs +++ b/panel/server/src/routes/servers.rs @@ -578,7 +578,12 @@ pub async fn sync(GameServer(server): GameServer, State(state): State, kick.push(json!({ "uuid": uuid, "message": verdict.message })); } } - Ok(Json(json!({ "ok": true, "kick": kick, "sync_interval_secs": SYNC_INTERVAL_SECS }))) + // Events for plugins to turn into their own (level-ups, achievements, guild changes). + let notifications = { + let mut conn = state.db.acquire().await?; + super::integrations::take_notifications(&mut conn, server.id).await? + }; + Ok(Json(json!({ "ok": true, "kick": kick, "sync_interval_secs": SYNC_INTERVAL_SECS, "notifications": notifications }))) } async fn update_progression_for_delta( diff --git a/panel/server/tests/dev_api.rs b/panel/server/tests/dev_api.rs new file mode 100644 index 0000000..4fcf143 --- /dev/null +++ b/panel/server/tests/dev_api.rs @@ -0,0 +1,86 @@ +//! What plugins can read and do through the server API (player info, XP, +//! quest objectives, friends, guilds). + +mod common; +use common::*; + +#[tokio::test] +async fn plugins_read_players_and_change_progress() { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + let mut tokens = vec![]; + for n in ["Steve", "Alex"] { + t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": n, "password": "password123"}))).await; + tokens.push(t.login(n, "password123").await); + } + let (steve, alex) = (t.uuid("Steve").await, t.uuid("Alex").await); + let (_, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "SMP", "instance_id": "smp"}))).await; + let sid = srv["server"]["id"].as_i64().unwrap(); + let key = srv["token"].as_str().unwrap().to_string(); + let call = |path: &str, body: Value| { + let (t, key, path) = (&t, key.clone(), path.to_string()); + async move { t.call("POST", &format!("/api/server/v1/{path}"), Some(&key), Some(body)).await } + }; + t.call("PUT", "/api/admin/progression", Some(&admin), Some(json!({"daily_quest_limit": 0, "weekly_quest_limit": 0}))).await; + + // Unknown players are reported, not errors. + let (s, v) = call("player/info", json!({"uuid": "00000000-0000-0000-0000-00000000dead"})).await; + assert_eq!((s, v["exists"].clone()), (StatusCode::OK, json!(false))); + assert_eq!(call("player/info", json!({"uuid": "nope"})).await.0, StatusCode::BAD_REQUEST); + + // Info: levels, balance, guild, quests. + sqlx::query("INSERT INTO server_economy (server_id, uuid, username, balance, updated_at) VALUES (?, ?, 'Steve', 321.5, 'now')").bind(sid).bind(&steve).execute(&t.db).await.unwrap(); + let (_, g) = t.call("POST", "/api/v1/guilds", Some(&tokens[0]), Some(json!({"instance_id": "smp", "name": "Iron", "tag": "IRON"}))).await; + t.call("GET", "/api/v1/quests/my", Some(&tokens[0]), None).await; + let (s, info) = call("player/info", json!({"uuid": steve})).await; + assert_eq!(s, StatusCode::OK, "{info}"); + assert_eq!((info["name"].as_str(), info["balance"].as_f64(), info["guild"]["tag"].as_str(), info["guild"]["role"].as_str()), (Some("Steve"), Some(321.5), Some("IRON"), Some("leader"))); + // Founding a guild unlocks an achievement worth XP, so the player starts above zero. + let base = info["global"]["xp"].as_i64().unwrap(); + assert!(base > 0); + assert!(info["quests"]["daily"]["total"].as_i64().unwrap() > 5); + assert_eq!(info["quests"]["daily"]["completed"], 0); + + // addXP: global and server scoped, idempotent, negative takes away. + let xp = |op: &str, scope: &str, amount: i64| json!({"operation_id": op, "uuid": steve, "scope": scope, "amount": amount, "reason": "event"}); + let (s, r) = call("player/xp", xp("x1", "global", 1200)).await; + assert_eq!(s, StatusCode::OK, "{r}"); + assert_eq!(r["xp"].as_i64(), Some(base + 1200)); + assert!(r["level"].as_i64().unwrap() >= 5); + assert_eq!(call("player/xp", xp("x1", "global", 1200)).await.1, r, "a retry changes nothing"); + let (_, r) = call("player/xp", xp("x2", "server", 400)).await; + assert_eq!(r["level"], 2); + let (_, r) = call("player/xp", xp("x3", "global", -200)).await; + assert_eq!(r["xp"].as_i64(), Some(base + 1000)); + assert_eq!(call("player/xp", xp("x4", "global", 0)).await.0, StatusCode::BAD_REQUEST); + assert_eq!(call("player/xp", json!({"operation_id": "x5", "uuid": "00000000-0000-0000-0000-00000000dead", "amount": 5})).await.0, StatusCode::NOT_FOUND); + let (_, info) = call("player/info", json!({"uuid": steve})).await; + assert_eq!((info["global"]["xp"].as_i64(), info["server"]["xp"].as_i64(), info["server"]["level"].as_i64()), (Some(base + 1000), Some(400), Some(2))); + + // Quest objectives by quest id (complete it), and by action. + let (_, quests) = t.call("GET", "/api/v1/quests/my", Some(&tokens[0]), None).await; + let quest = quests.as_array().unwrap().iter().find(|q| q["quest"]["period"] == "daily").unwrap()["quest"]["id"].as_str().unwrap().to_string(); + let (s, r) = call("player/quest-objective", json!({"operation_id": "q1", "uuid": steve, "objective": quest, "amount": 0})).await; + assert_eq!((s, r["advanced"].clone()), (StatusCode::OK, json!(true)), "{r}"); + let (_, quests) = t.call("GET", "/api/v1/quests/my", Some(&tokens[0]), None).await; + let done = quests.as_array().unwrap().iter().find(|q| q["quest"]["id"] == quest.as_str()).unwrap(); + assert_eq!(done["completed"], true); + let (_, r) = call("player/quest-objective", json!({"operation_id": "q2", "uuid": steve, "objective": "block_broken:DIAMOND_ORE@minecraft:overworld", "amount": 3})).await; + assert_eq!(r["advanced"], true); + let (_, r) = call("player/quest-objective", json!({"operation_id": "q3", "uuid": steve, "objective": "nothing:at_all", "amount": 3})).await; + assert_eq!(r["advanced"], false); + let (_, info) = call("player/info", json!({"uuid": steve})).await; + assert_eq!(info["quests"]["daily"]["completed"], 1); + + // Friends and guilds. + t.call("POST", "/api/v1/friends/request", Some(&tokens[0]), Some(json!({"username": "Alex"}))).await; + t.call("POST", "/api/v1/friends/respond", Some(&tokens[1]), Some(json!({"target_uuid": steve, "accept": true}))).await; + let (_, f) = call("player/friends", json!({"uuid": steve})).await; + assert_eq!((f["friends"][0]["name"].as_str(), f["friends"][0]["uuid"].as_str()), (Some("Alex"), Some(alex.as_str()))); + let (_, gi) = call("guild/info", json!({"guild": "iron"})).await; + assert_eq!((gi["exists"].clone(), gi["name"].as_str(), gi["members"][0]["name"].as_str()), (json!(true), Some("Iron"), Some("Steve"))); + let (_, by_member) = call("guild/info", json!({"member": steve})).await; + assert_eq!(by_member["id"], g["id"]); + assert_eq!(call("guild/info", json!({"guild": "nobody"})).await.1["exists"], false); + assert_eq!(t.call("POST", "/api/server/v1/player/info", None, Some(json!({"uuid": steve}))).await.0, StatusCode::UNAUTHORIZED); +} diff --git a/panel/server/tests/integrations.rs b/panel/server/tests/integrations.rs new file mode 100644 index 0000000..0479d3c --- /dev/null +++ b/panel/server/tests/integrations.rs @@ -0,0 +1,160 @@ +//! Plugin integrations (LuckPerms ranks and group mapping) and the events +//! handed back to game servers with their sync. + +mod common; +use common::*; + +struct Env { + t: TestApp, + admin: String, + server: String, + sid: i64, +} + +async fn env() -> Env { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + let (_, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "SMP", "instance_id": "smp"}))).await; + Env { sid: srv["server"]["id"].as_i64().unwrap(), server: srv["token"].as_str().unwrap().to_string(), t, admin } +} + +impl Env { + async fn player(&self, name: &str) -> (String, String) { + self.t.call("POST", "/api/admin/users", Some(&self.admin), Some(json!({"username": name, "password": "password123"}))).await; + (self.t.login(name, "password123").await, self.t.uuid(name).await) + } + async fn report(&self, name: &str, data: Value) -> (StatusCode, Value) { + self.t.call("POST", "/api/server/v1/integrations/report", Some(&self.server), Some(json!({"name": name, "version": "5.4.1", "data": data}))).await + } + async fn sync(&self, online: &[(&str, &str)]) -> Value { + let online: Vec = online.iter().map(|(u, n)| json!({"uuid": u, "name": n})).collect(); + let (s, v) = self.t.call("POST", "/api/server/v1/sync", Some(&self.server), Some(json!({"online": online}))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + v + } + async fn groups_of(&self, name: &str) -> Vec { + sqlx::query_scalar("SELECT g.name FROM groups g JOIN user_groups ug ON ug.group_id = g.id JOIN users u ON u.id = ug.user_id WHERE u.username = ? ORDER BY g.name") + .bind(name).fetch_all(&self.t.db).await.unwrap() + } +} + +#[tokio::test] +async fn luckperms_ranks_mapping_and_sync_modes() { + let e = env().await; + let (_, steve) = e.player("Steve").await; + let (_, alex) = e.player("Alex").await; + for g in ["VIP", "Staff"] { + e.t.call("POST", "/api/admin/groups", Some(&e.admin), Some(json!({"name": g, "color": "#ff0000"}))).await; + } + let (_, groups) = e.t.call("GET", "/api/admin/groups", Some(&e.admin), None).await; + let id = |n: &str| groups.as_array().unwrap().iter().find(|g| g["name"] == n).unwrap()["id"].as_i64().unwrap(); + let (s, _) = e.t.call("PUT", &format!("/api/admin/groups/{}/luckperms", id("VIP")), Some(&e.admin), Some(json!({"luckperms_group": "vip"}))).await; + assert_eq!(s, StatusCode::OK); + let (s, _) = e.t.call("PUT", &format!("/api/admin/groups/{}/luckperms", id("Staff")), Some(&e.admin), Some(json!({"luckperms_group": "bad group!"}))).await; + assert_eq!(s, StatusCode::BAD_REQUEST); + e.t.call("PUT", &format!("/api/admin/groups/{}/luckperms", id("Staff")), Some(&e.admin), Some(json!({"luckperms_group": "Moderator"}))).await; + + let lp = |steve_groups: Value| json!({ + "groups": [{"name": "vip", "weight": 10}, {"name": "moderator", "weight": 50}], + "players": [ + {"uuid": steve, "primary": "VIP", "display": "VIP", "prefix": "§6[VIP] ", "weight": 10, "groups": steve_groups, "permissions": ["scopenet.command.*", "essentials.fly"]}, + {"uuid": alex, "primary": "default", "groups": ["default"]}, + {"uuid": "not-a-uuid", "primary": "x"} + ]}); + + // Default mode is display-only: ranks are stored, no group changes. + let (s, r) = e.report("luckperms", lp(json!(["default", "vip"]))).await; + assert_eq!(s, StatusCode::OK, "{r}"); + assert_eq!(r["config"]["mode"], "off"); + assert!(e.groups_of("Steve").await.is_empty()); + let (_, view) = e.t.call("GET", &format!("/api/admin/servers/{}/integrations", e.sid), Some(&e.admin), None).await; + let ranks = view["ranks"].as_array().unwrap(); + assert_eq!(ranks.len(), 2); + assert_eq!(ranks[0]["name"], "Steve"); + assert_eq!(ranks[0]["primary"], "vip"); + assert_eq!(ranks[0]["prefix"], "§6[VIP] "); + assert_eq!(view["integrations"][0]["name"], "luckperms"); + assert_eq!(view["integrations"][0]["data"]["groups"][1]["name"], "moderator"); + + // game_to_panel: mapped groups follow LuckPerms, including removal. + let (s, _) = e.t.call("PUT", "/api/admin/integrations/settings", Some(&e.admin), Some(json!({"luckperms_sync": "game_to_panel"}))).await; + assert_eq!(s, StatusCode::OK); + e.report("luckperms", lp(json!(["default", "vip", "moderator"]))).await; + assert_eq!(e.groups_of("Steve").await, ["Staff", "VIP"]); + let (_, r) = e.report("luckperms", lp(json!(["default"]))).await; + assert!(e.groups_of("Steve").await.is_empty(), "removed in LuckPerms, removed here"); + assert!(r["config"]["assign"].as_array().unwrap().is_empty()); + + // panel_to_game: the panel's groups are pushed to the game as assignments. + sqlx::query("INSERT INTO user_groups (user_id, group_id) SELECT u.id, ? FROM users u WHERE u.username = 'Alex'").bind(id("VIP")).execute(&e.t.db).await.unwrap(); + e.t.call("PUT", "/api/admin/integrations/settings", Some(&e.admin), Some(json!({"luckperms_sync": "panel_to_game"}))).await; + let (_, r) = e.report("luckperms", lp(json!(["default", "moderator"]))).await; + let assign = r["config"]["assign"].as_array().unwrap(); + let for_alex = assign.iter().find(|a| a["uuid"] == alex).unwrap(); + assert_eq!(for_alex["add"], json!(["vip"])); + let for_steve = assign.iter().find(|a| a["uuid"] == steve).unwrap(); + assert_eq!(for_steve["remove"], json!(["moderator"]), "LuckPerms has a mapped group the panel doesn't"); + + let (s, _) = e.t.call("PUT", "/api/admin/integrations/settings", Some(&e.admin), Some(json!({"luckperms_sync": "sideways"}))).await; + assert_eq!(s, StatusCode::BAD_REQUEST); + let (s, _) = e.report("nonsense", json!({})).await; + assert_eq!(s, StatusCode::BAD_REQUEST); + let (s, _) = e.t.call("POST", "/api/server/v1/integrations/report", None, Some(json!({"name": "spark"}))).await; + assert_eq!(s, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn other_integrations_are_stored_per_server() { + let e = env().await; + e.report("spark", json!({"tps": {"m1": 19.98}, "mspt": {"mean": 12.5, "p95": 31.0}})).await; + e.report("worldguard", json!({"regions": [{"world": "world", "id": "spawn", "min": [-50, 0, -50], "max": [50, 255, 50], "owners": ["admin"]}]})).await; + e.report("spark", json!({"tps": {"m1": 20.0}})).await; + let (_, view) = e.t.call("GET", &format!("/api/admin/servers/{}/integrations", e.sid), Some(&e.admin), None).await; + let names: Vec<&str> = view["integrations"].as_array().unwrap().iter().map(|i| i["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["spark", "worldguard"]); + assert_eq!(view["integrations"][0]["data"]["tps"]["m1"], 20.0, "the latest report replaces the previous one"); + let big = json!({"pad": "x".repeat(1_100_000)}); + assert_eq!(e.report("worldguard", big).await.0, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn game_servers_receive_events_with_their_sync() { + let e = env().await; + let (_, steve) = e.player("Steve").await; + let (_, alex) = e.player("Alex").await; + assert!(e.sync(&[(&steve, "Steve")]).await["notifications"].as_array().unwrap().is_empty()); + + // Level-ups: global and per-server. + let adjust = |scope: &str, mode: &str, amount: i64| { + let (t, admin, uuid, sid) = (&e.t, e.admin.clone(), steve.clone(), e.sid); + let body = if scope == "global" { json!({"scope": "global", "mode": mode, "amount": amount}) } else { json!({"scope": "server", "server_id": sid, "mode": mode, "amount": amount}) }; + async move { t.call("POST", &format!("/api/admin/progression/players/{uuid}/adjust"), Some(&admin), Some(body)).await } + }; + adjust("global", "set_xp", 10).await; + adjust("global", "set_xp", 1200).await; // level 1 -> 5 + adjust("server", "set_xp", 10).await; + adjust("server", "set_xp", 300).await; // level 1 -> 2 + let n = e.sync(&[(&steve, "Steve")]).await["notifications"].clone(); + let kinds: Vec<(&str, &str)> = n.as_array().unwrap().iter().map(|x| (x["kind"].as_str().unwrap(), x["data"]["scope"].as_str().unwrap_or(""))).collect(); + assert_eq!(kinds, [("level_up", "global"), ("level_up", "server")], "{n}"); + assert_eq!((n[0]["data"]["previous"].as_i64(), n[0]["data"]["level"].as_i64()), (Some(1), Some(5))); + assert_eq!(n[0]["uuid"], steve); + // Delivered once. + assert!(e.sync(&[(&steve, "Steve")]).await["notifications"].as_array().unwrap().is_empty()); + // Dropping levels doesn't announce anything. + adjust("global", "reset", 0).await; + assert!(e.sync(&[(&steve, "Steve")]).await["notifications"].as_array().unwrap().is_empty()); + + // Achievements and guild changes. + let ach: String = sqlx::query_scalar("SELECT id FROM achievements LIMIT 1").fetch_one(&e.t.db).await.unwrap(); + sqlx::query("INSERT INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, 'now')").bind(&steve).bind(&ach).execute(&e.t.db).await.unwrap(); + sqlx::query("INSERT INTO guilds (id, instance_id, name, tag, leader_uuid, created_at) VALUES ('g1', 'smp', 'Iron', 'IRON', ?, 'now')").bind(&alex).execute(&e.t.db).await.unwrap(); + sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES ('g1', ?, 'Steve', 'member', 'now')").bind(&steve).execute(&e.t.db).await.unwrap(); + sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES ('g1', ?, 'Alex', 'leader', 'now')").bind(&alex).execute(&e.t.db).await.unwrap(); + sqlx::query("DELETE FROM guild_members WHERE uuid = ?").bind(&steve).execute(&e.t.db).await.unwrap(); + let n = e.sync(&[(&steve, "Steve")]).await["notifications"].clone(); + let kinds: Vec<&str> = n.as_array().unwrap().iter().map(|x| x["kind"].as_str().unwrap()).collect(); + assert_eq!(kinds, ["achievement", "guild_join", "guild_leave"], "Alex was offline, so only Steve's events: {n}"); + assert_eq!(n[0]["data"]["id"], ach); + assert_eq!(n[1]["data"]["tag"], "IRON"); +}