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:
Claude committed 2026-09-30 17:51:05 +00:00
1 parent 78da10364f
commit 5defda1128
10 files changed
+442 -127

No files matched your search

+14
View File
@@ -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> {
+53 -1
View File
@@ -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(
+1
View File
@@ -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))
+76
View File
@@ -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);
}