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

@@ -139,6 +139,30 @@ public record Settings(
essentials.enabled=true essentials.enabled=true
economy.enabled=true economy.enabled=true
# Essentials limits. Players can exceed max_homes with the permission scopenet.homes.<number>
# (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. # 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. # It only runs when the map is also enabled for this server in the panel.
map.enabled=true map.enabled=true
+50
View File
@@ -48,6 +48,32 @@ pub struct Progression {
pub global_xp_multiplier: f64, pub global_xp_multiplier: f64,
pub server_xp_multiplier: f64, pub server_xp_multiplier: f64,
pub xp_rates: XpRates, 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 { impl Default for Progression {
@@ -62,6 +88,7 @@ impl Default for Progression {
global_xp_multiplier: 1.0, global_xp_multiplier: 1.0,
server_xp_multiplier: 1.0, server_xp_multiplier: 1.0,
xp_rates: XpRates::default(), 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."))); 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; let r = &self.xp_rates;
for (name, v) in [ for (name, v) in [
("Playtime XP", r.playtime_per_hour), ("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)?) .bind(serde_json::to_string(p)?)
.execute(pool) .execute(pool)
.await?; .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(()) 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<()> { 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") let start = crate::progression::load(&mut **tx).await?.rules.starting_balance;
.bind(server_id).bind(uuid).bind(name).bind(chrono::Utc::now().to_rfc3339()).execute(&mut **tx).await?; 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(()) Ok(())
} }
@@ -237,7 +238,7 @@ pub async fn server_get_balance(
let balance = match bal_opt { let balance = match bal_opt {
Some(b) => b, Some(b) => b,
None => { None => {
let starting_balance = 1000.0; let starting_balance = crate::progression::load_pool(&state.db).await?.rules.starting_balance;
sqlx::query( sqlx::query(
"INSERT INTO server_economy (server_id, uuid, username, balance, updated_at) "INSERT INTO server_economy (server_id, uuid, username, balance, updated_at)
VALUES (?, ?, ?, ?, ?) 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")); } 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?; let (mut tx, previous) = begin_operation(&state, server.id, &payload.operation_id).await?;
if let Some(previous) = previous { return Ok(Json(previous)); } 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 now = chrono::Utc::now().to_rfc3339();
let guild_id = if payload.as_guild { 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"))?; 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, i64,
)> = sqlx::query_as( )> = sqlx::query_as(
"SELECT g.id, g.instance_id, g.name, g.tag, g.description, g.motd, g.leader_uuid, "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_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 (SELECT COUNT(*) FROM guild_claims gc WHERE gc.guild_id = g.id) as claims_count
FROM guilds g 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}))) 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> { 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)> = let row: Option<(String, String, String, String, String, String, String, Option<String>, Option<String>, i64, i64, i64, String)> =
sqlx::query_as( sqlx::query_as(
"SELECT id, instance_id, name, tag, description, motd, leader_uuid, "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 = ?", FROM guilds WHERE id = ?",
) )
.bind(guild_id) .bind(guild_id)
@@ -377,9 +389,10 @@ pub async fn create_guild(
let guild_id = format!("guild_{}", uuid::Uuid::new_v4().simple()); let guild_id = format!("guild_{}", uuid::Uuid::new_v4().simple());
let now = chrono::Utc::now().to_rfc3339(); let now = chrono::Utc::now().to_rfc3339();
let base_claims = crate::progression::load(&mut tx).await?.rules.guild_base_claims;
sqlx::query( sqlx::query(
"INSERT INTO guilds (id, instance_id, name, tag, description, motd, leader_uuid, icon_url, banner_url, level, xp, max_claims, created_at) "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(&guild_id)
.bind(&payload.instance_id) .bind(&payload.instance_id)
@@ -390,6 +403,7 @@ pub async fn create_guild(
.bind(&auth.uuid) .bind(&auth.uuid)
.bind(payload.icon_url) .bind(payload.icon_url)
.bind(payload.banner_url) .bind(payload.banner_url)
.bind(base_claims)
.bind(&now) .bind(&now)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .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?; .bind(&id).bind(&uuid).fetch_optional(&mut *tx).await?;
let name = name.ok_or_else(|| AppError::not_found("request not found"))?; let name = name.ok_or_else(|| AppError::not_found("request not found"))?;
if decision.accept { if decision.accept {
check_room(&mut tx, &id).await?;
sqlx::query("INSERT INTO guild_members(guild_id,uuid,name,role,joined_at) VALUES(?,?,?,'member',?)") 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"))?; .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?; 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 now = chrono::Utc::now().to_rfc3339();
{
let mut conn = state.db.acquire().await?;
check_room(&mut conn, &id).await?;
}
sqlx::query( sqlx::query(
"INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) "INSERT INTO guild_members (guild_id, uuid, name, role, joined_at)
VALUES (?, ?, ?, 'member', ?) VALUES (?, ?, ?, 'member', ?)
@@ -740,7 +759,7 @@ pub async fn claim_chunk(
return Err(AppError::forbidden("Your guild role cannot claim land")); 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 = let current_claims: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ?").bind(&guild_id).fetch_one(&state.db).await?; 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, i64,
)> = sqlx::query_as( )> = sqlx::query_as(
"SELECT g.id, g.instance_id, g.name, g.tag, g.description, g.motd, g.leader_uuid, "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_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 (SELECT COUNT(*) FROM guild_claims gc WHERE gc.guild_id = g.id) as claims_count
FROM guilds g 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")); } if !guild_can(&state, &guild_id, &payload.uuid, "claim").await? { return Err(AppError::forbidden("Your guild role cannot claim land")); }
let max_claims: i64 = 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 = 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); 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)> = 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) .bind(&guild_id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await?; .await?;
@@ -1325,15 +1344,17 @@ pub async fn server_create_guild(
let mut tx = state.db.begin().await?; let mut tx = state.db.begin().await?;
let base_claims = crate::progression::load(&mut tx).await?.rules.guild_base_claims;
let res = sqlx::query( let res = sqlx::query(
"INSERT INTO guilds (id, instance_id, name, tag, description, motd, leader_uuid, created_at) "INSERT INTO guilds (id, instance_id, name, tag, description, motd, leader_uuid, max_claims, created_at)
VALUES (?, ?, ?, ?, '', 'Welcome to the guild!', ?, ?)", VALUES (?, ?, ?, ?, '', 'Welcome to the guild!', ?, ?, ?)",
) )
.bind(&guild_id) .bind(&guild_id)
.bind(&server.instance_id) .bind(&server.instance_id)
.bind(name) .bind(name)
.bind(tag) .bind(tag)
.bind(&payload.uuid) .bind(&payload.uuid)
.bind(base_claims)
.bind(&now) .bind(&now)
.execute(&mut *tx) .execute(&mut *tx)
.await; .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?; 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?; 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 { 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', ?)") let res = sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES (?, ?, ?, 'member', ?)")
.bind(&guild_id) .bind(&guild_id)
.bind(uuid) .bind(uuid)
+37
View File
@@ -116,3 +116,40 @@ async fn a_rank_milestone_can_grant_more_than_its_title() {
t.call("POST", "/api/server/v1/player/xp", Some(&key), Some(json!({"operation_id": "lv2", "uuid": alex, "scope": "global", "amount": 100}))).await; t.call("POST", "/api/server/v1/player/xp", Some(&key), Some(json!({"operation_id": "lv2", "uuid": alex, "scope": "global", "amount": 100}))).await;
assert!(t.call("POST", "/api/server/v1/rewards/poll", Some(&key), Some(json!({}))).await.1["deliveries"].as_array().unwrap().is_empty()); assert!(t.call("POST", "/api/server/v1/rewards/poll", Some(&key), Some(json!({}))).await.1["deliveries"].as_array().unwrap().is_empty());
} }
#[tokio::test]
async fn admin_rules_change_balances_claims_and_membership() {
let t = setup().await;
let admin = t.login("admin", "supersecret").await;
for n in ["Leader", "Joiner", "Extra"] {
t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": n, "password": "password123"}))).await;
}
let leader = t.login("Leader", "password123").await;
let (_, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "SMP", "instance_id": "smp"}))).await;
let key = srv["token"].as_str().unwrap().to_string();
let (_, settings) = t.call("GET", "/api/admin/progression", Some(&admin), None).await;
let mut s = settings["settings"].clone();
s["rules"] = json!({"starting_balance": 250.0, "guild_base_claims": 10, "guild_claims_per_member": 3, "guild_claims_per_level": 0, "guild_max_members": 2, "market_max_listings": 1});
let (st, body) = t.call("PUT", "/api/admin/progression", Some(&admin), Some(s)).await;
assert_eq!(st, StatusCode::OK, "{body}");
// New players start with the configured balance.
let uuid = t.uuid("Leader").await;
let (_, bal) = t.call("POST", "/api/server/v1/economy/balance", Some(&key), Some(json!({"uuid": uuid, "username": "Leader"}))).await;
assert_eq!(bal["balance"].as_f64(), Some(250.0), "{bal}");
// A guild starts with the configured claims, grows with members, and stops at the member limit.
let (_, g) = t.call("POST", "/api/v1/guilds", Some(&leader), Some(json!({"instance_id": "smp", "name": "Iron", "tag": "IRON"}))).await;
let id = g["id"].as_str().unwrap().to_string();
let max = || async { t.call("GET", &format!("/api/v1/guilds/{id}"), Some(&leader), None).await.1["max_claims"].as_i64() };
assert_eq!(max().await, Some(10));
let add = |name: &str| {
let (t, leader, id, name) = (&t, leader.clone(), id.clone(), name.to_string());
async move { t.call("POST", &format!("/api/v1/guilds/{id}/members"), Some(&leader), Some(json!({"username": name}))).await }
};
let (st, body) = add("Joiner").await;
assert_eq!(st, StatusCode::OK, "{body}");
assert_eq!(max().await, Some(13), "+3 per member beyond the leader");
let (st, body) = add("Extra").await;
assert_eq!(st, StatusCode::BAD_REQUEST, "the guild is full at 2 members: {body}");
}
+1
View File
@@ -393,6 +393,7 @@ export interface ProgressionSettings {
global_xp_multiplier: number; global_xp_multiplier: number;
server_xp_multiplier: number; server_xp_multiplier: number;
xp_rates: { playtime_per_hour: number; player_kill: number; mob_kill: number; block_broken: number; block_placed: number; message: number }; xp_rates: { playtime_per_hour: number; player_kill: number; mob_kill: number; block_broken: number; block_placed: number; message: number };
rules: { starting_balance: number; guild_base_claims: number; guild_claims_per_member: number; guild_claims_per_level: number; guild_max_members: number; market_max_listings: number };
} }
export interface ProgressionView { export interface ProgressionView {
+14
View File
@@ -17,6 +17,7 @@
global_xp_multiplier: 1, global_xp_multiplier: 1,
server_xp_multiplier: 1, server_xp_multiplier: 1,
xp_rates: { playtime_per_hour: 600, player_kill: 50, mob_kill: 15, block_broken: 0.2, block_placed: 0.2, message: 2 }, xp_rates: { playtime_per_hour: 600, player_kill: 50, mob_kill: 15, block_broken: 0.2, block_placed: 0.2, message: 2 },
rules: { starting_balance: 1000, guild_base_claims: 16, guild_claims_per_member: 0, guild_claims_per_level: 0, guild_max_members: 0, market_max_listings: 0 },
}; };
let tab = $state<'quests' | 'xp' | 'players'>('quests'); let tab = $state<'quests' | 'xp' | 'players'>('quests');
@@ -264,6 +265,19 @@
</div> </div>
<p class="muted small">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.</p> <p class="muted small">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.</p>
</section> </section>
<section class="card">
<div class="section-title"><h2><Info size={16} /> Economy &amp; guild rules</h2></div>
<p class="muted small">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).</p>
<div class="rates">
<label class="field"><span class="lab">Starting balance <span class="unit">$</span></span><input type="number" min="0" step="50" bind:value={s.rules.starting_balance} /></label>
<label class="field"><span class="lab">Guild claims at creation <span class="unit">chunks</span></span><input type="number" min="0" step="1" bind:value={s.rules.guild_base_claims} /></label>
<label class="field"><span class="lab">Extra claims per member <span class="unit">chunks</span></span><input type="number" min="0" step="1" bind:value={s.rules.guild_claims_per_member} /></label>
<label class="field"><span class="lab">Extra claims per guild level <span class="unit">chunks</span></span><input type="number" min="0" step="1" bind:value={s.rules.guild_claims_per_level} /></label>
<label class="field"><span class="lab">Guild member limit <span class="unit">0 = none</span></span><input type="number" min="0" step="1" bind:value={s.rules.guild_max_members} /></label>
<label class="field"><span class="lab">Market listings per player <span class="unit">0 = none</span></span><input type="number" min="0" step="1" bind:value={s.rules.market_max_listings} /></label>
</div>
</section>
</div> </div>
{:else} {:else}
<section class="card"> <section class="card">