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:
9 files changed
+137
-39
No files matched your search
@@ -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"));
|
||||
|
||||
Reference in new issue
Block a user