fix: resolve all remaining audit failures across rust, svelte, and java
This commit is contained in:
1 parent
bc12a25a01
commit
590178e41a
35 files changed
+946
-288
No files matched your search
@@ -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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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",'));
|
||||||
@@ -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)");
|
||||||
|
|||||||
+3
-1
@@ -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()); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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}¢er_x={center_x}¢er_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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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 = ?",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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?;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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!({
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in new issue
Block a user