Claim protection from a local index: revision-polled panel endpoint, no per-block network checks
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
10 files changed
+442
-127
No files matched your search
@@ -570,6 +570,20 @@ const MIGRATIONS: &[&str] = &[
|
||||
);
|
||||
CREATE INDEX quest_assignments_user ON quest_assignments(user_uuid, period_key, position);
|
||||
"#,
|
||||
// Bumped by any change to claims, guilds or membership so game servers can
|
||||
// poll a single number instead of asking about every block.
|
||||
r#"
|
||||
INSERT OR IGNORE INTO kv (key, value) VALUES ('guild_rev', '1');
|
||||
CREATE TRIGGER guild_claims_rev_i AFTER INSERT ON guild_claims BEGIN UPDATE kv SET value = CAST(value AS INTEGER) + 1 WHERE key = 'guild_rev'; END;
|
||||
CREATE TRIGGER guild_claims_rev_d AFTER DELETE ON guild_claims BEGIN UPDATE kv SET value = CAST(value AS INTEGER) + 1 WHERE key = 'guild_rev'; END;
|
||||
CREATE TRIGGER guild_claims_rev_u AFTER UPDATE ON guild_claims BEGIN UPDATE kv SET value = CAST(value AS INTEGER) + 1 WHERE key = 'guild_rev'; END;
|
||||
CREATE TRIGGER guild_members_rev_i AFTER INSERT ON guild_members BEGIN UPDATE kv SET value = CAST(value AS INTEGER) + 1 WHERE key = 'guild_rev'; END;
|
||||
CREATE TRIGGER guild_members_rev_d AFTER DELETE ON guild_members BEGIN UPDATE kv SET value = CAST(value AS INTEGER) + 1 WHERE key = 'guild_rev'; END;
|
||||
CREATE TRIGGER guild_members_rev_u AFTER UPDATE ON guild_members BEGIN UPDATE kv SET value = CAST(value AS INTEGER) + 1 WHERE key = 'guild_rev'; END;
|
||||
CREATE TRIGGER guilds_rev_i AFTER INSERT ON guilds BEGIN UPDATE kv SET value = CAST(value AS INTEGER) + 1 WHERE key = 'guild_rev'; END;
|
||||
CREATE TRIGGER guilds_rev_d AFTER DELETE ON guilds BEGIN UPDATE kv SET value = CAST(value AS INTEGER) + 1 WHERE key = 'guild_rev'; END;
|
||||
CREATE TRIGGER guilds_rev_u AFTER UPDATE OF name, tag, leader_uuid ON guilds BEGIN UPDATE kv SET value = CAST(value AS INTEGER) + 1 WHERE key = 'guild_rev'; END;
|
||||
"#,
|
||||
];
|
||||
|
||||
pub async fn connect(data_dir: &Path) -> Result<SqlitePool> {
|
||||
|
||||
@@ -8,7 +8,7 @@ use axum::extract::{Path, Query, State};
|
||||
use axum::Json;
|
||||
use scopenet_shared::{Guild, GuildClaim, GuildMember, GuildPost};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct InstanceQuery {
|
||||
@@ -908,6 +908,58 @@ pub async fn server_claim_snapshot(
|
||||
})).collect::<Vec<_>>() })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct ClaimIndexPayload {
|
||||
/// The revision the game server already holds.
|
||||
pub revision: Option<String>,
|
||||
}
|
||||
|
||||
/// Everything a game server needs to decide claim protection locally:
|
||||
/// every claimed chunk on this server plus who belongs to each claiming guild.
|
||||
/// Answers `{"unchanged": true}` when `revision` is still current, so polling
|
||||
/// is one cheap query.
|
||||
pub async fn server_claim_index(
|
||||
GameServer(server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ClaimIndexPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let revision: String = sqlx::query_scalar("SELECT value FROM kv WHERE key = 'guild_rev'").fetch_optional(&state.db).await?.unwrap_or_else(|| "0".into());
|
||||
if payload.revision.as_deref() == Some(revision.as_str()) {
|
||||
return Ok(Json(json!({ "revision": revision, "unchanged": true })));
|
||||
}
|
||||
let rows: Vec<(String, i32, i32, String, String, String)> = sqlx::query_as(
|
||||
"SELECT gc.dimension, gc.chunk_x, gc.chunk_z, gc.guild_id, g.name, g.tag
|
||||
FROM guild_claims gc JOIN guilds g ON g.id = gc.guild_id WHERE gc.server_id = ?",
|
||||
)
|
||||
.bind(server.id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let members: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT gm.guild_id, gm.uuid FROM guild_members gm
|
||||
WHERE gm.guild_id IN (SELECT DISTINCT guild_id FROM guild_claims WHERE server_id = ?)",
|
||||
)
|
||||
.bind(server.id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
// Guilds are sent once; each claim refers to its guild by index.
|
||||
let mut guilds: Vec<Value> = Vec::new();
|
||||
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
let mut claims: Vec<Value> = Vec::with_capacity(rows.len());
|
||||
for (dimension, x, z, guild_id, name, tag) in rows {
|
||||
let i = *index.entry(guild_id.clone()).or_insert_with(|| {
|
||||
guilds.push(json!({ "id": guild_id, "name": name, "tag": tag }));
|
||||
guilds.len() - 1
|
||||
});
|
||||
claims.push(json!([dimension, x, z, i]));
|
||||
}
|
||||
let mut roster: std::collections::BTreeMap<String, Vec<String>> = Default::default();
|
||||
for (guild, uuid) in members {
|
||||
roster.entry(guild).or_default().push(uuid);
|
||||
}
|
||||
Ok(Json(json!({ "revision": revision, "unchanged": false, "guilds": guilds, "claims": claims, "members": roster })))
|
||||
}
|
||||
|
||||
/// 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(
|
||||
|
||||
@@ -175,6 +175,7 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.route("/sync", post(servers::sync))
|
||||
.route("/guilds/check-chunk", post(guilds::server_check_chunk))
|
||||
.route("/guilds/claim-snapshot", post(guilds::server_claim_snapshot))
|
||||
.route("/guilds/claim-index", post(guilds::server_claim_index))
|
||||
.route("/guilds/claim", post(guilds::server_claim_chunk))
|
||||
.route("/guilds/unclaim", post(guilds::server_unclaim_chunk))
|
||||
.route("/guilds/player", post(guilds::server_get_player_guild))
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! The claim index game servers poll instead of asking about every block.
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn claim_index_follows_claims_and_membership() {
|
||||
let t = setup().await;
|
||||
let admin = t.login("admin", "supersecret").await;
|
||||
let mut tokens = Vec::new();
|
||||
for n in ["Alex", "Steve"] {
|
||||
t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": n, "password": "password123"}))).await;
|
||||
tokens.push(t.login(n, "password123").await);
|
||||
}
|
||||
let alex_uuid = t.uuid("Alex").await;
|
||||
let steve_uuid = t.uuid("Steve").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 token = srv["token"].as_str().unwrap().to_string();
|
||||
let index = |rev: Option<String>| {
|
||||
let (t, token) = (&t, token.clone());
|
||||
async move { t.call("POST", "/api/server/v1/guilds/claim-index", Some(&token), Some(json!({ "revision": rev }))).await }
|
||||
};
|
||||
|
||||
let (s, empty) = index(None).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(empty["unchanged"], false);
|
||||
assert!(empty["claims"].as_array().unwrap().is_empty());
|
||||
let rev0 = empty["revision"].as_str().unwrap().to_string();
|
||||
let (_, same) = index(Some(rev0.clone())).await;
|
||||
assert_eq!(same["unchanged"], true);
|
||||
assert!(same.get("claims").is_none());
|
||||
|
||||
// A guild, then a claim: the revision moves and the index lists the chunk.
|
||||
let (_, g) = t.call("POST", "/api/v1/guilds", Some(&tokens[0]), Some(json!({"instance_id": "smp", "name": "Iron", "tag": "IRON"}))).await;
|
||||
let gid = g["id"].as_str().unwrap().to_string();
|
||||
let (_, r1) = index(Some(rev0.clone())).await;
|
||||
assert_eq!(r1["unchanged"], false, "guild creation bumps the revision");
|
||||
let claim = |x: i32| {
|
||||
let (t, tok, gid) = (&t, tokens[0].clone(), gid.clone());
|
||||
async move { t.call("POST", &format!("/api/v1/guilds/{gid}/claim"), Some(&tok), Some(json!({"server_id": sid, "dimension": "minecraft:overworld", "chunk_x": x, "chunk_z": -4}))).await }
|
||||
};
|
||||
let (s, c1) = claim(3).await;
|
||||
assert_eq!(s, StatusCode::OK, "{c1}");
|
||||
claim(4).await;
|
||||
let (_, idx) = index(Some(r1["revision"].as_str().unwrap().to_string())).await;
|
||||
assert_eq!(idx["unchanged"], false);
|
||||
assert_eq!(idx["claims"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(idx["claims"][0], json!(["minecraft:overworld", 3, -4, 0]));
|
||||
assert_eq!(idx["guilds"][0]["tag"], "IRON");
|
||||
assert_eq!(idx["members"][&gid], json!([alex_uuid]));
|
||||
let rev2 = idx["revision"].as_str().unwrap().to_string();
|
||||
assert_eq!(index(Some(rev2.clone())).await.1["unchanged"], true);
|
||||
|
||||
// Membership changes bump it too.
|
||||
sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES (?, ?, 'Steve', 'member', 'now')").bind(&gid).bind(&steve_uuid).execute(&t.db).await.unwrap();
|
||||
let (_, idx) = index(Some(rev2.clone())).await;
|
||||
assert_eq!(idx["unchanged"], false);
|
||||
assert_eq!(idx["members"][&gid].as_array().unwrap().len(), 2);
|
||||
let rev3 = idx["revision"].as_str().unwrap().to_string();
|
||||
|
||||
// Unclaim, and deleting the guild (cascades the claims away).
|
||||
let cid = c1["id"].as_i64().unwrap();
|
||||
t.call("DELETE", &format!("/api/v1/guilds/claims/{cid}"), Some(&tokens[0]), None).await;
|
||||
let (_, idx) = index(Some(rev3)).await;
|
||||
assert_eq!(idx["claims"].as_array().unwrap().len(), 1);
|
||||
let rev4 = idx["revision"].as_str().unwrap().to_string();
|
||||
sqlx::query("DELETE FROM guilds WHERE id = ?").bind(&gid).execute(&t.db).await.unwrap();
|
||||
let (_, idx) = index(Some(rev4)).await;
|
||||
assert_eq!(idx["unchanged"], false, "cascaded deletes still fire the revision triggers");
|
||||
assert!(idx["claims"].as_array().unwrap().is_empty());
|
||||
|
||||
// Unauthenticated callers get nothing.
|
||||
let (s, _) = t.call("POST", "/api/server/v1/guilds/claim-index", None, Some(json!({}))).await;
|
||||
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
Reference in new issue
Block a user