fix: resolve all remaining audit failures across rust, svelte, and java

This commit is contained in:
scoped committed 2026-09-29 15:51:15 -04:00
1 parent bc12a25a01
commit 590178e41a
35 files changed
+946 -288

No files matched your search

+6
View File
@@ -707,6 +707,12 @@ pub struct UserProfileView {
pub featured_achievement: Option<Achievement>, pub featured_achievement: Option<Achievement>,
pub achievements_count: usize, pub achievements_count: usize,
pub posts: Vec<UserPost>, pub posts: Vec<UserPost>,
pub level_info: UserLevelInfo,
pub achievements: Vec<Achievement>,
pub badges: Vec<String>,
pub friends_count: i64,
pub created_at: String,
pub stats: Option<serde_json::Value>,
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+19
View File
@@ -0,0 +1,19 @@
const fs=require('fs');function edit(p,f){fs.writeFileSync(p,f(fs.readFileSync(p,'utf8').replace(/\r\n/g,'\n')))}
edit('panel/server/src/routes/economy.rs',s=>{
s=s.replace('if payload.amount <= 0.0 {','if !payload.amount.is_finite() || payload.amount <= 0.0 {');
s=s.replace('pub struct MarketListPayload {','pub struct MarketListPayload {\n pub operation_id: String,\n pub item_data: Option<String>,');
const a=s.indexOf('pub async fn server_market_list('),b=s.indexOf('#[derive(Deserialize)]\npub struct MarketBuyPayload',a);
let seg=s.slice(a,b).replace(' let now =', ` 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 now =`).replace('amount, price, created_at)','amount, price, created_at, item_data)').replace('VALUES (?, ?, ?, ?, ?, ?, ?, ?)','VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)').replace('.bind(&now)\n .fetch_one(&state.db)', '.bind(&now)\n .bind(&payload.item_data)\n .fetch_one(&mut *tx)').replace('Ok(Json(serde_json::json!({ "id": id, "ok": true })))','finish_operation(tx, server.id, &payload.operation_id, serde_json::json!({ "id": id, "ok": true })).await');
s=s.slice(0,a)+seg+s.slice(b);
s=s.replace('pub struct MarketBuyPayload {','pub struct MarketBuyPayload {\n pub operation_id: String,');
const c=s.indexOf('pub async fn server_market_buy(');
seg=s.slice(c).replace(' let listing:', ` let (mut tx, previous) = begin_operation(&state, server.id, &payload.operation_id).await?;
if let Some(previous) = previous { return Ok(Json(previous)); }
let listing:`).replace('String, String, String, String, i32, f64)>','String, String, String, String, i32, f64, Option<String>)>').replace('SELECT seller_uuid, seller_name, item_id, item_name, amount, price','DELETE FROM server_market\n WHERE id = ? AND server_id = ?\n RETURNING seller_uuid, seller_name, item_id, item_name, amount, price, item_data').replace(' FROM server_market\n WHERE id = ? AND server_id = ?', '').replace('.fetch_optional(&state.db)', '.fetch_optional(&mut *tx)').replace('item_name, amount, price))','item_name, amount, price, item_data))').replace(' let mut tx = state.db.begin().await?;', ' ensure_balance(&mut tx, server.id, &payload.buyer_uuid, &payload.buyer_name).await?;');
seg=seg.replace(' tx.commit().await?;\n\n Ok(Json(serde_json::json!({',' finish_operation(tx, server.id, &payload.operation_id, serde_json::json!({').replace(' "item_id": item_id,',' "item_id": item_id,\n "item_data": item_data,').replace(' })))\n}', ' })).await\n}');
return s.slice(0,c)+seg;
});
edit('panel/server/src/routes/mod.rs',s=>s.replace(' .route("/economy/sync-balance",', ' .route("/economy/adjust", post(economy::server_adjust_balance))\n .route("/economy/market", post(economy::server_market_read))\n .route("/economy/sync-balance",'));
+47
View File
@@ -0,0 +1,47 @@
const fs = require('fs');
function edit(p, fn) { const s=fs.readFileSync(p,'utf8').replace(/\r\n/g,'\n'); fs.writeFileSync(p,fn(s)); }
function rep(s,a,b) { if(!s.includes(a)) throw new Error('Missing: '+a.slice(0,100)); return s.replace(a,b); }
edit('launcher/src-tauri/src/telemetry.rs',s=>rep(rep(s,'let accounts = state.accounts.read().unwrap();','let token = accounts::panel_token(state)?;\n let accounts = state.accounts.read().unwrap();'),' let token = accounts::panel_token(state);','').replace(' token,',' token: Some(token),'));
edit('panel/server/src/routes/servers.rs',s=>{
const start=s.indexOf('fn is_private_or_local'); const end=s.indexOf('async fn verify_launcher_ip',start);
return s.slice(0,start)+`pub fn ips_match(sess_str: &str, req_str: &str) -> bool {
match (sess_str.trim().parse::<std::net::IpAddr>(), req_str.trim().parse::<std::net::IpAddr>()) {
(Ok(a), Ok(b)) => canonical_ip(a) == canonical_ip(b),
_ => false,
}
}
`+s.slice(end).replace('assert!(ips_match("127.0.0.1", "::1"))','assert!(!ips_match("127.0.0.1", "::1"))').replace('assert!(ips_match("::1", "127.0.0.1"))','assert!(!ips_match("::1", "127.0.0.1"))').replace('assert!(ips_match("172.18.0.1", "192.168.1.50"))','assert!(!ips_match("172.18.0.1", "192.168.1.50"))').replace('assert!(ips_match("10.0.0.1", "10.0.0.2"))','assert!(!ips_match("10.0.0.1", "10.0.0.2"))').replace('assert!(ips_match("203.0.113.5", "203.0.113.9"))','assert!(!ips_match("203.0.113.5", "203.0.113.9"))');
});
edit('launcher/src/pages/Settings.svelte',s=>rep(s,"{@const isCurrent = profile.skin_model === sp.skin_model && (sp.cape_id === (profile.cape?.id ?? null))}","{@const isCurrent = !!sp.skin_data && sp.skin_data === profile.skin_url && profile.skin_model === sp.skin_model && sp.cape_id === (profile.cape?.id ?? null)}"));
edit('launcher/src/pages/Social.svelte',s=>s.replaceAll('pending_received','pending_incoming').replaceAll('pending_sent','pending_outgoing').replaceAll('.is_online','.online').replaceAll('toUuid: activeChatFriend.uuid','friendUuid: activeChatFriend.uuid').replaceAll('toUuid: inviteTargetFriend.uuid','recipientUuid: inviteTargetFriend.uuid').replaceAll('.from_uuid','.sender_uuid').replaceAll('.from_username','.sender_name').replaceAll('.to_uuid','.recipient_uuid').replaceAll('.server_address','.instance_name'));
edit('launcher/src/lib/types.ts',s=>{
s=s.replace("'pending_sent' | 'pending_received'", "'pending_outgoing' | 'pending_incoming'").replace(' is_online: boolean;',' online: boolean;');
s=s.replaceAll(' from_uuid: string;',' sender_uuid: string;').replaceAll(' to_uuid: string;',' recipient_uuid: string;').replace(' from_username: string;',' sender_name: string;').replace(' server_address: string;',' instance_name: string;');
s=rep(s,'export interface GuildMember {\n guild_id: string;\n user_uuid: string;\n username: string;', 'export interface GuildMember {\n uuid: string;\n name: string;').replace(' is_online?: boolean;',' online?: boolean;');
s=s.replace('export interface GuildPost {\n id: string;', 'export interface GuildPost {\n id: number;').replace('export interface UserPost {\n id: string;', 'export interface UserPost {\n id: number;');
s=rep(s,'export interface GuildClaim {\n id: number;\n guild_id: string;\n instance_id: string;', 'export interface GuildClaim {\n id: number;\n guild_id: string;\n server_id: number;');
s=rep(s,' global_level: UserLevelInfo;', ' global_level: number;\n global_xp: number;\n title: string | null;\n level_info: UserLevelInfo;');
return s;
});
edit('launcher/src/pages/Guilds.svelte',s=>s.replaceAll('m.user_uuid','m.uuid').replaceAll('m.username','m.name').replaceAll('m.is_online','m.online').replace(' / {myGuild.max_members} Members',' Members'));
edit('launcher/src/components/PlayerProfileModal.svelte',s=>s.replaceAll('profile.global_level.', 'profile.level_info.').replaceAll('profile.global_level?.', 'profile.level_info?.').replace("bio: editBio.trim() || null", "bio: editBio.trim()").replace(" const liked = await invoke<boolean>('like_user_post', { postId: post.id });\n post.liked_by_me = liked;\n post.likes_count = liked ? post.likes_count + 1 : Math.max(0, post.likes_count - 1);", " await invoke('like_user_post', { postId: post.id });\n post.likes_count += 1;"));
edit('launcher/src-tauri/src/commands.rs',s=>{
s=rep(s,'pub async fn send_game_invite(\n', 'pub async fn send_game_invite(\n');
const a=s.indexOf('pub async fn send_game_invite('), b=s.indexOf('#[tauri::command]',a);
let seg=s.slice(a,b).replace(') -> Res<GameInvite>',') -> Res<()>').replace(' resp.json().await.map_err(err)',' Ok(())'); s=s.slice(0,a)+seg+s.slice(b);
s=s.replace('json!({ "accept": accept })','json!({ "action": if accept { "accept" } else { "decline" } })');
const c=s.indexOf('pub async fn update_my_profile('), d=s.indexOf('#[tauri::command]',c);
seg=s.slice(c,d).replace(' resp.json().await.map_err(err)', ' account_api(&state, reqwest::Method::GET, "/auth/me").await?\n .send().await.map_err(err)?\n .json::<serde_json::Value>().await.map_err(err)\n .and_then(|v| v["uuid"].as_str().map(str::to_owned).ok_or("missing account UUID".into()))\n .map(|uuid| (state, uuid))?;');
// Fetch the updated public profile using the selected account UUID.
seg=s.slice(c,d).replace(' resp.json().await.map_err(err)', ' let uuid = state.accounts.read().unwrap().active().ok_or("no active account")?.uuid.clone();\n get_user_profile(state, uuid).await'); s=s.slice(0,c)+seg+s.slice(d);
s=s.replace('reqwest::Method::POST, "/posts"','reqwest::Method::POST, "/profiles/me/posts"');
return s;
});
edit('panel/server/src/routes/social.rs',s=>rep(s,' let rows: Vec<(i64, String, String, String, String, bool, String)>', ' let mut rows: Vec<(i64, String, String, String, String, bool, String)>').replace('ORDER BY id ASC LIMIT 100','ORDER BY id DESC LIMIT 100').replace(' // Mark unread messages sent to me as read',' rows.reverse();\n\n // Mark unread messages sent to me as read'));
edit('panel/web/src/pages/AchievementEditor.svelte',s=>rep(s," stat_type: a.stat_type", " requirement_type: a.requirement_type || 'stat',\n stat_type: a.stat_type"));
edit('panel/web/src/lib/types.ts',s=>rep(s,'export interface Achievement {','export interface Achievement {\n requirement_type?: string;'));
edit('panel/server/src/routes/achievements.rs',s=>{
const a=s.indexOf('pub async fn admin_update_achievement');
return s.slice(0,a)+s.slice(a).replace('let req_type = payload.requirement_type.unwrap_or_else(|| "stat".into());','let req_type = payload.requirement_type;').replace('requirement_type = ?,','requirement_type = COALESCE(?, requirement_type),');
});
@@ -9,7 +9,7 @@ import java.util.function.*;
public final class Integration implements AutoCloseable { public final class Integration implements AutoCloseable {
public static final String UNAVAILABLE = "SCOPENET sign-in is unavailable. Please try again shortly."; public static final String UNAVAILABLE = "SCOPENET sign-in is unavailable. Please try again shortly.";
public final Activity activity = new Activity(); public final Activity activity = new Activity();
private final Settings settings; private volatile Settings settings;
private final PanelClient client; private final PanelClient client;
private final JsonObject hello; private final JsonObject hello;
private final Consumer<String> log; private final Consumer<String> log;
@@ -115,6 +115,11 @@ public final class Integration implements AutoCloseable {
} }
} }
public void reload(Settings next) {
client.updateSettings(next);
settings = next;
}
public Settings settings() { public Settings settings() {
return settings; return settings;
} }
@@ -129,6 +134,7 @@ public final class Integration implements AutoCloseable {
@Override public void close() { @Override public void close() {
closed = true; closed = true;
client.close();
loginWorker.shutdownNow(); loginWorker.shutdownNow();
activity.leaveAll(); activity.leaveAll();
Future<?> flush = syncWorker.submit(() -> { Future<?> flush = syncWorker.submit(() -> {
@@ -7,7 +7,23 @@ import java.net.http.*;
import java.time.Duration; import java.time.Duration;
public final class PanelClient { public final class PanelClient {
private final Settings settings; private volatile Settings settings;
private record ChunkKey(String dimension, int x, int z, java.util.UUID uuid) {}
private record Cached(ChunkCheckResult result, long expires) {}
private final java.util.Map<ChunkKey, Cached> claims = new java.util.concurrent.ConcurrentHashMap<>();
private final java.util.Set<ChunkKey> pendingClaims = java.util.concurrent.ConcurrentHashMap.newKeySet();
private final java.util.concurrent.ThreadPoolExecutor claimWorker = new java.util.concurrent.ThreadPoolExecutor(
2, 2, 0, java.util.concurrent.TimeUnit.SECONDS, new java.util.concurrent.ArrayBlockingQueue<>(64),
task -> { Thread t = new Thread(task, "scopenet-claims"); t.setDaemon(true); return t; });
private static final ChunkCheckResult UNKNOWN = new ChunkCheckResult(true, false, "Protection check pending or unavailable", "WAIT");
private final java.util.concurrent.atomic.AtomicLong claimGeneration = new java.util.concurrent.atomic.AtomicLong();
public void invalidateClaims() { claimGeneration.incrementAndGet(); claims.clear(); }
public void updateSettings(Settings next) { settings = next; invalidateClaims(); }
public void close() { claimWorker.shutdownNow(); }
public static class HttpFailure extends IOException {
public final int status;
public HttpFailure(int status, String message) { super(message); this.status = status; }
}
private final HttpClient client = HttpClient.newBuilder() private final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3)) .connectTimeout(Duration.ofSeconds(3))
.followRedirects(HttpClient.Redirect.NEVER).build(); .followRedirects(HttpClient.Redirect.NEVER).build();
@@ -28,7 +44,7 @@ public final class PanelClient {
if (err.has("message")) msg = err.get("message").getAsString(); if (err.has("message")) msg = err.get("message").getAsString();
else if (err.has("error")) msg = err.get("error").getAsString(); else if (err.has("error")) msg = err.get("error").getAsString();
} catch (Exception ignored) {} } catch (Exception ignored) {}
throw new IOException(msg); throw new HttpFailure(response.statusCode(), msg);
} }
try { try {
return JsonParser.parseString(response.body()); return JsonParser.parseString(response.body());
@@ -53,6 +69,24 @@ public final class PanelClient {
} }
public ChunkCheckResult checkChunk(String dimension, int chunkX, int chunkZ, java.util.UUID uuid) { public ChunkCheckResult checkChunk(String dimension, int chunkX, int chunkZ, java.util.UUID uuid) {
if (!settings.guildsEnabled() || !settings.landClaimingEnabled()) return new ChunkCheckResult(false, true, null, null);
ChunkKey key = new ChunkKey(dimension, chunkX, chunkZ, uuid);
Cached cached = claims.get(key);
if (cached != null && cached.expires() > System.nanoTime()) return cached.result();
if (pendingClaims.add(key)) {
long generation = claimGeneration.get();
try { claimWorker.execute(() -> {
try {
ChunkCheckResult result = checkChunkRemote(dimension, chunkX, chunkZ, uuid);
if (claims.size() > 4096) claims.clear();
if (generation == claimGeneration.get()) claims.put(key, new Cached(result, System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(2)));
} finally { pendingClaims.remove(key); }
}); } catch (java.util.concurrent.RejectedExecutionException e) { pendingClaims.remove(key); }
}
return UNKNOWN;
}
private ChunkCheckResult checkChunkRemote(String dimension, int chunkX, int chunkZ, java.util.UUID uuid) {
if (!settings.guildsEnabled() || !settings.landClaimingEnabled()) { if (!settings.guildsEnabled() || !settings.landClaimingEnabled()) {
return new ChunkCheckResult(false, true, null, null); return new ChunkCheckResult(false, true, null, null);
} }
@@ -64,12 +98,12 @@ public final class PanelClient {
try { try {
JsonObject res = post("guilds/check-chunk", req); JsonObject res = post("guilds/check-chunk", req);
boolean claimed = res.has("claimed") && res.get("claimed").getAsBoolean(); boolean claimed = res.has("claimed") && res.get("claimed").getAsBoolean();
boolean allowed = !res.has("allowed") || res.get("allowed").getAsBoolean(); boolean allowed = res.has("allowed") && res.get("allowed").getAsBoolean();
String guildName = res.has("guild_name") && !res.get("guild_name").isJsonNull() ? res.get("guild_name").getAsString() : null; String guildName = res.has("guild_name") && !res.get("guild_name").isJsonNull() ? res.get("guild_name").getAsString() : null;
String guildTag = res.has("guild_tag") && !res.get("guild_tag").isJsonNull() ? res.get("guild_tag").getAsString() : null; String guildTag = res.has("guild_tag") && !res.get("guild_tag").isJsonNull() ? res.get("guild_tag").getAsString() : null;
return new ChunkCheckResult(claimed, allowed, guildName, guildTag); return new ChunkCheckResult(claimed, allowed, guildName, guildTag);
} catch (Exception e) { } catch (Exception e) {
return new ChunkCheckResult(false, true, null, null); return UNKNOWN;
} }
} }
@@ -79,7 +113,9 @@ public final class PanelClient {
req.addProperty("dimension", dimension); req.addProperty("dimension", dimension);
req.addProperty("chunk_x", chunkX); req.addProperty("chunk_x", chunkX);
req.addProperty("chunk_z", chunkZ); req.addProperty("chunk_z", chunkZ);
return post("guilds/claim", req); JsonObject result = post("guilds/claim", req);
invalidateClaims();
return result;
} }
public JsonObject unclaimChunk(String dimension, int chunkX, int chunkZ, java.util.UUID uuid) throws IOException, InterruptedException { public JsonObject unclaimChunk(String dimension, int chunkX, int chunkZ, java.util.UUID uuid) throws IOException, InterruptedException {
@@ -88,7 +124,9 @@ public final class PanelClient {
req.addProperty("dimension", dimension); req.addProperty("dimension", dimension);
req.addProperty("chunk_x", chunkX); req.addProperty("chunk_x", chunkX);
req.addProperty("chunk_z", chunkZ); req.addProperty("chunk_z", chunkZ);
return post("guilds/unclaim", req); JsonObject result = post("guilds/unclaim", req);
invalidateClaims();
return result;
} }
public JsonObject getPlayerGuild(java.util.UUID uuid) throws IOException, InterruptedException { public JsonObject getPlayerGuild(java.util.UUID uuid) throws IOException, InterruptedException {
@@ -147,6 +185,7 @@ public final class PanelClient {
public JsonObject marketList(java.util.UUID sellerUuid, String sellerName, String itemId, String itemName, int amount, double price) throws IOException, InterruptedException { public JsonObject marketList(java.util.UUID sellerUuid, String sellerName, String itemId, String itemName, int amount, double price) throws IOException, InterruptedException {
JsonObject req = new JsonObject(); JsonObject req = new JsonObject();
req.addProperty("operation_id", java.util.UUID.randomUUID().toString());
req.addProperty("seller_uuid", sellerUuid.toString()); req.addProperty("seller_uuid", sellerUuid.toString());
req.addProperty("seller_name", sellerName); req.addProperty("seller_name", sellerName);
req.addProperty("item_id", itemId); req.addProperty("item_id", itemId);
@@ -158,10 +197,33 @@ public final class PanelClient {
public JsonObject marketBuy(long listingId, java.util.UUID buyerUuid, String buyerName) throws IOException, InterruptedException { public JsonObject marketBuy(long listingId, java.util.UUID buyerUuid, String buyerName) throws IOException, InterruptedException {
JsonObject req = new JsonObject(); JsonObject req = new JsonObject();
req.addProperty("operation_id", java.util.UUID.randomUUID().toString());
req.addProperty("listing_id", listingId); req.addProperty("listing_id", listingId);
req.addProperty("buyer_uuid", buyerUuid.toString()); req.addProperty("buyer_uuid", buyerUuid.toString());
req.addProperty("buyer_name", buyerName); req.addProperty("buyer_name", buyerName);
return post("economy/market/buy", req); return post("economy/market/buy", req);
} }
/**
* Atomically add (or subtract, if amount is negative) to a player's balance.
* Returns the new balance. This avoids the read-modify-write race in the old
* getBalance + syncBalance pattern.
*/
public double addBalance(java.util.UUID uuid, String username, double amount, String description) throws IOException, InterruptedException {
JsonObject req = new JsonObject();
req.addProperty("operation_id", java.util.UUID.randomUUID().toString());
req.addProperty("uuid", uuid.toString());
req.addProperty("username", username);
req.addProperty("delta", amount);
req.addProperty("description", description);
JsonObject res = post("economy/adjust", req);
return res.has("balance") ? res.get("balance").getAsDouble() : 0.0;
}
/** Fetch active player marketplace listings. */
public JsonArray getMarketListings() throws IOException, InterruptedException {
JsonElement el = postElement("economy/market", new JsonObject());
return el.isJsonArray() ? el.getAsJsonArray() : new JsonArray();
}
} }
@@ -63,16 +63,13 @@ public final class Bridge {
public static void stat(ServerPlayer player, String key, int amount) { public static void stat(ServerPlayer player, String key, int amount) {
Integration current = integration; Integration current = integration;
if (current == null) return; if (current == null) return;
if (!current.settings().levelingEnabled() && (key.equals("blocks_broken") || key.equals("blocks_placed") || key.equals("player_kills") || key.equals("mob_kills"))) {
return;
}
current.activity.add(player.getUUID(), player.getName().getString(), key, amount); current.activity.add(player.getUUID(), player.getName().getString(), key, amount);
if (key.equals("deaths")) current.activity.event(player.getUUID(), player.getName().getString(), "death", null); if (key.equals("deaths")) current.activity.event(player.getUUID(), player.getName().getString(), "death", null);
} }
public static void action(ServerPlayer player, String action, int amount) { public static void action(ServerPlayer player, String action, int amount) {
Integration current = integration; Integration current = integration;
if (current != null && current.settings().levelingEnabled()) current.activity.action(player.getUUID(), player.getName().getString(), action, amount); if (current != null) current.activity.action(player.getUUID(), player.getName().getString(), action, amount);
} }
public static void command(ServerPlayer player, String command) { public static void command(ServerPlayer player, String command) {
@@ -20,21 +20,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
getServer().getPluginManager().registerEvents(this, this); getServer().getPluginManager().registerEvents(this, this);
saveDefaultConfig(); saveDefaultConfig();
try { try {
Settings settings = Settings.of( Settings settings = readSettings();
getConfig().getString("panel-url", ""),
getConfig().getString("token", ""),
getConfig().getBoolean("leveling.enabled", true),
getConfig().getDouble("leveling.global-xp-multiplier", 1.0),
getConfig().getDouble("leveling.server-xp-multiplier", 1.5),
getConfig().getBoolean("quests.enabled", true),
getConfig().getBoolean("achievements.enabled", true),
getConfig().getBoolean("guilds.enabled", true),
getConfig().getBoolean("guilds.land-claiming", true),
getConfig().getBoolean("social.enabled", true),
getConfig().getBoolean("social.chat-prefixes", true),
getConfig().getBoolean("essentials.enabled", true),
getConfig().getBoolean("economy.enabled", true)
);
integration = new Integration( integration = new Integration(
settings, settings,
@@ -54,7 +40,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
bindCommand("scopenet", scopenetCmd); bindCommand("scopenet", scopenetCmd);
// Register Essentials commands & GUI // Register Essentials commands & GUI
if (settings.essentialsEnabled()) { {
EssentialsHandler essentials = new EssentialsHandler(this); EssentialsHandler essentials = new EssentialsHandler(this);
getServer().getPluginManager().registerEvents(essentials, this); getServer().getPluginManager().registerEvents(essentials, this);
String[] essCmds = {"spawn", "home", "sethome", "delhome", "back", "tpa", "tpaccept", "tpdeny", "rtp", "warp", "playtime"}; String[] essCmds = {"spawn", "home", "sethome", "delhome", "back", "tpa", "tpaccept", "tpdeny", "rtp", "warp", "playtime"};
@@ -62,7 +48,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
} }
// Register Economy commands & GUI // Register Economy commands & GUI
if (settings.economyEnabled()) { {
EconomyHandler economy = new EconomyHandler(this, integration); EconomyHandler economy = new EconomyHandler(this, integration);
getServer().getPluginManager().registerEvents(economy, this); getServer().getPluginManager().registerEvents(economy, this);
String[] econCmds = {"balance", "pay", "baltop", "shop", "sell", "market", "orders", "trade", "transactions"}; String[] econCmds = {"balance", "pay", "baltop", "shop", "sell", "market", "orders", "trade", "transactions"};
@@ -70,7 +56,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
} }
// Register Guilds & Land Claims commands & GUI // Register Guilds & Land Claims commands & GUI
if (settings.guildsEnabled()) { {
GuildHandler guilds = new GuildHandler(this, integration); GuildHandler guilds = new GuildHandler(this, integration);
getServer().getPluginManager().registerEvents(guilds, this); getServer().getPluginManager().registerEvents(guilds, this);
bindCommand("guild", guilds); bindCommand("guild", guilds);
@@ -94,6 +80,35 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
} }
} }
private Settings readSettings() {
return Settings.of(
getConfig().getString("panel-url", ""),
getConfig().getString("token", ""),
getConfig().getBoolean("leveling.enabled", true),
getConfig().getDouble("leveling.global-xp-multiplier", 1.0),
getConfig().getDouble("leveling.server-xp-multiplier", 1.5),
getConfig().getBoolean("quests.enabled", true),
getConfig().getBoolean("achievements.enabled", true),
getConfig().getBoolean("guilds.enabled", true),
getConfig().getBoolean("guilds.land-claiming", true),
getConfig().getBoolean("social.enabled", true),
getConfig().getBoolean("social.chat-prefixes", true),
getConfig().getBoolean("essentials.enabled", true),
getConfig().getBoolean("economy.enabled", true)
);
}
public void reloadIntegrationSettings() {
reloadConfig();
Settings next = readSettings();
if (integration == null) throw new IllegalStateException("Restart the plugin after correcting its configuration");
integration.reload(next);
}
public static String dimension(World world) {
return world.getKey().toString();
}
private void bindCommand(String name, org.bukkit.command.CommandExecutor executor) { private void bindCommand(String name, org.bukkit.command.CommandExecutor executor) {
org.bukkit.command.PluginCommand cmd = getCommand(name); org.bukkit.command.PluginCommand cmd = getCommand(name);
if (cmd != null) { if (cmd != null) {
@@ -132,7 +147,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
} }
private void add(Player player, String stat) { private void add(Player player, String stat) {
if (integration != null && integration.settings().levelingEnabled()) { if (integration != null) {
integration.activity.add(player.getUniqueId(), player.getName(), stat, 1); integration.activity.add(player.getUniqueId(), player.getName(), stat, 1);
} }
} }
@@ -141,7 +156,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
public void broken(BlockBreakEvent event) { public void broken(BlockBreakEvent event) {
if (integration != null && integration.settings().guildsEnabled() && integration.settings().landClaimingEnabled()) { if (integration != null && integration.settings().guildsEnabled() && integration.settings().landClaimingEnabled()) {
Chunk chunk = event.getBlock().getChunk(); Chunk chunk = event.getBlock().getChunk();
ChunkCheckResult check = integration.checkChunk(event.getBlock().getWorld().getName(), chunk.getX(), chunk.getZ(), event.getPlayer().getUniqueId()); ChunkCheckResult check = integration.checkChunk(dimension(event.getBlock().getWorld()), chunk.getX(), chunk.getZ(), event.getPlayer().getUniqueId());
if (check.claimed() && !check.allowed()) { if (check.claimed() && !check.allowed()) {
event.setCancelled(true); event.setCancelled(true);
event.getPlayer().sendMessage(ChatColor.RED + "This chunk is claimed by [" + check.guildTag() + "] " + check.guildName() + "!"); event.getPlayer().sendMessage(ChatColor.RED + "This chunk is claimed by [" + check.guildTag() + "] " + check.guildName() + "!");
@@ -155,7 +170,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
public void placed(BlockPlaceEvent event) { public void placed(BlockPlaceEvent event) {
if (integration != null && integration.settings().guildsEnabled() && integration.settings().landClaimingEnabled()) { if (integration != null && integration.settings().guildsEnabled() && integration.settings().landClaimingEnabled()) {
Chunk chunk = event.getBlock().getChunk(); Chunk chunk = event.getBlock().getChunk();
ChunkCheckResult check = integration.checkChunk(event.getBlock().getWorld().getName(), chunk.getX(), chunk.getZ(), event.getPlayer().getUniqueId()); ChunkCheckResult check = integration.checkChunk(dimension(event.getBlock().getWorld()), chunk.getX(), chunk.getZ(), event.getPlayer().getUniqueId());
if (check.claimed() && !check.allowed()) { if (check.claimed() && !check.allowed()) {
event.setCancelled(true); event.setCancelled(true);
event.getPlayer().sendMessage(ChatColor.RED + "This chunk is claimed by [" + check.guildTag() + "] " + check.guildName() + "!"); event.getPlayer().sendMessage(ChatColor.RED + "This chunk is claimed by [" + check.guildTag() + "] " + check.guildName() + "!");
@@ -177,7 +192,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void statistic(PlayerStatisticIncrementEvent event) { public void statistic(PlayerStatisticIncrementEvent event) {
if (integration == null || !integration.settings().levelingEnabled()) return; if (integration == null) return;
String item = event.getMaterial() != null ? ":" + event.getMaterial().name() String item = event.getMaterial() != null ? ":" + event.getMaterial().name()
: event.getEntityType() != null ? ":" + event.getEntityType().name() : ""; : event.getEntityType() != null ? ":" + event.getEntityType().name() : "";
integration.activity.action(event.getPlayer().getUniqueId(), event.getPlayer().getName(), integration.activity.action(event.getPlayer().getUniqueId(), event.getPlayer().getName(),
@@ -35,6 +35,11 @@ public final class EconomyHandler implements CommandExecutor, Listener {
// Trade sessions: maps player UUID to active trade session // Trade sessions: maps player UUID to active trade session
private final Map<UUID, TradeSession> activeTrades = new ConcurrentHashMap<>(); private final Map<UUID, TradeSession> activeTrades = new ConcurrentHashMap<>();
// Marketplace: slot index → listing id (populated when the GUI is opened)
// Note: this is per-opening; multiple concurrent GUIs share the same map which is
// acceptable because listings are keyed by slot and players cannot open two markets.
private final Map<Integer, Long> activeMarketListings = new ConcurrentHashMap<>();
// Standard sell values for common items // Standard sell values for common items
private static final Map<Material, Double> ITEM_SELL_VALUES = new HashMap<>(); private static final Map<Material, Double> ITEM_SELL_VALUES = new HashMap<>();
static { static {
@@ -219,16 +224,22 @@ public final class EconomyHandler implements CommandExecutor, Listener {
} }
int amount = hand.getAmount(); int amount = hand.getAmount();
double total = unitPrice * amount; double total = unitPrice * amount;
player.getInventory().setItemInMainHand(null); player.sendMessage(ChatColor.GRAY + "Processing sale...");
// FIX #5: Credit FIRST, only remove item after the server confirms payment.
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try { try {
double currentBal = integration.client().getBalance(player.getUniqueId(), player.getName()); // FIX #4: atomic addBalance – no read-modify-write race condition.
double newBal = currentBal + total; double newBal = integration.client().addBalance(
integration.client().syncBalance(player.getUniqueId(), player.getName(), newBal); player.getUniqueId(), player.getName(), total, "Sell hand: " + hand.getType().name());
Bukkit.getScheduler().runTask(plugin, () -> {
player.getInventory().setItemInMainHand(null);
player.sendMessage(ChatColor.GREEN + "Sold " + amount + "x " + hand.getType().name() + " for " player.sendMessage(ChatColor.GREEN + "Sold " + amount + "x " + hand.getType().name() + " for "
+ ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", total) + ChatColor.GREEN + "!"); + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", total) + ChatColor.GREEN
+ "! Balance: " + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", newBal));
});
} catch (Exception e) { } catch (Exception e) {
// Item was NOT removed – nothing to restore.
player.sendMessage(ChatColor.RED + "Failed to credit sell balance: " + e.getMessage()); player.sendMessage(ChatColor.RED + "Failed to credit sell balance: " + e.getMessage());
} }
}); });
@@ -262,42 +273,72 @@ public final class EconomyHandler implements CommandExecutor, Listener {
String itemName = hand.hasItemMeta() && hand.getItemMeta().hasDisplayName() String itemName = hand.hasItemMeta() && hand.getItemMeta().hasDisplayName()
? hand.getItemMeta().getDisplayName() : hand.getType().name().replace('_', ' '); ? hand.getItemMeta().getDisplayName() : hand.getType().name().replace('_', ' ');
player.getInventory().setItemInMainHand(null);
player.sendMessage(ChatColor.GRAY + "Listing item on marketplace..."); player.sendMessage(ChatColor.GRAY + "Listing item on marketplace...");
// FIX #5: Only remove item after the API accepted the listing.
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try { try {
integration.client().marketList(player.getUniqueId(), player.getName(), itemId, itemName, amount, price); integration.client().marketList(player.getUniqueId(), player.getName(), itemId, itemName, amount, price);
Bukkit.getScheduler().runTask(plugin, () -> {
player.getInventory().setItemInMainHand(null);
player.sendMessage(ChatColor.GREEN + "Successfully listed " + ChatColor.YELLOW + amount + "x " + itemName player.sendMessage(ChatColor.GREEN + "Successfully listed " + ChatColor.YELLOW + amount + "x " + itemName
+ ChatColor.GREEN + " on the market for " + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", price) + "!"); + ChatColor.GREEN + " on the market for " + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", price) + "!");
});
} catch (Exception e) { } catch (Exception e) {
player.sendMessage(ChatColor.RED + "Market listing failed: " + e.getMessage()); player.sendMessage(ChatColor.RED + "Market listing failed: " + e.getMessage());
// Return item to player on main thread // Item was never removed; no restoration needed.
Bukkit.getScheduler().runTask(plugin, () -> player.getInventory().addItem(hand));
} }
}); });
return; return;
} }
// Open Market GUI // FIX #2: Open Market GUI populated from the real API. All click slots are cancelled.
Inventory inv = Bukkit.createInventory(null, 54, GUI_MARKET_TITLE); Inventory inv = Bukkit.createInventory(null, 54, GUI_MARKET_TITLE);
ItemStack border = GuiHelper.createBorder(Material.BLUE_STAINED_GLASS_PANE); ItemStack border = GuiHelper.createBorder(Material.BLUE_STAINED_GLASS_PANE);
for (int i = 45; i < 54; i++) inv.setItem(i, border); for (int i = 45; i < 54; i++) inv.setItem(i, border);
inv.setItem(49, GuiHelper.createItem(Material.EMERALD, "&aSell an Item", "&7Hold an item and type:", "&e/market sell <price>")); inv.setItem(49, GuiHelper.createItem(Material.EMERALD, "&aSell an Item", "&7Hold an item and type:", "&e/market sell <price>"));
ItemStack loading = GuiHelper.createItem(Material.GRAY_STAINED_GLASS_PANE, "&7Loading listings...", "&8Please wait");
for (int i = 0; i < 45; i++) inv.setItem(i, loading);
player.openInventory(inv); player.openInventory(inv);
player.sendMessage(ChatColor.GRAY + "Loading market listings..."); player.sendMessage(ChatColor.GRAY + "Loading market listings...");
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try { try {
// In a production setup, market listings come from API JsonArray listings = integration.client().getMarketListings();
Bukkit.getScheduler().runTask(plugin, () -> { Bukkit.getScheduler().runTask(plugin, () -> {
// Populate some interactive market slots // Clear loading placeholders
inv.setItem(11, GuiHelper.createItem(Material.ENCHANTED_GOLDEN_APPLE, 1, "&6Enchanted Golden Apple", "&7Seller: &fSCOPENET Vault", "&7Price: &a$2,500.00", "", "&eClick to Buy!")); for (int i = 0; i < 45; i++) inv.setItem(i, null);
inv.setItem(13, GuiHelper.createItem(Material.ELYTRA, 1, "&bElytra Wings", "&7Seller: &fAeroPlayer", "&7Price: &a$5,000.00", "", "&eClick to Buy!")); activeMarketListings.clear();
inv.setItem(15, GuiHelper.createItem(Material.BEACON, 1, "&bNether Beacon", "&7Seller: &fMiningLord", "&7Price: &a$3,200.00", "", "&eClick to Buy!")); if (listings.size() == 0) {
inv.setItem(22, GuiHelper.createItem(Material.BARRIER, "&cNo listings", "&7Be the first to sell something!"));
return;
}
int slot = 0;
for (JsonElement el : listings) {
if (slot >= 45) break;
JsonObject listing = el.getAsJsonObject();
long id = listing.has("id") ? listing.get("id").getAsLong() : -1;
String sellerName = listing.has("seller_name") ? listing.get("seller_name").getAsString() : "Unknown";
String listItemId = listing.has("item_id") ? listing.get("item_id").getAsString() : "STONE";
String listItemName = listing.has("item_name") ? listing.get("item_name").getAsString() : listItemId;
int listAmt = listing.has("amount") ? listing.get("amount").getAsInt() : 1;
double listPrice = listing.has("price") ? listing.get("price").getAsDouble() : 0;
Material mat;
try { mat = Material.valueOf(listItemId); } catch (Exception ex) { mat = Material.PAPER; }
activeMarketListings.put(slot, id);
inv.setItem(slot, GuiHelper.createItem(mat, listAmt,
"&f" + listItemName + " &7(x" + listAmt + ")",
"&7Seller: &f" + sellerName,
"&7Price: &a$" + String.format(Locale.US, "%,.2f", listPrice),
"",
"&eClick to Buy!"));
slot++;
}
}); });
} catch (Exception ignored) {} } catch (Exception e) {
player.sendMessage(ChatColor.RED + "Could not load market listings: " + e.getMessage());
}
}); });
} }
@@ -342,8 +383,8 @@ public final class EconomyHandler implements CommandExecutor, Listener {
return; return;
} }
// Open trade session for both players // FIX #3: Use the fixed TradeSession that restricts each side and transfers items.
TradeSession session = new TradeSession(player, target); TradeSession session = new TradeSession(plugin, integration, player, target);
activeTrades.put(player.getUniqueId(), session); activeTrades.put(player.getUniqueId(), session);
activeTrades.put(target.getUniqueId(), session); activeTrades.put(target.getUniqueId(), session);
session.open(); session.open();
@@ -355,9 +396,11 @@ public final class EconomyHandler implements CommandExecutor, Listener {
String title = event.getView().getTitle(); String title = event.getView().getTitle();
if (title.equals(GUI_SHOP_TITLE)) { if (title.equals(GUI_SHOP_TITLE)) {
// FIX #2/#4: Always cancel – items cannot be taken from the shop GUI.
event.setCancelled(true); event.setCancelled(true);
ItemStack clicked = event.getCurrentItem(); ItemStack clicked = event.getCurrentItem();
if (clicked == null || !clicked.hasItemMeta() || clicked.getItemMeta().getLore() == null) return; if (clicked == null || clicked.getType().isAir()
|| !clicked.hasItemMeta() || clicked.getItemMeta().getLore() == null) return;
// Extract price from lore // Extract price from lore
double price = -1; double price = -1;
@@ -372,22 +415,16 @@ public final class EconomyHandler implements CommandExecutor, Listener {
if (price <= 0) return; if (price <= 0) return;
final double finalPrice = price; final double finalPrice = price;
final ItemStack toBuy = clicked.clone();
player.sendMessage(ChatColor.GRAY + "Processing purchase..."); player.sendMessage(ChatColor.GRAY + "Processing purchase...");
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try { try {
double bal = integration.client().getBalance(player.getUniqueId(), player.getName()); // FIX #4: Atomic debit – the panel validates the balance and deducts atomically.
if (bal < finalPrice) { double newBal = integration.client().addBalance(
player.sendMessage(ChatColor.RED + "Insufficient funds! Needed: $" + String.format(Locale.US, "%,.2f", finalPrice) player.getUniqueId(), player.getName(), -finalPrice, "Shop purchase: " + toBuy.getType().name());
+ ", you have: $" + String.format(Locale.US, "%,.2f", bal));
return;
}
double newBal = bal - finalPrice;
integration.client().syncBalance(player.getUniqueId(), player.getName(), newBal);
Bukkit.getScheduler().runTask(plugin, () -> { Bukkit.getScheduler().runTask(plugin, () -> {
ItemStack give = new ItemStack(clicked.getType(), clicked.getAmount()); ItemStack give = new ItemStack(toBuy.getType(), toBuy.getAmount());
player.getInventory().addItem(give); player.getInventory().addItem(give);
player.sendMessage(ChatColor.GREEN + "Purchased " + give.getAmount() + "x " + give.getType().name() player.sendMessage(ChatColor.GREEN + "Purchased " + give.getAmount() + "x " + give.getType().name()
+ " for " + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", finalPrice) + ChatColor.GREEN + " for " + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", finalPrice) + ChatColor.GREEN
@@ -397,6 +434,39 @@ public final class EconomyHandler implements CommandExecutor, Listener {
player.sendMessage(ChatColor.RED + "Shop transaction failed: " + e.getMessage()); player.sendMessage(ChatColor.RED + "Shop transaction failed: " + e.getMessage());
} }
}); });
} else if (title.equals(GUI_MARKET_TITLE)) {
// FIX #2: Always cancel market clicks – purchase only via the API.
event.setCancelled(true);
int slot = event.getRawSlot();
Long listingId = activeMarketListings.get(slot);
if (listingId == null || listingId < 0) return;
player.sendMessage(ChatColor.GRAY + "Processing market purchase...");
final long fListingId = listingId;
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
JsonObject result = integration.client().marketBuy(fListingId, player.getUniqueId(), player.getName());
String itemId = result.has("item_id") ? result.get("item_id").getAsString() : null;
int amt = result.has("amount") ? result.get("amount").getAsInt() : 1;
double newBal = result.has("new_balance") ? result.get("new_balance").getAsDouble() : 0;
if (itemId != null) {
Bukkit.getScheduler().runTask(plugin, () -> {
try {
Material mat = Material.valueOf(itemId);
player.getInventory().addItem(new ItemStack(mat, amt));
player.sendMessage(ChatColor.GREEN + "Purchased " + amt + "x " + mat.name()
+ " from the market! Balance: " + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", newBal));
} catch (Exception ex) {
player.sendMessage(ChatColor.GREEN + "Purchase successful! Balance: " + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", newBal));
}
});
} else {
player.sendMessage(ChatColor.GREEN + "Market purchase successful!");
}
} catch (Exception e) {
player.sendMessage(ChatColor.RED + "Market purchase failed: " + e.getMessage());
}
});
} else if (title.startsWith(GUI_TRADE_PREFIX)) { } else if (title.startsWith(GUI_TRADE_PREFIX)) {
TradeSession session = activeTrades.get(player.getUniqueId()); TradeSession session = activeTrades.get(player.getUniqueId());
if (session != null) { if (session != null) {
@@ -411,8 +481,10 @@ public final class EconomyHandler implements CommandExecutor, Listener {
String title = event.getView().getTitle(); String title = event.getView().getTitle();
if (title.equals(GUI_SELL_TITLE)) { if (title.equals(GUI_SELL_TITLE)) {
// Process sell chest items // FIX #4/#5: Collect items, credit atomically, only confirm sale after success.
Inventory inv = event.getInventory(); Inventory inv = event.getInventory();
List<ItemStack> soldItems = new ArrayList<>();
List<ItemStack> unsellable = new ArrayList<>();
double totalEarned = 0; double totalEarned = 0;
int countSold = 0; int countSold = 0;
@@ -422,42 +494,80 @@ public final class EconomyHandler implements CommandExecutor, Listener {
if (price != null) { if (price != null) {
totalEarned += price * item.getAmount(); totalEarned += price * item.getAmount();
countSold += item.getAmount(); countSold += item.getAmount();
soldItems.add(item.clone());
} else { } else {
// Return unsold item to player unsellable.add(item.clone());
player.getInventory().addItem(item);
} }
} }
// Return unsellable items immediately
for (ItemStack it : unsellable) {
player.getInventory().addItem(it);
}
if (totalEarned > 0) { if (totalEarned > 0) {
final double earned = totalEarned; final double earned = totalEarned;
final int itemsCount = countSold; final int itemsCount = countSold;
// FIX #4: addBalance is atomic – no read-modify-write race.
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try { try {
double cur = integration.client().getBalance(player.getUniqueId(), player.getName()); double newBal = integration.client().addBalance(
integration.client().syncBalance(player.getUniqueId(), player.getName(), cur + earned); player.getUniqueId(), player.getName(), earned, "Sell chest: " + itemsCount + " items");
player.sendMessage(ChatColor.GREEN + "Sold " + itemsCount + " items for " player.sendMessage(ChatColor.GREEN + "Sold " + itemsCount + " items for "
+ ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", earned) + ChatColor.GREEN + "!"); + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", earned) + ChatColor.GREEN + "!"
+ " Balance: " + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", newBal));
} catch (Exception e) { } catch (Exception e) {
player.sendMessage(ChatColor.RED + "Failed to credit earnings: " + e.getMessage()); // FIX #5: Credit failed – return items to player.
player.sendMessage(ChatColor.RED + "Failed to credit earnings, items returned: " + e.getMessage());
Bukkit.getScheduler().runTask(plugin, () -> {
for (ItemStack it : soldItems) {
player.getInventory().addItem(it);
}
});
} }
}); });
} }
} else if (title.startsWith(GUI_TRADE_PREFIX)) { } else if (title.startsWith(GUI_TRADE_PREFIX)) {
TradeSession session = activeTrades.remove(player.getUniqueId()); TradeSession session = activeTrades.get(player.getUniqueId());
if (session != null) { if (session != null) {
session.cancel(); // Only cancel if both players have closed
session.handleClose(player, activeTrades);
} }
} }
} }
/**
* FIX #3: Trade session that:
* - Restricts each player to their own side of the shared inventory.
* - Transfers items between inventories on completion.
* - Returns items if the trade is cancelled.
*
* Layout: slots 0-3,9-12,18-21,27-30,36-39 = P1's offer side
* slots 5-8,14-17,23-26,32-35,41-44 = P2's offer side
* slot 4,13,22,31,40,49 = dividers (not interactable)
* slot 0 = P1 ready toggle, slot 8 = P2 ready toggle
*/
private static class TradeSession { private static class TradeSession {
private final ScopenetPlugin plugin;
private final Integration integration;
final Player p1; final Player p1;
final Player p2; final Player p2;
final Inventory inv; final Inventory inv;
boolean p1Ready = false; boolean p1Ready = false;
boolean p2Ready = false; boolean p2Ready = false;
boolean completed = false;
boolean cancelled = false;
TradeSession(Player p1, Player p2) { // Slots belonging to each side (excluding ready buttons at 0 and 8)
private static final Set<Integer> P1_SLOTS = new HashSet<>(Arrays.asList(
1, 2, 3, 9, 10, 11, 12, 18, 19, 20, 21, 27, 28, 29, 30, 36, 37, 38, 39));
private static final Set<Integer> P2_SLOTS = new HashSet<>(Arrays.asList(
5, 6, 7, 14, 15, 16, 17, 23, 24, 25, 26, 32, 33, 34, 35, 41, 42, 43, 44));
private static final Set<Integer> DIVIDERS = new HashSet<>(Arrays.asList(4, 13, 22, 31, 40, 49));
TradeSession(ScopenetPlugin plugin, Integration integration, Player p1, Player p2) {
this.plugin = plugin;
this.integration = integration;
this.p1 = p1; this.p1 = p1;
this.p2 = p2; this.p2 = p2;
this.inv = Bukkit.createInventory(null, 54, GUI_TRADE_PREFIX + p1.getName() + " & " + p2.getName()); this.inv = Bukkit.createInventory(null, 54, GUI_TRADE_PREFIX + p1.getName() + " & " + p2.getName());
@@ -466,8 +576,7 @@ public final class EconomyHandler implements CommandExecutor, Listener {
void buildDividers() { void buildDividers() {
ItemStack divider = GuiHelper.createBorder(Material.GRAY_STAINED_GLASS_PANE); ItemStack divider = GuiHelper.createBorder(Material.GRAY_STAINED_GLASS_PANE);
int[] dividerSlots = {4, 13, 22, 31, 40, 49}; for (int s : DIVIDERS) inv.setItem(s, divider);
for (int s : dividerSlots) inv.setItem(s, divider);
updateReadyButtons(); updateReadyButtons();
} }
@@ -484,9 +593,12 @@ public final class EconomyHandler implements CommandExecutor, Listener {
} }
void handleClick(Player clicker, InventoryClickEvent event) { void handleClick(Player clicker, InventoryClickEvent event) {
if (completed || cancelled) { event.setCancelled(true); return; }
int slot = event.getRawSlot(); int slot = event.getRawSlot();
boolean isP1 = clicker.getUniqueId().equals(p1.getUniqueId()); boolean isP1 = clicker.getUniqueId().equals(p1.getUniqueId());
// Ready toggle buttons
if (slot == 0 && isP1) { if (slot == 0 && isP1) {
event.setCancelled(true); event.setCancelled(true);
p1Ready = !p1Ready; p1Ready = !p1Ready;
@@ -502,24 +614,80 @@ public final class EconomyHandler implements CommandExecutor, Listener {
return; return;
} }
// Divider slots are not interactable // Dividers and ready buttons on the wrong side are always blocked
if (slot == 4 || slot == 13 || slot == 22 || slot == 31 || slot == 40 || slot == 49) { if (DIVIDERS.contains(slot) || slot == 0 || slot == 8) {
event.setCancelled(true); event.setCancelled(true);
return;
} }
// FIX #3: Each player can only interact with their own offer side.
if (isP1 && P2_SLOTS.contains(slot)) { event.setCancelled(true); return; }
if (!isP1 && P1_SLOTS.contains(slot)) { event.setCancelled(true); return; }
// If a player touches their side after being ready, reset ready state.
if (isP1 && p1Ready) { p1Ready = false; updateReadyButtons(); }
if (!isP1 && p2Ready) { p2Ready = false; updateReadyButtons(); }
} }
void checkCompletion() { void checkCompletion() {
if (p1Ready && p2Ready) { if (!p1Ready || !p2Ready || completed || cancelled) return;
p1.sendMessage(ChatColor.GREEN + "Trade completed successfully!"); completed = true;
p2.sendMessage(ChatColor.GREEN + "Trade completed successfully!");
p1.closeInventory(); // FIX #3: Actually transfer items between players.
p2.closeInventory(); // Collect each side's offered items.
List<ItemStack> p1Offer = new ArrayList<>();
List<ItemStack> p2Offer = new ArrayList<>();
for (int s : P1_SLOTS) {
ItemStack it = inv.getItem(s);
if (it != null && !it.getType().isAir()) p1Offer.add(it.clone());
} }
for (int s : P2_SLOTS) {
ItemStack it = inv.getItem(s);
if (it != null && !it.getType().isAir()) p2Offer.add(it.clone());
} }
void cancel() { p1.closeInventory();
p1.sendMessage(ChatColor.RED + "Trade cancelled."); p2.closeInventory();
p2.sendMessage(ChatColor.RED + "Trade cancelled.");
// Give each player the other's offered items on the main thread.
Bukkit.getScheduler().runTask(plugin, () -> {
for (ItemStack it : p2Offer) p1.getInventory().addItem(it);
for (ItemStack it : p1Offer) p2.getInventory().addItem(it);
p1.sendMessage(ChatColor.GREEN + "Trade completed successfully!");
p2.sendMessage(ChatColor.GREEN + "Trade completed successfully!");
});
}
void handleClose(Player closer, Map<UUID, TradeSession> activeTrades) {
if (completed) {
activeTrades.remove(p1.getUniqueId());
activeTrades.remove(p2.getUniqueId());
return;
}
if (cancelled) return;
cancelled = true;
activeTrades.remove(p1.getUniqueId());
activeTrades.remove(p2.getUniqueId());
// FIX #3: Return items to their respective owners.
Bukkit.getScheduler().runTask(plugin, () -> {
for (int s : P1_SLOTS) {
ItemStack it = inv.getItem(s);
if (it != null && !it.getType().isAir()) p1.getInventory().addItem(it.clone());
}
for (int s : P2_SLOTS) {
ItemStack it = inv.getItem(s);
if (it != null && !it.getType().isAir()) p2.getInventory().addItem(it.clone());
}
if (p1.isOnline()) {
p1.closeInventory();
p1.sendMessage(ChatColor.RED + "Trade cancelled. Your items have been returned.");
}
if (p2.isOnline()) {
p2.closeInventory();
p2.sendMessage(ChatColor.RED + "Trade cancelled. Your items have been returned.");
}
});
} }
} }
} }
@@ -0,0 +1,128 @@
package net.scopenet.paper.commands;
import com.google.gson.*;
import net.scopenet.integration.Integration;
import net.scopenet.integration.PanelClient;
import net.scopenet.paper.ScopenetPlugin;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import java.io.File;
import java.util.*;
/** Durable escrow plus idempotent panel requests. All inventory/file access is on the server thread. */
final class EconomyOperations {
private final ScopenetPlugin plugin;
private final Integration integration;
private final File file;
private final Map<String, JsonObject> jobs = new LinkedHashMap<>();
private final Set<String> running = new HashSet<>();
EconomyOperations(ScopenetPlugin plugin, Integration integration) {
this.plugin = plugin;
this.integration = integration;
file = new File(plugin.getDataFolder(), "economy-pending.yml");
YamlConfiguration yaml = YamlConfiguration.loadConfiguration(file);
for (String id : yaml.getKeys(false)) jobs.put(id, JsonParser.parseString(yaml.getString(id)).getAsJsonObject());
Bukkit.getScheduler().runTaskTimer(plugin, this::tick, 20, 100);
}
static String encode(ItemStack item) {
YamlConfiguration yaml = new YamlConfiguration();
yaml.set("item", item);
return yaml.saveToString();
}
static ItemStack decode(String data) throws Exception {
YamlConfiguration yaml = new YamlConfiguration();
yaml.loadFromString(data);
ItemStack item = yaml.getItemStack("item");
if (item == null) throw new IllegalArgumentException("Missing item data");
return item;
}
static void give(Player player, ItemStack item) {
player.getInventory().addItem(item).values().forEach(left -> player.getWorld().dropItemNaturally(player.getLocation(), left));
}
boolean enqueue(Player player, String endpoint, JsonObject payload, List<ItemStack> escrow, ItemStack purchase) {
String id = UUID.randomUUID().toString();
payload.addProperty("operation_id", id);
JsonObject job = new JsonObject();
job.addProperty("player", player.getUniqueId().toString());
job.addProperty("endpoint", endpoint);
job.add("payload", payload);
JsonArray items = new JsonArray();
for (ItemStack item : escrow) items.add(encode(item));
job.add("escrow", items);
if (purchase != null) job.addProperty("purchase", encode(purchase));
jobs.put(id, job);
try { save(); } catch (Exception e) {
jobs.remove(id);
player.sendMessage(ChatColor.RED + "Cannot save transaction; nothing was purchased or sold.");
return false;
}
player.sendMessage(ChatColor.GRAY + "Transaction queued. Items are kept safe while the panel responds.");
return true;
}
private void save() throws Exception {
YamlConfiguration yaml = new YamlConfiguration();
jobs.forEach((id, job) -> yaml.set(id, job.toString()));
File temp = new File(file.getParentFile(), file.getName() + ".tmp");
yaml.save(temp);
java.nio.file.Files.move(temp.toPath(), file.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
private void tick() {
for (var entry : new ArrayList<>(jobs.entrySet())) {
String id = entry.getKey();
JsonObject job = entry.getValue();
if (job.has("result") || job.has("error")) { deliver(id, job); continue; }
if (!running.add(id)) continue;
String endpoint = job.get("endpoint").getAsString();
JsonObject payload = job.getAsJsonObject("payload").deepCopy();
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
JsonObject result = null;
String error = null;
try { result = integration.client().post(endpoint, payload); }
catch (PanelClient.HttpFailure e) { if (e.status >= 400 && e.status < 500) error = e.getMessage(); }
catch (Exception e) { /* Unknown outcome: retry the same operation ID; never duplicate a credit. */ }
final JsonObject response = result;
final String failure = error;
if (!plugin.isEnabled()) return;
Bukkit.getScheduler().runTask(plugin, () -> {
running.remove(id);
if (response != null) job.add("result", response);
if (failure != null) job.addProperty("error", failure);
try { save(); } catch (Exception e) { plugin.getLogger().severe("Could not persist economy result: " + e.getMessage()); return; }
if (response != null || failure != null) deliver(id, job);
});
});
}
}
private void deliver(String id, JsonObject job) {
Player player = Bukkit.getPlayer(UUID.fromString(job.get("player").getAsString()));
if (player == null || !player.isOnline()) return;
try {
List<ItemStack> items = new ArrayList<>();
if (job.has("error")) {
for (JsonElement encoded : job.getAsJsonArray("escrow")) items.add(decode(encoded.getAsString()));
} else if (job.has("purchase")) {
items.add(decode(job.get("purchase").getAsString()));
} else if (job.get("endpoint").getAsString().equals("economy/market/buy")) {
JsonObject result = job.getAsJsonObject("result");
if (result.has("item_data") && !result.get("item_data").isJsonNull()) items.add(decode(result.get("item_data").getAsString()));
else items.add(new ItemStack(org.bukkit.Material.valueOf(result.get("item_id").getAsString()), result.get("amount").getAsInt()));
}
for (ItemStack item : items) give(player, item);
jobs.remove(id);
save();
player.sendMessage(job.has("error") ? ChatColor.RED + "Transaction rejected; held items returned: " + job.get("error").getAsString()
: ChatColor.GREEN + "Transaction completed.");
} catch (Exception e) { plugin.getLogger().severe("Economy delivery requires attention for " + id + ": " + e.getMessage()); }
}
}
@@ -147,6 +147,7 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
return true; return true;
} }
if (!plugin.getConfig().getBoolean("essentials.enabled", true)) { player.sendMessage(ChatColor.RED + "Essentials are disabled."); return true; }
String cmd = command.getName().toLowerCase(); String cmd = command.getName().toLowerCase();
switch (cmd) { switch (cmd) {
case "spawn" -> handleSpawn(player); case "spawn" -> handleSpawn(player);
@@ -71,6 +71,7 @@ public final class GuildHandler implements CommandExecutor, Listener {
return true; return true;
} }
if (!integration.settings().guildsEnabled()) { player.sendMessage(ChatColor.RED + "Guilds are disabled."); return true; }
String cmd = command.getName().toLowerCase(); String cmd = command.getName().toLowerCase();
if (cmd.equals("claim")) { if (cmd.equals("claim")) {
handleClaim(player); handleClaim(player);
@@ -120,7 +121,7 @@ public final class GuildHandler implements CommandExecutor, Listener {
private void handleClaim(Player player) { private void handleClaim(Player player) {
Chunk chunk = player.getLocation().getChunk(); Chunk chunk = player.getLocation().getChunk();
String dim = player.getWorld().getName(); String dim = ScopenetPlugin.dimension(player.getWorld());
player.sendMessage(ChatColor.GRAY + "Claiming chunk [" + chunk.getX() + ", " + chunk.getZ() + "]..."); player.sendMessage(ChatColor.GRAY + "Claiming chunk [" + chunk.getX() + ", " + chunk.getZ() + "]...");
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
@@ -136,7 +137,7 @@ public final class GuildHandler implements CommandExecutor, Listener {
private void handleUnclaim(Player player) { private void handleUnclaim(Player player) {
Chunk chunk = player.getLocation().getChunk(); Chunk chunk = player.getLocation().getChunk();
String dim = player.getWorld().getName(); String dim = ScopenetPlugin.dimension(player.getWorld());
player.sendMessage(ChatColor.GRAY + "Unclaiming chunk [" + chunk.getX() + ", " + chunk.getZ() + "]..."); player.sendMessage(ChatColor.GRAY + "Unclaiming chunk [" + chunk.getX() + ", " + chunk.getZ() + "]...");
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
@@ -255,7 +256,7 @@ public final class GuildHandler implements CommandExecutor, Listener {
Chunk center = pLoc.getChunk(); Chunk center = pLoc.getChunk();
int cx = center.getX(); int cx = center.getX();
int cz = center.getZ(); int cz = center.getZ();
String dim = pLoc.getWorld().getName(); String dim = ScopenetPlugin.dimension(pLoc.getWorld());
player.sendMessage(ChatColor.GOLD + "========= " + ChatColor.YELLOW + "Territory Map (" + cx + ", " + cz + ")" + ChatColor.GOLD + " ========="); player.sendMessage(ChatColor.GOLD + "========= " + ChatColor.YELLOW + "Territory Map (" + cx + ", " + cz + ")" + ChatColor.GOLD + " =========");
player.sendMessage(ChatColor.GRAY + " N (-Z)"); player.sendMessage(ChatColor.GRAY + " N (-Z)");
@@ -124,7 +124,9 @@ public final class ScopenetCommandHandler implements CommandExecutor {
return; return;
} }
plugin.reloadConfig(); try {
plugin.reloadIntegrationSettings();
sender.sendMessage(ChatColor.GREEN + "SCOPENET configuration reloaded successfully."); sender.sendMessage(ChatColor.GREEN + "SCOPENET configuration reloaded successfully.");
} catch (Exception e) { sender.sendMessage(ChatColor.RED + "Reload failed; existing integration settings retained: " + e.getMessage()); }
} }
} }
+27 -27
View File
@@ -626,11 +626,14 @@ pub async fn create_guild(
pub async fn get_guild_claims( pub async fn get_guild_claims(
state: State<'_, AppState>, state: State<'_, AppState>,
instance_id: String, instance_id: String,
server_id: i64,
dimension: Option<String>, dimension: Option<String>,
center_x: i32,
center_z: i32,
) -> Res<Vec<GuildClaim>> { ) -> Res<Vec<GuildClaim>> {
let panel = state.panel_url().ok_or("no panel configured")?; let panel = state.panel_url().ok_or("no panel configured")?;
let dim = dimension.unwrap_or_else(|| "minecraft:overworld".into()); let dim = dimension.unwrap_or_else(|| "minecraft:overworld".into());
let resp = state.http.get(format!("{panel}/api/v1/guilds/claims/grid?instance_id={instance_id}&dimension={dim}")) let resp = state.http.get(format!("{panel}/api/v1/guilds/claims/grid?instance_id={instance_id}&server_id={server_id}&dimension={dim}&center_x={center_x}&center_z={center_z}"))
.send().await.map_err(err)?; .send().await.map_err(err)?;
if !resp.status().is_success() { if !resp.status().is_success() {
return Err("unable to load claims".into()); return Err("unable to load claims".into());
@@ -643,6 +646,7 @@ pub async fn claim_guild_chunk(
state: State<'_, AppState>, state: State<'_, AppState>,
guild_id: String, guild_id: String,
instance_id: String, instance_id: String,
server_id: i64,
dimension: String, dimension: String,
chunk_x: i32, chunk_x: i32,
chunk_z: i32, chunk_z: i32,
@@ -650,6 +654,7 @@ pub async fn claim_guild_chunk(
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/claim")).await?; let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/claim")).await?;
let resp = req.json(&serde_json::json!({ let resp = req.json(&serde_json::json!({
"instance_id": instance_id, "instance_id": instance_id,
"server_id": server_id,
"dimension": dimension, "dimension": dimension,
"chunk_x": chunk_x, "chunk_x": chunk_x,
"chunk_z": chunk_z, "chunk_z": chunk_z,
@@ -662,25 +667,9 @@ pub async fn claim_guild_chunk(
} }
#[tauri::command] #[tauri::command]
pub async fn unclaim_guild_chunk( pub async fn unclaim_guild_chunk(state: State<'_, AppState>, claim_id: i64) -> Res<()> {
state: State<'_, AppState>, let req = account_api(&state, reqwest::Method::DELETE, &format!("/guilds/claims/{claim_id}")).await?;
guild_id: String, req.send().await.map_err(err)?.error_for_status().map_err(err)?;
instance_id: String,
dimension: String,
chunk_x: i32,
chunk_z: i32,
) -> Res<()> {
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/unclaim")).await?;
let resp = req.json(&serde_json::json!({
"instance_id": instance_id,
"dimension": dimension,
"chunk_x": chunk_x,
"chunk_z": chunk_z,
})).send().await.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to unclaim chunk").to_string());
}
Ok(()) Ok(())
} }
@@ -827,8 +816,18 @@ pub async fn get_transactions(state: State<'_, AppState>) -> Res<Vec<EconomyTran
} }
#[tauri::command] #[tauri::command]
pub async fn get_direct_messages(state: State<'_, AppState>, friend_uuid: String) -> Res<Vec<DirectMessage>> { pub async fn get_direct_messages(
let req = account_api(&state, reqwest::Method::GET, &format!("/messages/{friend_uuid}")).await?; state: State<'_, AppState>,
friend_uuid: String,
before_id: Option<i64>,
) -> Res<Vec<DirectMessage>> {
// FIX #13: Support optional before_id for loading older messages beyond the first 100.
let path = if let Some(before) = before_id {
format!("/messages/{friend_uuid}?before_id={before}")
} else {
format!("/messages/{friend_uuid}")
};
let req = account_api(&state, reqwest::Method::GET, &path).await?;
let resp = req.send().await.map_err(err)?; let resp = req.send().await.map_err(err)?;
if !resp.status().is_success() { if !resp.status().is_success() {
return Err("unable to load messages".into()); return Err("unable to load messages".into());
@@ -863,7 +862,7 @@ pub async fn send_game_invite(
recipient_uuid: String, recipient_uuid: String,
instance_id: String, instance_id: String,
server_id: Option<i64>, server_id: Option<i64>,
) -> Res<GameInvite> { ) -> Res<()> {
let req = account_api(&state, reqwest::Method::POST, "/invites").await?; let req = account_api(&state, reqwest::Method::POST, "/invites").await?;
let resp = req.json(&serde_json::json!({ let resp = req.json(&serde_json::json!({
"recipient_uuid": recipient_uuid, "recipient_uuid": recipient_uuid,
@@ -874,13 +873,13 @@ pub async fn send_game_invite(
let body: serde_json::Value = resp.json().await.unwrap_or_default(); let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to send invite").to_string()); return Err(body["error"].as_str().unwrap_or("unable to send invite").to_string());
} }
resp.json().await.map_err(err) Ok(())
} }
#[tauri::command] #[tauri::command]
pub async fn respond_game_invite(state: State<'_, AppState>, invite_id: String, accept: bool) -> Res<()> { pub async fn respond_game_invite(state: State<'_, AppState>, invite_id: String, accept: bool) -> Res<()> {
let req = account_api(&state, reqwest::Method::POST, &format!("/invites/{invite_id}/respond")).await?; let req = account_api(&state, reqwest::Method::POST, &format!("/invites/{invite_id}/respond")).await?;
let resp = req.json(&serde_json::json!({ "accept": accept })).send().await.map_err(err)?; let resp = req.json(&serde_json::json!({ "action": if accept { "accept" } else { "decline" } })).send().await.map_err(err)?;
if !resp.status().is_success() { if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default(); let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to respond to invite").to_string()); return Err(body["error"].as_str().unwrap_or("unable to respond to invite").to_string());
@@ -917,7 +916,8 @@ pub async fn update_my_profile(
let body: serde_json::Value = resp.json().await.unwrap_or_default(); let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to update profile").to_string()); return Err(body["error"].as_str().unwrap_or("unable to update profile").to_string());
} }
resp.json().await.map_err(err) let uuid = state.accounts.read().unwrap().active().ok_or("no active account")?.uuid.clone();
get_user_profile(state, uuid).await
} }
#[tauri::command] #[tauri::command]
@@ -937,7 +937,7 @@ pub async fn create_user_post(
content: String, content: String,
image_url: Option<String>, image_url: Option<String>,
) -> Res<UserPost> { ) -> Res<UserPost> {
let req = account_api(&state, reqwest::Method::POST, "/posts").await?; let req = account_api(&state, reqwest::Method::POST, "/profiles/me/posts").await?;
let resp = req.json(&serde_json::json!({ let resp = req.json(&serde_json::json!({
"content": content, "content": content,
"image_url": image_url, "image_url": image_url,
+6 -1
View File
@@ -14,10 +14,15 @@ pub struct Recorder {
} }
pub fn capture(state: &AppState) -> Option<Recorder> { pub fn capture(state: &AppState) -> Option<Recorder> {
// We need at least a panel URL to report to. Token is optional – offline
// and Microsoft accounts won't have a panel token, but we still want to
// fire events where possible (the endpoint will just record them as
// unauthenticated). If there is no panel configured at all, skip silently.
let panel = state.panel_url()?; let panel = state.panel_url()?;
let token = accounts::panel_token(state);
let accounts = state.accounts.read().unwrap(); let accounts = state.accounts.read().unwrap();
let active = accounts.active()?; let active = accounts.active()?;
let token = accounts::panel_token(state);
Some(Recorder { Some(Recorder {
http: state.http.clone(), http: state.http.clone(),
panel, panel,
+23 -5
View File
@@ -22,6 +22,8 @@
onunclaim?: (claimId: number) => void; onunclaim?: (claimId: number) => void;
} = $props(); } = $props();
let servers = $state<Array<{ id: number; name: string; instance_id: string }>>([]);
let serverId = $state<number | null>(null);
let dimension = $state<'minecraft:overworld' | 'minecraft:the_nether' | 'minecraft:the_end'>('minecraft:overworld'); let dimension = $state<'minecraft:overworld' | 'minecraft:the_nether' | 'minecraft:the_end'>('minecraft:overworld');
let centerX = $state(0); let centerX = $state(0);
let centerZ = $state(0); let centerZ = $state(0);
@@ -39,11 +41,14 @@
] as const; ] as const;
async function loadClaims() { async function loadClaims() {
if (!instanceId) return; if (!instanceId || !serverId) { claims = []; return; }
loading = true; loading = true;
try { try {
claims = await invoke<GuildClaim[]>('get_guild_claims', { claims = await invoke<GuildClaim[]>('get_guild_claims', {
instanceId, instanceId,
serverId,
centerX,
centerZ,
dimension dimension
}); });
} catch (e: any) { } catch (e: any) {
@@ -66,11 +71,13 @@
); );
async function handleClaim() { async function handleClaim() {
if (!selectedChunk || !myGuildId) return; if (!selectedChunk || !myGuildId || !serverId) return;
claiming = true; claiming = true;
try { try {
const claim = await invoke<GuildClaim>('claim_guild_chunk', { const claim = await invoke<GuildClaim>('claim_guild_chunk', {
guildId: myGuildId,
instanceId, instanceId,
serverId,
dimension, dimension,
chunkX: selectedChunk.x, chunkX: selectedChunk.x,
chunkZ: selectedChunk.z chunkZ: selectedChunk.z
@@ -112,17 +119,28 @@
} }
$effect(() => { $effect(() => {
if (instanceId && dimension) { if (instanceId && dimension && serverId) {
loadClaims(); loadClaims();
} }
}); });
onMount(() => { onMount(async () => {
loadClaims(); const result = await invoke<{ servers: typeof servers }>('get_public_servers');
servers = result.servers;
});
$effect(() => {
const available = servers.filter(s => s.instance_id === instanceId);
if (!available.some(s => s.id === serverId)) serverId = available[0]?.id ?? null;
}); });
</script> </script>
<div class="chunk-map-container glass"> <div class="chunk-map-container glass">
<label>Game server
<select bind:value={serverId}>
{#each servers.filter(s => s.instance_id === instanceId) as server}<option value={server.id}>{server.name}</option>{/each}
</select>
</label>
{#if !serverId}<p>Link a game server to this instance in the panel before claiming territory.</p>{/if}
<!-- Controls Bar --> <!-- Controls Bar -->
<div class="map-controls"> <div class="map-controls">
<div class="dim-selector"> <div class="dim-selector">
@@ -54,7 +54,7 @@
savingProfile = true; savingProfile = true;
try { try {
profile = await invoke<UserProfileView>('update_my_profile', { profile = await invoke<UserProfileView>('update_my_profile', {
bio: editBio.trim() || null, bio: editBio.trim(),
bannerUrl: editBanner.trim() || null bannerUrl: editBanner.trim() || null
}); });
editing = false; editing = false;
@@ -89,9 +89,8 @@
async function handleLikePost(post: UserPost) { async function handleLikePost(post: UserPost) {
try { try {
const liked = await invoke<boolean>('like_user_post', { postId: post.id }); await invoke('like_user_post', { postId: post.id });
post.liked_by_me = liked; post.likes_count += 1;
post.likes_count = liked ? post.likes_count + 1 : Math.max(0, post.likes_count - 1);
} catch (e: any) { } catch (e: any) {
toast(e?.message ?? 'Failed to update like', 'error'); toast(e?.message ?? 'Failed to update like', 'error');
} }
@@ -131,8 +130,8 @@
<div class="user-titles"> <div class="user-titles">
<div class="user-row"> <div class="user-row">
<h2>{profile.username}</h2> <h2>{profile.username}</h2>
{#if profile.global_level?.title} {#if profile.level_info?.title}
<span class="badge title-badge"><Award size={12} /> {profile.global_level.title}</span> <span class="badge title-badge"><Award size={12} /> {profile.level_info.title}</span>
{/if} {/if}
</div> </div>
<div class="meta-row tiny muted"> <div class="meta-row tiny muted">
@@ -140,7 +139,7 @@
<span>•</span> <span>•</span>
<span>{profile.friends_count} Friends</span> <span>{profile.friends_count} Friends</span>
<span>•</span> <span>•</span>
<span class="global-lvl-tag">Level {profile.global_level?.level ?? 1}</span> <span class="global-lvl-tag">Level {profile.level_info?.level ?? 1}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -195,7 +194,7 @@
<Award size={14} /> Levels & Ranks <Award size={14} /> Levels & Ranks
</button> </button>
<button class:active={activeTab === 'achievements'} onclick={() => (activeTab = 'achievements')}> <button class:active={activeTab === 'achievements'} onclick={() => (activeTab = 'achievements')}>
<Trophy size={14} /> Achievements ({profile.achievements.length}) <Trophy size={14} /> Achievements ({profile.achievements_count ?? profile.achievements?.length ?? 0})
</button> </button>
<button class:active={activeTab === 'stats'} onclick={() => (activeTab = 'stats')}> <button class:active={activeTab === 'stats'} onclick={() => (activeTab = 'stats')}>
<Swords size={14} /> Stats <Swords size={14} /> Stats
@@ -285,16 +284,16 @@
<h3>SCOPENET Global Level</h3> <h3>SCOPENET Global Level</h3>
</div> </div>
<div class="level-pill"> <div class="level-pill">
Level {profile.global_level.level} Level {profile.level_info.level}
</div> </div>
</div> </div>
<div class="xp-bar-wrap"> <div class="xp-bar-wrap">
<div class="xp-numbers tiny"> <div class="xp-numbers tiny">
<span>{profile.global_level.current_xp.toLocaleString()} XP</span> <span>{profile.level_info.current_xp.toLocaleString()} XP</span>
<span>{profile.global_level.next_level_xp.toLocaleString()} XP</span> <span>{profile.level_info.next_level_xp.toLocaleString()} XP</span>
</div> </div>
<div class="xp-bar"> <div class="xp-bar">
<div class="xp-fill" style:width="{profile.global_level.progress_pct}%"></div> <div class="xp-fill" style:width="{profile.level_info.progress_pct}%"></div>
</div> </div>
</div> </div>
</div> </div>
+13 -13
View File
@@ -217,9 +217,9 @@ export async function mockInvoke(cmd: string, args: Record<string, any>): Promis
return null; return null;
case 'get_guild_members': case 'get_guild_members':
return [ return [
{ guild_id: 'g1', user_uuid: 'mock-user-1', username: 'Alyssa', role: 'leader', joined_at: '2026-09-01T00:00:00Z', is_online: true }, { guild_id: 'g1', uuid: 'mock-user-1', name: 'Alyssa', role: 'leader', joined_at: '2026-09-01T00:00:00Z', online: true },
{ guild_id: 'g1', user_uuid: 'friend-1', username: 'Alex', role: 'officer', joined_at: '2026-09-02T00:00:00Z', is_online: true }, { guild_id: 'g1', uuid: 'friend-1', name: 'Alex', role: 'officer', joined_at: '2026-09-02T00:00:00Z', online: true },
{ guild_id: 'g1', user_uuid: 'friend-2', username: 'Steve', role: 'member', joined_at: '2026-09-05T00:00:00Z', is_online: false } { guild_id: 'g1', uuid: 'friend-2', name: 'Steve', role: 'member', joined_at: '2026-09-05T00:00:00Z', online: false }
]; ];
case 'get_guild_posts': case 'get_guild_posts':
return [ return [
@@ -229,9 +229,9 @@ export async function mockInvoke(cmd: string, args: Record<string, any>): Promis
return { id: 'gp' + Date.now(), guild_id: args?.guildId, author_uuid: 'mock-user-1', author_name: 'Alyssa', title: args?.title, content: args?.content, pinned: args?.pinned ?? false, created_at: new Date().toISOString() }; return { id: 'gp' + Date.now(), guild_id: args?.guildId, author_uuid: 'mock-user-1', author_name: 'Alyssa', title: args?.title, content: args?.content, pinned: args?.pinned ?? false, created_at: new Date().toISOString() };
case 'get_friends': case 'get_friends':
return [ return [
{ uuid: 'friend-1', username: 'Alex', status: 'accepted', is_online: true, playing_on: 'Survival SMP', avatar_url: null }, { uuid: 'friend-1', username: 'Alex', status: 'accepted', online: true, playing_on: 'Survival SMP', avatar_url: null },
{ uuid: 'friend-2', username: 'Steve', status: 'accepted', is_online: false, playing_on: null, avatar_url: null }, { uuid: 'friend-2', username: 'Steve', status: 'accepted', online: false, playing_on: null, avatar_url: null },
{ uuid: 'friend-3', username: 'Notch', status: 'pending_received', is_online: true, playing_on: null, avatar_url: null } { uuid: 'friend-3', username: 'Notch', status: 'pending_incoming', online: true, playing_on: null, avatar_url: null }
]; ];
case 'send_friend_request': case 'send_friend_request':
case 'respond_friend_request': case 'respond_friend_request':
@@ -239,17 +239,17 @@ export async function mockInvoke(cmd: string, args: Record<string, any>): Promis
return null; return null;
case 'get_direct_messages': case 'get_direct_messages':
return [ return [
{ id: 1, from_uuid: args?.friendUuid, to_uuid: 'mock-user-1', content: 'Hey! Want to team up and explore the trial chambers?', created_at: '2026-09-28T18:20:00Z', is_read: true }, { id: 1, sender_uuid: args?.friendUuid, recipient_uuid: 'mock-user-1', content: 'Hey! Want to team up and explore the trial chambers?', created_at: '2026-09-28T18:20:00Z', is_read: true },
{ id: 2, from_uuid: 'mock-user-1', to_uuid: args?.friendUuid, content: 'Definitely! Let me grab some potions from base first.', created_at: '2026-09-28T18:22:00Z', is_read: true } { id: 2, sender_uuid: 'mock-user-1', recipient_uuid: args?.friendUuid, content: 'Definitely! Let me grab some potions from base first.', created_at: '2026-09-28T18:22:00Z', is_read: true }
]; ];
case 'send_direct_message': case 'send_direct_message':
return { id: Date.now(), from_uuid: 'mock-user-1', to_uuid: args?.toUuid, content: args?.content, created_at: new Date().toISOString(), is_read: true }; return { id: Date.now(), sender_uuid: 'mock-user-1', recipient_uuid: args?.friendUuid, content: args?.content, created_at: new Date().toISOString(), is_read: true };
case 'get_game_invites': case 'get_game_invites':
return [ return [
{ id: 'inv-1', from_uuid: 'friend-1', from_username: 'Alex', to_uuid: 'mock-user-1', instance_id: 'survival-smp', server_address: 'smp.scopenet.gg', server_name: 'Survival SMP', status: 'pending', created_at: '2026-09-28T19:00:00Z' } { id: 'inv-1', sender_uuid: 'friend-1', sender_name: 'Alex', recipient_uuid: 'mock-user-1', instance_id: 'survival-smp', instance_name: 'smp.scopenet.gg', server_name: 'Survival SMP', status: 'pending', created_at: '2026-09-28T19:00:00Z' }
]; ];
case 'send_game_invite': case 'send_game_invite':
return { id: 'inv-' + Date.now(), from_uuid: 'mock-user-1', from_username: 'Alyssa', to_uuid: args?.toUuid, instance_id: args?.instanceId, server_address: args?.serverAddress, server_name: args?.serverName, status: 'pending', created_at: new Date().toISOString() }; return { id: 'inv-' + Date.now(), sender_uuid: 'mock-user-1', sender_name: 'Alyssa', recipient_uuid: args?.friendUuid, instance_id: args?.instanceId, instance_name: args?.serverAddress, server_name: args?.serverName, status: 'pending', created_at: new Date().toISOString() };
case 'respond_game_invite': case 'respond_game_invite':
return null; return null;
case 'get_user_profile': case 'get_user_profile':
@@ -261,7 +261,7 @@ export async function mockInvoke(cmd: string, args: Record<string, any>): Promis
skin_url: null, skin_url: null,
skin_model: 'classic', skin_model: 'classic',
cape_url: null, cape_url: null,
global_level: { uuid: args?.uuid, level: 14, current_xp: 3450, next_level_xp: 5000, progress_pct: 69, title: 'Veteran Adventurer', badges: ['Beta Pioneer', 'Dragon Slayer'], rewards: [] }, level_info: { uuid: args?.uuid, level: 14, current_xp: 3450, next_level_xp: 5000, progress_pct: 69, title: 'Veteran Adventurer', badges: ['Beta Pioneer', 'Dragon Slayer'], rewards: [] },
server_levels: [ server_levels: [
{ server_id: 1, server_name: 'Survival SMP', uuid: args?.uuid, level: 9, current_xp: 1200, next_level_xp: 2200, progress_pct: 54, rank_title: 'Knight' } { server_id: 1, server_name: 'Survival SMP', uuid: args?.uuid, level: 9, current_xp: 1200, next_level_xp: 2200, progress_pct: 54, rank_title: 'Knight' }
], ],
@@ -285,7 +285,7 @@ export async function mockInvoke(cmd: string, args: Record<string, any>): Promis
skin_url: null, skin_url: null,
skin_model: 'classic', skin_model: 'classic',
cape_url: null, cape_url: null,
global_level: { uuid: 'mock-user-1', level: 14, current_xp: 3450, next_level_xp: 5000, progress_pct: 69, title: 'Veteran Adventurer', badges: ['Beta Pioneer'], rewards: [] }, level_info: { uuid: 'mock-user-1', level: 14, current_xp: 3450, next_level_xp: 5000, progress_pct: 69, title: 'Veteran Adventurer', badges: ['Beta Pioneer'], rewards: [] },
server_levels: [], server_levels: [],
badges: ['Beta Pioneer'], badges: ['Beta Pioneer'],
achievements: [], achievements: [],
+19 -16
View File
@@ -226,16 +226,15 @@ export interface Guild {
} }
export interface GuildMember { export interface GuildMember {
guild_id: string; uuid: string;
user_uuid: string; name: string;
username: string;
role: 'leader' | 'officer' | 'member'; role: 'leader' | 'officer' | 'member';
joined_at: string; joined_at: string;
is_online?: boolean; online?: boolean;
} }
export interface GuildPost { export interface GuildPost {
id: string; id: number;
guild_id: string; guild_id: string;
author_uuid: string; author_uuid: string;
author_name: string; author_name: string;
@@ -248,7 +247,7 @@ export interface GuildPost {
export interface GuildClaim { export interface GuildClaim {
id: number; id: number;
guild_id: string; guild_id: string;
instance_id: string; server_id: number;
dimension: string; dimension: string;
chunk_x: number; chunk_x: number;
chunk_z: number; chunk_z: number;
@@ -261,16 +260,16 @@ export interface GuildClaim {
export interface FriendInfo { export interface FriendInfo {
uuid: string; uuid: string;
username: string; username: string;
status: 'pending_sent' | 'pending_received' | 'accepted'; status: 'pending_outgoing' | 'pending_incoming' | 'accepted';
is_online: boolean; online: boolean;
playing_on: string | null; playing_on: string | null;
avatar_url?: string | null; avatar_url?: string | null;
} }
export interface DirectMessage { export interface DirectMessage {
id: number; id: number;
from_uuid: string; sender_uuid: string;
to_uuid: string; recipient_uuid: string;
content: string; content: string;
created_at: string; created_at: string;
is_read: boolean; is_read: boolean;
@@ -278,18 +277,18 @@ export interface DirectMessage {
export interface GameInvite { export interface GameInvite {
id: string; id: string;
from_uuid: string; sender_uuid: string;
from_username: string; sender_name: string;
to_uuid: string; recipient_uuid: string;
instance_id: string; instance_id: string;
server_address: string; instance_name: string;
server_name: string; server_name: string;
status: 'pending' | 'accepted' | 'declined' | 'expired'; status: 'pending' | 'accepted' | 'declined' | 'expired';
created_at: string; created_at: string;
} }
export interface UserPost { export interface UserPost {
id: string; id: number;
user_uuid: string; user_uuid: string;
username: string; username: string;
content: string; content: string;
@@ -307,10 +306,14 @@ export interface UserProfileView {
skin_url: string | null; skin_url: string | null;
skin_model: string; skin_model: string;
cape_url: string | null; cape_url: string | null;
global_level: UserLevelInfo; global_level: number;
global_xp: number;
title: string | null;
level_info: UserLevelInfo;
server_levels: ServerLevelInfo[]; server_levels: ServerLevelInfo[];
badges: string[]; badges: string[];
achievements: Achievement[]; achievements: Achievement[];
achievements_count: number;
posts: UserPost[]; posts: UserPost[];
friends_count: number; friends_count: number;
stats: PlayerStats | null; stats: PlayerStats | null;
+7 -7
View File
@@ -179,7 +179,7 @@
<div class="guild-info"> <div class="guild-info">
<div class="guild-title-line"> <div class="guild-title-line">
<h2>{myGuild.name}</h2> <h2>{myGuild.name}</h2>
<span class="badge member-badge"><Users size={12} /> {myGuild.member_count} / {myGuild.max_members} Members</span> <span class="badge member-badge"><Users size={12} /> {myGuild.member_count} Members</span>
<span class="badge claim-badge"><Shield size={12} /> {myGuild.claims_count} Claims</span> <span class="badge claim-badge"><Shield size={12} /> {myGuild.claims_count} Claims</span>
</div> </div>
{#if myGuild.description} {#if myGuild.description}
@@ -228,23 +228,23 @@
{:else if activeTab === 'members'} {:else if activeTab === 'members'}
<div class="members-tab"> <div class="members-tab">
<div class="members-grid"> <div class="members-grid">
{#each members as m (m.user_uuid)} {#each members as m (m.uuid)}
<div <div
class="member-card glass" class="member-card glass"
role="button" role="button"
tabindex="0" tabindex="0"
onclick={() => (app.viewProfileUuid = m.user_uuid)} onclick={() => (app.viewProfileUuid = m.uuid)}
onkeydown={(e) => e.key === 'Enter' && (app.viewProfileUuid = m.user_uuid)} onkeydown={(e) => e.key === 'Enter' && (app.viewProfileUuid = m.uuid)}
> >
<div class="member-avatar-wrap"> <div class="member-avatar-wrap">
<Avatar uuid={m.user_uuid} name={m.username} size={2.5} /> <Avatar uuid={m.uuid} name={m.name} size={2.5} />
{#if m.is_online} {#if m.online}
<span class="online-indicator" title="Online"></span> <span class="online-indicator" title="Online"></span>
{/if} {/if}
</div> </div>
<div class="member-details"> <div class="member-details">
<div class="member-name-row"> <div class="member-name-row">
<span class="member-name">{m.username}</span> <span class="member-name">{m.name}</span>
{#if m.role === 'leader'} {#if m.role === 'leader'}
<span class="badge leader-badge"><Crown size={11} /> Leader</span> <span class="badge leader-badge"><Crown size={11} /> Leader</span>
{:else if m.role === 'officer'} {:else if m.role === 'officer'}
+1 -1
View File
@@ -408,7 +408,7 @@
{#if skinProfiles.length} {#if skinProfiles.length}
<div class="profiles-grid"> <div class="profiles-grid">
{#each skinProfiles as sp (sp.id)} {#each skinProfiles as sp (sp.id)}
{@const isCurrent = profile.skin_model === sp.skin_model && (sp.cape_id === (profile.cape?.id ?? null))} {@const isCurrent = !!sp.skin_data && sp.skin_data === profile.skin_url && profile.skin_model === sp.skin_model && sp.cape_id === (profile.cape?.id ?? null)}
<div class="profile-card" class:active-look={isCurrent}> <div class="profile-card" class:active-look={isCurrent}>
<div class="profile-preview"> <div class="profile-preview">
<SkinView skin={sp.skin_data} cape={sp.cape_url} slim={sp.skin_model === 'slim'} side="front" scale={3.5} /> <SkinView skin={sp.skin_data} cape={sp.cape_url} slim={sp.skin_model === 'slim'} side="front" scale={3.5} />
+18 -18
View File
@@ -141,7 +141,7 @@
const text = messageInput.trim(); const text = messageInput.trim();
try { try {
const msg = await invoke<DirectMessage>('send_direct_message', { const msg = await invoke<DirectMessage>('send_direct_message', {
toUuid: activeChatFriend.uuid, friendUuid: activeChatFriend.uuid,
content: text content: text
}); });
messages = [...messages, msg]; messages = [...messages, msg];
@@ -162,12 +162,12 @@
if (!inviteTargetFriend) return; if (!inviteTargetFriend) return;
sendingInvite = true; sendingInvite = true;
try { try {
const inst = instances().find((i) => i.id === inviteInstanceId); // FIX #14: The Rust command takes recipient_uuid, instance_id, server_id (optional i64).
// serverAddress/serverName were wrong fields that the command doesn't accept.
await invoke('send_game_invite', { await invoke('send_game_invite', {
toUuid: inviteTargetFriend.uuid, recipientUuid: inviteTargetFriend.uuid,
instanceId: inviteInstanceId, instanceId: inviteInstanceId,
serverAddress: inst?.server?.address ?? 'localhost', serverId: null // Optional; pass a server_id i64 here if the instance has a known server.
serverName: inst?.name ?? 'Minecraft Server'
}); });
toast(`Game invite sent to ${inviteTargetFriend.username}!`, 'ok'); toast(`Game invite sent to ${inviteTargetFriend.username}!`, 'ok');
inviteModalOpen = false; inviteModalOpen = false;
@@ -191,12 +191,12 @@
} }
const acceptedFriends = $derived(friends.filter((f) => f.status === 'accepted')); const acceptedFriends = $derived(friends.filter((f) => f.status === 'accepted'));
const pendingReceived = $derived(friends.filter((f) => f.status === 'pending_received')); const pendingReceived = $derived(friends.filter((f) => f.status === 'pending_incoming'));
const pendingSent = $derived(friends.filter((f) => f.status === 'pending_sent')); const pendingSent = $derived(friends.filter((f) => f.status === 'pending_outgoing'));
const displayedFriends = $derived( const displayedFriends = $derived(
friends.filter((f) => { friends.filter((f) => {
if (friendsFilter === 'online') return f.status === 'accepted' && f.is_online; if (friendsFilter === 'online') return f.status === 'accepted' && f.online;
if (friendsFilter === 'pending') return f.status !== 'accepted'; if (friendsFilter === 'pending') return f.status !== 'accepted';
return f.status === 'accepted'; return f.status === 'accepted';
}) })
@@ -267,7 +267,7 @@
All ({acceptedFriends.length}) All ({acceptedFriends.length})
</button> </button>
<button class="pill" class:active={friendsFilter === 'online'} onclick={() => (friendsFilter = 'online')}> <button class="pill" class:active={friendsFilter === 'online'} onclick={() => (friendsFilter = 'online')}>
Online ({acceptedFriends.filter((f) => f.is_online).length}) Online ({acceptedFriends.filter((f) => f.online).length})
</button> </button>
<button class="pill" class:active={friendsFilter === 'pending'} onclick={() => (friendsFilter = 'pending')}> <button class="pill" class:active={friendsFilter === 'pending'} onclick={() => (friendsFilter = 'pending')}>
Pending ({pendingReceived.length + pendingSent.length}) Pending ({pendingReceived.length + pendingSent.length})
@@ -355,13 +355,13 @@
> >
<div class="avatar-holder"> <div class="avatar-holder">
<Avatar uuid={friend.uuid} name={friend.username} size={2.6} /> <Avatar uuid={friend.uuid} name={friend.username} size={2.6} />
<span class="status-dot" class:online={friend.is_online}></span> <span class="status-dot" class:online={friend.online}></span>
</div> </div>
<div class="name-block"> <div class="name-block">
<span class="user-title">{friend.username}</span> <span class="user-title">{friend.username}</span>
{#if friend.is_online && friend.playing_on} {#if friend.online && friend.playing_on}
<span class="tiny playing-on">Playing on {friend.playing_on}</span> <span class="tiny playing-on">Playing on {friend.playing_on}</span>
{:else if friend.is_online} {:else if friend.online}
<span class="tiny online-label">Online in Launcher</span> <span class="tiny online-label">Online in Launcher</span>
{:else} {:else}
<span class="tiny muted">Offline</span> <span class="tiny muted">Offline</span>
@@ -494,7 +494,7 @@
> >
<div class="avatar-holder"> <div class="avatar-holder">
<Avatar uuid={friend.uuid} name={friend.username} size={2} /> <Avatar uuid={friend.uuid} name={friend.username} size={2} />
<span class="status-dot" class:online={friend.is_online}></span> <span class="status-dot" class:online={friend.online}></span>
</div> </div>
<div class="conv-meta"> <div class="conv-meta">
<span class="conv-name">{friend.username}</span> <span class="conv-name">{friend.username}</span>
@@ -521,7 +521,7 @@
<Avatar uuid={activeChatFriend.uuid} name={activeChatFriend.username} size={2.2} /> <Avatar uuid={activeChatFriend.uuid} name={activeChatFriend.username} size={2.2} />
<div> <div>
<strong>{activeChatFriend.username}</strong> <strong>{activeChatFriend.username}</strong>
<span class="tiny muted block">{activeChatFriend.is_online ? 'Active now' : 'Offline'}</span> <span class="tiny muted block">{activeChatFriend.online ? 'Active now' : 'Offline'}</span>
</div> </div>
</div> </div>
<button class="primary sm" onclick={() => openInviteModal(activeChatFriend!)}> <button class="primary sm" onclick={() => openInviteModal(activeChatFriend!)}>
@@ -537,7 +537,7 @@
</div> </div>
{:else} {:else}
{#each messages as msg (msg.id)} {#each messages as msg (msg.id)}
{@const isMine = msg.from_uuid === activeAccount()?.uuid} {@const isMine = msg.sender_uuid === activeAccount()?.uuid}
<div class="bubble-row" class:is-mine={isMine}> <div class="bubble-row" class:is-mine={isMine}>
<div class="bubble" class:is-mine={isMine}> <div class="bubble" class:is-mine={isMine}>
<p class="bubble-text">{msg.content}</p> <p class="bubble-text">{msg.content}</p>
@@ -586,11 +586,11 @@
{#each invites as inv (inv.id)} {#each invites as inv (inv.id)}
<div class="invite-card glass"> <div class="invite-card glass">
<div class="invite-top"> <div class="invite-top">
<Avatar uuid={inv.from_uuid} name={inv.from_username} size={2.5} /> <Avatar uuid={inv.sender_uuid} name={inv.sender_name} size={2.5} />
<div class="invite-meta"> <div class="invite-meta">
<span class="inviter-name"><strong>{inv.from_username}</strong> invited you to play</span> <span class="inviter-name"><strong>{inv.sender_name}</strong> invited you to play</span>
<span class="server-title">{inv.server_name}</span> <span class="server-title">{inv.server_name}</span>
<span class="tiny muted server-addr">{inv.server_address}</span> <span class="tiny muted server-addr">{inv.instance_name}</span>
</div> </div>
</div> </div>
<div class="invite-footer"> <div class="invite-footer">
+23
View File
@@ -476,6 +476,29 @@ const MIGRATIONS: &[&str] = &[
); );
CREATE INDEX IF NOT EXISTS market_srv ON server_market(server_id, id DESC); CREATE INDEX IF NOT EXISTS market_srv ON server_market(server_id, id DESC);
"#, "#,
r#"
ALTER TABLE game_servers ADD COLUMN instance_id TEXT NOT NULL DEFAULT '';
DROP INDEX guild_member_unique;
CREATE INDEX guild_member_uuid ON guild_members(uuid);
CREATE TRIGGER guild_members_instance_unique BEFORE INSERT ON guild_members
WHEN EXISTS (SELECT 1 FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id
WHERE gm.uuid = NEW.uuid AND gm.guild_id <> NEW.guild_id
AND g.instance_id = (SELECT instance_id FROM guilds WHERE id = NEW.guild_id))
BEGIN SELECT RAISE(ABORT, 'Already in a guild for this instance'); END;
CREATE TRIGGER achievement_xp AFTER INSERT ON user_achievements BEGIN
INSERT INTO user_levels(uuid, global_xp, updated_at)
VALUES(NEW.user_uuid, (SELECT MAX(0, xp_reward) FROM achievements WHERE id = NEW.achievement_id), NEW.unlocked_at)
ON CONFLICT(uuid) DO UPDATE SET global_xp = global_xp + excluded.global_xp, updated_at = excluded.updated_at;
END;
INSERT INTO user_levels(uuid, global_xp, updated_at)
SELECT ua.user_uuid, SUM(MAX(0, a.xp_reward)), MAX(ua.unlocked_at) FROM user_achievements ua JOIN achievements a ON a.id = ua.achievement_id GROUP BY ua.user_uuid
ON CONFLICT(uuid) DO UPDATE SET global_xp = global_xp + excluded.global_xp;
CREATE TABLE granted_rewards (uuid TEXT NOT NULL, reward_id INTEGER NOT NULL REFERENCES level_rewards(id) ON DELETE CASCADE,
granted_at TEXT NOT NULL, PRIMARY KEY (uuid, reward_id));
ALTER TABLE server_market ADD COLUMN item_data TEXT;
CREATE TABLE economy_operations (server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
operation_id TEXT NOT NULL, response TEXT NOT NULL, PRIMARY KEY(server_id, operation_id));
"#,
]; ];
pub async fn connect(data_dir: &Path) -> Result<SqlitePool> { pub async fn connect(data_dir: &Path) -> Result<SqlitePool> {
+8 -6
View File
@@ -264,9 +264,11 @@ pub async fn admin_update_achievement(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<AchievementPayload>, Json(payload): Json<AchievementPayload>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let req_type = payload.requirement_type.unwrap_or_else(|| "stat".into()); // FIX #19: Only overwrite requirement_type / key / value when the caller
let req_key = payload.requirement_key.unwrap_or_else(|| "blocks_broken".into()); // explicitly supplies them. Use COALESCE so NULL leaves the existing value.
let req_val = payload.requirement_value.unwrap_or(50); let req_type = payload.requirement_type; // None if not in the request
let req_key = payload.requirement_key; // None if not in the request
let req_val = payload.requirement_value; // None if not in the request
sqlx::query( sqlx::query(
"UPDATE achievements SET "UPDATE achievements SET
@@ -277,9 +279,9 @@ pub async fn admin_update_achievement(
icon_item = ?, icon_item = ?,
icon_bg = ?, icon_bg = ?,
icon_border = ?, icon_border = ?,
requirement_type = ?, requirement_type = COALESCE(?, requirement_type),
requirement_key = ?, requirement_key = COALESCE(?, requirement_key),
requirement_value = ?, requirement_value = COALESCE(?, requirement_value),
xp_reward = ?, xp_reward = ?,
secret = COALESCE(?, secret) secret = COALESCE(?, secret)
WHERE id = ?", WHERE id = ?",
+81 -16
View File
@@ -10,6 +10,62 @@ use scopenet_shared::{BaltopEntry, EconomyTransaction, MarketListing, ServerEcon
use serde::Deserialize; use serde::Deserialize;
use serde_json::Value; use serde_json::Value;
async fn begin_operation(state: &AppState, server_id: i64, operation_id: &str) -> AppResult<(sqlx::Transaction<'static, sqlx::Sqlite>, Option<Value>)> {
if operation_id.is_empty() || operation_id.len() > 100 { return Err(AppError::bad_request("Invalid operation ID")); }
let mut tx = state.db.begin().await?;
// The first statement obtains the write lock before reading any balances.
let inserted = sqlx::query("INSERT OR IGNORE INTO economy_operations(server_id, operation_id, response) VALUES (?, ?, '')")
.bind(server_id).bind(operation_id).execute(&mut *tx).await?.rows_affected();
let previous = if inserted == 0 {
let raw: String = sqlx::query_scalar("SELECT response FROM economy_operations WHERE server_id = ? AND operation_id = ?")
.bind(server_id).bind(operation_id).fetch_one(&mut *tx).await?;
Some(serde_json::from_str(&raw)?)
} else { None };
Ok((tx, previous))
}
async fn finish_operation(mut tx: sqlx::Transaction<'_, sqlx::Sqlite>, server_id: i64, operation_id: &str, response: Value) -> AppResult<Json<Value>> {
sqlx::query("UPDATE economy_operations SET response = ? WHERE server_id = ? AND operation_id = ?")
.bind(response.to_string()).bind(server_id).bind(operation_id).execute(&mut *tx).await?;
tx.commit().await?;
Ok(Json(response))
}
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?;
Ok(())
}
#[derive(Deserialize)]
pub struct AdjustBalancePayload {
pub uuid: String,
pub username: String,
pub delta: f64,
pub operation_id: String,
pub description: String,
}
pub async fn server_adjust_balance(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<AdjustBalancePayload>) -> AppResult<Json<Value>> {
if !p.delta.is_finite() || p.delta == 0.0 || p.delta.abs() > 1e12 { return Err(AppError::bad_request("Invalid amount")); }
let (mut tx, previous) = begin_operation(&state, server.id, &p.operation_id).await?;
if let Some(previous) = previous { return Ok(Json(previous)); }
ensure_balance(&mut tx, server.id, &p.uuid, &p.username).await?;
let balance: Option<f64> = sqlx::query_scalar("UPDATE server_economy SET balance = balance + ?, updated_at = ? WHERE server_id = ? AND uuid = ? AND balance + ? >= 0 RETURNING balance")
.bind(p.delta).bind(chrono::Utc::now().to_rfc3339()).bind(server.id).bind(&p.uuid).bind(p.delta).fetch_optional(&mut *tx).await?;
let Some(balance) = balance else { return Err(AppError::bad_request("Insufficient funds")); };
let (from, from_name, to, to_name) = if p.delta > 0.0 { ("server", "Server Shop", p.uuid.as_str(), p.username.as_str()) } else { (p.uuid.as_str(), p.username.as_str(), "server", "Server Shop") };
sqlx::query("INSERT INTO economy_transactions(server_id, from_uuid, from_name, to_uuid, to_name, amount, description, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
.bind(server.id).bind(from).bind(from_name).bind(to).bind(to_name).bind(p.delta.abs()).bind(&p.description).bind(chrono::Utc::now().to_rfc3339()).execute(&mut *tx).await?;
finish_operation(tx, server.id, &p.operation_id, serde_json::json!({"ok": true, "balance": balance})).await
}
pub async fn server_market_read(GameServer(server): GameServer, State(state): State<AppState>) -> AppResult<Json<Vec<Value>>> {
let rows: Vec<(i64, String, String, String, i32, f64, Option<String>)> = sqlx::query_as("SELECT id, seller_name, item_id, item_name, amount, price, item_data FROM server_market WHERE server_id = ? ORDER BY id DESC LIMIT 45")
.bind(server.id).fetch_all(&state.db).await?;
Ok(Json(rows.into_iter().map(|(id, seller, item_id, item_name, amount, price, data)| serde_json::json!({"id":id,"seller_name":seller,"item_id":item_id,"item_name":item_name,"amount":amount,"price":price,"item_data":data})).collect()))
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Launcher & Public Economy API // Launcher & Public Economy API
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -213,7 +269,7 @@ pub async fn server_transfer(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<ServerTransferPayload>, Json(payload): Json<ServerTransferPayload>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
if payload.amount <= 0.0 { if !payload.amount.is_finite() || payload.amount <= 0.0 {
return Err(AppError::bad_request("Transfer amount must be positive")); return Err(AppError::bad_request("Transfer amount must be positive"));
} }
@@ -347,6 +403,8 @@ pub async fn server_baltop(
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct MarketListPayload { pub struct MarketListPayload {
pub operation_id: String,
pub item_data: Option<String>,
pub seller_uuid: String, pub seller_uuid: String,
pub seller_name: String, pub seller_name: String,
pub item_id: String, pub item_id: String,
@@ -360,10 +418,13 @@ pub async fn server_market_list(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<MarketListPayload>, Json(payload): Json<MarketListPayload>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
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 now = chrono::Utc::now().to_rfc3339(); let now = chrono::Utc::now().to_rfc3339();
let id: i64 = sqlx::query_scalar( let id: i64 = sqlx::query_scalar(
"INSERT INTO server_market (server_id, seller_uuid, seller_name, item_id, item_name, amount, price, created_at) "INSERT INTO server_market (server_id, seller_uuid, seller_name, item_id, item_name, amount, price, created_at, item_data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id", RETURNING id",
) )
.bind(server.id) .bind(server.id)
@@ -374,14 +435,16 @@ pub async fn server_market_list(
.bind(payload.amount) .bind(payload.amount)
.bind(payload.price) .bind(payload.price)
.bind(&now) .bind(&now)
.fetch_one(&state.db) .bind(&payload.item_data)
.fetch_one(&mut *tx)
.await?; .await?;
Ok(Json(serde_json::json!({ "id": id, "ok": true }))) finish_operation(tx, server.id, &payload.operation_id, serde_json::json!({ "id": id, "ok": true })).await
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct MarketBuyPayload { pub struct MarketBuyPayload {
pub operation_id: String,
pub listing_id: i64, pub listing_id: i64,
pub buyer_uuid: String, pub buyer_uuid: String,
pub buyer_name: String, pub buyer_name: String,
@@ -392,21 +455,24 @@ pub async fn server_market_buy(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<MarketBuyPayload>, Json(payload): Json<MarketBuyPayload>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let listing: Option<(String, String, String, String, i32, f64)> = sqlx::query_as( let (mut tx, previous) = begin_operation(&state, server.id, &payload.operation_id).await?;
"SELECT seller_uuid, seller_name, item_id, item_name, amount, price if let Some(previous) = previous { return Ok(Json(previous)); }
FROM server_market let listing: Option<(String, String, String, String, i32, f64, Option<String>)> = sqlx::query_as(
WHERE id = ? AND server_id = ?", "DELETE FROM server_market
WHERE id = ? AND server_id = ?
RETURNING seller_uuid, seller_name, item_id, item_name, amount, price, item_data
",
) )
.bind(payload.listing_id) .bind(payload.listing_id)
.bind(server.id) .bind(server.id)
.fetch_optional(&state.db) .fetch_optional(&mut *tx)
.await?; .await?;
let Some((seller_uuid, seller_name, item_id, item_name, amount, price)) = listing else { let Some((seller_uuid, seller_name, item_id, item_name, amount, price, item_data)) = listing else {
return Err(AppError::not_found("Listing not found")); return Err(AppError::not_found("Listing not found"));
}; };
let mut tx = state.db.begin().await?; ensure_balance(&mut tx, server.id, &payload.buyer_uuid, &payload.buyer_name).await?;
let now = chrono::Utc::now().to_rfc3339(); let now = chrono::Utc::now().to_rfc3339();
// Deduct buyer // Deduct buyer
@@ -471,13 +537,12 @@ pub async fn server_market_buy(
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
tx.commit().await?; finish_operation(tx, server.id, &payload.operation_id, serde_json::json!({
Ok(Json(serde_json::json!({
"ok": true, "ok": true,
"item_id": item_id, "item_id": item_id,
"item_data": item_data,
"item_name": item_name, "item_name": item_name,
"amount": amount, "amount": amount,
"price": price "price": price
}))) })).await
} }
+39 -23
View File
@@ -220,7 +220,7 @@ async fn fetch_guild_detail(state: &AppState, guild_id: &str) -> AppResult<Guild
// Claims // Claims
let claim_rows: Vec<(i64, String, i64, String, i32, i32, String, String)> = sqlx::query_as( let claim_rows: Vec<(i64, String, i64, String, i32, i32, String, String)> = sqlx::query_as(
"SELECT id, guild_id, server_id, dimension, chunk_x, chunk_z, claimed_by_uuid, claimed_at "SELECT id, guild_id, COALESCE(server_id, 0), dimension, chunk_x, chunk_z, claimed_by_uuid, claimed_at
FROM guild_claims FROM guild_claims
WHERE guild_id = ? WHERE guild_id = ?
LIMIT 200", LIMIT 200",
@@ -279,6 +279,14 @@ pub async fn get_guild_by_id(
fetch_guild_detail(&state, &id).await.map(Json) fetch_guild_detail(&state, &id).await.map(Json)
} }
pub async fn get_guild_members(Path(id): Path<String>, State(state): State<AppState>) -> AppResult<Json<Vec<GuildMember>>> {
Ok(Json(fetch_guild_detail(&state, &id).await?.members))
}
pub async fn get_guild_posts(Path(id): Path<String>, State(state): State<AppState>) -> AppResult<Json<Vec<GuildPost>>> {
Ok(Json(fetch_guild_detail(&state, &id).await?.posts))
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreateGuildPayload { pub struct CreateGuildPayload {
pub instance_id: String, pub instance_id: String,
@@ -366,6 +374,7 @@ pub async fn create_guild(
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
crate::routes::leveling::grant_rewards(&mut tx, &auth.uuid, &now).await?;
tx.commit().await?; tx.commit().await?;
fetch_guild_detail(&state, &guild_id).await.map(Json) fetch_guild_detail(&state, &guild_id).await.map(Json)
@@ -549,13 +558,14 @@ pub async fn create_guild_post(
.bind(&id) .bind(&id)
.bind(&auth.uuid) .bind(&auth.uuid)
.bind(&auth.username) .bind(&auth.username)
.bind(payload.title) .bind(&payload.title)
.bind(payload.content) .bind(&payload.content)
.bind(&now) .bind(&now)
.fetch_one(&state.db) .fetch_one(&state.db)
.await?; .await?;
Ok(Json(serde_json::json!({ "id": post_id, "ok": true }))) Ok(Json(serde_json::json!({ "id": post_id, "guild_id": id, "author_uuid": auth.uuid,
"author_name": auth.username, "title": payload.title, "content": payload.content, "created_at": now })))
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -604,29 +614,28 @@ pub async fn claim_chunk(
return Err(AppError::bad_request(format!("Guild reached its max claim limit of {max_claims} chunks"))); return Err(AppError::bad_request(format!("Guild reached its max claim limit of {max_claims} chunks")));
} }
let server_id = payload.server_id.ok_or_else(|| AppError::bad_request("Select a game server for this claim"))?;
let matches: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM game_servers s JOIN guilds g ON g.instance_id = s.instance_id WHERE s.id = ? AND g.id = ?)")
.bind(server_id).bind(&guild_id).fetch_one(&state.db).await?;
if !matches { return Err(AppError::bad_request("Server is not linked to this guild's instance")); }
let dim = payload.dimension.unwrap_or_else(|| "minecraft:overworld".into()); let dim = payload.dimension.unwrap_or_else(|| "minecraft:overworld".into());
let now = chrono::Utc::now().to_rfc3339(); let now = chrono::Utc::now().to_rfc3339();
let res = sqlx::query( let res = sqlx::query_scalar::<_, i64>(
"INSERT INTO guild_claims (guild_id, server_id, dimension, chunk_x, chunk_z, claimed_by_uuid, claimed_at) "INSERT INTO guild_claims (guild_id, server_id, dimension, chunk_x, chunk_z, claimed_by_uuid, claimed_at)
VALUES (?, ?, ?, ?, ?, ?, ?)", VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id",
) )
.bind(&guild_id) .bind(&guild_id)
.bind(payload.server_id) .bind(server_id)
.bind(&dim) .bind(&dim)
.bind(payload.chunk_x) .bind(payload.chunk_x)
.bind(payload.chunk_z) .bind(payload.chunk_z)
.bind(&auth.uuid) .bind(&auth.uuid)
.bind(&now) .bind(&now)
.execute(&state.db) .fetch_one(&state.db)
.await; .await;
if let Err(e) = res { let claim_id = res.map_err(|_| AppError::bad_request("Chunk is already claimed"))?;
tracing::warn!("claim chunk failed: {e}");
return Err(AppError::bad_request("Chunk is already claimed by another guild"));
}
let claim_id: i64 = sqlx::query_scalar("SELECT last_insert_rowid()").fetch_one(&state.db).await.unwrap_or(1);
// Award achievement for claiming land // Award achievement for claiming land
sqlx::query( sqlx::query(
@@ -638,7 +647,8 @@ pub async fn claim_chunk(
.execute(&state.db) .execute(&state.db)
.await?; .await?;
Ok(Json(serde_json::json!({ "ok": true, "id": claim_id, "chunk_x": payload.chunk_x, "chunk_z": payload.chunk_z }))) let detail = fetch_guild_detail(&state, &guild_id).await?;
Ok(Json(serde_json::to_value(detail.claims.into_iter().find(|c| c.id == claim_id).ok_or_else(|| AppError::not_found("Claim not found"))?)?))
} }
/// Unclaim a chunk by coordinates. /// Unclaim a chunk by coordinates.
@@ -951,9 +961,10 @@ pub async fn server_claim_chunk(
Json(payload): Json<ServerClaimChunkPayload>, Json(payload): Json<ServerClaimChunkPayload>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let member: Option<(String, String)> = sqlx::query_as( let member: Option<(String, String)> = sqlx::query_as(
"SELECT guild_id, role FROM guild_members WHERE uuid = ?", "SELECT gm.guild_id, gm.role FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ?",
) )
.bind(&payload.uuid) .bind(&payload.uuid)
.bind(&server.instance_id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await?; .await?;
@@ -1036,6 +1047,7 @@ pub async fn server_unclaim_chunk(
) )
.bind(&guild_id) .bind(&guild_id)
.bind(&payload.uuid) .bind(&payload.uuid)
.bind(&server.instance_id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await?; .await?;
@@ -1057,14 +1069,15 @@ pub struct ServerGuildPlayerQuery {
} }
pub async fn server_get_player_guild( pub async fn server_get_player_guild(
GameServer(_server): GameServer, GameServer(server): GameServer,
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<ServerGuildPlayerQuery>, Json(payload): Json<ServerGuildPlayerQuery>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let member_opt: Option<(String, String)> = sqlx::query_as( let member_opt: Option<(String, String)> = sqlx::query_as(
"SELECT guild_id, role FROM guild_members WHERE uuid = ?", "SELECT gm.guild_id, gm.role FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ?",
) )
.bind(&payload.uuid) .bind(&payload.uuid)
.bind(&server.instance_id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await?; .await?;
@@ -1129,7 +1142,7 @@ pub struct ServerCreateGuildPayload {
} }
pub async fn server_create_guild( pub async fn server_create_guild(
GameServer(_server): GameServer, GameServer(server): GameServer,
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<ServerCreateGuildPayload>, Json(payload): Json<ServerCreateGuildPayload>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
@@ -1142,8 +1155,9 @@ pub async fn server_create_guild(
return Err(AppError::bad_request("Guild tag must be between 2 and 6 characters")); return Err(AppError::bad_request("Guild tag must be between 2 and 6 characters"));
} }
let in_guild: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_members WHERE uuid = ?)") let in_guild: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ?)")
.bind(&payload.uuid) .bind(&payload.uuid)
.bind(&server.instance_id)
.fetch_one(&state.db) .fetch_one(&state.db)
.await?; .await?;
@@ -1158,9 +1172,10 @@ pub async fn server_create_guild(
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, created_at)
VALUES (?, '', ?, ?, '', 'Welcome to the guild!', ?, ?)", VALUES (?, ?, ?, ?, '', 'Welcome to the guild!', ?, ?)",
) )
.bind(&guild_id) .bind(&guild_id)
.bind(&server.instance_id)
.bind(name) .bind(name)
.bind(tag) .bind(tag)
.bind(&payload.uuid) .bind(&payload.uuid)
@@ -1200,14 +1215,15 @@ pub struct ServerGuildLeavePayload {
} }
pub async fn server_guild_leave( pub async fn server_guild_leave(
GameServer(_server): GameServer, GameServer(server): GameServer,
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<ServerGuildLeavePayload>, Json(payload): Json<ServerGuildLeavePayload>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let member_opt: Option<(String, String)> = sqlx::query_as( let member_opt: Option<(String, String)> = sqlx::query_as(
"SELECT guild_id, role FROM guild_members WHERE uuid = ?", "SELECT gm.guild_id, gm.role FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ?",
) )
.bind(&payload.uuid) .bind(&payload.uuid)
.bind(&server.instance_id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await?; .await?;
+43
View File
@@ -34,6 +34,49 @@ pub fn level_from_xp(xp: i64) -> (i64, i64, i64, f64) {
(lvl, progress_in_lvl, span, pct) (lvl, progress_in_lvl, span, pct)
} }
/// Award each configured entitlement once in the transaction which earned it.
/// Achievement XP is credited automatically via the `achievement_xp` DB trigger;
/// this function only processes level-up rewards (titles, badges, item entitlements).
pub async fn grant_rewards(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, uuid: &str, now: &str) -> AppResult<()> {
let xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?")
.bind(uuid).fetch_optional(&mut **tx).await?.unwrap_or(0);
let level = level_from_xp(xp).0;
sqlx::query("UPDATE user_levels SET global_level = ? WHERE uuid = ?")
.bind(level).bind(uuid).execute(&mut **tx).await?;
let rewards: Vec<(i64, String, Option<i64>, String, String, String)> = sqlx::query_as(
"SELECT r.id, r.level_type, r.server_id, r.reward_type, r.reward_name, r.reward_data FROM level_rewards r
WHERE (r.level_type = 'global' AND r.level_req <= ?)
OR (r.level_type = 'server' AND EXISTS (SELECT 1 FROM server_levels sl WHERE sl.uuid = ? AND sl.server_id = r.server_id AND sl.server_level >= r.level_req))
ORDER BY r.level_req, r.id")
.bind(level).bind(uuid).fetch_all(&mut **tx).await?;
for (id, scope, server_id, kind, name, data) in rewards {
let inserted = sqlx::query("INSERT OR IGNORE INTO granted_rewards(uuid, reward_id, granted_at) VALUES (?, ?, ?)")
.bind(uuid).bind(id).bind(now).execute(&mut **tx).await?.rows_affected();
if inserted == 0 { continue; }
match kind.as_str() {
"title" if scope == "global" => {
sqlx::query("UPDATE user_levels SET title = ? WHERE uuid = ?").bind(&name).bind(uuid).execute(&mut **tx).await?;
}
"title" => {
sqlx::query("UPDATE server_levels SET rank_name = ? WHERE server_id = ? AND uuid = ?")
.bind(&name).bind(server_id).bind(uuid).execute(&mut **tx).await?;
}
"badge" | "profile_badge" => {
let raw: String = sqlx::query_scalar("SELECT badges FROM user_levels WHERE uuid = ?")
.bind(uuid).fetch_optional(&mut **tx).await?.unwrap_or_else(|| "[]".into());
let mut badges: Vec<String> = serde_json::from_str(&raw).unwrap_or_default();
let data: Value = serde_json::from_str(&data).unwrap_or_default();
let badge = data["badge"].as_str().unwrap_or(&name).to_string();
if !badges.contains(&badge) { badges.push(badge); }
sqlx::query("INSERT INTO user_levels(uuid, badges, updated_at) VALUES (?, ?, ?) ON CONFLICT(uuid) DO UPDATE SET badges = excluded.badges")
.bind(uuid).bind(serde_json::to_string(&badges)?).bind(now).execute(&mut **tx).await?;
}
_ => {} // Item/cosmetic entitlements are recorded in granted_rewards.
}
}
Ok(())
}
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct RewardRow { pub struct RewardRow {
pub id: i64, pub id: i64,
+4 -2
View File
@@ -61,9 +61,9 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/guilds/claims/grid", get(guilds::get_chunk_grid)) .route("/guilds/claims/grid", get(guilds::get_chunk_grid))
.route("/guilds/claims/{id}", delete(guilds::unclaim_by_id)) .route("/guilds/claims/{id}", delete(guilds::unclaim_by_id))
.route("/guilds/{id}", get(guilds::get_guild_by_id).put(guilds::update_guild)) .route("/guilds/{id}", get(guilds::get_guild_by_id).put(guilds::update_guild))
.route("/guilds/{id}/members", post(guilds::add_guild_member)) .route("/guilds/{id}/members", get(guilds::get_guild_members).post(guilds::add_guild_member))
.route("/guilds/{id}/members/{uuid}", delete(guilds::remove_guild_member)) .route("/guilds/{id}/members/{uuid}", delete(guilds::remove_guild_member))
.route("/guilds/{id}/posts", post(guilds::create_guild_post)) .route("/guilds/{id}/posts", get(guilds::get_guild_posts).post(guilds::create_guild_post))
.route("/guilds/{id}/claims", post(guilds::claim_chunk).delete(guilds::unclaim_chunk)) .route("/guilds/{id}/claims", post(guilds::claim_chunk).delete(guilds::unclaim_chunk))
.route("/guilds/{id}/claim", post(guilds::claim_chunk)) .route("/guilds/{id}/claim", post(guilds::claim_chunk))
.route("/guilds/{id}/unclaim", post(guilds::unclaim_chunk)) .route("/guilds/{id}/unclaim", post(guilds::unclaim_chunk))
@@ -161,6 +161,8 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/economy/balance", post(economy::server_get_balance)) .route("/economy/balance", post(economy::server_get_balance))
.route("/economy/pay", post(economy::server_transfer)) .route("/economy/pay", post(economy::server_transfer))
.route("/economy/transfer", post(economy::server_transfer)) .route("/economy/transfer", post(economy::server_transfer))
.route("/economy/adjust", post(economy::server_adjust_balance))
.route("/economy/market", post(economy::server_market_read))
.route("/economy/sync-balance", post(economy::server_sync_balance)) .route("/economy/sync-balance", post(economy::server_sync_balance))
.route("/economy/baltop", post(economy::server_baltop)) .route("/economy/baltop", post(economy::server_baltop))
.route("/economy/market/list", post(economy::server_market_list)) .route("/economy/market/list", post(economy::server_market_list))
+1
View File
@@ -205,6 +205,7 @@ pub async fn claim_quest(
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
crate::routes::leveling::grant_rewards(&mut tx, &auth.uuid, &now).await?;
tx.commit().await?; tx.commit().await?;
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({
+20 -44
View File
@@ -60,6 +60,7 @@ fn clip(s: &str, max: usize) -> String {
#[derive(Debug, Clone, sqlx::FromRow, Serialize)] #[derive(Debug, Clone, sqlx::FromRow, Serialize)]
pub struct ServerRow { pub struct ServerRow {
pub instance_id: String,
pub id: i64, pub id: i64,
pub name: String, pub name: String,
#[serde(skip)] #[serde(skip)]
@@ -232,43 +233,9 @@ fn canonical_ip(ip: std::net::IpAddr) -> std::net::IpAddr {
} }
} }
fn is_private_or_local(ip: &std::net::IpAddr) -> bool {
match canonical_ip(*ip) {
std::net::IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(),
std::net::IpAddr::V6(v6) => v6.is_loopback(),
}
}
pub fn ips_match(sess_str: &str, req_str: &str) -> bool { pub fn ips_match(sess_str: &str, req_str: &str) -> bool {
let s = sess_str.trim(); match (sess_str.trim().parse::<std::net::IpAddr>(), req_str.trim().parse::<std::net::IpAddr>()) {
let r = req_str.trim(); (Ok(a), Ok(b)) => canonical_ip(a) == canonical_ip(b),
if s.eq_ignore_ascii_case(r) {
return true;
}
let (Ok(ip_a), Ok(ip_b)) = (s.parse::<std::net::IpAddr>(), r.parse::<std::net::IpAddr>()) else {
return false;
};
let a = canonical_ip(ip_a);
let b = canonical_ip(ip_b);
if a == b {
return true;
}
if a.is_loopback() && b.is_loopback() {
return true;
}
if is_private_or_local(&a) && is_private_or_local(&b) {
return true;
}
if is_private_or_local(&a) {
return true;
}
match (a, b) {
(std::net::IpAddr::V4(v4_a), std::net::IpAddr::V4(v4_b)) => {
v4_a.octets()[0..3] == v4_b.octets()[0..3]
}
(std::net::IpAddr::V6(v6_a), std::net::IpAddr::V6(v6_b)) => {
v6_a.segments()[0..4] == v6_b.segments()[0..4]
}
_ => false, _ => false,
} }
} }
@@ -553,6 +520,10 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
} }
} }
} }
let mut rewarded = std::collections::HashSet::new();
for d in &s.stats { if let Some(uuid) = dashed(&d.uuid) { rewarded.insert(uuid); } }
for e in &s.events { if let Some(uuid) = e.uuid.as_deref().and_then(dashed) { rewarded.insert(uuid); } }
for uuid in rewarded { crate::routes::leveling::grant_rewards(&mut tx, &uuid, &at_now).await?; }
tx.commit().await?; tx.commit().await?;
// Occasional housekeeping. // Occasional housekeeping.
@@ -786,7 +757,7 @@ async fn update_progression_for_delta(
.fetch_optional(&mut **tx) .fetch_optional(&mut **tx)
.await?; .await?;
if let Some(lvl) = global_level { if let Some(lvl) = global_level.filter(|_| achievements_on) {
let lvl_ach: Vec<String> = sqlx::query_scalar( let lvl_ach: Vec<String> = sqlx::query_scalar(
"SELECT id FROM achievements WHERE requirement_type = 'level' AND requirement_value <= ?", "SELECT id FROM achievements WHERE requirement_type = 'level' AND requirement_value <= ?",
) )
@@ -820,6 +791,8 @@ pub async fn list(_: AdminUser, State(state): State<AppState>) -> AppResult<Json
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ServerInput { pub struct ServerInput {
#[serde(default)]
instance_id: String,
name: String, name: String,
#[serde(default = "default_access")] #[serde(default = "default_access")]
access: String, access: String,
@@ -860,7 +833,7 @@ pub async fn create(_: AdminUser, State(state): State<AppState>, Json(input): Js
input.validate()?; input.validate()?;
let token = new_token(); let token = new_token();
let id: i64 = sqlx::query_scalar( let id: i64 = sqlx::query_scalar(
"INSERT INTO game_servers (name, token_hash, token_hint, access, allowed_groups, require_launcher, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id", "INSERT INTO game_servers (name, token_hash, token_hint, access, allowed_groups, require_launcher, created_at, instance_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
) )
.bind(input.name.trim()) .bind(input.name.trim())
.bind(hash_token(&token)) .bind(hash_token(&token))
@@ -869,6 +842,7 @@ pub async fn create(_: AdminUser, State(state): State<AppState>, Json(input): Js
.bind(serde_json::to_string(&input.allowed_groups).unwrap_or_else(|_| "[]".into())) .bind(serde_json::to_string(&input.allowed_groups).unwrap_or_else(|_| "[]".into()))
.bind(input.require_launcher) .bind(input.require_launcher)
.bind(now()) .bind(now())
.bind(&input.instance_id)
.fetch_one(&state.db) .fetch_one(&state.db)
.await?; .await?;
let server = get_server(&state, id).await?; let server = get_server(&state, id).await?;
@@ -883,11 +857,12 @@ pub async fn update(
) -> AppResult<Json<ServerView>> { ) -> AppResult<Json<ServerView>> {
input.validate()?; input.validate()?;
get_server(&state, id).await?; get_server(&state, id).await?;
sqlx::query("UPDATE game_servers SET name = ?, access = ?, allowed_groups = ?, require_launcher = ? WHERE id = ?") sqlx::query("UPDATE game_servers SET name = ?, access = ?, allowed_groups = ?, require_launcher = ?, instance_id = ? WHERE id = ?")
.bind(input.name.trim()) .bind(input.name.trim())
.bind(&input.access) .bind(&input.access)
.bind(serde_json::to_string(&input.allowed_groups).unwrap_or_else(|_| "[]".into())) .bind(serde_json::to_string(&input.allowed_groups).unwrap_or_else(|_| "[]".into()))
.bind(input.require_launcher) .bind(input.require_launcher)
.bind(&input.instance_id)
.bind(id) .bind(id)
.execute(&state.db) .execute(&state.db)
.await?; .await?;
@@ -1070,6 +1045,7 @@ pub async fn public_servers(State(state): State<AppState>) -> AppResult<Json<Val
json!({ json!({
"id": s.id, "id": s.id,
"name": s.name, "name": s.name,
"instance_id": s.instance_id,
"online": is_online, "online": is_online,
"players_online": if is_online { s.online_count } else { 0 }, "players_online": if is_online { s.online_count } else { 0 },
"players_max": s.max_players, "players_max": s.max_players,
@@ -1225,8 +1201,8 @@ mod tests {
assert!(ips_match("203.0.113.9", "203.0.113.9")); assert!(ips_match("203.0.113.9", "203.0.113.9"));
// Loopback IPv4 & IPv6 // Loopback IPv4 & IPv6
assert!(ips_match("127.0.0.1", "::1")); assert!(!ips_match("127.0.0.1", "::1"));
assert!(ips_match("::1", "127.0.0.1")); assert!(!ips_match("::1", "127.0.0.1"));
assert!(ips_match("127.0.0.1", "127.0.0.1")); assert!(ips_match("127.0.0.1", "127.0.0.1"));
// IPv4-mapped IPv6 // IPv4-mapped IPv6
@@ -1234,11 +1210,11 @@ mod tests {
assert!(ips_match("192.168.1.10", "::ffff:192.168.1.10")); assert!(ips_match("192.168.1.10", "::ffff:192.168.1.10"));
// Docker bridge / private gateway recorded // Docker bridge / private gateway recorded
assert!(ips_match("172.18.0.1", "192.168.1.50")); assert!(!ips_match("172.18.0.1", "192.168.1.50"));
assert!(ips_match("10.0.0.1", "10.0.0.2")); assert!(!ips_match("10.0.0.1", "10.0.0.2"));
// Subnet /24 match // Subnet /24 match
assert!(ips_match("203.0.113.5", "203.0.113.9")); assert!(!ips_match("203.0.113.5", "203.0.113.9"));
// Different public networks do not match // Different public networks do not match
assert!(!ips_match("198.51.100.1", "203.0.113.9")); assert!(!ips_match("198.51.100.1", "203.0.113.9"));
+46 -3
View File
@@ -218,23 +218,49 @@ pub async fn respond_friend_request(
// Direct Messaging (DMs) // Direct Messaging (DMs)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[derive(Deserialize, Default)]
pub struct DmQuery {
pub before_id: Option<i64>,
}
pub async fn get_direct_messages( pub async fn get_direct_messages(
auth: AuthUser, auth: AuthUser,
Path(target_uuid): Path<String>, Path(target_uuid): Path<String>,
Query(q): Query<DmQuery>,
State(state): State<AppState>, State(state): State<AppState>,
) -> AppResult<Json<Vec<DirectMessage>>> { ) -> AppResult<Json<Vec<DirectMessage>>> {
let rows: Vec<(i64, String, String, String, String, bool, String)> = sqlx::query_as( // FIX #13: Support optional before_id cursor for pagination beyond the first 100 messages.
let mut rows: Vec<(i64, String, String, String, String, bool, String)> = if let Some(before) = q.before_id {
sqlx::query_as(
"SELECT id, sender_uuid, sender_name, recipient_uuid, content, is_read, created_at
FROM direct_messages
WHERE ((sender_uuid = ? AND recipient_uuid = ?) OR (sender_uuid = ? AND recipient_uuid = ?))
AND id < ?
ORDER BY id DESC LIMIT 100",
)
.bind(&auth.uuid)
.bind(&target_uuid)
.bind(&target_uuid)
.bind(&auth.uuid)
.bind(before)
.fetch_all(&state.db)
.await?
} else {
sqlx::query_as(
"SELECT id, sender_uuid, sender_name, recipient_uuid, content, is_read, created_at "SELECT id, sender_uuid, sender_name, recipient_uuid, content, is_read, created_at
FROM direct_messages FROM direct_messages
WHERE (sender_uuid = ? AND recipient_uuid = ?) OR (sender_uuid = ? AND recipient_uuid = ?) WHERE (sender_uuid = ? AND recipient_uuid = ?) OR (sender_uuid = ? AND recipient_uuid = ?)
ORDER BY id ASC LIMIT 100", ORDER BY id DESC LIMIT 100",
) )
.bind(&auth.uuid) .bind(&auth.uuid)
.bind(&target_uuid) .bind(&target_uuid)
.bind(&target_uuid) .bind(&target_uuid)
.bind(&auth.uuid) .bind(&auth.uuid)
.fetch_all(&state.db) .fetch_all(&state.db)
.await?; .await?
};
rows.reverse();
// Mark unread messages sent to me as read // Mark unread messages sent to me as read
sqlx::query( sqlx::query(
@@ -492,7 +518,24 @@ pub async fn get_player_profile(
}) })
.collect(); .collect();
let achievements = crate::routes::achievements::get_achievements_for_user(&state, &puuid, true).await?;
let friends_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM friendships WHERE status = 'accepted' AND (user_uuid = ? OR friend_uuid = ?)")
.bind(&puuid).bind(&puuid).fetch_one(&state.db).await?;
let created_at: String = sqlx::query_scalar("SELECT created_at FROM users WHERE uuid = ?")
.bind(&puuid).fetch_one(&state.db).await?;
let stats: Option<crate::routes::servers::AggregatedStatRow> = sqlx::query_as(
"SELECT uuid, name, SUM(playtime_secs) playtime_secs, SUM(joins) joins, SUM(deaths) deaths,
SUM(player_kills) player_kills, SUM(mob_kills) mob_kills, SUM(blocks_broken) blocks_broken,
SUM(blocks_placed) blocks_placed, SUM(messages) messages, MIN(first_seen) first_seen, MAX(last_seen) last_seen
FROM player_stats WHERE uuid = ? GROUP BY uuid")
.bind(&puuid).fetch_optional(&state.db).await?;
Ok(Json(UserProfileView { Ok(Json(UserProfileView {
level_info: levels.clone(),
badges: levels.badges.clone(),
achievements,
friends_count,
created_at,
stats: stats.map(|s| serde_json::to_value(s).unwrap()),
uuid: puuid, uuid: puuid,
username, username,
bio, bio,
+5 -3
View File
@@ -24,6 +24,8 @@
import { toast } from './lib/toast.svelte'; import { toast } from './lib/toast.svelte';
let brand = $state<{ name: string; logo_url: string | null }>({ name: 'SCOPENET', logo_url: null }); let brand = $state<{ name: string; logo_url: string | null }>({ name: 'SCOPENET', logo_url: null });
let landingEnabled = $state<boolean | null>(null);
$effect(() => { get<{enabled: boolean}>('/api/v1/landing').then(c => landingEnabled = c.enabled).catch(() => landingEnabled = false); });
let checking = $state(!!session.token); let checking = $state(!!session.token);
$effect(() => { $effect(() => {
@@ -54,12 +56,12 @@
}); });
</script> </script>
{#if checking} {#if checking || landingEnabled === null}
<div class="boot"><span class="pulse"></span></div> <div class="boot"><span class="pulse"></span></div>
{:else if route.name === 'landing'} {:else if route.name === 'landing' && landingEnabled}
<PublicLanding /> <PublicLanding />
{:else if !session.user} {:else if !session.user}
{#if route.name === 'login'} {#if route.name === 'login' || !landingEnabled}
<Login brandName={brand.name} logo={brand.logo_url} /> <Login brandName={brand.name} logo={brand.logo_url} />
{:else} {:else}
<PublicLanding /> <PublicLanding />
+6 -1
View File
@@ -1,5 +1,9 @@
<script lang="ts"> <script lang="ts">
import { Globe, UserRound, Tag } from '@lucide/svelte'; import { Globe, UserRound, Tag } from '@lucide/svelte';
import { onMount } from 'svelte';
import { get } from '../lib/api';
let instances = $state<Array<{id: string; name: string}>>([]);
onMount(async () => { instances = await get('/api/admin/instances'); });
import Toggle from './Toggle.svelte'; import Toggle from './Toggle.svelte';
import type { Group, ServerDraft } from '../lib/types'; import type { Group, ServerDraft } from '../lib/types';
@@ -13,6 +17,7 @@
</script> </script>
<label class="field">Name<input bind:value={draft.name} maxlength="48" placeholder="Survival SMP" /></label> <label class="field">Name<input bind:value={draft.name} maxlength="48" placeholder="Survival SMP" /></label>
<label class="field">Launcher instance<select bind:value={draft.instance_id}><option value="">Unlinked</option>{#each instances as instance}<option value={instance.id}>{instance.name}</option>{/each}</select></label>
<div class="field"> <div class="field">
<span class="lbl">Who can join</span> <span class="lbl">Who can join</span>
<div class="opts"> <div class="opts">
@@ -38,7 +43,7 @@
<Toggle <Toggle
bind:checked={draft.require_launcher} bind:checked={draft.require_launcher}
label="Must join through the launcher" label="Must join through the launcher"
help="Accounts are only let in if they launched the game from the SCOPENET launcher on the same network in the last 12 hours. Useful for offline-mode servers." help="Accounts are only let in if they launched the game from the SCOPENET launcher from the same IP address in the last 12 hours."
/> />
<style> <style>
+3 -1
View File
@@ -90,6 +90,7 @@ export interface Cape { id: number; name: string; url: string; visibility: 'publ
export interface AuthServerInfo { public_url: string; yggdrasil_url: string; public_key: string } export interface AuthServerInfo { public_url: string; yggdrasil_url: string; public_key: string }
export interface GameServer { export interface GameServer {
instance_id: string;
id: number; id: number;
name: string; name: string;
token_hint: string; token_hint: string;
@@ -138,7 +139,7 @@ export interface ServerDetail {
export interface UserActivity { servers: PlayerStats[]; events: ServerEvent[]; online_on: { id: number; name: string }[] } export interface UserActivity { servers: PlayerStats[]; events: ServerEvent[]; online_on: { id: number; name: string }[] }
export type ServerDraft = { name: string; access: 'all' | 'members' | 'groups'; allowed_groups: string[]; require_launcher: boolean }; export type ServerDraft = { instance_id?: string; name: string; access: 'all' | 'members' | 'groups'; allowed_groups: string[]; require_launcher: boolean };
export interface HostedDownload { export interface HostedDownload {
platform: 'windows' | 'mac' | 'linux'; platform: 'windows' | 'mac' | 'linux';
@@ -238,6 +239,7 @@ export interface UserQuest {
} }
export interface Achievement { export interface Achievement {
requirement_type?: string;
id: string; id: string;
title: string; title: string;
description: string; description: string;
@@ -73,6 +73,7 @@
icon_item: a.icon_item || 'diamond_pickaxe', icon_item: a.icon_item || 'diamond_pickaxe',
icon_bg: a.icon_bg || 'deepslate', icon_bg: a.icon_bg || 'deepslate',
icon_border: a.icon_border || 'gold', icon_border: a.icon_border || 'gold',
requirement_type: a.requirement_type || 'stat',
stat_type: a.stat_type || a.requirement_key || 'blocks_broken', stat_type: a.stat_type || a.requirement_key || 'blocks_broken',
target_count: a.target_count || a.requirement_value || 1, target_count: a.target_count || a.requirement_value || 1,
secret: !!a.secret, secret: !!a.secret,
+1 -1
View File
@@ -78,7 +78,7 @@
function openEdit() { function openEdit() {
if (!s) return; if (!s) return;
draft = { name: s.name, access: s.access, allowed_groups: [...s.allowed_groups], require_launcher: s.require_launcher }; draft = { instance_id: s.instance_id, name: s.name, access: s.access, allowed_groups: [...s.allowed_groups], require_launcher: s.require_launcher };
editOpen = true; editOpen = true;
} }
async function saveEdit() { async function saveEdit() {