Panel: plugin integrations (LuckPerms ranks + group mapping, reports), server event queue, developer API endpoints

This commit is contained in:
Claude committed 2026-09-30 18:06:18 +00:00
1 parent c256c15d0e
commit badb5fccc0
10 files changed
+906 -2

No files matched your search

+86
View File
@@ -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);
}
+160
View File
@@ -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<Value> = 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<String> {
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");
}