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))