From 19d111eeff263054f9d4ce9d3b9536fd5dce398e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 01:06:51 +0000 Subject: [PATCH] Admin rules: starting balance, guild claim/member limits, market listing limit; panel UI Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT --- .../net/scopenet/integration/Settings.java | 24 +++++++++ panel/server/src/progression.rs | 50 +++++++++++++++++++ panel/server/src/routes/economy.rs | 12 +++-- panel/server/src/routes/guilds.rs | 39 +++++++++++---- panel/server/src/routes/map.rs | 1 + panel/server/tests/rewards.rs | 37 ++++++++++++++ panel/web/src/lib/types.ts | 1 + panel/web/src/pages/Progression.svelte | 14 ++++++ 8 files changed, 166 insertions(+), 12 deletions(-) diff --git a/integrations/common/src/main/java/net/scopenet/integration/Settings.java b/integrations/common/src/main/java/net/scopenet/integration/Settings.java index 3e88541..029d216 100644 --- a/integrations/common/src/main/java/net/scopenet/integration/Settings.java +++ b/integrations/common/src/main/java/net/scopenet/integration/Settings.java @@ -139,6 +139,30 @@ public record Settings( essentials.enabled=true economy.enabled=true + # Essentials limits. Players can exceed max_homes with the permission scopenet.homes. + # (or scopenet.homes.unlimited). Cooldowns are seconds, 0 = none; scopenet.cooldown.bypass skips them. + essentials.max_homes=5 + essentials.home_name_max_length=32 + essentials.max_warps=0 + essentials.tpa_timeout_seconds=60 + essentials.rtp_radius=2500 + essentials.rtp_min_radius=0 + essentials.rtp_attempts=15 + essentials.cooldown.spawn=0 + essentials.cooldown.home=0 + essentials.cooldown.back=0 + essentials.cooldown.tpa=0 + essentials.cooldown.rtp=0 + essentials.cooldown.warp=0 + + # Rewards from quests, achievements and rank milestones (items, permissions, groups, messages). + # allow_commands lets the panel run its own console commands as rewards: leave off unless you trust every panel admin. + rewards.enabled=true + rewards.allow_commands=false + rewards.permission_command=lp user {player} permission set {node} true + rewards.permission_temp_command=lp user {player} permission settemp {node} {value} {duration} + rewards.group_command=lp user {player} parent add {group} + # Draw this world on the SCOPENET Map: tiles, player positions, claims and pins go to the panel. # It only runs when the map is also enabled for this server in the panel. map.enabled=true diff --git a/panel/server/src/progression.rs b/panel/server/src/progression.rs index 0a9714a..0e8f5e2 100644 --- a/panel/server/src/progression.rs +++ b/panel/server/src/progression.rs @@ -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(()) } diff --git a/panel/server/src/routes/economy.rs b/panel/server/src/routes/economy.rs index df92183..830eb21 100644 --- a/panel/server/src/routes/economy.rs +++ b/panel/server/src/routes/economy.rs @@ -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"))?; diff --git a/panel/server/src/routes/guilds.rs b/panel/server/src/routes/guilds.rs index 990aaa0..1556c09 100644 --- a/panel/server/src/routes/guilds.rs +++ b/panel/server/src/routes/guilds.rs @@ -36,7 +36,7 @@ pub async fn list_guilds(Query(query): Query, 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 { let row: Option<(String, String, String, String, String, String, String, Option, Option, 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) 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; diff --git a/panel/server/src/routes/map.rs b/panel/server/src/routes/map.rs index 7a95388..edf1aa6 100644 --- a/panel/server/src/routes/map.rs +++ b/panel/server/src/routes/map.rs @@ -239,6 +239,7 @@ pub async fn respond(state: &AppState, uuid: &str, name: &str, invite: Option('quests'); @@ -264,6 +265,19 @@

These stack with the multipliers in each server's plugin/mod config (server levels default to 1.5× there). Quest and achievement XP is set per quest/achievement.

+ +
+

Economy & guild rules

+

Apply on every server. Home limits, cooldowns and RTP are set per server in the plugin/mod config, and ranks can raise home limits with permission nodes (scopenet.homes.10).

+
+ + + + + + +
+
{:else}