Admin rules: starting balance, guild claim/member limits, market listing limit; panel UI

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-10-01 01:06:51 +00:00
1 parent c859628e93
commit 19d111eeff
8 files changed
+166 -12

No files matched your search

+50
View File
@@ -48,6 +48,32 @@ pub struct Progression {
pub global_xp_multiplier: f64,
pub server_xp_multiplier: f64,
pub xp_rates: XpRates,
/// Economy and guild rules that apply on every server.
pub rules: Rules,
}
/// Network-wide limits an admin can change without touching a server. 0 means "no limit" where it says so.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Rules {
/// Balance a player starts with on each server economy.
pub starting_balance: f64,
/// Chunks a new guild may claim.
pub guild_base_claims: i64,
/// Extra claimable chunks for every member beyond the leader.
pub guild_claims_per_member: i64,
/// Extra claimable chunks for every guild level above 1.
pub guild_claims_per_level: i64,
/// Most members a guild may have (0 = no limit).
pub guild_max_members: i64,
/// Most listings one player may have on a server's market (0 = no limit).
pub market_max_listings: i64,
}
impl Default for Rules {
fn default() -> Self {
Self { starting_balance: 1000.0, guild_base_claims: 16, guild_claims_per_member: 0, guild_claims_per_level: 0, guild_max_members: 0, market_max_listings: 0 }
}
}
impl Default for Progression {
@@ -62,6 +88,7 @@ impl Default for Progression {
global_xp_multiplier: 1.0,
server_xp_multiplier: 1.0,
xp_rates: XpRates::default(),
rules: Rules::default(),
}
}
}
@@ -89,6 +116,21 @@ impl Progression {
return Err(AppError::bad_request(format!("{name} must be between 0 and 100.")));
}
}
let rules = &self.rules;
if !rules.starting_balance.is_finite() || !(0.0..=1e9).contains(&rules.starting_balance) {
return bad("Starting balance must be between 0 and 1,000,000,000.");
}
for (name, v, max) in [
("Guild base claims", rules.guild_base_claims, 100_000),
("Claims per member", rules.guild_claims_per_member, 10_000),
("Claims per guild level", rules.guild_claims_per_level, 10_000),
("Guild member limit", rules.guild_max_members, 10_000),
("Market listing limit", rules.market_max_listings, 10_000),
] {
if !(0..=max).contains(&v) {
return Err(AppError::bad_request(format!("{name} must be between 0 and {max}.")));
}
}
let r = &self.xp_rates;
for (name, v) in [
("Playtime XP", r.playtime_per_hour),
@@ -196,6 +238,14 @@ pub async fn save(pool: &sqlx::SqlitePool, p: &Progression) -> AppResult<()> {
.bind(serde_json::to_string(p)?)
.execute(pool)
.await?;
// The same rules as plain rows, so SQL can use them (guild claim limits are worked out in queries).
for (key, value) in [
("rule_claims_per_member", p.rules.guild_claims_per_member),
("rule_claims_per_level", p.rules.guild_claims_per_level),
("rule_guild_max_members", p.rules.guild_max_members),
] {
sqlx::query("INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").bind(key).bind(value.to_string()).execute(pool).await?;
}
Ok(())
}
+9 -3
View File
@@ -32,8 +32,9 @@ pub(crate) async fn finish_operation(mut tx: sqlx::Transaction<'_, sqlx::Sqlite>
}
pub(crate) async fn ensure_balance(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, server_id: i64, uuid: &str, name: &str) -> AppResult<()> {
sqlx::query("INSERT INTO server_economy(server_id, uuid, username, balance, updated_at) VALUES (?, ?, ?, 1000, ?) ON CONFLICT(server_id, uuid) DO NOTHING")
.bind(server_id).bind(uuid).bind(name).bind(chrono::Utc::now().to_rfc3339()).execute(&mut **tx).await?;
let start = crate::progression::load(&mut **tx).await?.rules.starting_balance;
sqlx::query("INSERT INTO server_economy(server_id, uuid, username, balance, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(server_id, uuid) DO NOTHING")
.bind(server_id).bind(uuid).bind(name).bind(start).bind(chrono::Utc::now().to_rfc3339()).execute(&mut **tx).await?;
Ok(())
}
@@ -237,7 +238,7 @@ pub async fn server_get_balance(
let balance = match bal_opt {
Some(b) => b,
None => {
let starting_balance = 1000.0;
let starting_balance = crate::progression::load_pool(&state.db).await?.rules.starting_balance;
sqlx::query(
"INSERT INTO server_economy (server_id, uuid, username, balance, updated_at)
VALUES (?, ?, ?, ?, ?)
@@ -432,6 +433,11 @@ pub async fn server_market_list(
if !payload.price.is_finite() || payload.price <= 0.0 || payload.amount <= 0 || payload.amount > 64 { return Err(AppError::bad_request("Invalid listing")); }
let (mut tx, previous) = begin_operation(&state, server.id, &payload.operation_id).await?;
if let Some(previous) = previous { return Ok(Json(previous)); }
let limit = crate::progression::load(&mut tx).await?.rules.market_max_listings;
if limit > 0 {
let mine: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM server_market WHERE server_id = ? AND seller_uuid = ?").bind(server.id).bind(&payload.seller_uuid).fetch_one(&mut *tx).await?;
if mine >= limit { return Err(AppError::bad_request(format!("You can have at most {limit} market listings at once"))); }
}
let now = chrono::Utc::now().to_rfc3339();
let guild_id = if payload.as_guild {
let m = crate::routes::guild_bank::membership(&mut tx, &server, &payload.seller_uuid).await?.ok_or_else(|| AppError::forbidden("You are not in a guild"))?;
+30 -9
View File
@@ -36,7 +36,7 @@ pub async fn list_guilds(Query(query): Query<InstanceQuery>, State(state): State
i64,
)> = sqlx::query_as(
"SELECT g.id, g.instance_id, g.name, g.tag, g.description, g.motd, g.leader_uuid,
g.icon_url, g.banner_url, g.level, g.xp, (g.max_claims + COALESCE((SELECT SUM(b.claim_chunks) FROM player_bonuses b JOIN guild_members m ON m.uuid = b.uuid WHERE m.guild_id = g.id), 0)), g.created_at,
g.icon_url, g.banner_url, g.level, g.xp, (g.max_claims + COALESCE((SELECT SUM(b.claim_chunks) FROM player_bonuses b JOIN guild_members m ON m.uuid = b.uuid WHERE m.guild_id = g.id), 0) + COALESCE((SELECT CAST(value AS INTEGER) FROM kv WHERE key = 'rule_claims_per_member'), 0) * MAX(0, (SELECT COUNT(*) FROM guild_members m2 WHERE m2.guild_id = g.id) - 1) + COALESCE((SELECT CAST(value AS INTEGER) FROM kv WHERE key = 'rule_claims_per_level'), 0) * MAX(0, g.level - 1)), g.created_at,
(SELECT COUNT(*) FROM guild_members gm WHERE gm.guild_id = g.id) as member_count,
(SELECT COUNT(*) FROM guild_claims gc WHERE gc.guild_id = g.id) as claims_count
FROM guilds g
@@ -210,11 +210,23 @@ async fn wallet_transfer(state: &AppState, auth: &AuthUser, guild_id: &str, p: G
Ok(Json(serde_json::json!({"balance":balance})))
}
/// Refuse a new member when the guild is at the admin-set member limit (0 = no limit).
pub async fn check_room(conn: &mut sqlx::SqliteConnection, guild_id: &str) -> AppResult<()> {
let limit: i64 = sqlx::query_scalar("SELECT COALESCE((SELECT CAST(value AS INTEGER) FROM kv WHERE key = 'rule_guild_max_members'), 0)").fetch_one(&mut *conn).await?;
if limit > 0 {
let members: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_members WHERE guild_id = ?").bind(guild_id).fetch_one(&mut *conn).await?;
if members >= limit {
return Err(AppError::bad_request(format!("That guild is full ({limit} members)")));
}
}
Ok(())
}
async fn fetch_guild_detail(state: &AppState, guild_id: &str) -> AppResult<GuildDetail> {
let row: Option<(String, String, String, String, String, String, String, Option<String>, Option<String>, i64, i64, i64, String)> =
sqlx::query_as(
"SELECT id, instance_id, name, tag, description, motd, leader_uuid,
icon_url, banner_url, level, xp, (max_claims + COALESCE((SELECT SUM(b.claim_chunks) FROM player_bonuses b JOIN guild_members m ON m.uuid = b.uuid WHERE m.guild_id = guilds.id), 0)), created_at
icon_url, banner_url, level, xp, (guilds.max_claims + COALESCE((SELECT SUM(b.claim_chunks) FROM player_bonuses b JOIN guild_members m ON m.uuid = b.uuid WHERE m.guild_id = guilds.id), 0) + COALESCE((SELECT CAST(value AS INTEGER) FROM kv WHERE key = 'rule_claims_per_member'), 0) * MAX(0, (SELECT COUNT(*) FROM guild_members m2 WHERE m2.guild_id = guilds.id) - 1) + COALESCE((SELECT CAST(value AS INTEGER) FROM kv WHERE key = 'rule_claims_per_level'), 0) * MAX(0, guilds.level - 1)), created_at
FROM guilds WHERE id = ?",
)
.bind(guild_id)
@@ -377,9 +389,10 @@ pub async fn create_guild(
let guild_id = format!("guild_{}", uuid::Uuid::new_v4().simple());
let now = chrono::Utc::now().to_rfc3339();
let base_claims = crate::progression::load(&mut tx).await?.rules.guild_base_claims;
sqlx::query(
"INSERT INTO guilds (id, instance_id, name, tag, description, motd, leader_uuid, icon_url, banner_url, level, xp, max_claims, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, 16, ?)",
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, ?)",
)
.bind(&guild_id)
.bind(&payload.instance_id)
@@ -390,6 +403,7 @@ pub async fn create_guild(
.bind(&auth.uuid)
.bind(payload.icon_url)
.bind(payload.banner_url)
.bind(base_claims)
.bind(&now)
.execute(&mut *tx)
.await?;
@@ -498,6 +512,7 @@ pub async fn respond_join_request(auth: AuthUser, Path((id, uuid)): Path<(String
.bind(&id).bind(&uuid).fetch_optional(&mut *tx).await?;
let name = name.ok_or_else(|| AppError::not_found("request not found"))?;
if decision.accept {
check_room(&mut tx, &id).await?;
sqlx::query("INSERT INTO guild_members(guild_id,uuid,name,role,joined_at) VALUES(?,?,?,'member',?)")
.bind(&id).bind(&uuid).bind(&name).bind(crate::db::now()).execute(&mut *tx).await.map_err(|_| AppError::bad_request("Player has already joined a guild"))?;
sqlx::query("DELETE FROM guild_join_requests WHERE uuid=?").bind(&uuid).execute(&mut *tx).await?;
@@ -612,6 +627,10 @@ pub async fn add_guild_member(
let now = chrono::Utc::now().to_rfc3339();
{
let mut conn = state.db.acquire().await?;
check_room(&mut conn, &id).await?;
}
sqlx::query(
"INSERT INTO guild_members (guild_id, uuid, name, role, joined_at)
VALUES (?, ?, ?, 'member', ?)
@@ -740,7 +759,7 @@ pub async fn claim_chunk(
return Err(AppError::forbidden("Your guild role cannot claim land"));
}
let max_claims: i64 = sqlx::query_scalar("SELECT (max_claims + COALESCE((SELECT SUM(b.claim_chunks) FROM player_bonuses b JOIN guild_members m ON m.uuid = b.uuid WHERE m.guild_id = guilds.id), 0)) FROM guilds WHERE id = ?").bind(&guild_id).fetch_one(&state.db).await?;
let max_claims: i64 = sqlx::query_scalar("SELECT (guilds.max_claims + COALESCE((SELECT SUM(b.claim_chunks) FROM player_bonuses b JOIN guild_members m ON m.uuid = b.uuid WHERE m.guild_id = guilds.id), 0) + COALESCE((SELECT CAST(value AS INTEGER) FROM kv WHERE key = 'rule_claims_per_member'), 0) * MAX(0, (SELECT COUNT(*) FROM guild_members m2 WHERE m2.guild_id = guilds.id) - 1) + COALESCE((SELECT CAST(value AS INTEGER) FROM kv WHERE key = 'rule_claims_per_level'), 0) * MAX(0, guilds.level - 1)) FROM guilds WHERE id = ?").bind(&guild_id).fetch_one(&state.db).await?;
let current_claims: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ?").bind(&guild_id).fetch_one(&state.db).await?;
@@ -932,7 +951,7 @@ pub async fn admin_list_guilds(_admin: AdminUser, State(state): State<AppState>)
i64,
)> = sqlx::query_as(
"SELECT g.id, g.instance_id, g.name, g.tag, g.description, g.motd, g.leader_uuid,
g.icon_url, g.banner_url, g.level, g.xp, (g.max_claims + COALESCE((SELECT SUM(b.claim_chunks) FROM player_bonuses b JOIN guild_members m ON m.uuid = b.uuid WHERE m.guild_id = g.id), 0)), g.created_at,
g.icon_url, g.banner_url, g.level, g.xp, (g.max_claims + COALESCE((SELECT SUM(b.claim_chunks) FROM player_bonuses b JOIN guild_members m ON m.uuid = b.uuid WHERE m.guild_id = g.id), 0) + COALESCE((SELECT CAST(value AS INTEGER) FROM kv WHERE key = 'rule_claims_per_member'), 0) * MAX(0, (SELECT COUNT(*) FROM guild_members m2 WHERE m2.guild_id = g.id) - 1) + COALESCE((SELECT CAST(value AS INTEGER) FROM kv WHERE key = 'rule_claims_per_level'), 0) * MAX(0, g.level - 1)), g.created_at,
(SELECT COUNT(*) FROM guild_members gm WHERE gm.guild_id = g.id) as member_count,
(SELECT COUNT(*) FROM guild_claims gc WHERE gc.guild_id = g.id) as claims_count
FROM guilds g
@@ -1150,7 +1169,7 @@ pub async fn server_claim_chunk(
if !guild_can(&state, &guild_id, &payload.uuid, "claim").await? { return Err(AppError::forbidden("Your guild role cannot claim land")); }
let max_claims: i64 =
sqlx::query_scalar("SELECT (max_claims + COALESCE((SELECT SUM(b.claim_chunks) FROM player_bonuses b JOIN guild_members m ON m.uuid = b.uuid WHERE m.guild_id = guilds.id), 0)) FROM guilds WHERE id = ?").bind(&guild_id).fetch_one(&state.db).await.unwrap_or(16);
sqlx::query_scalar("SELECT (guilds.max_claims + COALESCE((SELECT SUM(b.claim_chunks) FROM player_bonuses b JOIN guild_members m ON m.uuid = b.uuid WHERE m.guild_id = guilds.id), 0) + COALESCE((SELECT CAST(value AS INTEGER) FROM kv WHERE key = 'rule_claims_per_member'), 0) * MAX(0, (SELECT COUNT(*) FROM guild_members m2 WHERE m2.guild_id = guilds.id) - 1) + COALESCE((SELECT CAST(value AS INTEGER) FROM kv WHERE key = 'rule_claims_per_level'), 0) * MAX(0, guilds.level - 1)) FROM guilds WHERE id = ?").bind(&guild_id).fetch_one(&state.db).await.unwrap_or(16);
let current_claims: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ?").bind(&guild_id).fetch_one(&state.db).await.unwrap_or(0);
@@ -1250,7 +1269,7 @@ pub async fn server_get_player_guild(
};
let guild_opt: Option<(String, String, String, String, String, i64, i64, i64)> =
sqlx::query_as("SELECT name, tag, description, motd, leader_uuid, level, xp, (max_claims + COALESCE((SELECT SUM(b.claim_chunks) FROM player_bonuses b JOIN guild_members m ON m.uuid = b.uuid WHERE m.guild_id = guilds.id), 0)) FROM guilds WHERE id = ?")
sqlx::query_as("SELECT name, tag, description, motd, leader_uuid, level, xp, (guilds.max_claims + COALESCE((SELECT SUM(b.claim_chunks) FROM player_bonuses b JOIN guild_members m ON m.uuid = b.uuid WHERE m.guild_id = guilds.id), 0) + COALESCE((SELECT CAST(value AS INTEGER) FROM kv WHERE key = 'rule_claims_per_member'), 0) * MAX(0, (SELECT COUNT(*) FROM guild_members m2 WHERE m2.guild_id = guilds.id) - 1) + COALESCE((SELECT CAST(value AS INTEGER) FROM kv WHERE key = 'rule_claims_per_level'), 0) * MAX(0, guilds.level - 1)) FROM guilds WHERE id = ?")
.bind(&guild_id)
.fetch_optional(&state.db)
.await?;
@@ -1325,15 +1344,17 @@ pub async fn server_create_guild(
let mut tx = state.db.begin().await?;
let base_claims = crate::progression::load(&mut tx).await?.rules.guild_base_claims;
let res = sqlx::query(
"INSERT INTO guilds (id, instance_id, name, tag, description, motd, leader_uuid, created_at)
VALUES (?, ?, ?, ?, '', 'Welcome to the guild!', ?, ?)",
"INSERT INTO guilds (id, instance_id, name, tag, description, motd, leader_uuid, max_claims, created_at)
VALUES (?, ?, ?, ?, '', 'Welcome to the guild!', ?, ?, ?)",
)
.bind(&guild_id)
.bind(&server.instance_id)
.bind(name)
.bind(tag)
.bind(&payload.uuid)
.bind(base_claims)
.bind(&now)
.execute(&mut *tx)
.await;
+1
View File
@@ -239,6 +239,7 @@ pub async fn respond(state: &AppState, uuid: &str, name: &str, invite: Option<i6
sqlx::query("UPDATE guild_invites SET status = ? WHERE id = ?").bind(if accept { "accepted" } else { "declined" }).bind(id).execute(&mut *tx).await?;
let (gname, gtag): (String, String) = sqlx::query_as("SELECT name, tag FROM guilds WHERE id = ?").bind(&guild_id).fetch_one(&mut *tx).await?;
if accept {
crate::routes::guilds::check_room(&mut tx, &guild_id).await?;
let res = sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES (?, ?, ?, 'member', ?)")
.bind(&guild_id)
.bind(uuid)