diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 78639fb..2fd0264 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -707,6 +707,12 @@ pub struct UserProfileView { pub featured_achievement: Option, pub achievements_count: usize, pub posts: Vec, + pub level_info: UserLevelInfo, + pub achievements: Vec, + pub badges: Vec, + pub friends_count: i64, + pub created_at: String, + pub stats: Option, } // --------------------------------------------------------------------------- diff --git a/fix-phase.cjs b/fix-phase.cjs new file mode 100644 index 0000000..aeeea6b --- /dev/null +++ b/fix-phase.cjs @@ -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,'); + 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)>').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",')); diff --git a/fix-review.cjs b/fix-review.cjs new file mode 100644 index 0000000..1a963a1 --- /dev/null +++ b/fix-review.cjs @@ -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::(), req_str.trim().parse::()) { + (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('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',') -> 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::().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),'); +}); diff --git a/integrations/common/src/main/java/net/scopenet/integration/Integration.java b/integrations/common/src/main/java/net/scopenet/integration/Integration.java index 053c878..5fee580 100644 --- a/integrations/common/src/main/java/net/scopenet/integration/Integration.java +++ b/integrations/common/src/main/java/net/scopenet/integration/Integration.java @@ -9,7 +9,7 @@ import java.util.function.*; public final class Integration implements AutoCloseable { public static final String UNAVAILABLE = "SCOPENET sign-in is unavailable. Please try again shortly."; public final Activity activity = new Activity(); - private final Settings settings; + private volatile Settings settings; private final PanelClient client; private final JsonObject hello; private final Consumer log; @@ -115,6 +115,11 @@ public final class Integration implements AutoCloseable { } } + public void reload(Settings next) { + client.updateSettings(next); + settings = next; + } + public Settings settings() { return settings; } @@ -129,6 +134,7 @@ public final class Integration implements AutoCloseable { @Override public void close() { closed = true; + client.close(); loginWorker.shutdownNow(); activity.leaveAll(); Future flush = syncWorker.submit(() -> { diff --git a/integrations/common/src/main/java/net/scopenet/integration/PanelClient.java b/integrations/common/src/main/java/net/scopenet/integration/PanelClient.java index a425654..eeede60 100644 --- a/integrations/common/src/main/java/net/scopenet/integration/PanelClient.java +++ b/integrations/common/src/main/java/net/scopenet/integration/PanelClient.java @@ -7,7 +7,23 @@ import java.net.http.*; import java.time.Duration; 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 claims = new java.util.concurrent.ConcurrentHashMap<>(); + private final java.util.Set 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() .connectTimeout(Duration.ofSeconds(3)) .followRedirects(HttpClient.Redirect.NEVER).build(); @@ -28,7 +44,7 @@ public final class PanelClient { if (err.has("message")) msg = err.get("message").getAsString(); else if (err.has("error")) msg = err.get("error").getAsString(); } catch (Exception ignored) {} - throw new IOException(msg); + throw new HttpFailure(response.statusCode(), msg); } try { 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) { + 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()) { return new ChunkCheckResult(false, true, null, null); } @@ -64,12 +98,12 @@ public final class PanelClient { try { JsonObject res = post("guilds/check-chunk", req); 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 guildTag = res.has("guild_tag") && !res.get("guild_tag").isJsonNull() ? res.get("guild_tag").getAsString() : null; return new ChunkCheckResult(claimed, allowed, guildName, guildTag); } 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("chunk_x", chunkX); 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 { @@ -88,7 +124,9 @@ public final class PanelClient { req.addProperty("dimension", dimension); req.addProperty("chunk_x", chunkX); 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 { @@ -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 { JsonObject req = new JsonObject(); + req.addProperty("operation_id", java.util.UUID.randomUUID().toString()); req.addProperty("seller_uuid", sellerUuid.toString()); req.addProperty("seller_name", sellerName); 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 { JsonObject req = new JsonObject(); + req.addProperty("operation_id", java.util.UUID.randomUUID().toString()); req.addProperty("listing_id", listingId); req.addProperty("buyer_uuid", buyerUuid.toString()); req.addProperty("buyer_name", buyerName); 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(); + } } diff --git a/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java b/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java index 6d0f8c8..d86016d 100644 --- a/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java +++ b/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java @@ -63,16 +63,13 @@ public final class Bridge { public static void stat(ServerPlayer player, String key, int amount) { Integration current = integration; 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); if (key.equals("deaths")) current.activity.event(player.getUUID(), player.getName().getString(), "death", null); } public static void action(ServerPlayer player, String action, int amount) { 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) { diff --git a/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java b/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java index 7642efb..e5d9eb3 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java @@ -20,21 +20,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener { getServer().getPluginManager().registerEvents(this, this); saveDefaultConfig(); try { - Settings settings = 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) - ); + Settings settings = readSettings(); integration = new Integration( settings, @@ -54,7 +40,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener { bindCommand("scopenet", scopenetCmd); // Register Essentials commands & GUI - if (settings.essentialsEnabled()) { + { EssentialsHandler essentials = new EssentialsHandler(this); getServer().getPluginManager().registerEvents(essentials, this); 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 - if (settings.economyEnabled()) { + { EconomyHandler economy = new EconomyHandler(this, integration); getServer().getPluginManager().registerEvents(economy, this); 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 - if (settings.guildsEnabled()) { + { GuildHandler guilds = new GuildHandler(this, integration); getServer().getPluginManager().registerEvents(guilds, this); 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) { org.bukkit.command.PluginCommand cmd = getCommand(name); if (cmd != null) { @@ -132,7 +147,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener { } 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); } } @@ -141,7 +156,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener { public void broken(BlockBreakEvent event) { if (integration != null && integration.settings().guildsEnabled() && integration.settings().landClaimingEnabled()) { 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()) { event.setCancelled(true); 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) { if (integration != null && integration.settings().guildsEnabled() && integration.settings().landClaimingEnabled()) { 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()) { event.setCancelled(true); 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) public void statistic(PlayerStatisticIncrementEvent event) { - if (integration == null || !integration.settings().levelingEnabled()) return; + if (integration == null) return; String item = event.getMaterial() != null ? ":" + event.getMaterial().name() : event.getEntityType() != null ? ":" + event.getEntityType().name() : ""; integration.activity.action(event.getPlayer().getUniqueId(), event.getPlayer().getName(), diff --git a/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyHandler.java b/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyHandler.java index beb9860..3d875dd 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyHandler.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyHandler.java @@ -35,6 +35,11 @@ public final class EconomyHandler implements CommandExecutor, Listener { // Trade sessions: maps player UUID to active trade session private final Map 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 activeMarketListings = new ConcurrentHashMap<>(); + // Standard sell values for common items private static final Map ITEM_SELL_VALUES = new HashMap<>(); static { @@ -219,16 +224,22 @@ public final class EconomyHandler implements CommandExecutor, Listener { } int amount = hand.getAmount(); 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, () -> { try { - double currentBal = integration.client().getBalance(player.getUniqueId(), player.getName()); - double newBal = currentBal + total; - integration.client().syncBalance(player.getUniqueId(), player.getName(), newBal); - player.sendMessage(ChatColor.GREEN + "Sold " + amount + "x " + hand.getType().name() + " for " - + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", total) + ChatColor.GREEN + "!"); + // FIX #4: atomic addBalance – no read-modify-write race condition. + double newBal = integration.client().addBalance( + 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 " + + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", total) + ChatColor.GREEN + + "! Balance: " + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", newBal)); + }); } catch (Exception e) { + // Item was NOT removed – nothing to restore. 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() ? hand.getItemMeta().getDisplayName() : hand.getType().name().replace('_', ' '); - player.getInventory().setItemInMainHand(null); player.sendMessage(ChatColor.GRAY + "Listing item on marketplace..."); + // FIX #5: Only remove item after the API accepted the listing. Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { try { integration.client().marketList(player.getUniqueId(), player.getName(), itemId, itemName, amount, price); - 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) + "!"); + Bukkit.getScheduler().runTask(plugin, () -> { + player.getInventory().setItemInMainHand(null); + 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) + "!"); + }); } catch (Exception e) { player.sendMessage(ChatColor.RED + "Market listing failed: " + e.getMessage()); - // Return item to player on main thread - Bukkit.getScheduler().runTask(plugin, () -> player.getInventory().addItem(hand)); + // Item was never removed; no restoration needed. } }); 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); ItemStack border = GuiHelper.createBorder(Material.BLUE_STAINED_GLASS_PANE); 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 ")); + 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.sendMessage(ChatColor.GRAY + "Loading market listings..."); Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { try { - // In a production setup, market listings come from API + JsonArray listings = integration.client().getMarketListings(); Bukkit.getScheduler().runTask(plugin, () -> { - // Populate some interactive market slots - inv.setItem(11, GuiHelper.createItem(Material.ENCHANTED_GOLDEN_APPLE, 1, "&6Enchanted Golden Apple", "&7Seller: &fSCOPENET Vault", "&7Price: &a$2,500.00", "", "&eClick to Buy!")); - inv.setItem(13, GuiHelper.createItem(Material.ELYTRA, 1, "&bElytra Wings", "&7Seller: &fAeroPlayer", "&7Price: &a$5,000.00", "", "&eClick to Buy!")); - inv.setItem(15, GuiHelper.createItem(Material.BEACON, 1, "&bNether Beacon", "&7Seller: &fMiningLord", "&7Price: &a$3,200.00", "", "&eClick to Buy!")); + // Clear loading placeholders + for (int i = 0; i < 45; i++) inv.setItem(i, null); + activeMarketListings.clear(); + 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; } - // Open trade session for both players - TradeSession session = new TradeSession(player, target); + // FIX #3: Use the fixed TradeSession that restricts each side and transfers items. + TradeSession session = new TradeSession(plugin, integration, player, target); activeTrades.put(player.getUniqueId(), session); activeTrades.put(target.getUniqueId(), session); session.open(); @@ -355,9 +396,11 @@ public final class EconomyHandler implements CommandExecutor, Listener { String title = event.getView().getTitle(); if (title.equals(GUI_SHOP_TITLE)) { + // FIX #2/#4: Always cancel – items cannot be taken from the shop GUI. event.setCancelled(true); 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 double price = -1; @@ -372,22 +415,16 @@ public final class EconomyHandler implements CommandExecutor, Listener { if (price <= 0) return; final double finalPrice = price; + final ItemStack toBuy = clicked.clone(); player.sendMessage(ChatColor.GRAY + "Processing purchase..."); Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { try { - double bal = integration.client().getBalance(player.getUniqueId(), player.getName()); - if (bal < finalPrice) { - player.sendMessage(ChatColor.RED + "Insufficient funds! Needed: $" + String.format(Locale.US, "%,.2f", finalPrice) - + ", you have: $" + String.format(Locale.US, "%,.2f", bal)); - return; - } - - double newBal = bal - finalPrice; - integration.client().syncBalance(player.getUniqueId(), player.getName(), newBal); - + // FIX #4: Atomic debit – the panel validates the balance and deducts atomically. + double newBal = integration.client().addBalance( + player.getUniqueId(), player.getName(), -finalPrice, "Shop purchase: " + toBuy.getType().name()); 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.sendMessage(ChatColor.GREEN + "Purchased " + give.getAmount() + "x " + give.getType().name() + " 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()); } }); + } 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)) { TradeSession session = activeTrades.get(player.getUniqueId()); if (session != null) { @@ -411,8 +481,10 @@ public final class EconomyHandler implements CommandExecutor, Listener { String title = event.getView().getTitle(); 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(); + List soldItems = new ArrayList<>(); + List unsellable = new ArrayList<>(); double totalEarned = 0; int countSold = 0; @@ -422,42 +494,80 @@ public final class EconomyHandler implements CommandExecutor, Listener { if (price != null) { totalEarned += price * item.getAmount(); countSold += item.getAmount(); + soldItems.add(item.clone()); } else { - // Return unsold item to player - player.getInventory().addItem(item); + unsellable.add(item.clone()); } } + // Return unsellable items immediately + for (ItemStack it : unsellable) { + player.getInventory().addItem(it); + } + if (totalEarned > 0) { final double earned = totalEarned; final int itemsCount = countSold; + // FIX #4: addBalance is atomic – no read-modify-write race. Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { try { - double cur = integration.client().getBalance(player.getUniqueId(), player.getName()); - integration.client().syncBalance(player.getUniqueId(), player.getName(), cur + earned); + double newBal = integration.client().addBalance( + player.getUniqueId(), player.getName(), earned, "Sell chest: " + itemsCount + " items"); 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) { - 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)) { - TradeSession session = activeTrades.remove(player.getUniqueId()); + TradeSession session = activeTrades.get(player.getUniqueId()); 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 final ScopenetPlugin plugin; + private final Integration integration; final Player p1; final Player p2; final Inventory inv; boolean p1Ready = 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 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 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 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.p2 = p2; 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() { ItemStack divider = GuiHelper.createBorder(Material.GRAY_STAINED_GLASS_PANE); - int[] dividerSlots = {4, 13, 22, 31, 40, 49}; - for (int s : dividerSlots) inv.setItem(s, divider); + for (int s : DIVIDERS) inv.setItem(s, divider); updateReadyButtons(); } @@ -484,9 +593,12 @@ public final class EconomyHandler implements CommandExecutor, Listener { } void handleClick(Player clicker, InventoryClickEvent event) { + if (completed || cancelled) { event.setCancelled(true); return; } + int slot = event.getRawSlot(); boolean isP1 = clicker.getUniqueId().equals(p1.getUniqueId()); + // Ready toggle buttons if (slot == 0 && isP1) { event.setCancelled(true); p1Ready = !p1Ready; @@ -502,24 +614,80 @@ public final class EconomyHandler implements CommandExecutor, Listener { return; } - // Divider slots are not interactable - if (slot == 4 || slot == 13 || slot == 22 || slot == 31 || slot == 40 || slot == 49) { + // Dividers and ready buttons on the wrong side are always blocked + if (DIVIDERS.contains(slot) || slot == 0 || slot == 8) { 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() { - if (p1Ready && p2Ready) { + if (!p1Ready || !p2Ready || completed || cancelled) return; + completed = true; + + // FIX #3: Actually transfer items between players. + // Collect each side's offered items. + List p1Offer = new ArrayList<>(); + List 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()); + } + + p1.closeInventory(); + p2.closeInventory(); + + // 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!"); - p1.closeInventory(); - p2.closeInventory(); - } + }); } - void cancel() { - p1.sendMessage(ChatColor.RED + "Trade cancelled."); - p2.sendMessage(ChatColor.RED + "Trade cancelled."); + void handleClose(Player closer, Map 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."); + } + }); } } } diff --git a/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyOperations.java b/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyOperations.java new file mode 100644 index 0000000..57bbd2a --- /dev/null +++ b/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyOperations.java @@ -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 jobs = new LinkedHashMap<>(); + private final Set 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 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 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()); } + } +} diff --git a/integrations/paper/src/main/java/net/scopenet/paper/commands/EssentialsHandler.java b/integrations/paper/src/main/java/net/scopenet/paper/commands/EssentialsHandler.java index 70879b3..cdbe561 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/commands/EssentialsHandler.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/commands/EssentialsHandler.java @@ -147,6 +147,7 @@ public final class EssentialsHandler implements CommandExecutor, Listener { return true; } + if (!plugin.getConfig().getBoolean("essentials.enabled", true)) { player.sendMessage(ChatColor.RED + "Essentials are disabled."); return true; } String cmd = command.getName().toLowerCase(); switch (cmd) { case "spawn" -> handleSpawn(player); diff --git a/integrations/paper/src/main/java/net/scopenet/paper/commands/GuildHandler.java b/integrations/paper/src/main/java/net/scopenet/paper/commands/GuildHandler.java index e9099e0..ce5399b 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/commands/GuildHandler.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/commands/GuildHandler.java @@ -71,6 +71,7 @@ public final class GuildHandler implements CommandExecutor, Listener { return true; } + if (!integration.settings().guildsEnabled()) { player.sendMessage(ChatColor.RED + "Guilds are disabled."); return true; } String cmd = command.getName().toLowerCase(); if (cmd.equals("claim")) { handleClaim(player); @@ -120,7 +121,7 @@ public final class GuildHandler implements CommandExecutor, Listener { private void handleClaim(Player player) { 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() + "]..."); Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { @@ -136,7 +137,7 @@ public final class GuildHandler implements CommandExecutor, Listener { private void handleUnclaim(Player player) { 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() + "]..."); Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { @@ -255,7 +256,7 @@ public final class GuildHandler implements CommandExecutor, Listener { Chunk center = pLoc.getChunk(); int cx = center.getX(); 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.GRAY + " N (-Z)"); diff --git a/integrations/paper/src/main/java/net/scopenet/paper/commands/ScopenetCommandHandler.java b/integrations/paper/src/main/java/net/scopenet/paper/commands/ScopenetCommandHandler.java index 59b4916..51bae0f 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/commands/ScopenetCommandHandler.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/commands/ScopenetCommandHandler.java @@ -124,7 +124,9 @@ public final class ScopenetCommandHandler implements CommandExecutor { return; } - plugin.reloadConfig(); - sender.sendMessage(ChatColor.GREEN + "SCOPENET configuration reloaded successfully."); + try { + plugin.reloadIntegrationSettings(); + sender.sendMessage(ChatColor.GREEN + "SCOPENET configuration reloaded successfully."); + } catch (Exception e) { sender.sendMessage(ChatColor.RED + "Reload failed; existing integration settings retained: " + e.getMessage()); } } } diff --git a/launcher/src-tauri/src/commands.rs b/launcher/src-tauri/src/commands.rs index cbec442..b93c272 100644 --- a/launcher/src-tauri/src/commands.rs +++ b/launcher/src-tauri/src/commands.rs @@ -626,11 +626,14 @@ pub async fn create_guild( pub async fn get_guild_claims( state: State<'_, AppState>, instance_id: String, + server_id: i64, dimension: Option, + center_x: i32, + center_z: i32, ) -> Res> { let panel = state.panel_url().ok_or("no panel configured")?; 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}¢er_x={center_x}¢er_z={center_z}")) .send().await.map_err(err)?; if !resp.status().is_success() { return Err("unable to load claims".into()); @@ -643,6 +646,7 @@ pub async fn claim_guild_chunk( state: State<'_, AppState>, guild_id: String, instance_id: String, + server_id: i64, dimension: String, chunk_x: 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 resp = req.json(&serde_json::json!({ "instance_id": instance_id, + "server_id": server_id, "dimension": dimension, "chunk_x": chunk_x, "chunk_z": chunk_z, @@ -662,25 +667,9 @@ pub async fn claim_guild_chunk( } #[tauri::command] -pub async fn unclaim_guild_chunk( - state: State<'_, AppState>, - guild_id: String, - 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()); - } +pub async fn unclaim_guild_chunk(state: State<'_, AppState>, claim_id: i64) -> Res<()> { + let req = account_api(&state, reqwest::Method::DELETE, &format!("/guilds/claims/{claim_id}")).await?; + req.send().await.map_err(err)?.error_for_status().map_err(err)?; Ok(()) } @@ -827,8 +816,18 @@ pub async fn get_transactions(state: State<'_, AppState>) -> Res, friend_uuid: String) -> Res> { - let req = account_api(&state, reqwest::Method::GET, &format!("/messages/{friend_uuid}")).await?; +pub async fn get_direct_messages( + state: State<'_, AppState>, + friend_uuid: String, + before_id: Option, +) -> Res> { + // 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)?; if !resp.status().is_success() { return Err("unable to load messages".into()); @@ -863,7 +862,7 @@ pub async fn send_game_invite( recipient_uuid: String, instance_id: String, server_id: Option, -) -> Res { +) -> Res<()> { let req = account_api(&state, reqwest::Method::POST, "/invites").await?; let resp = req.json(&serde_json::json!({ "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(); return Err(body["error"].as_str().unwrap_or("unable to send invite").to_string()); } - resp.json().await.map_err(err) + Ok(()) } #[tauri::command] 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 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() { 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()); @@ -917,7 +916,8 @@ pub async fn update_my_profile( 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()); } - 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] @@ -937,7 +937,7 @@ pub async fn create_user_post( content: String, image_url: Option, ) -> Res { - 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!({ "content": content, "image_url": image_url, diff --git a/launcher/src-tauri/src/telemetry.rs b/launcher/src-tauri/src/telemetry.rs index 489bd6a..d50cccb 100644 --- a/launcher/src-tauri/src/telemetry.rs +++ b/launcher/src-tauri/src/telemetry.rs @@ -14,10 +14,15 @@ pub struct Recorder { } pub fn capture(state: &AppState) -> Option { + // 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 token = accounts::panel_token(state); let accounts = state.accounts.read().unwrap(); let active = accounts.active()?; - let token = accounts::panel_token(state); + Some(Recorder { http: state.http.clone(), panel, diff --git a/launcher/src/components/ChunkMap.svelte b/launcher/src/components/ChunkMap.svelte index 28b1bc8..00223f3 100644 --- a/launcher/src/components/ChunkMap.svelte +++ b/launcher/src/components/ChunkMap.svelte @@ -22,6 +22,8 @@ onunclaim?: (claimId: number) => void; } = $props(); + let servers = $state>([]); + let serverId = $state(null); let dimension = $state<'minecraft:overworld' | 'minecraft:the_nether' | 'minecraft:the_end'>('minecraft:overworld'); let centerX = $state(0); let centerZ = $state(0); @@ -39,11 +41,14 @@ ] as const; async function loadClaims() { - if (!instanceId) return; + if (!instanceId || !serverId) { claims = []; return; } loading = true; try { claims = await invoke('get_guild_claims', { instanceId, + serverId, + centerX, + centerZ, dimension }); } catch (e: any) { @@ -66,11 +71,13 @@ ); async function handleClaim() { - if (!selectedChunk || !myGuildId) return; + if (!selectedChunk || !myGuildId || !serverId) return; claiming = true; try { const claim = await invoke('claim_guild_chunk', { + guildId: myGuildId, instanceId, + serverId, dimension, chunkX: selectedChunk.x, chunkZ: selectedChunk.z @@ -112,17 +119,28 @@ } $effect(() => { - if (instanceId && dimension) { + if (instanceId && dimension && serverId) { loadClaims(); } }); - onMount(() => { - loadClaims(); + onMount(async () => { + 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; });
+ + {#if !serverId}

Link a game server to this instance in the panel before claiming territory.

{/if}
diff --git a/launcher/src/components/PlayerProfileModal.svelte b/launcher/src/components/PlayerProfileModal.svelte index af69a22..205cf67 100644 --- a/launcher/src/components/PlayerProfileModal.svelte +++ b/launcher/src/components/PlayerProfileModal.svelte @@ -54,7 +54,7 @@ savingProfile = true; try { profile = await invoke('update_my_profile', { - bio: editBio.trim() || null, + bio: editBio.trim(), bannerUrl: editBanner.trim() || null }); editing = false; @@ -89,9 +89,8 @@ async function handleLikePost(post: UserPost) { try { - const liked = await invoke('like_user_post', { postId: post.id }); - post.liked_by_me = liked; - post.likes_count = liked ? post.likes_count + 1 : Math.max(0, post.likes_count - 1); + await invoke('like_user_post', { postId: post.id }); + post.likes_count += 1; } catch (e: any) { toast(e?.message ?? 'Failed to update like', 'error'); } @@ -131,8 +130,8 @@

{profile.username}

- {#if profile.global_level?.title} - {profile.global_level.title} + {#if profile.level_info?.title} + {profile.level_info.title} {/if}
@@ -140,7 +139,7 @@ • {profile.friends_count} Friends • - Level {profile.global_level?.level ?? 1} + Level {profile.level_info?.level ?? 1}
@@ -195,7 +194,7 @@ Levels & Ranks
- Level {profile.global_level.level} + Level {profile.level_info.level}
- {profile.global_level.current_xp.toLocaleString()} XP - {profile.global_level.next_level_xp.toLocaleString()} XP + {profile.level_info.current_xp.toLocaleString()} XP + {profile.level_info.next_level_xp.toLocaleString()} XP
-
+
diff --git a/launcher/src/lib/mock.ts b/launcher/src/lib/mock.ts index c6257b1..57a15f2 100644 --- a/launcher/src/lib/mock.ts +++ b/launcher/src/lib/mock.ts @@ -217,9 +217,9 @@ export async function mockInvoke(cmd: string, args: Record): Promis return null; case 'get_guild_members': 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', user_uuid: 'friend-1', username: 'Alex', role: 'officer', joined_at: '2026-09-02T00:00:00Z', is_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: 'mock-user-1', name: 'Alyssa', role: 'leader', joined_at: '2026-09-01T00:00:00Z', online: true }, + { guild_id: 'g1', uuid: 'friend-1', name: 'Alex', role: 'officer', joined_at: '2026-09-02T00:00:00Z', online: true }, + { guild_id: 'g1', uuid: 'friend-2', name: 'Steve', role: 'member', joined_at: '2026-09-05T00:00:00Z', online: false } ]; case 'get_guild_posts': return [ @@ -229,9 +229,9 @@ export async function mockInvoke(cmd: string, args: Record): 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() }; case 'get_friends': return [ - { uuid: 'friend-1', username: 'Alex', status: 'accepted', is_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-3', username: 'Notch', status: 'pending_received', is_online: true, playing_on: null, 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', online: false, 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 'respond_friend_request': @@ -239,17 +239,17 @@ export async function mockInvoke(cmd: string, args: Record): Promis return null; case 'get_direct_messages': 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: 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: 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, 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': - 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': 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': - 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': return null; case 'get_user_profile': @@ -261,7 +261,7 @@ export async function mockInvoke(cmd: string, args: Record): Promis skin_url: null, skin_model: 'classic', 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_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): Promis skin_url: null, skin_model: 'classic', 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: [], badges: ['Beta Pioneer'], achievements: [], diff --git a/launcher/src/lib/types.ts b/launcher/src/lib/types.ts index e14e36c..97a2108 100644 --- a/launcher/src/lib/types.ts +++ b/launcher/src/lib/types.ts @@ -226,16 +226,15 @@ export interface Guild { } export interface GuildMember { - guild_id: string; - user_uuid: string; - username: string; + uuid: string; + name: string; role: 'leader' | 'officer' | 'member'; joined_at: string; - is_online?: boolean; + online?: boolean; } export interface GuildPost { - id: string; + id: number; guild_id: string; author_uuid: string; author_name: string; @@ -248,7 +247,7 @@ export interface GuildPost { export interface GuildClaim { id: number; guild_id: string; - instance_id: string; + server_id: number; dimension: string; chunk_x: number; chunk_z: number; @@ -261,16 +260,16 @@ export interface GuildClaim { export interface FriendInfo { uuid: string; username: string; - status: 'pending_sent' | 'pending_received' | 'accepted'; - is_online: boolean; + status: 'pending_outgoing' | 'pending_incoming' | 'accepted'; + online: boolean; playing_on: string | null; avatar_url?: string | null; } export interface DirectMessage { id: number; - from_uuid: string; - to_uuid: string; + sender_uuid: string; + recipient_uuid: string; content: string; created_at: string; is_read: boolean; @@ -278,18 +277,18 @@ export interface DirectMessage { export interface GameInvite { id: string; - from_uuid: string; - from_username: string; - to_uuid: string; + sender_uuid: string; + sender_name: string; + recipient_uuid: string; instance_id: string; - server_address: string; + instance_name: string; server_name: string; status: 'pending' | 'accepted' | 'declined' | 'expired'; created_at: string; } export interface UserPost { - id: string; + id: number; user_uuid: string; username: string; content: string; @@ -307,10 +306,14 @@ export interface UserProfileView { skin_url: string | null; skin_model: string; cape_url: string | null; - global_level: UserLevelInfo; + global_level: number; + global_xp: number; + title: string | null; + level_info: UserLevelInfo; server_levels: ServerLevelInfo[]; badges: string[]; achievements: Achievement[]; + achievements_count: number; posts: UserPost[]; friends_count: number; stats: PlayerStats | null; diff --git a/launcher/src/pages/Guilds.svelte b/launcher/src/pages/Guilds.svelte index bdea874..b75a9c5 100644 --- a/launcher/src/pages/Guilds.svelte +++ b/launcher/src/pages/Guilds.svelte @@ -179,7 +179,7 @@

{myGuild.name}

- {myGuild.member_count} / {myGuild.max_members} Members + {myGuild.member_count} Members {myGuild.claims_count} Claims
{#if myGuild.description} @@ -228,23 +228,23 @@ {:else if activeTab === 'members'}
- {#each members as m (m.user_uuid)} + {#each members as m (m.uuid)}
(app.viewProfileUuid = m.user_uuid)} - onkeydown={(e) => e.key === 'Enter' && (app.viewProfileUuid = m.user_uuid)} + onclick={() => (app.viewProfileUuid = m.uuid)} + onkeydown={(e) => e.key === 'Enter' && (app.viewProfileUuid = m.uuid)} >
- - {#if m.is_online} + + {#if m.online} {/if}
- {m.username} + {m.name} {#if m.role === 'leader'} Leader {:else if m.role === 'officer'} diff --git a/launcher/src/pages/Settings.svelte b/launcher/src/pages/Settings.svelte index 248bcba..8311eb6 100644 --- a/launcher/src/pages/Settings.svelte +++ b/launcher/src/pages/Settings.svelte @@ -408,7 +408,7 @@ {#if skinProfiles.length}
{#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)}
diff --git a/launcher/src/pages/Social.svelte b/launcher/src/pages/Social.svelte index 0b377bc..3f2547c 100644 --- a/launcher/src/pages/Social.svelte +++ b/launcher/src/pages/Social.svelte @@ -141,7 +141,7 @@ const text = messageInput.trim(); try { const msg = await invoke('send_direct_message', { - toUuid: activeChatFriend.uuid, + friendUuid: activeChatFriend.uuid, content: text }); messages = [...messages, msg]; @@ -162,12 +162,12 @@ if (!inviteTargetFriend) return; sendingInvite = true; 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', { - toUuid: inviteTargetFriend.uuid, + recipientUuid: inviteTargetFriend.uuid, instanceId: inviteInstanceId, - serverAddress: inst?.server?.address ?? 'localhost', - serverName: inst?.name ?? 'Minecraft Server' + serverId: null // Optional; pass a server_id i64 here if the instance has a known server. }); toast(`Game invite sent to ${inviteTargetFriend.username}!`, 'ok'); inviteModalOpen = false; @@ -191,12 +191,12 @@ } const acceptedFriends = $derived(friends.filter((f) => f.status === 'accepted')); - const pendingReceived = $derived(friends.filter((f) => f.status === 'pending_received')); - const pendingSent = $derived(friends.filter((f) => f.status === 'pending_sent')); + const pendingReceived = $derived(friends.filter((f) => f.status === 'pending_incoming')); + const pendingSent = $derived(friends.filter((f) => f.status === 'pending_outgoing')); const displayedFriends = $derived( 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'; return f.status === 'accepted'; }) @@ -267,7 +267,7 @@ All ({acceptedFriends.length})
{:else} {#each messages as msg (msg.id)} - {@const isMine = msg.from_uuid === activeAccount()?.uuid} + {@const isMine = msg.sender_uuid === activeAccount()?.uuid}

{msg.content}

@@ -586,11 +586,11 @@ {#each invites as inv (inv.id)}
- +
- {inv.from_username} invited you to play + {inv.sender_name} invited you to play {inv.server_name} - {inv.server_address} + {inv.instance_name}