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:
8 files changed
+166
-12
No files matched your search
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user