Cross-server network: shared economy groups for balances and guild banks

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 18:23:47 +00:00
1 parent 485a81e0a4
commit 745c2178ee
9 files changed
+137 -39

No files matched your search

+44 -3
View File
@@ -63,6 +63,14 @@ pub struct ServerRow {
pub instance_id: String,
pub map_url: String,
pub live_map_enabled: bool,
/// Servers with the same non-empty name share player balances and guild banks.
#[sqlx(default)]
pub economy_group: String,
/// The server whose balances this one uses: itself, or the oldest in its economy group.
/// Only filled in for token-authenticated game server requests.
#[sqlx(default)]
#[serde(skip)]
pub economy_id: i64,
pub id: i64,
pub name: String,
#[serde(skip)]
@@ -122,10 +130,24 @@ impl FromRequestParts<AppState> for GameServer {
.bind(hash_token(token.trim()))
.fetch_optional(&state.db)
.await?;
row.map(GameServer).ok_or_else(|| AppError::unauthorized("unknown server token — create one under Servers in the panel"))
let mut server = row.ok_or_else(|| AppError::unauthorized("unknown server token — create one under Servers in the panel"))?;
server.economy_id = economy_scope(&state.db, server.id).await?;
Ok(GameServer(server))
}
}
/// Which server's balances `server_id` uses (see [`ServerRow::economy_group`]).
pub async fn economy_scope(db: &sqlx::SqlitePool, server_id: i64) -> AppResult<i64> {
Ok(sqlx::query_scalar(
"SELECT COALESCE((SELECT MIN(b.id) FROM game_servers b WHERE b.economy_group <> '' AND b.economy_group = a.economy_group COLLATE NOCASE), a.id)
FROM game_servers a WHERE a.id = ?",
)
.bind(server_id)
.fetch_optional(db)
.await?
.unwrap_or(server_id))
}
#[derive(Deserialize, Default)]
#[serde(default)]
pub struct Hello {
@@ -845,6 +867,8 @@ pub struct ServerInput {
map_url: String,
#[serde(default)]
live_map_enabled: bool,
#[serde(default)]
economy_group: String,
}
fn default_access() -> String {
@@ -862,6 +886,10 @@ impl ServerInput {
if self.access == "groups" && self.allowed_groups.is_empty() {
return Err(AppError::bad_request("pick at least one group"));
}
let group = self.economy_group.trim();
if group.chars().count() > 32 || !group.chars().all(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_')) {
return Err(AppError::bad_request("economy groups are up to 32 letters, digits, spaces, - or _"));
}
if !self.map_url.trim().is_empty() {
let url = reqwest::Url::parse(self.map_url.trim()).map_err(|_| AppError::bad_request("map URL must be an absolute URL"))?;
if url.scheme() != "https" && !(url.scheme() == "http" && matches!(url.host_str(), Some("localhost" | "127.0.0.1"))) {
@@ -890,7 +918,7 @@ pub async fn create(_: AdminUser, State(state): State<AppState>, Json(input): Js
input.validate()?;
let token = new_token();
let id: i64 = sqlx::query_scalar(
"INSERT INTO game_servers (name, token_hash, token_hint, access, allowed_groups, require_launcher, created_at, instance_id, map_url, live_map_enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
"INSERT INTO game_servers (name, token_hash, token_hint, access, allowed_groups, require_launcher, created_at, instance_id, map_url, live_map_enabled, economy_group) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
)
.bind(input.name.trim())
.bind(hash_token(&token))
@@ -902,6 +930,7 @@ pub async fn create(_: AdminUser, State(state): State<AppState>, Json(input): Js
.bind(&input.instance_id)
.bind(input.map_url.trim())
.bind(input.live_map_enabled)
.bind(input.economy_group.trim())
.fetch_one(&state.db)
.await?;
let server = get_server(&state, id).await?;
@@ -916,7 +945,7 @@ pub async fn update(
) -> AppResult<Json<ServerView>> {
input.validate()?;
get_server(&state, id).await?;
sqlx::query("UPDATE game_servers SET name = ?, access = ?, allowed_groups = ?, require_launcher = ?, instance_id = ?, map_url = ?, live_map_enabled = ? WHERE id = ?")
sqlx::query("UPDATE game_servers SET name = ?, access = ?, allowed_groups = ?, require_launcher = ?, instance_id = ?, map_url = ?, live_map_enabled = ?, economy_group = ? WHERE id = ?")
.bind(input.name.trim())
.bind(&input.access)
.bind(serde_json::to_string(&input.allowed_groups).unwrap_or_else(|_| "[]".into()))
@@ -924,6 +953,7 @@ pub async fn update(
.bind(&input.instance_id)
.bind(input.map_url.trim())
.bind(input.live_map_enabled)
.bind(input.economy_group.trim())
.bind(id)
.execute(&state.db)
.await?;
@@ -943,6 +973,17 @@ pub async fn regenerate_token(_: AdminUser, State(state): State<AppState>, Path(
}
pub async fn remove(_: AdminUser, State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
// If this server holds its group's shared balances, hand them to the next server first.
let group: String = sqlx::query_scalar("SELECT economy_group FROM game_servers WHERE id = ?").bind(id).fetch_optional(&state.db).await?.unwrap_or_default();
if !group.is_empty() && economy_scope(&state.db, id).await? == id {
let next: Option<i64> = sqlx::query_scalar("SELECT MIN(id) FROM game_servers WHERE id <> ? AND economy_group = ? COLLATE NOCASE").bind(id).bind(&group).fetch_one(&state.db).await?;
if let Some(next) = next {
for table in ["server_economy", "guild_wallets", "guild_wallet_transactions"] {
let sql = if table == "guild_wallet_transactions" { format!("UPDATE {table} SET server_id = ? WHERE server_id = ?") } else { format!("UPDATE OR IGNORE {table} SET server_id = ? WHERE server_id = ?") };
sqlx::query(&sql).bind(next).bind(id).execute(&state.db).await?;
}
}
}
let done = sqlx::query("DELETE FROM game_servers WHERE id = ?").bind(id).execute(&state.db).await?;
if done.rows_affected() == 0 {
return Err(AppError::not_found("server not found"));