feat: complete in-game commands, GUIs, admin panel fixes, and launcher enhancements
This commit is contained in:
1 parent
a9ff92e1d1
commit
752f0cc43d
34 files changed
+4170
-181
No files matched your search
+101
-1
@@ -417,13 +417,34 @@ pub struct LaunchEvent {
|
|||||||
pub struct UserLevelInfo {
|
pub struct UserLevelInfo {
|
||||||
pub uuid: String,
|
pub uuid: String,
|
||||||
pub global_xp: i64,
|
pub global_xp: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub current_xp: i64,
|
||||||
pub global_level: i64,
|
pub global_level: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub level: i64,
|
||||||
pub current_level_xp: i64,
|
pub current_level_xp: i64,
|
||||||
pub next_level_xp: i64,
|
pub next_level_xp: i64,
|
||||||
pub progress_pct: f64,
|
pub progress_pct: f64,
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
pub badges: Vec<String>,
|
pub badges: Vec<String>,
|
||||||
pub server_levels: Vec<ServerLevelInfo>,
|
pub server_levels: Vec<ServerLevelInfo>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rank: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rewards: Vec<LevelReward>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
pub struct LevelReward {
|
||||||
|
pub id: i64,
|
||||||
|
pub level: i64,
|
||||||
|
pub reward_type: String,
|
||||||
|
pub reward_value: String,
|
||||||
|
pub description: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub icon: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub server_id: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
@@ -450,16 +471,26 @@ pub struct Quest {
|
|||||||
pub xp_reward: i64,
|
pub xp_reward: i64,
|
||||||
pub icon: String,
|
pub icon: String,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub quest_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub stat_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub active: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
pub struct UserQuest {
|
pub struct UserQuest {
|
||||||
#[serde(flatten)]
|
|
||||||
pub quest: Quest,
|
pub quest: Quest,
|
||||||
|
#[serde(default)]
|
||||||
pub progress: i64,
|
pub progress: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub current_count: i64,
|
||||||
pub completed: bool,
|
pub completed: bool,
|
||||||
pub claimed: bool,
|
pub claimed: bool,
|
||||||
pub period_key: String,
|
pub period_key: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub expires_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -473,17 +504,86 @@ pub struct Achievement {
|
|||||||
pub description: String,
|
pub description: String,
|
||||||
pub category: String,
|
pub category: String,
|
||||||
pub icon_frame: String, // "task", "goal", "challenge"
|
pub icon_frame: String, // "task", "goal", "challenge"
|
||||||
|
#[serde(default)]
|
||||||
|
pub frame_type: Option<String>,
|
||||||
pub icon_item: String,
|
pub icon_item: String,
|
||||||
pub icon_bg: String,
|
pub icon_bg: String,
|
||||||
pub icon_border: String,
|
pub icon_border: String,
|
||||||
pub xp_reward: i64,
|
pub xp_reward: i64,
|
||||||
pub secret: bool,
|
pub secret: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub stat_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub target_count: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
pub unlocked: bool,
|
pub unlocked: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub unlocked_at: Option<String>,
|
pub unlocked_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Economy
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
pub struct ServerEconomyBalance {
|
||||||
|
pub server_id: i64,
|
||||||
|
pub server_name: String,
|
||||||
|
pub balance: f64,
|
||||||
|
pub currency_symbol: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
pub struct EconomyTransaction {
|
||||||
|
pub id: i64,
|
||||||
|
pub server_id: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub server_name: Option<String>,
|
||||||
|
pub from_uuid: String,
|
||||||
|
pub from_name: String,
|
||||||
|
pub to_uuid: String,
|
||||||
|
pub to_name: String,
|
||||||
|
pub amount: f64,
|
||||||
|
pub description: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
pub struct BaltopEntry {
|
||||||
|
pub rank: usize,
|
||||||
|
pub uuid: String,
|
||||||
|
pub username: String,
|
||||||
|
pub balance: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
pub struct MarketListing {
|
||||||
|
pub id: i64,
|
||||||
|
pub server_id: i64,
|
||||||
|
pub seller_uuid: String,
|
||||||
|
pub seller_name: String,
|
||||||
|
pub item_id: String,
|
||||||
|
pub item_name: String,
|
||||||
|
pub amount: i32,
|
||||||
|
pub price: f64,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
pub struct MemberProfile {
|
||||||
|
pub uuid: String,
|
||||||
|
pub username: String,
|
||||||
|
pub role: String,
|
||||||
|
pub status: String,
|
||||||
|
pub skin_url: Option<String>,
|
||||||
|
pub global_level: i64,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub playtime_secs: i64,
|
||||||
|
pub last_seen: Option<String>,
|
||||||
|
pub online: bool,
|
||||||
|
pub is_friend: bool,
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Guilds & Land Claiming
|
// Guilds & Land Claiming
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -119,6 +119,10 @@ public final class Integration implements AutoCloseable {
|
|||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public PanelClient client() {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
public ChunkCheckResult checkChunk(String dimension, int chunkX, int chunkZ, UUID uuid) {
|
public ChunkCheckResult checkChunk(String dimension, int chunkX, int chunkZ, UUID uuid) {
|
||||||
return client.checkChunk(dimension, chunkX, chunkZ, uuid);
|
return client.checkChunk(dimension, chunkX, chunkZ, uuid);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,24 +14,33 @@ public final class PanelClient {
|
|||||||
|
|
||||||
public PanelClient(Settings settings) { this.settings = settings; }
|
public PanelClient(Settings settings) { this.settings = settings; }
|
||||||
|
|
||||||
public JsonObject post(String endpoint, JsonObject payload) throws IOException, InterruptedException {
|
public JsonElement postElement(String endpoint, JsonObject payload) throws IOException, InterruptedException {
|
||||||
HttpRequest request = HttpRequest.newBuilder(URI.create(settings.panel() + "/api/server/v1/" + endpoint))
|
HttpRequest request = HttpRequest.newBuilder(URI.create(settings.panel() + "/api/server/v1/" + endpoint))
|
||||||
.timeout(Duration.ofSeconds(5))
|
.timeout(Duration.ofSeconds(5))
|
||||||
.header("Authorization", "Bearer " + settings.token())
|
.header("Authorization", "Bearer " + settings.token())
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(payload.toString())).build();
|
.POST(HttpRequest.BodyPublishers.ofString(payload.toString())).build();
|
||||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
if (response.statusCode() != 200) {
|
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||||
// Never log the token, request body, or an untrusted HTML error page.
|
String msg = "HTTP " + response.statusCode();
|
||||||
throw new IOException("Panel " + endpoint + " returned HTTP " + response.statusCode());
|
try {
|
||||||
|
JsonObject err = JsonParser.parseString(response.body()).getAsJsonObject();
|
||||||
|
if (err.has("message")) msg = err.get("message").getAsString();
|
||||||
|
else if (err.has("error")) msg = err.get("error").getAsString();
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
throw new IOException(msg);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return JsonParser.parseString(response.body()).getAsJsonObject();
|
return JsonParser.parseString(response.body());
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
throw new IOException("Invalid panel response", e);
|
throw new IOException("Invalid panel response", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public JsonObject post(String endpoint, JsonObject payload) throws IOException, InterruptedException {
|
||||||
|
return postElement(endpoint, payload).getAsJsonObject();
|
||||||
|
}
|
||||||
|
|
||||||
public static String verdict(JsonObject response) throws IOException {
|
public static String verdict(JsonObject response) throws IOException {
|
||||||
JsonElement allowed = response.get("allowed");
|
JsonElement allowed = response.get("allowed");
|
||||||
if (allowed == null || !allowed.isJsonPrimitive() || !allowed.getAsJsonPrimitive().isBoolean()) {
|
if (allowed == null || !allowed.isJsonPrimitive() || !allowed.getAsJsonPrimitive().isBoolean()) {
|
||||||
@@ -63,5 +72,96 @@ public final class PanelClient {
|
|||||||
return new ChunkCheckResult(false, true, null, null);
|
return new ChunkCheckResult(false, true, null, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public JsonObject claimChunk(String dimension, int chunkX, int chunkZ, java.util.UUID uuid) throws IOException, InterruptedException {
|
||||||
|
JsonObject req = new JsonObject();
|
||||||
|
req.addProperty("uuid", uuid.toString());
|
||||||
|
req.addProperty("dimension", dimension);
|
||||||
|
req.addProperty("chunk_x", chunkX);
|
||||||
|
req.addProperty("chunk_z", chunkZ);
|
||||||
|
return post("guilds/claim", req);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonObject unclaimChunk(String dimension, int chunkX, int chunkZ, java.util.UUID uuid) throws IOException, InterruptedException {
|
||||||
|
JsonObject req = new JsonObject();
|
||||||
|
req.addProperty("uuid", uuid.toString());
|
||||||
|
req.addProperty("dimension", dimension);
|
||||||
|
req.addProperty("chunk_x", chunkX);
|
||||||
|
req.addProperty("chunk_z", chunkZ);
|
||||||
|
return post("guilds/unclaim", req);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonObject getPlayerGuild(java.util.UUID uuid) throws IOException, InterruptedException {
|
||||||
|
JsonObject req = new JsonObject();
|
||||||
|
req.addProperty("uuid", uuid.toString());
|
||||||
|
return post("guilds/player", req);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonObject createGuild(java.util.UUID uuid, String username, String name, String tag) throws IOException, InterruptedException {
|
||||||
|
JsonObject req = new JsonObject();
|
||||||
|
req.addProperty("uuid", uuid.toString());
|
||||||
|
req.addProperty("username", username);
|
||||||
|
req.addProperty("name", name);
|
||||||
|
req.addProperty("tag", tag);
|
||||||
|
return post("guilds/create", req);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonObject leaveGuild(java.util.UUID uuid) throws IOException, InterruptedException {
|
||||||
|
JsonObject req = new JsonObject();
|
||||||
|
req.addProperty("uuid", uuid.toString());
|
||||||
|
return post("guilds/leave", req);
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getBalance(java.util.UUID uuid, String username) throws IOException, InterruptedException {
|
||||||
|
JsonObject req = new JsonObject();
|
||||||
|
req.addProperty("uuid", uuid.toString());
|
||||||
|
req.addProperty("username", username);
|
||||||
|
JsonObject res = post("economy/balance", req);
|
||||||
|
return res.has("balance") ? res.get("balance").getAsDouble() : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double transfer(java.util.UUID fromUuid, String fromName, java.util.UUID toUuid, String toName, double amount, String description) throws IOException, InterruptedException {
|
||||||
|
JsonObject req = new JsonObject();
|
||||||
|
req.addProperty("from_uuid", fromUuid.toString());
|
||||||
|
req.addProperty("from_name", fromName);
|
||||||
|
req.addProperty("to_uuid", toUuid.toString());
|
||||||
|
req.addProperty("to_name", toName);
|
||||||
|
req.addProperty("amount", amount);
|
||||||
|
req.addProperty("description", description);
|
||||||
|
JsonObject res = post("economy/transfer", req);
|
||||||
|
return res.has("from_balance") ? res.get("from_balance").getAsDouble() : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void syncBalance(java.util.UUID uuid, String username, double newBalance) throws IOException, InterruptedException {
|
||||||
|
JsonObject req = new JsonObject();
|
||||||
|
req.addProperty("uuid", uuid.toString());
|
||||||
|
req.addProperty("username", username);
|
||||||
|
req.addProperty("new_balance", newBalance);
|
||||||
|
post("economy/sync-balance", req);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonArray getBaltop() throws IOException, InterruptedException {
|
||||||
|
JsonElement el = postElement("economy/baltop", new JsonObject());
|
||||||
|
return el.isJsonArray() ? el.getAsJsonArray() : new JsonArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonObject marketList(java.util.UUID sellerUuid, String sellerName, String itemId, String itemName, int amount, double price) throws IOException, InterruptedException {
|
||||||
|
JsonObject req = new JsonObject();
|
||||||
|
req.addProperty("seller_uuid", sellerUuid.toString());
|
||||||
|
req.addProperty("seller_name", sellerName);
|
||||||
|
req.addProperty("item_id", itemId);
|
||||||
|
req.addProperty("item_name", itemName);
|
||||||
|
req.addProperty("amount", amount);
|
||||||
|
req.addProperty("price", price);
|
||||||
|
return post("economy/market/list", req);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonObject marketBuy(long listingId, java.util.UUID buyerUuid, String buyerName) throws IOException, InterruptedException {
|
||||||
|
JsonObject req = new JsonObject();
|
||||||
|
req.addProperty("listing_id", listingId);
|
||||||
|
req.addProperty("buyer_uuid", buyerUuid.toString());
|
||||||
|
req.addProperty("buyer_name", buyerName);
|
||||||
|
return post("economy/market/buy", req);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,7 +19,9 @@ public record Settings(
|
|||||||
boolean guildsEnabled,
|
boolean guildsEnabled,
|
||||||
boolean landClaimingEnabled,
|
boolean landClaimingEnabled,
|
||||||
boolean socialEnabled,
|
boolean socialEnabled,
|
||||||
boolean chatPrefixesEnabled
|
boolean chatPrefixesEnabled,
|
||||||
|
boolean essentialsEnabled,
|
||||||
|
boolean economyEnabled
|
||||||
) {
|
) {
|
||||||
public Settings {
|
public Settings {
|
||||||
if (panel == null || panel.getHost() == null || panel.getRawUserInfo() != null
|
if (panel == null || panel.getHost() == null || panel.getRawUserInfo() != null
|
||||||
@@ -34,11 +36,27 @@ public record Settings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Settings(URI panel, String token) {
|
public Settings(URI panel, String token) {
|
||||||
this(panel, token, true, 1.0, 1.5, true, true, true, true, true, true);
|
this(panel, token, true, 1.0, 1.5, true, true, true, true, true, true, true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Settings(
|
||||||
|
URI panel,
|
||||||
|
String token,
|
||||||
|
boolean levelingEnabled,
|
||||||
|
double globalXpMultiplier,
|
||||||
|
double serverXpMultiplier,
|
||||||
|
boolean questsEnabled,
|
||||||
|
boolean achievementsEnabled,
|
||||||
|
boolean guildsEnabled,
|
||||||
|
boolean landClaimingEnabled,
|
||||||
|
boolean socialEnabled,
|
||||||
|
boolean chatPrefixesEnabled
|
||||||
|
) {
|
||||||
|
this(panel, token, levelingEnabled, globalXpMultiplier, serverXpMultiplier, questsEnabled, achievementsEnabled, guildsEnabled, landClaimingEnabled, socialEnabled, chatPrefixesEnabled, true, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Settings of(String panel, String token) {
|
public static Settings of(String panel, String token) {
|
||||||
return of(panel, token, true, 1.0, 1.5, true, true, true, true, true, true);
|
return of(panel, token, true, 1.0, 1.5, true, true, true, true, true, true, true, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Settings of(
|
public static Settings of(
|
||||||
@@ -53,6 +71,24 @@ public record Settings(
|
|||||||
boolean landClaimingEnabled,
|
boolean landClaimingEnabled,
|
||||||
boolean socialEnabled,
|
boolean socialEnabled,
|
||||||
boolean chatPrefixesEnabled
|
boolean chatPrefixesEnabled
|
||||||
|
) {
|
||||||
|
return of(panel, token, levelingEnabled, globalXpMultiplier, serverXpMultiplier, questsEnabled, achievementsEnabled, guildsEnabled, landClaimingEnabled, socialEnabled, chatPrefixesEnabled, true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Settings of(
|
||||||
|
String panel,
|
||||||
|
String token,
|
||||||
|
boolean levelingEnabled,
|
||||||
|
double globalXpMultiplier,
|
||||||
|
double serverXpMultiplier,
|
||||||
|
boolean questsEnabled,
|
||||||
|
boolean achievementsEnabled,
|
||||||
|
boolean guildsEnabled,
|
||||||
|
boolean landClaimingEnabled,
|
||||||
|
boolean socialEnabled,
|
||||||
|
boolean chatPrefixesEnabled,
|
||||||
|
boolean essentialsEnabled,
|
||||||
|
boolean economyEnabled
|
||||||
) {
|
) {
|
||||||
return new Settings(
|
return new Settings(
|
||||||
URI.create(panel.trim().replaceAll("/+$", "")),
|
URI.create(panel.trim().replaceAll("/+$", "")),
|
||||||
@@ -65,7 +101,9 @@ public record Settings(
|
|||||||
guildsEnabled,
|
guildsEnabled,
|
||||||
landClaimingEnabled,
|
landClaimingEnabled,
|
||||||
socialEnabled,
|
socialEnabled,
|
||||||
chatPrefixesEnabled
|
chatPrefixesEnabled,
|
||||||
|
essentialsEnabled,
|
||||||
|
economyEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +125,8 @@ public record Settings(
|
|||||||
guilds.land_claiming=true
|
guilds.land_claiming=true
|
||||||
social.enabled=true
|
social.enabled=true
|
||||||
social.chat_prefixes=true
|
social.chat_prefixes=true
|
||||||
|
essentials.enabled=true
|
||||||
|
economy.enabled=true
|
||||||
""";
|
""";
|
||||||
Files.writeString(path, defaultProps, StandardCharsets.UTF_8);
|
Files.writeString(path, defaultProps, StandardCharsets.UTF_8);
|
||||||
}
|
}
|
||||||
@@ -105,7 +145,9 @@ public record Settings(
|
|||||||
Boolean.parseBoolean(properties.getProperty("guilds.enabled", "true")),
|
Boolean.parseBoolean(properties.getProperty("guilds.enabled", "true")),
|
||||||
Boolean.parseBoolean(properties.getProperty("guilds.land_claiming", "true")),
|
Boolean.parseBoolean(properties.getProperty("guilds.land_claiming", "true")),
|
||||||
Boolean.parseBoolean(properties.getProperty("social.enabled", "true")),
|
Boolean.parseBoolean(properties.getProperty("social.enabled", "true")),
|
||||||
Boolean.parseBoolean(properties.getProperty("social.chat_prefixes", "true"))
|
Boolean.parseBoolean(properties.getProperty("social.chat_prefixes", "true")),
|
||||||
|
Boolean.parseBoolean(properties.getProperty("essentials.enabled", "true")),
|
||||||
|
Boolean.parseBoolean(properties.getProperty("economy.enabled", "true"))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package net.scopenet.paper;
|
package net.scopenet.paper;
|
||||||
|
|
||||||
import net.scopenet.integration.*;
|
import net.scopenet.integration.*;
|
||||||
|
import net.scopenet.paper.commands.*;
|
||||||
import org.bukkit.*;
|
import org.bukkit.*;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
import org.bukkit.event.*;
|
import org.bukkit.event.*;
|
||||||
@@ -30,7 +31,9 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
|||||||
getConfig().getBoolean("guilds.enabled", true),
|
getConfig().getBoolean("guilds.enabled", true),
|
||||||
getConfig().getBoolean("guilds.land-claiming", true),
|
getConfig().getBoolean("guilds.land-claiming", true),
|
||||||
getConfig().getBoolean("social.enabled", true),
|
getConfig().getBoolean("social.enabled", true),
|
||||||
getConfig().getBoolean("social.chat-prefixes", true)
|
getConfig().getBoolean("social.chat-prefixes", true),
|
||||||
|
getConfig().getBoolean("essentials.enabled", true),
|
||||||
|
getConfig().getBoolean("economy.enabled", true)
|
||||||
);
|
);
|
||||||
|
|
||||||
integration = new Integration(
|
integration = new Integration(
|
||||||
@@ -45,6 +48,36 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
Bukkit.getScheduler().runTaskTimer(this, integration::tick, 1, 1);
|
Bukkit.getScheduler().runTaskTimer(this, integration::tick, 1, 1);
|
||||||
|
|
||||||
|
// Register /scopenet admin & help command
|
||||||
|
ScopenetCommandHandler scopenetCmd = new ScopenetCommandHandler(this, integration);
|
||||||
|
bindCommand("scopenet", scopenetCmd);
|
||||||
|
|
||||||
|
// Register Essentials commands & GUI
|
||||||
|
if (settings.essentialsEnabled()) {
|
||||||
|
EssentialsHandler essentials = new EssentialsHandler(this);
|
||||||
|
getServer().getPluginManager().registerEvents(essentials, this);
|
||||||
|
String[] essCmds = {"spawn", "home", "sethome", "delhome", "back", "tpa", "tpaccept", "tpdeny", "rtp", "warp", "playtime"};
|
||||||
|
for (String c : essCmds) bindCommand(c, essentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register Economy commands & GUI
|
||||||
|
if (settings.economyEnabled()) {
|
||||||
|
EconomyHandler economy = new EconomyHandler(this, integration);
|
||||||
|
getServer().getPluginManager().registerEvents(economy, this);
|
||||||
|
String[] econCmds = {"balance", "pay", "baltop", "shop", "sell", "market", "orders", "trade", "transactions"};
|
||||||
|
for (String c : econCmds) bindCommand(c, economy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register Guilds & Land Claims commands & GUI
|
||||||
|
if (settings.guildsEnabled()) {
|
||||||
|
GuildHandler guilds = new GuildHandler(this, integration);
|
||||||
|
getServer().getPluginManager().registerEvents(guilds, this);
|
||||||
|
bindCommand("guild", guilds);
|
||||||
|
bindCommand("claim", guilds);
|
||||||
|
bindCommand("unclaim", guilds);
|
||||||
|
}
|
||||||
|
|
||||||
// Reloading an auth plugin cannot silently admit existing sessions.
|
// Reloading an auth plugin cannot silently admit existing sessions.
|
||||||
for (Player player : Bukkit.getOnlinePlayers()) player.kickPlayer("SCOPENET restarted. Please reconnect.");
|
for (Player player : Bukkit.getOnlinePlayers()) player.kickPlayer("SCOPENET restarted. Please reconnect.");
|
||||||
getLogger().info("SCOPENET integration initialized. Configured modules:");
|
getLogger().info("SCOPENET integration initialized. Configured modules:");
|
||||||
@@ -53,12 +86,23 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
|||||||
getLogger().info(" - Achievements: " + (settings.achievementsEnabled() ? "ENABLED" : "DISABLED"));
|
getLogger().info(" - Achievements: " + (settings.achievementsEnabled() ? "ENABLED" : "DISABLED"));
|
||||||
getLogger().info(" - Guilds & Land Claiming: " + (settings.guildsEnabled() && settings.landClaimingEnabled() ? "ENABLED" : "DISABLED"));
|
getLogger().info(" - Guilds & Land Claiming: " + (settings.guildsEnabled() && settings.landClaimingEnabled() ? "ENABLED" : "DISABLED"));
|
||||||
getLogger().info(" - Social: " + (settings.socialEnabled() ? "ENABLED" : "DISABLED"));
|
getLogger().info(" - Social: " + (settings.socialEnabled() ? "ENABLED" : "DISABLED"));
|
||||||
|
getLogger().info(" - Essentials: " + (settings.essentialsEnabled() ? "ENABLED" : "DISABLED"));
|
||||||
|
getLogger().info(" - Economy: " + (settings.economyEnabled() ? "ENABLED" : "DISABLED"));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
getLogger().severe("SCOPENET is blocking joins: " + e.getMessage());
|
getLogger().severe("SCOPENET is blocking joins: " + e.getMessage());
|
||||||
for (Player player : Bukkit.getOnlinePlayers()) player.kickPlayer(Integration.UNAVAILABLE);
|
for (Player player : Bukkit.getOnlinePlayers()) player.kickPlayer(Integration.UNAVAILABLE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void bindCommand(String name, org.bukkit.command.CommandExecutor executor) {
|
||||||
|
org.bukkit.command.PluginCommand cmd = getCommand(name);
|
||||||
|
if (cmd != null) {
|
||||||
|
cmd.setExecutor(executor);
|
||||||
|
} else {
|
||||||
|
getLogger().warning("Could not bind command '/" + name + "' - not found in plugin.yml");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override public void onDisable() {
|
@Override public void onDisable() {
|
||||||
if (integration != null) integration.close();
|
if (integration != null) integration.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,525 @@
|
|||||||
|
package net.scopenet.paper.commands;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import net.scopenet.integration.Integration;
|
||||||
|
import net.scopenet.paper.ScopenetPlugin;
|
||||||
|
import net.scopenet.paper.gui.GuiHelper;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.command.Command;
|
||||||
|
import org.bukkit.command.CommandExecutor;
|
||||||
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||||
|
import org.bukkit.event.inventory.InventoryCloseEvent;
|
||||||
|
import org.bukkit.inventory.Inventory;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
public final class EconomyHandler implements CommandExecutor, Listener {
|
||||||
|
private final ScopenetPlugin plugin;
|
||||||
|
private final Integration integration;
|
||||||
|
|
||||||
|
private static final String GUI_SHOP_TITLE = ChatColor.DARK_GREEN + "SCOPENET Server Shop";
|
||||||
|
private static final String GUI_SELL_TITLE = ChatColor.GOLD + "Sell Chest (Place Items Here)";
|
||||||
|
private static final String GUI_MARKET_TITLE = ChatColor.BLUE + "Player Marketplace";
|
||||||
|
private static final String GUI_TRADE_PREFIX = ChatColor.DARK_PURPLE + "Trade: ";
|
||||||
|
|
||||||
|
// Trade sessions: maps player UUID to active trade session
|
||||||
|
private final Map<UUID, TradeSession> activeTrades = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// Standard sell values for common items
|
||||||
|
private static final Map<Material, Double> ITEM_SELL_VALUES = new HashMap<>();
|
||||||
|
static {
|
||||||
|
ITEM_SELL_VALUES.put(Material.COBBLESTONE, 0.1);
|
||||||
|
ITEM_SELL_VALUES.put(Material.STONE, 0.2);
|
||||||
|
ITEM_SELL_VALUES.put(Material.OAK_LOG, 0.5);
|
||||||
|
ITEM_SELL_VALUES.put(Material.BIRCH_LOG, 0.5);
|
||||||
|
ITEM_SELL_VALUES.put(Material.SPRUCE_LOG, 0.5);
|
||||||
|
ITEM_SELL_VALUES.put(Material.COAL, 1.0);
|
||||||
|
ITEM_SELL_VALUES.put(Material.RAW_COPPER, 1.5);
|
||||||
|
ITEM_SELL_VALUES.put(Material.COPPER_INGOT, 2.0);
|
||||||
|
ITEM_SELL_VALUES.put(Material.RAW_IRON, 3.0);
|
||||||
|
ITEM_SELL_VALUES.put(Material.IRON_INGOT, 4.0);
|
||||||
|
ITEM_SELL_VALUES.put(Material.RAW_GOLD, 6.0);
|
||||||
|
ITEM_SELL_VALUES.put(Material.GOLD_INGOT, 8.0);
|
||||||
|
ITEM_SELL_VALUES.put(Material.REDSTONE, 1.5);
|
||||||
|
ITEM_SELL_VALUES.put(Material.LAPIS_LAZULI, 2.0);
|
||||||
|
ITEM_SELL_VALUES.put(Material.DIAMOND, 35.0);
|
||||||
|
ITEM_SELL_VALUES.put(Material.EMERALD, 20.0);
|
||||||
|
ITEM_SELL_VALUES.put(Material.NETHERITE_SCRAP, 150.0);
|
||||||
|
ITEM_SELL_VALUES.put(Material.NETHERITE_INGOT, 650.0);
|
||||||
|
ITEM_SELL_VALUES.put(Material.WHEAT, 0.5);
|
||||||
|
ITEM_SELL_VALUES.put(Material.CARROT, 0.5);
|
||||||
|
ITEM_SELL_VALUES.put(Material.POTATO, 0.5);
|
||||||
|
ITEM_SELL_VALUES.put(Material.BEEF, 1.0);
|
||||||
|
ITEM_SELL_VALUES.put(Material.COOKED_BEEF, 2.0);
|
||||||
|
ITEM_SELL_VALUES.put(Material.PORKCHOP, 1.0);
|
||||||
|
ITEM_SELL_VALUES.put(Material.COOKED_PORKCHOP, 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public EconomyHandler(ScopenetPlugin plugin, Integration integration) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.integration = integration;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||||
|
if (!(sender instanceof Player player)) {
|
||||||
|
sender.sendMessage(ChatColor.RED + "Only in-game players can execute economy commands.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String cmd = command.getName().toLowerCase();
|
||||||
|
switch (cmd) {
|
||||||
|
case "balance" -> handleBalance(player);
|
||||||
|
case "pay" -> handlePay(player, args);
|
||||||
|
case "baltop" -> handleBaltop(player);
|
||||||
|
case "shop" -> handleShop(player);
|
||||||
|
case "sell" -> handleSell(player, args);
|
||||||
|
case "market" -> handleMarket(player, args);
|
||||||
|
case "orders" -> handleOrders(player);
|
||||||
|
case "trade" -> handleTrade(player, args);
|
||||||
|
case "transactions" -> handleTransactions(player);
|
||||||
|
default -> { return false; }
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleBalance(Player player) {
|
||||||
|
player.sendMessage(ChatColor.GRAY + "Fetching your balance...");
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
try {
|
||||||
|
double bal = integration.client().getBalance(player.getUniqueId(), player.getName());
|
||||||
|
player.sendMessage(ChatColor.GOLD + "=== SCOPENET Economy ===");
|
||||||
|
player.sendMessage(ChatColor.YELLOW + "Your Balance: " + ChatColor.GREEN + "$" + String.format(Locale.US, "%,.2f", bal));
|
||||||
|
} catch (Exception e) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Could not reach economy server: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handlePay(Player player, String[] args) {
|
||||||
|
if (args.length < 2) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Usage: /pay <player> <amount>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Player target = Bukkit.getPlayer(args[0]);
|
||||||
|
if (target == null || !target.isOnline()) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Player '" + args[0] + "' is not online.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.getUniqueId().equals(player.getUniqueId())) {
|
||||||
|
player.sendMessage(ChatColor.RED + "You cannot pay yourself.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
double amount;
|
||||||
|
try {
|
||||||
|
amount = Double.parseDouble(args[1]);
|
||||||
|
if (amount <= 0 || Double.isNaN(amount)) throw new NumberFormatException();
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Invalid amount specified. Must be positive number.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
player.sendMessage(ChatColor.GRAY + "Processing payment...");
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
try {
|
||||||
|
double newBal = integration.client().transfer(
|
||||||
|
player.getUniqueId(), player.getName(),
|
||||||
|
target.getUniqueId(), target.getName(),
|
||||||
|
amount, "Player payment to " + target.getName()
|
||||||
|
);
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Sent " + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", amount)
|
||||||
|
+ ChatColor.GREEN + " to " + ChatColor.YELLOW + target.getName() + ChatColor.GREEN + ". New Balance: "
|
||||||
|
+ ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", newBal));
|
||||||
|
target.sendMessage(ChatColor.GREEN + "Received " + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", amount)
|
||||||
|
+ ChatColor.GREEN + " from " + ChatColor.YELLOW + player.getName() + ChatColor.GREEN + "!");
|
||||||
|
} catch (Exception e) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Transfer failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleBaltop(Player player) {
|
||||||
|
player.sendMessage(ChatColor.GRAY + "Loading leaderboard...");
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
try {
|
||||||
|
JsonArray baltop = integration.client().getBaltop();
|
||||||
|
player.sendMessage(ChatColor.GOLD + "================= " + ChatColor.YELLOW + "BALTOP (Richest Players)" + ChatColor.GOLD + " =================");
|
||||||
|
if (baltop.size() == 0) {
|
||||||
|
player.sendMessage(ChatColor.GRAY + "No players found on the leaderboard.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < Math.min(10, baltop.size()); i++) {
|
||||||
|
JsonObject entry = baltop.get(i).getAsJsonObject();
|
||||||
|
int rank = entry.has("rank") ? entry.get("rank").getAsInt() : (i + 1);
|
||||||
|
String name = entry.has("username") ? entry.get("username").getAsString() : "Unknown";
|
||||||
|
double bal = entry.has("balance") ? entry.get("balance").getAsDouble() : 0.0;
|
||||||
|
ChatColor rankColor = rank == 1 ? ChatColor.GOLD : (rank == 2 ? ChatColor.WHITE : (rank == 3 ? ChatColor.YELLOW : ChatColor.GRAY));
|
||||||
|
player.sendMessage(rankColor + "#" + rank + " " + ChatColor.WHITE + name + ": " + ChatColor.GREEN + "$" + String.format(Locale.US, "%,.2f", bal));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Could not fetch baltop: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleShop(Player player) {
|
||||||
|
Inventory inv = Bukkit.createInventory(null, 54, GUI_SHOP_TITLE);
|
||||||
|
|
||||||
|
// Fill border
|
||||||
|
ItemStack border = GuiHelper.createBorder(Material.BLACK_STAINED_GLASS_PANE);
|
||||||
|
for (int i = 0; i < 9; i++) inv.setItem(i, border);
|
||||||
|
for (int i = 45; i < 54; i++) inv.setItem(i, border);
|
||||||
|
|
||||||
|
// Category / Popular items in shop
|
||||||
|
inv.setItem(10, GuiHelper.createItem(Material.DIAMOND, "&bDiamond &7(x1)", "&7Price: &a$100.00", "", "&eClick to Buy!"));
|
||||||
|
inv.setItem(11, GuiHelper.createItem(Material.NETHERITE_INGOT, "&5Netherite Ingot &7(x1)", "&7Price: &a$1,500.00", "", "&eClick to Buy!"));
|
||||||
|
inv.setItem(12, GuiHelper.createItem(Material.IRON_INGOT, 16, "&fIron Ingot &7(x16)", "&7Price: &a$80.00", "", "&eClick to Buy!"));
|
||||||
|
inv.setItem(13, GuiHelper.createItem(Material.GOLD_INGOT, 16, "&6Gold Ingot &7(x16)", "&7Price: &a$150.00", "", "&eClick to Buy!"));
|
||||||
|
inv.setItem(14, GuiHelper.createItem(Material.EMERALD, 16, "&aEmerald &7(x16)", "&7Price: &a$250.00", "", "&eClick to Buy!"));
|
||||||
|
inv.setItem(15, GuiHelper.createItem(Material.OAK_LOG, 32, "&6Oak Wood &7(x32)", "&7Price: &a$20.00", "", "&eClick to Buy!"));
|
||||||
|
inv.setItem(16, GuiHelper.createItem(Material.STONE, 64, "&7Stone &7(x64)", "&7Price: &a$25.00", "", "&eClick to Buy!"));
|
||||||
|
|
||||||
|
inv.setItem(19, GuiHelper.createItem(Material.COOKED_BEEF, 16, "&cCooked Steak &7(x16)", "&7Price: &a$40.00", "", "&eClick to Buy!"));
|
||||||
|
inv.setItem(20, GuiHelper.createItem(Material.GOLDEN_APPLE, 4, "&eGolden Apple &7(x4)", "&7Price: &a$300.00", "", "&eClick to Buy!"));
|
||||||
|
inv.setItem(21, GuiHelper.createItem(Material.EXPERIENCE_BOTTLE, 16, "&aBottle o' Enchanting &7(x16)", "&7Price: &a$200.00", "", "&eClick to Buy!"));
|
||||||
|
inv.setItem(22, GuiHelper.createItem(Material.ENDER_PEARL, 8, "&3Ender Pearl &7(x8)", "&7Price: &a$120.00", "", "&eClick to Buy!"));
|
||||||
|
inv.setItem(23, GuiHelper.createItem(Material.OBSIDIAN, 14, "&8Obsidian &7(x14)", "&7Price: &a$150.00", "", "&eClick to Buy!"));
|
||||||
|
inv.setItem(24, GuiHelper.createItem(Material.TORCH, 64, "&eTorches &7(x64)", "&7Price: &a$10.00", "", "&eClick to Buy!"));
|
||||||
|
inv.setItem(25, GuiHelper.createItem(Material.BOW, "&bPower Bow", "&7Price: &a$100.00", "", "&eClick to Buy!"));
|
||||||
|
|
||||||
|
inv.setItem(49, GuiHelper.createItem(Material.GOLD_INGOT, "&eCheck Balance / Sell Items", "&7Type &a/sell &7to sell your loot!", "&7Type &a/market &7for player listings."));
|
||||||
|
|
||||||
|
player.openInventory(inv);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleSell(Player player, String[] args) {
|
||||||
|
if (args.length > 0 && args[0].equalsIgnoreCase("hand")) {
|
||||||
|
ItemStack hand = player.getInventory().getItemInMainHand();
|
||||||
|
if (hand.getType().isAir()) {
|
||||||
|
player.sendMessage(ChatColor.RED + "You are not holding any item to sell.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Double unitPrice = ITEM_SELL_VALUES.get(hand.getType());
|
||||||
|
if (unitPrice == null) {
|
||||||
|
player.sendMessage(ChatColor.RED + "This item (" + hand.getType().name() + ") cannot be sold to the server shop.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int amount = hand.getAmount();
|
||||||
|
double total = unitPrice * amount;
|
||||||
|
player.getInventory().setItemInMainHand(null);
|
||||||
|
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
try {
|
||||||
|
double currentBal = integration.client().getBalance(player.getUniqueId(), player.getName());
|
||||||
|
double newBal = currentBal + total;
|
||||||
|
integration.client().syncBalance(player.getUniqueId(), player.getName(), newBal);
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Sold " + amount + "x " + hand.getType().name() + " for "
|
||||||
|
+ ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", total) + ChatColor.GREEN + "!");
|
||||||
|
} catch (Exception e) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Failed to credit sell balance: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open sell chest GUI
|
||||||
|
Inventory sellInv = Bukkit.createInventory(null, 54, GUI_SELL_TITLE);
|
||||||
|
player.openInventory(sellInv);
|
||||||
|
player.sendMessage(ChatColor.YELLOW + "Place items to sell in the chest. Close the chest to confirm your sale!");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleMarket(Player player, String[] args) {
|
||||||
|
if (args.length >= 2 && args[0].equalsIgnoreCase("sell")) {
|
||||||
|
ItemStack hand = player.getInventory().getItemInMainHand();
|
||||||
|
if (hand.getType().isAir()) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Hold the item you want to sell in your main hand.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
double price;
|
||||||
|
try {
|
||||||
|
price = Double.parseDouble(args[1]);
|
||||||
|
if (price <= 0 || Double.isNaN(price)) throw new NumberFormatException();
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Invalid price. Usage: /market sell <price>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int amount = hand.getAmount();
|
||||||
|
String itemId = hand.getType().name();
|
||||||
|
String itemName = hand.hasItemMeta() && hand.getItemMeta().hasDisplayName()
|
||||||
|
? hand.getItemMeta().getDisplayName() : hand.getType().name().replace('_', ' ');
|
||||||
|
|
||||||
|
player.getInventory().setItemInMainHand(null);
|
||||||
|
player.sendMessage(ChatColor.GRAY + "Listing item on marketplace...");
|
||||||
|
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
try {
|
||||||
|
integration.client().marketList(player.getUniqueId(), player.getName(), itemId, itemName, amount, price);
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Successfully listed " + ChatColor.YELLOW + amount + "x " + itemName
|
||||||
|
+ ChatColor.GREEN + " on the market for " + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", price) + "!");
|
||||||
|
} catch (Exception e) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Market listing failed: " + e.getMessage());
|
||||||
|
// Return item to player on main thread
|
||||||
|
Bukkit.getScheduler().runTask(plugin, () -> player.getInventory().addItem(hand));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open Market GUI
|
||||||
|
Inventory inv = Bukkit.createInventory(null, 54, GUI_MARKET_TITLE);
|
||||||
|
ItemStack border = GuiHelper.createBorder(Material.BLUE_STAINED_GLASS_PANE);
|
||||||
|
for (int i = 45; i < 54; i++) inv.setItem(i, border);
|
||||||
|
inv.setItem(49, GuiHelper.createItem(Material.EMERALD, "&aSell an Item", "&7Hold an item and type:", "&e/market sell <price>"));
|
||||||
|
|
||||||
|
player.openInventory(inv);
|
||||||
|
player.sendMessage(ChatColor.GRAY + "Loading market listings...");
|
||||||
|
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
try {
|
||||||
|
// In a production setup, market listings come from API
|
||||||
|
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||||
|
// Populate some interactive market slots
|
||||||
|
inv.setItem(11, GuiHelper.createItem(Material.ENCHANTED_GOLDEN_APPLE, 1, "&6Enchanted Golden Apple", "&7Seller: &fSCOPENET Vault", "&7Price: &a$2,500.00", "", "&eClick to Buy!"));
|
||||||
|
inv.setItem(13, GuiHelper.createItem(Material.ELYTRA, 1, "&bElytra Wings", "&7Seller: &fAeroPlayer", "&7Price: &a$5,000.00", "", "&eClick to Buy!"));
|
||||||
|
inv.setItem(15, GuiHelper.createItem(Material.BEACON, 1, "&bNether Beacon", "&7Seller: &fMiningLord", "&7Price: &a$3,200.00", "", "&eClick to Buy!"));
|
||||||
|
});
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleOrders(Player player) {
|
||||||
|
player.sendMessage(ChatColor.GOLD + "=== Your Active Market Orders ===");
|
||||||
|
player.sendMessage(ChatColor.GRAY + "Use " + ChatColor.YELLOW + "/market sell <price>" + ChatColor.GRAY + " to list items, or visit the " + ChatColor.YELLOW + "/market" + ChatColor.GRAY + " GUI.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleTransactions(Player player) {
|
||||||
|
player.sendMessage(ChatColor.GOLD + "=== Recent Transactions ===");
|
||||||
|
player.sendMessage(ChatColor.GRAY + "Loading transaction history...");
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
try {
|
||||||
|
double bal = integration.client().getBalance(player.getUniqueId(), player.getName());
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Current Balance: " + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", bal));
|
||||||
|
player.sendMessage(ChatColor.GRAY + "For full transaction logs and charts, check the SCOPENET Launcher Stats page!");
|
||||||
|
} catch (Exception e) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleTrade(Player player, String[] args) {
|
||||||
|
if (args.length < 1) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Usage: /trade <player>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Player target = Bukkit.getPlayer(args[0]);
|
||||||
|
if (target == null || !target.isOnline()) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Player '" + args[0] + "' is not online.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.getUniqueId().equals(player.getUniqueId())) {
|
||||||
|
player.sendMessage(ChatColor.RED + "You cannot trade with yourself.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTrades.containsKey(player.getUniqueId()) || activeTrades.containsKey(target.getUniqueId())) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Either you or the other player is already in a trade.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open trade session for both players
|
||||||
|
TradeSession session = new TradeSession(player, target);
|
||||||
|
activeTrades.put(player.getUniqueId(), session);
|
||||||
|
activeTrades.put(target.getUniqueId(), session);
|
||||||
|
session.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onInventoryClick(InventoryClickEvent event) {
|
||||||
|
if (!(event.getWhoClicked() instanceof Player player)) return;
|
||||||
|
String title = event.getView().getTitle();
|
||||||
|
|
||||||
|
if (title.equals(GUI_SHOP_TITLE)) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
ItemStack clicked = event.getCurrentItem();
|
||||||
|
if (clicked == null || !clicked.hasItemMeta() || clicked.getItemMeta().getLore() == null) return;
|
||||||
|
|
||||||
|
// Extract price from lore
|
||||||
|
double price = -1;
|
||||||
|
for (String loreLine : clicked.getItemMeta().getLore()) {
|
||||||
|
String raw = ChatColor.stripColor(loreLine);
|
||||||
|
if (raw.startsWith("Price: $")) {
|
||||||
|
try {
|
||||||
|
price = Double.parseDouble(raw.replace("Price: $", "").replace(",", "").trim());
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (price <= 0) return;
|
||||||
|
final double finalPrice = price;
|
||||||
|
|
||||||
|
player.sendMessage(ChatColor.GRAY + "Processing purchase...");
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
try {
|
||||||
|
double bal = integration.client().getBalance(player.getUniqueId(), player.getName());
|
||||||
|
if (bal < finalPrice) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Insufficient funds! Needed: $" + String.format(Locale.US, "%,.2f", finalPrice)
|
||||||
|
+ ", you have: $" + String.format(Locale.US, "%,.2f", bal));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
double newBal = bal - finalPrice;
|
||||||
|
integration.client().syncBalance(player.getUniqueId(), player.getName(), newBal);
|
||||||
|
|
||||||
|
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||||
|
ItemStack give = new ItemStack(clicked.getType(), clicked.getAmount());
|
||||||
|
player.getInventory().addItem(give);
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Purchased " + give.getAmount() + "x " + give.getType().name()
|
||||||
|
+ " for " + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", finalPrice) + ChatColor.GREEN
|
||||||
|
+ "! Balance: " + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", newBal));
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Shop transaction failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (title.startsWith(GUI_TRADE_PREFIX)) {
|
||||||
|
TradeSession session = activeTrades.get(player.getUniqueId());
|
||||||
|
if (session != null) {
|
||||||
|
session.handleClick(player, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onInventoryClose(InventoryCloseEvent event) {
|
||||||
|
if (!(event.getPlayer() instanceof Player player)) return;
|
||||||
|
String title = event.getView().getTitle();
|
||||||
|
|
||||||
|
if (title.equals(GUI_SELL_TITLE)) {
|
||||||
|
// Process sell chest items
|
||||||
|
Inventory inv = event.getInventory();
|
||||||
|
double totalEarned = 0;
|
||||||
|
int countSold = 0;
|
||||||
|
|
||||||
|
for (ItemStack item : inv.getContents()) {
|
||||||
|
if (item == null || item.getType().isAir()) continue;
|
||||||
|
Double price = ITEM_SELL_VALUES.get(item.getType());
|
||||||
|
if (price != null) {
|
||||||
|
totalEarned += price * item.getAmount();
|
||||||
|
countSold += item.getAmount();
|
||||||
|
} else {
|
||||||
|
// Return unsold item to player
|
||||||
|
player.getInventory().addItem(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalEarned > 0) {
|
||||||
|
final double earned = totalEarned;
|
||||||
|
final int itemsCount = countSold;
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
try {
|
||||||
|
double cur = integration.client().getBalance(player.getUniqueId(), player.getName());
|
||||||
|
integration.client().syncBalance(player.getUniqueId(), player.getName(), cur + earned);
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Sold " + itemsCount + " items for "
|
||||||
|
+ ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", earned) + ChatColor.GREEN + "!");
|
||||||
|
} catch (Exception e) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Failed to credit earnings: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (title.startsWith(GUI_TRADE_PREFIX)) {
|
||||||
|
TradeSession session = activeTrades.remove(player.getUniqueId());
|
||||||
|
if (session != null) {
|
||||||
|
session.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class TradeSession {
|
||||||
|
final Player p1;
|
||||||
|
final Player p2;
|
||||||
|
final Inventory inv;
|
||||||
|
boolean p1Ready = false;
|
||||||
|
boolean p2Ready = false;
|
||||||
|
|
||||||
|
TradeSession(Player p1, Player p2) {
|
||||||
|
this.p1 = p1;
|
||||||
|
this.p2 = p2;
|
||||||
|
this.inv = Bukkit.createInventory(null, 54, GUI_TRADE_PREFIX + p1.getName() + " & " + p2.getName());
|
||||||
|
buildDividers();
|
||||||
|
}
|
||||||
|
|
||||||
|
void buildDividers() {
|
||||||
|
ItemStack divider = GuiHelper.createBorder(Material.GRAY_STAINED_GLASS_PANE);
|
||||||
|
int[] dividerSlots = {4, 13, 22, 31, 40, 49};
|
||||||
|
for (int s : dividerSlots) inv.setItem(s, divider);
|
||||||
|
updateReadyButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateReadyButtons() {
|
||||||
|
inv.setItem(0, GuiHelper.createItem(p1Ready ? Material.LIME_CONCRETE : Material.RED_CONCRETE,
|
||||||
|
p1.getName() + ": " + (p1Ready ? "&aREADY" : "&cNOT READY"), "&7Click to toggle ready status."));
|
||||||
|
inv.setItem(8, GuiHelper.createItem(p2Ready ? Material.LIME_CONCRETE : Material.RED_CONCRETE,
|
||||||
|
p2.getName() + ": " + (p2Ready ? "&aREADY" : "&cNOT READY"), "&7Click to toggle ready status."));
|
||||||
|
}
|
||||||
|
|
||||||
|
void open() {
|
||||||
|
p1.openInventory(inv);
|
||||||
|
p2.openInventory(inv);
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleClick(Player clicker, InventoryClickEvent event) {
|
||||||
|
int slot = event.getRawSlot();
|
||||||
|
boolean isP1 = clicker.getUniqueId().equals(p1.getUniqueId());
|
||||||
|
|
||||||
|
if (slot == 0 && isP1) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
p1Ready = !p1Ready;
|
||||||
|
updateReadyButtons();
|
||||||
|
checkCompletion();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (slot == 8 && !isP1) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
p2Ready = !p2Ready;
|
||||||
|
updateReadyButtons();
|
||||||
|
checkCompletion();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Divider slots are not interactable
|
||||||
|
if (slot == 4 || slot == 13 || slot == 22 || slot == 31 || slot == 40 || slot == 49) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void checkCompletion() {
|
||||||
|
if (p1Ready && p2Ready) {
|
||||||
|
p1.sendMessage(ChatColor.GREEN + "Trade completed successfully!");
|
||||||
|
p2.sendMessage(ChatColor.GREEN + "Trade completed successfully!");
|
||||||
|
p1.closeInventory();
|
||||||
|
p2.closeInventory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancel() {
|
||||||
|
p1.sendMessage(ChatColor.RED + "Trade cancelled.");
|
||||||
|
p2.sendMessage(ChatColor.RED + "Trade cancelled.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
package net.scopenet.paper.commands;
|
||||||
|
|
||||||
|
import net.scopenet.paper.ScopenetPlugin;
|
||||||
|
import net.scopenet.paper.gui.GuiHelper;
|
||||||
|
import org.bukkit.*;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
|
import org.bukkit.command.Command;
|
||||||
|
import org.bukkit.command.CommandExecutor;
|
||||||
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.configuration.file.YamlConfiguration;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.EventPriority;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||||
|
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||||
|
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||||
|
import org.bukkit.inventory.Inventory;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
public final class EssentialsHandler implements CommandExecutor, Listener {
|
||||||
|
private final ScopenetPlugin plugin;
|
||||||
|
private final File homesFile;
|
||||||
|
private final File warpsFile;
|
||||||
|
private final YamlConfiguration homesConfig;
|
||||||
|
private final YamlConfiguration warpsConfig;
|
||||||
|
|
||||||
|
private final Map<UUID, Map<String, Location>> homes = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, Location> warps = new ConcurrentHashMap<>();
|
||||||
|
private final Map<UUID, Location> lastLocations = new ConcurrentHashMap<>();
|
||||||
|
private final Map<UUID, UUID> pendingTpa = new ConcurrentHashMap<>(); // target -> requester
|
||||||
|
private final Map<UUID, Long> tpaTimestamp = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private static final String GUI_HOMES_TITLE = ChatColor.DARK_GRAY + "Homes Manager";
|
||||||
|
private static final String GUI_WARPS_TITLE = ChatColor.DARK_GRAY + "Server Warps";
|
||||||
|
|
||||||
|
public EssentialsHandler(ScopenetPlugin plugin) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.homesFile = new File(plugin.getDataFolder(), "homes.yml");
|
||||||
|
this.warpsFile = new File(plugin.getDataFolder(), "warps.yml");
|
||||||
|
this.homesConfig = YamlConfiguration.loadConfiguration(homesFile);
|
||||||
|
this.warpsConfig = YamlConfiguration.loadConfiguration(warpsFile);
|
||||||
|
loadHomes();
|
||||||
|
loadWarps();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadHomes() {
|
||||||
|
if (!homesFile.exists()) return;
|
||||||
|
for (String uuidStr : homesConfig.getKeys(false)) {
|
||||||
|
try {
|
||||||
|
UUID uuid = UUID.fromString(uuidStr);
|
||||||
|
Map<String, Location> playerHomes = new HashMap<>();
|
||||||
|
if (homesConfig.isConfigurationSection(uuidStr)) {
|
||||||
|
for (String homeName : homesConfig.getConfigurationSection(uuidStr).getKeys(false)) {
|
||||||
|
Location loc = homesConfig.getLocation(uuidStr + "." + homeName);
|
||||||
|
if (loc != null) playerHomes.put(homeName.toLowerCase(), loc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
homes.put(uuid, playerHomes);
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveHomes() {
|
||||||
|
try {
|
||||||
|
for (Map.Entry<UUID, Map<String, Location>> entry : homes.entrySet()) {
|
||||||
|
String uuidStr = entry.getKey().toString();
|
||||||
|
homesConfig.set(uuidStr, null);
|
||||||
|
for (Map.Entry<String, Location> home : entry.getValue().entrySet()) {
|
||||||
|
homesConfig.set(uuidStr + "." + home.getKey(), home.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
homesConfig.save(homesFile);
|
||||||
|
} catch (IOException e) {
|
||||||
|
plugin.getLogger().warning("Failed to save homes: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadWarps() {
|
||||||
|
if (!warpsFile.exists()) return;
|
||||||
|
for (String warpName : warpsConfig.getKeys(false)) {
|
||||||
|
Location loc = warpsConfig.getLocation(warpName);
|
||||||
|
if (loc != null) warps.put(warpName.toLowerCase(), loc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveWarps() {
|
||||||
|
try {
|
||||||
|
for (Map.Entry<String, Location> entry : warps.entrySet()) {
|
||||||
|
warpsConfig.set(entry.getKey(), entry.getValue());
|
||||||
|
}
|
||||||
|
warpsConfig.save(warpsFile);
|
||||||
|
} catch (IOException e) {
|
||||||
|
plugin.getLogger().warning("Failed to save warps: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||||
|
public void onTeleport(PlayerTeleportEvent event) {
|
||||||
|
if (event.getFrom() != null && event.getCause() != PlayerTeleportEvent.TeleportCause.UNKNOWN) {
|
||||||
|
lastLocations.put(event.getPlayer().getUniqueId(), event.getFrom());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR)
|
||||||
|
public void onDeath(PlayerDeathEvent event) {
|
||||||
|
lastLocations.put(event.getEntity().getUniqueId(), event.getEntity().getLocation());
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onInventoryClick(InventoryClickEvent event) {
|
||||||
|
if (!(event.getWhoClicked() instanceof Player player)) return;
|
||||||
|
String title = event.getView().getTitle();
|
||||||
|
if (title.equals(GUI_HOMES_TITLE)) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
ItemStack item = event.getCurrentItem();
|
||||||
|
if (item == null || !item.hasItemMeta() || item.getItemMeta().getDisplayName() == null) return;
|
||||||
|
String homeName = ChatColor.stripColor(item.getItemMeta().getDisplayName()).replace("Home: ", "").trim().toLowerCase();
|
||||||
|
Map<String, Location> playerHomes = homes.get(player.getUniqueId());
|
||||||
|
if (playerHomes != null && playerHomes.containsKey(homeName)) {
|
||||||
|
player.closeInventory();
|
||||||
|
player.teleport(playerHomes.get(homeName));
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Teleported to home: " + ChatColor.YELLOW + homeName);
|
||||||
|
}
|
||||||
|
} else if (title.equals(GUI_WARPS_TITLE)) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
ItemStack item = event.getCurrentItem();
|
||||||
|
if (item == null || !item.hasItemMeta() || item.getItemMeta().getDisplayName() == null) return;
|
||||||
|
String warpName = ChatColor.stripColor(item.getItemMeta().getDisplayName()).replace("Warp: ", "").trim().toLowerCase();
|
||||||
|
if (warps.containsKey(warpName)) {
|
||||||
|
player.closeInventory();
|
||||||
|
player.teleport(warps.get(warpName));
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Teleported to warp: " + ChatColor.YELLOW + warpName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||||
|
if (!(sender instanceof Player player)) {
|
||||||
|
sender.sendMessage(ChatColor.RED + "Only in-game players can execute essentials commands.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String cmd = command.getName().toLowerCase();
|
||||||
|
switch (cmd) {
|
||||||
|
case "spawn" -> handleSpawn(player);
|
||||||
|
case "home" -> handleHome(player, args);
|
||||||
|
case "sethome" -> handleSetHome(player, args);
|
||||||
|
case "delhome" -> handleDelHome(player, args);
|
||||||
|
case "back" -> handleBack(player);
|
||||||
|
case "tpa" -> handleTpa(player, args);
|
||||||
|
case "tpaccept" -> handleTpAccept(player);
|
||||||
|
case "tpdeny" -> handleTpDeny(player);
|
||||||
|
case "rtp" -> handleRtp(player);
|
||||||
|
case "warp" -> handleWarp(player, args);
|
||||||
|
case "playtime" -> handlePlaytime(player, args);
|
||||||
|
default -> { return false; }
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleSpawn(Player player) {
|
||||||
|
Location spawnLoc = player.getWorld().getSpawnLocation().add(0.5, 0, 0.5);
|
||||||
|
player.teleport(spawnLoc);
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Teleported to spawn.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleHome(Player player, String[] args) {
|
||||||
|
Map<String, Location> playerHomes = homes.computeIfAbsent(player.getUniqueId(), k -> new HashMap<>());
|
||||||
|
if (args.length == 0) {
|
||||||
|
if (playerHomes.isEmpty()) {
|
||||||
|
player.sendMessage(ChatColor.RED + "You have no homes set! Set one with " + ChatColor.YELLOW + "/sethome [name]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (playerHomes.size() == 1) {
|
||||||
|
Location loc = playerHomes.values().iterator().next();
|
||||||
|
player.teleport(loc);
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Teleported to home.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openHomePicker(player, playerHomes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String homeName = args[0].toLowerCase();
|
||||||
|
Location loc = playerHomes.get(homeName);
|
||||||
|
if (loc == null) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Home '" + homeName + "' not found. Your homes: " + ChatColor.YELLOW + String.join(", ", playerHomes.keySet()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
player.teleport(loc);
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Teleported to home " + ChatColor.YELLOW + homeName + ChatColor.GREEN + ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openHomePicker(Player player, Map<String, Location> playerHomes) {
|
||||||
|
Inventory inv = Bukkit.createInventory(null, 27, GUI_HOMES_TITLE);
|
||||||
|
int slot = 10;
|
||||||
|
Material[] bedColors = {
|
||||||
|
Material.RED_BED, Material.BLUE_BED, Material.LIME_BED,
|
||||||
|
Material.YELLOW_BED, Material.PURPLE_BED, Material.ORANGE_BED, Material.CYAN_BED
|
||||||
|
};
|
||||||
|
int idx = 0;
|
||||||
|
for (Map.Entry<String, Location> entry : playerHomes.entrySet()) {
|
||||||
|
if (slot > 16) break;
|
||||||
|
Material mat = bedColors[idx % bedColors.length];
|
||||||
|
Location l = entry.getValue();
|
||||||
|
String world = l.getWorld() != null ? l.getWorld().getName() : "world";
|
||||||
|
ItemStack item = GuiHelper.createItem(
|
||||||
|
mat,
|
||||||
|
"&aHome: &f" + entry.getKey(),
|
||||||
|
"&7World: &f" + world,
|
||||||
|
"&7Coords: &f" + l.getBlockX() + ", " + l.getBlockY() + ", " + l.getBlockZ(),
|
||||||
|
"",
|
||||||
|
"&eClick to teleport!"
|
||||||
|
);
|
||||||
|
inv.setItem(slot++, item);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
player.openInventory(inv);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleSetHome(Player player, String[] args) {
|
||||||
|
String homeName = args.length > 0 ? args[0].toLowerCase() : "home";
|
||||||
|
int maxHomes = plugin.getConfig().getInt("essentials.max-homes", 5);
|
||||||
|
Map<String, Location> playerHomes = homes.computeIfAbsent(player.getUniqueId(), k -> new HashMap<>());
|
||||||
|
|
||||||
|
if (!playerHomes.containsKey(homeName) && playerHomes.size() >= maxHomes) {
|
||||||
|
player.sendMessage(ChatColor.RED + "You have reached your limit of " + maxHomes + " homes.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
playerHomes.put(homeName, player.getLocation());
|
||||||
|
saveHomes();
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Home " + ChatColor.YELLOW + homeName + ChatColor.GREEN + " set successfully.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleDelHome(Player player, String[] args) {
|
||||||
|
String homeName = args.length > 0 ? args[0].toLowerCase() : "home";
|
||||||
|
Map<String, Location> playerHomes = homes.get(player.getUniqueId());
|
||||||
|
if (playerHomes == null || playerHomes.remove(homeName) == null) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Home '" + homeName + "' does not exist.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveHomes();
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Home " + ChatColor.YELLOW + homeName + ChatColor.GREEN + " deleted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleBack(Player player) {
|
||||||
|
Location last = lastLocations.get(player.getUniqueId());
|
||||||
|
if (last == null) {
|
||||||
|
player.sendMessage(ChatColor.RED + "No previous location found to return to.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
player.teleport(last);
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Teleported back to your previous location.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleTpa(Player player, String[] args) {
|
||||||
|
if (args.length < 1) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Usage: /tpa <player>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Player target = Bukkit.getPlayer(args[0]);
|
||||||
|
if (target == null || !target.isOnline()) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Player '" + args[0] + "' is not online.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target.getUniqueId().equals(player.getUniqueId())) {
|
||||||
|
player.sendMessage(ChatColor.RED + "You cannot teleport to yourself.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingTpa.put(target.getUniqueId(), player.getUniqueId());
|
||||||
|
tpaTimestamp.put(target.getUniqueId(), System.currentTimeMillis());
|
||||||
|
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Teleport request sent to " + ChatColor.YELLOW + target.getName() + ChatColor.GREEN + ".");
|
||||||
|
target.sendMessage(ChatColor.YELLOW + player.getName() + ChatColor.GREEN + " has sent you a teleport request.");
|
||||||
|
target.sendMessage(ChatColor.GRAY + "Type " + ChatColor.YELLOW + "/tpaccept" + ChatColor.GRAY + " to accept or " + ChatColor.RED + "/tpdeny" + ChatColor.GRAY + " to deny (expires in 60s).");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleTpAccept(Player player) {
|
||||||
|
UUID requesterId = pendingTpa.get(player.getUniqueId());
|
||||||
|
Long time = tpaTimestamp.get(player.getUniqueId());
|
||||||
|
if (requesterId == null || time == null || System.currentTimeMillis() - time > 60_000) {
|
||||||
|
pendingTpa.remove(player.getUniqueId());
|
||||||
|
tpaTimestamp.remove(player.getUniqueId());
|
||||||
|
player.sendMessage(ChatColor.RED + "You have no active teleport requests.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Player requester = Bukkit.getPlayer(requesterId);
|
||||||
|
if (requester == null || !requester.isOnline()) {
|
||||||
|
player.sendMessage(ChatColor.RED + "The player who requested to teleport is no longer online.");
|
||||||
|
pendingTpa.remove(player.getUniqueId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requester.teleport(player.getLocation());
|
||||||
|
requester.sendMessage(ChatColor.GREEN + "Teleport request accepted by " + ChatColor.YELLOW + player.getName() + ChatColor.GREEN + "!");
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Accepted teleport request from " + ChatColor.YELLOW + requester.getName() + ChatColor.GREEN + ".");
|
||||||
|
pendingTpa.remove(player.getUniqueId());
|
||||||
|
tpaTimestamp.remove(player.getUniqueId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleTpDeny(Player player) {
|
||||||
|
UUID requesterId = pendingTpa.remove(player.getUniqueId());
|
||||||
|
tpaTimestamp.remove(player.getUniqueId());
|
||||||
|
if (requesterId == null) {
|
||||||
|
player.sendMessage(ChatColor.RED + "You have no active teleport requests.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Player requester = Bukkit.getPlayer(requesterId);
|
||||||
|
if (requester != null && requester.isOnline()) {
|
||||||
|
requester.sendMessage(ChatColor.RED + player.getName() + " denied your teleport request.");
|
||||||
|
}
|
||||||
|
player.sendMessage(ChatColor.YELLOW + "Teleport request denied.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleRtp(Player player) {
|
||||||
|
int radius = plugin.getConfig().getInt("essentials.rtp-radius", 2500);
|
||||||
|
World world = player.getWorld();
|
||||||
|
Random rng = new Random();
|
||||||
|
|
||||||
|
player.sendMessage(ChatColor.GRAY + "Searching for a safe wilderness destination...");
|
||||||
|
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||||
|
for (int i = 0; i < 15; i++) {
|
||||||
|
int x = rng.nextInt(radius * 2) - radius;
|
||||||
|
int z = rng.nextInt(radius * 2) - radius;
|
||||||
|
int y = world.getHighestBlockYAt(x, z);
|
||||||
|
Block block = world.getBlockAt(x, y - 1, z);
|
||||||
|
if (block.getType().isSolid() && block.getType() != Material.LAVA && block.getType() != Material.WATER) {
|
||||||
|
Location target = new Location(world, x + 0.5, y, z + 0.5);
|
||||||
|
player.teleport(target);
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Wilderness RTP: Teleported to " + ChatColor.YELLOW + x + ", " + y + ", " + z + ChatColor.GREEN + "!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
player.sendMessage(ChatColor.RED + "Could not find a safe RTP spot. Please try again.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleWarp(Player player, String[] args) {
|
||||||
|
if (args.length == 0) {
|
||||||
|
openWarpPicker(player);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String warpName = args[0].toLowerCase();
|
||||||
|
Location loc = warps.get(warpName);
|
||||||
|
if (loc == null) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Warp '" + warpName + "' does not exist.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
player.teleport(loc);
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Teleported to warp " + ChatColor.YELLOW + warpName + ChatColor.GREEN + ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openWarpPicker(Player player) {
|
||||||
|
Inventory inv = Bukkit.createInventory(null, 27, GUI_WARPS_TITLE);
|
||||||
|
int slot = 11;
|
||||||
|
// Default built-in warps if empty
|
||||||
|
if (warps.isEmpty()) {
|
||||||
|
warps.put("spawn", player.getWorld().getSpawnLocation());
|
||||||
|
saveWarps();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Map.Entry<String, Location> entry : warps.entrySet()) {
|
||||||
|
if (slot > 15) break;
|
||||||
|
ItemStack item = GuiHelper.createItem(
|
||||||
|
Material.ENDER_PEARL,
|
||||||
|
"&bWarp: &f" + entry.getKey(),
|
||||||
|
"&7World: &f" + (entry.getValue().getWorld() != null ? entry.getValue().getWorld().getName() : "world"),
|
||||||
|
"",
|
||||||
|
"&eClick to teleport!"
|
||||||
|
);
|
||||||
|
inv.setItem(slot++, item);
|
||||||
|
}
|
||||||
|
player.openInventory(inv);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handlePlaytime(Player player, String[] args) {
|
||||||
|
Player target = args.length > 0 ? Bukkit.getPlayer(args[0]) : player;
|
||||||
|
if (target == null) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Player not found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int ticksPlayed = target.getStatistic(Statistic.PLAY_ONE_MINUTE);
|
||||||
|
long seconds = ticksPlayed / 20;
|
||||||
|
long hours = seconds / 3600;
|
||||||
|
long minutes = (seconds % 3600) / 60;
|
||||||
|
long secs = seconds % 60;
|
||||||
|
|
||||||
|
player.sendMessage(ChatColor.GOLD + "=== Playtime: " + ChatColor.YELLOW + target.getName() + ChatColor.GOLD + " ===");
|
||||||
|
player.sendMessage(ChatColor.GRAY + "Total Time: " + ChatColor.GREEN + hours + "h " + minutes + "m " + secs + "s");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
package net.scopenet.paper.commands;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import net.scopenet.integration.ChunkCheckResult;
|
||||||
|
import net.scopenet.integration.Integration;
|
||||||
|
import net.scopenet.paper.ScopenetPlugin;
|
||||||
|
import net.scopenet.paper.gui.GuiHelper;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.Chunk;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.command.Command;
|
||||||
|
import org.bukkit.command.CommandExecutor;
|
||||||
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.configuration.file.YamlConfiguration;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||||
|
import org.bukkit.inventory.Inventory;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
public final class GuildHandler implements CommandExecutor, Listener {
|
||||||
|
private final ScopenetPlugin plugin;
|
||||||
|
private final Integration integration;
|
||||||
|
private final File guildHomesFile;
|
||||||
|
private final YamlConfiguration guildHomesConfig;
|
||||||
|
private final Map<String, Location> guildHomes = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private static final String GUI_GUILD_TITLE = ChatColor.DARK_AQUA + "Guild Dashboard";
|
||||||
|
|
||||||
|
public GuildHandler(ScopenetPlugin plugin, Integration integration) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.integration = integration;
|
||||||
|
this.guildHomesFile = new File(plugin.getDataFolder(), "guild_homes.yml");
|
||||||
|
this.guildHomesConfig = YamlConfiguration.loadConfiguration(guildHomesFile);
|
||||||
|
loadGuildHomes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadGuildHomes() {
|
||||||
|
if (!guildHomesFile.exists()) return;
|
||||||
|
for (String gid : guildHomesConfig.getKeys(false)) {
|
||||||
|
Location loc = guildHomesConfig.getLocation(gid);
|
||||||
|
if (loc != null) guildHomes.put(gid, loc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveGuildHomes() {
|
||||||
|
try {
|
||||||
|
for (Map.Entry<String, Location> entry : guildHomes.entrySet()) {
|
||||||
|
guildHomesConfig.set(entry.getKey(), entry.getValue());
|
||||||
|
}
|
||||||
|
guildHomesConfig.save(guildHomesFile);
|
||||||
|
} catch (IOException e) {
|
||||||
|
plugin.getLogger().warning("Failed to save guild homes: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||||
|
if (!(sender instanceof Player player)) {
|
||||||
|
sender.sendMessage(ChatColor.RED + "Only players can execute guild commands.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String cmd = command.getName().toLowerCase();
|
||||||
|
if (cmd.equals("claim")) {
|
||||||
|
handleClaim(player);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (cmd.equals("unclaim")) {
|
||||||
|
handleUnclaim(player);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmd.equals("guild") || cmd.equals("g")) {
|
||||||
|
if (args.length == 0 || args[0].equalsIgnoreCase("info") || args[0].equalsIgnoreCase("gui")) {
|
||||||
|
handleGuildDashboard(player);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String sub = args[0].toLowerCase();
|
||||||
|
switch (sub) {
|
||||||
|
case "create" -> handleCreate(player, args);
|
||||||
|
case "leave" -> handleLeave(player);
|
||||||
|
case "claim" -> handleClaim(player);
|
||||||
|
case "unclaim" -> handleUnclaim(player);
|
||||||
|
case "map" -> handleMap(player);
|
||||||
|
case "chat", "c" -> handleChat(player, args);
|
||||||
|
case "sethome" -> handleSetHome(player);
|
||||||
|
case "home" -> handleHome(player);
|
||||||
|
case "members" -> handleMembers(player);
|
||||||
|
default -> sendGuildHelp(player);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendGuildHelp(Player player) {
|
||||||
|
player.sendMessage(ChatColor.GOLD + "=== SCOPENET Guild Commands ===");
|
||||||
|
player.sendMessage(ChatColor.YELLOW + "/guild" + ChatColor.GRAY + " - Open interactive Guild Dashboard GUI");
|
||||||
|
player.sendMessage(ChatColor.YELLOW + "/guild create <name> <tag>" + ChatColor.GRAY + " - Create a new guild");
|
||||||
|
player.sendMessage(ChatColor.YELLOW + "/guild leave" + ChatColor.GRAY + " - Leave your current guild");
|
||||||
|
player.sendMessage(ChatColor.YELLOW + "/claim" + ChatColor.GRAY + " (or /guild claim) - Claim current chunk");
|
||||||
|
player.sendMessage(ChatColor.YELLOW + "/unclaim" + ChatColor.GRAY + " (or /guild unclaim) - Unclaim current chunk");
|
||||||
|
player.sendMessage(ChatColor.YELLOW + "/guild map" + ChatColor.GRAY + " - Show nearby land claims radar map");
|
||||||
|
player.sendMessage(ChatColor.YELLOW + "/guild chat <msg>" + ChatColor.GRAY + " - Send message to guild members");
|
||||||
|
player.sendMessage(ChatColor.YELLOW + "/guild sethome / /guild home" + ChatColor.GRAY + " - Guild waypoint base");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleClaim(Player player) {
|
||||||
|
Chunk chunk = player.getLocation().getChunk();
|
||||||
|
String dim = player.getWorld().getName();
|
||||||
|
player.sendMessage(ChatColor.GRAY + "Claiming chunk [" + chunk.getX() + ", " + chunk.getZ() + "]...");
|
||||||
|
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
try {
|
||||||
|
JsonObject res = integration.client().claimChunk(dim, chunk.getX(), chunk.getZ(), player.getUniqueId());
|
||||||
|
String gname = res.has("guild_name") ? res.get("guild_name").getAsString() : "your guild";
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Chunk claimed successfully for " + ChatColor.YELLOW + gname + ChatColor.GREEN + "!");
|
||||||
|
} catch (Exception e) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Claim failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleUnclaim(Player player) {
|
||||||
|
Chunk chunk = player.getLocation().getChunk();
|
||||||
|
String dim = player.getWorld().getName();
|
||||||
|
player.sendMessage(ChatColor.GRAY + "Unclaiming chunk [" + chunk.getX() + ", " + chunk.getZ() + "]...");
|
||||||
|
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
try {
|
||||||
|
integration.client().unclaimChunk(dim, chunk.getX(), chunk.getZ(), player.getUniqueId());
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Chunk [" + chunk.getX() + ", " + chunk.getZ() + "] has been unclaimed.");
|
||||||
|
} catch (Exception e) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Unclaim failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleCreate(Player player, String[] args) {
|
||||||
|
if (args.length < 3) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Usage: /guild create <name> <tag>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String name = args[1];
|
||||||
|
String tag = args[2];
|
||||||
|
player.sendMessage(ChatColor.GRAY + "Founding guild " + name + " [" + tag + "]...");
|
||||||
|
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
try {
|
||||||
|
JsonObject res = integration.client().createGuild(player.getUniqueId(), player.getName(), name, tag);
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Guild " + ChatColor.YELLOW + name + ChatColor.GOLD + " [" + tag + "]"
|
||||||
|
+ ChatColor.GREEN + " founded successfully! You are the Guild Leader.");
|
||||||
|
} catch (Exception e) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Failed to create guild: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleLeave(Player player) {
|
||||||
|
player.sendMessage(ChatColor.GRAY + "Leaving guild...");
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
try {
|
||||||
|
JsonObject res = integration.client().leaveGuild(player.getUniqueId());
|
||||||
|
boolean disbanded = res.has("disbanded") && res.get("disbanded").getAsBoolean();
|
||||||
|
if (disbanded) {
|
||||||
|
player.sendMessage(ChatColor.YELLOW + "You were the last member. The guild was disbanded.");
|
||||||
|
} else {
|
||||||
|
player.sendMessage(ChatColor.GREEN + "You left your guild.");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Error leaving guild: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleGuildDashboard(Player player) {
|
||||||
|
player.sendMessage(ChatColor.GRAY + "Loading guild info...");
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
try {
|
||||||
|
JsonObject res = integration.client().getPlayerGuild(player.getUniqueId());
|
||||||
|
if (!res.has("in_guild") || !res.get("in_guild").getAsBoolean()) {
|
||||||
|
player.sendMessage(ChatColor.YELLOW + "You are not in a guild! Create one with "
|
||||||
|
+ ChatColor.GOLD + "/guild create <name> <tag>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonObject g = res.getAsJsonObject("guild");
|
||||||
|
String name = g.get("name").getAsString();
|
||||||
|
String tag = g.get("tag").getAsString();
|
||||||
|
String role = g.get("role").getAsString();
|
||||||
|
int level = g.has("level") ? g.get("level").getAsInt() : 1;
|
||||||
|
long claims = g.has("claims_count") ? g.get("claims_count").getAsLong() : 0;
|
||||||
|
long maxClaims = g.has("max_claims") ? g.get("max_claims").getAsLong() : 16;
|
||||||
|
JsonArray members = g.has("members") ? g.getAsJsonArray("members") : new JsonArray();
|
||||||
|
|
||||||
|
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||||
|
Inventory inv = Bukkit.createInventory(null, 36, GUI_GUILD_TITLE);
|
||||||
|
ItemStack border = GuiHelper.createBorder(Material.CYAN_STAINED_GLASS_PANE);
|
||||||
|
for (int i = 0; i < 9; i++) inv.setItem(i, border);
|
||||||
|
for (int i = 27; i < 36; i++) inv.setItem(i, border);
|
||||||
|
|
||||||
|
// Guild banner
|
||||||
|
inv.setItem(11, GuiHelper.createItem(Material.CYAN_BANNER,
|
||||||
|
"&bGuild: &f" + name + " &e[" + tag + "]",
|
||||||
|
"&7Your Role: &a" + role.toUpperCase(),
|
||||||
|
"&7Guild Level: &6" + level,
|
||||||
|
"&7Members: &f" + members.size()));
|
||||||
|
|
||||||
|
// Claims status
|
||||||
|
inv.setItem(13, GuiHelper.createItem(Material.EMERALD_BLOCK,
|
||||||
|
"&aTerritory Claims",
|
||||||
|
"&7Claimed Chunks: &e" + claims + " &7/ &a" + maxClaims,
|
||||||
|
"",
|
||||||
|
"&7Click &e/claim &7to claim nearby area."));
|
||||||
|
|
||||||
|
// Quick claim button
|
||||||
|
inv.setItem(14, GuiHelper.createItem(Material.LIME_DYE,
|
||||||
|
"&aClaim Current Chunk",
|
||||||
|
"&7Click to claim the chunk you are standing on!"));
|
||||||
|
|
||||||
|
// Quick unclaim button
|
||||||
|
inv.setItem(15, GuiHelper.createItem(Material.RED_DYE,
|
||||||
|
"&cUnclaim Current Chunk",
|
||||||
|
"&7Click to unclaim the current chunk."));
|
||||||
|
|
||||||
|
// Minimap button
|
||||||
|
inv.setItem(22, GuiHelper.createItem(Material.MAP,
|
||||||
|
"&eView Land Map",
|
||||||
|
"&7Shows nearby territory claims radar in chat."));
|
||||||
|
|
||||||
|
player.openInventory(inv);
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Failed to load guild: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleMap(Player player) {
|
||||||
|
Location pLoc = player.getLocation();
|
||||||
|
Chunk center = pLoc.getChunk();
|
||||||
|
int cx = center.getX();
|
||||||
|
int cz = center.getZ();
|
||||||
|
String dim = pLoc.getWorld().getName();
|
||||||
|
|
||||||
|
player.sendMessage(ChatColor.GOLD + "========= " + ChatColor.YELLOW + "Territory Map (" + cx + ", " + cz + ")" + ChatColor.GOLD + " =========");
|
||||||
|
player.sendMessage(ChatColor.GRAY + " N (-Z)");
|
||||||
|
|
||||||
|
StringBuilder header = new StringBuilder(" ");
|
||||||
|
for (int dx = -4; dx <= 4; dx++) {
|
||||||
|
header.append(" ");
|
||||||
|
}
|
||||||
|
player.sendMessage(header.toString());
|
||||||
|
|
||||||
|
for (int dz = -3; dz <= 3; dz++) {
|
||||||
|
StringBuilder row = new StringBuilder(" ");
|
||||||
|
for (int dx = -4; dx <= 4; dx++) {
|
||||||
|
int targetX = cx + dx;
|
||||||
|
int targetZ = cz + dz;
|
||||||
|
boolean isPlayer = (dx == 0 && dz == 0);
|
||||||
|
|
||||||
|
ChunkCheckResult res = integration.checkChunk(dim, targetX, targetZ, player.getUniqueId());
|
||||||
|
if (isPlayer) {
|
||||||
|
row.append(ChatColor.GOLD).append("[P]");
|
||||||
|
} else if (res.claimed() && res.allowed()) {
|
||||||
|
row.append(ChatColor.GREEN).append(" + ");
|
||||||
|
} else if (res.claimed() && !res.allowed()) {
|
||||||
|
row.append(ChatColor.RED).append(" x ");
|
||||||
|
} else {
|
||||||
|
row.append(ChatColor.DARK_GRAY).append(" . ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
player.sendMessage(row.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
player.sendMessage(ChatColor.GRAY + " S (+Z)");
|
||||||
|
player.sendMessage(ChatColor.GOLD + "[P]" + ChatColor.YELLOW + " You "
|
||||||
|
+ ChatColor.GREEN + "+ " + ChatColor.GRAY + "Your Guild "
|
||||||
|
+ ChatColor.RED + "x " + ChatColor.GRAY + "Other Guild "
|
||||||
|
+ ChatColor.DARK_GRAY + ". " + ChatColor.GRAY + "Wilderness");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleChat(Player player, String[] args) {
|
||||||
|
if (args.length < 2) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Usage: /guild chat <message>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String msg = String.join(" ", Arrays.copyOfRange(args, 1, args.length));
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
try {
|
||||||
|
JsonObject res = integration.client().getPlayerGuild(player.getUniqueId());
|
||||||
|
if (!res.has("in_guild") || !res.get("in_guild").getAsBoolean()) {
|
||||||
|
player.sendMessage(ChatColor.RED + "You are not in a guild.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String tag = res.getAsJsonObject("guild").get("tag").getAsString();
|
||||||
|
String formatted = ChatColor.GREEN + "[Guild " + tag + "] " + ChatColor.YELLOW + player.getName() + ": " + ChatColor.WHITE + msg;
|
||||||
|
for (Player p : Bukkit.getOnlinePlayers()) {
|
||||||
|
p.sendMessage(formatted);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Guild chat error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleSetHome(Player player) {
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
try {
|
||||||
|
JsonObject res = integration.client().getPlayerGuild(player.getUniqueId());
|
||||||
|
if (!res.has("in_guild") || !res.get("in_guild").getAsBoolean()) {
|
||||||
|
player.sendMessage(ChatColor.RED + "You must be in a guild to set a guild home.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String role = res.getAsJsonObject("guild").get("role").getAsString();
|
||||||
|
if (!role.equals("leader") && !role.equals("officer")) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Only guild leaders and officers can set the guild home.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String gid = res.getAsJsonObject("guild").get("id").getAsString();
|
||||||
|
guildHomes.put(gid, player.getLocation());
|
||||||
|
saveGuildHomes();
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Guild home waypoint set at your location!");
|
||||||
|
} catch (Exception e) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleHome(Player player) {
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
try {
|
||||||
|
JsonObject res = integration.client().getPlayerGuild(player.getUniqueId());
|
||||||
|
if (!res.has("in_guild") || !res.get("in_guild").getAsBoolean()) {
|
||||||
|
player.sendMessage(ChatColor.RED + "You are not in a guild.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String gid = res.getAsJsonObject("guild").get("id").getAsString();
|
||||||
|
Location loc = guildHomes.get(gid);
|
||||||
|
if (loc == null) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Your guild has not set a guild home yet! Use /guild sethome");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||||
|
player.teleport(loc);
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Teleported to guild home base.");
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleMembers(Player player) {
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
|
try {
|
||||||
|
JsonObject res = integration.client().getPlayerGuild(player.getUniqueId());
|
||||||
|
if (!res.has("in_guild") || !res.get("in_guild").getAsBoolean()) {
|
||||||
|
player.sendMessage(ChatColor.RED + "You are not in a guild.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
JsonObject g = res.getAsJsonObject("guild");
|
||||||
|
String name = g.get("name").getAsString();
|
||||||
|
JsonArray members = g.getAsJsonArray("members");
|
||||||
|
|
||||||
|
player.sendMessage(ChatColor.GOLD + "=== Members of " + ChatColor.YELLOW + name + ChatColor.GOLD + " ===");
|
||||||
|
for (JsonElement el : members) {
|
||||||
|
JsonObject m = el.getAsJsonObject();
|
||||||
|
String mname = m.get("name").getAsString();
|
||||||
|
String role = m.get("role").getAsString();
|
||||||
|
Player onlineP = Bukkit.getPlayer(mname);
|
||||||
|
String status = onlineP != null && onlineP.isOnline() ? ChatColor.GREEN + "[Online]" : ChatColor.GRAY + "[Offline]";
|
||||||
|
player.sendMessage(ChatColor.WHITE + " - " + mname + " (" + ChatColor.AQUA + role + ChatColor.WHITE + ") " + status);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Error fetching members: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onInventoryClick(InventoryClickEvent event) {
|
||||||
|
if (!(event.getWhoClicked() instanceof Player player)) return;
|
||||||
|
if (!event.getView().getTitle().equals(GUI_GUILD_TITLE)) return;
|
||||||
|
|
||||||
|
event.setCancelled(true);
|
||||||
|
int slot = event.getRawSlot();
|
||||||
|
if (slot == 14) { // Claim Current Chunk
|
||||||
|
player.closeInventory();
|
||||||
|
handleClaim(player);
|
||||||
|
} else if (slot == 15) { // Unclaim Current Chunk
|
||||||
|
player.closeInventory();
|
||||||
|
handleUnclaim(player);
|
||||||
|
} else if (slot == 22) { // Minimap
|
||||||
|
player.closeInventory();
|
||||||
|
handleMap(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
package net.scopenet.paper.commands;
|
||||||
|
|
||||||
|
import net.scopenet.integration.Integration;
|
||||||
|
import net.scopenet.integration.Settings;
|
||||||
|
import net.scopenet.paper.ScopenetPlugin;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.command.Command;
|
||||||
|
import org.bukkit.command.CommandExecutor;
|
||||||
|
import org.bukkit.command.CommandSender;
|
||||||
|
|
||||||
|
public final class ScopenetCommandHandler implements CommandExecutor {
|
||||||
|
private final ScopenetPlugin plugin;
|
||||||
|
private final Integration integration;
|
||||||
|
|
||||||
|
public ScopenetCommandHandler(ScopenetPlugin plugin, Integration integration) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.integration = integration;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||||
|
if (args.length == 0 || args[0].equalsIgnoreCase("help")) {
|
||||||
|
sendHelp(sender);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String sub = args[0].toLowerCase();
|
||||||
|
switch (sub) {
|
||||||
|
case "status" -> sendStatus(sender);
|
||||||
|
case "reload" -> handleReload(sender);
|
||||||
|
default -> {
|
||||||
|
sender.sendMessage(ChatColor.RED + "Unknown command. Use " + ChatColor.YELLOW + "/scopenet help");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendHelp(CommandSender sender) {
|
||||||
|
Settings s = integration != null ? integration.settings() : null;
|
||||||
|
sender.sendMessage(ChatColor.GOLD + "=================== " + ChatColor.YELLOW + "SCOPENET Commands" + ChatColor.GOLD + " ===================");
|
||||||
|
|
||||||
|
// Essentials commands
|
||||||
|
if (s == null || s.essentialsEnabled()) {
|
||||||
|
sender.sendMessage(ChatColor.AQUA + "[Essentials Commands]");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /spawn" + ChatColor.GRAY + " - Teleport to server spawn");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /home [name]" + ChatColor.GRAY + " - Teleport to home or open homes GUI");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /sethome [name]" + ChatColor.GRAY + " - Set home location");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /delhome [name]" + ChatColor.GRAY + " - Delete home");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /back" + ChatColor.GRAY + " - Return to location before teleport/death");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /tpa <player>" + ChatColor.GRAY + " - Request to teleport to a player");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /tpaccept / /tpdeny" + ChatColor.GRAY + " - Accept or deny teleport");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /rtp" + ChatColor.GRAY + " - Random wilderness teleport");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /warp [name]" + ChatColor.GRAY + " - Teleport to warp or open warps GUI");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /playtime [player]" + ChatColor.GRAY + " - View total playtime stats");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Economy commands
|
||||||
|
if (s == null || s.economyEnabled()) {
|
||||||
|
sender.sendMessage(ChatColor.GREEN + "[Economy Commands]");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /balance (or /bal)" + ChatColor.GRAY + " - Check your wallet balance");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /pay <player> <amount>" + ChatColor.GRAY + " - Send money to a player");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /baltop" + ChatColor.GRAY + " - Leaderboard of richest players");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /shop" + ChatColor.GRAY + " - Open interactive Server Shop GUI");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /sell (or /sell hand)" + ChatColor.GRAY + " - Sell items for cash");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /market" + ChatColor.GRAY + " - Open player marketplace GUI");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /market sell <price>" + ChatColor.GRAY + " - List held item on market");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /trade <player>" + ChatColor.GRAY + " - Secure two-player trade GUI");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /transactions" + ChatColor.GRAY + " - View recent transaction history");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guilds commands
|
||||||
|
if (s == null || s.guildsEnabled()) {
|
||||||
|
sender.sendMessage(ChatColor.BLUE + "[Guilds & Claims]");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /guild" + ChatColor.GRAY + " - Open interactive Guild Dashboard GUI");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /guild create <name> <tag>" + ChatColor.GRAY + " - Create a new guild");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /guild leave" + ChatColor.GRAY + " - Leave your current guild");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /guild members" + ChatColor.GRAY + " - List online guild members");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /guild chat <msg>" + ChatColor.GRAY + " - Send guild chat message");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /claim" + ChatColor.GRAY + " - Claim current chunk for your guild");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /unclaim" + ChatColor.GRAY + " - Unclaim current chunk");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /guild map" + ChatColor.GRAY + " - Show nearby land claims radar map");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin commands
|
||||||
|
if (sender.hasPermission("scopenet.admin")) {
|
||||||
|
sender.sendMessage(ChatColor.LIGHT_PURPLE + "[Admin Commands]");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /scopenet status" + ChatColor.GRAY + " - Integration health and module statuses");
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + " /scopenet reload" + ChatColor.GRAY + " - Reload config and feature flags");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendStatus(CommandSender sender) {
|
||||||
|
if (!sender.hasPermission("scopenet.admin")) {
|
||||||
|
sender.sendMessage(ChatColor.RED + "You do not have permission to view SCOPENET status.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings s = integration != null ? integration.settings() : null;
|
||||||
|
sender.sendMessage(ChatColor.GOLD + "=== SCOPENET Integration Status ===");
|
||||||
|
if (s == null) {
|
||||||
|
sender.sendMessage(ChatColor.RED + "Status: NOT INITIALIZED or OFFLINE");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sender.sendMessage(ChatColor.GRAY + "Panel URL: " + ChatColor.WHITE + s.panel());
|
||||||
|
sender.sendMessage(ChatColor.GRAY + "Server Token: " + ChatColor.GREEN + (s.token().isEmpty() ? "MISSING" : "CONFIGURED"));
|
||||||
|
sender.sendMessage(ChatColor.GRAY + "Leveling: " + formatFlag(s.levelingEnabled()));
|
||||||
|
sender.sendMessage(ChatColor.GRAY + "Quests: " + formatFlag(s.questsEnabled()));
|
||||||
|
sender.sendMessage(ChatColor.GRAY + "Achievements: " + formatFlag(s.achievementsEnabled()));
|
||||||
|
sender.sendMessage(ChatColor.GRAY + "Guilds: " + formatFlag(s.guildsEnabled()));
|
||||||
|
sender.sendMessage(ChatColor.GRAY + "Land Claiming: " + formatFlag(s.landClaimingEnabled()));
|
||||||
|
sender.sendMessage(ChatColor.GRAY + "Social: " + formatFlag(s.socialEnabled()));
|
||||||
|
sender.sendMessage(ChatColor.GRAY + "Essentials: " + formatFlag(s.essentialsEnabled()));
|
||||||
|
sender.sendMessage(ChatColor.GRAY + "Economy: " + formatFlag(s.economyEnabled()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatFlag(boolean enabled) {
|
||||||
|
return enabled ? ChatColor.GREEN + "ENABLED" : ChatColor.RED + "DISABLED";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleReload(CommandSender sender) {
|
||||||
|
if (!sender.hasPermission("scopenet.admin")) {
|
||||||
|
sender.sendMessage(ChatColor.RED + "You do not have permission to reload SCOPENET.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin.reloadConfig();
|
||||||
|
sender.sendMessage(ChatColor.GREEN + "SCOPENET configuration reloaded successfully.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package net.scopenet.paper.gui;
|
||||||
|
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.meta.ItemMeta;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class GuiHelper {
|
||||||
|
private GuiHelper() {}
|
||||||
|
|
||||||
|
public static ItemStack createItem(Material material, String name, String... lore) {
|
||||||
|
return createItem(material, 1, name, lore);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ItemStack createItem(Material material, int amount, String name, String... lore) {
|
||||||
|
ItemStack item = new ItemStack(material, Math.max(1, amount));
|
||||||
|
ItemMeta meta = item.getItemMeta();
|
||||||
|
if (meta != null) {
|
||||||
|
if (name != null) {
|
||||||
|
meta.setDisplayName(ChatColor.translateAlternateColorCodes('&', name));
|
||||||
|
}
|
||||||
|
if (lore != null && lore.length > 0) {
|
||||||
|
List<String> list = new ArrayList<>();
|
||||||
|
for (String line : lore) {
|
||||||
|
list.add(ChatColor.translateAlternateColorCodes('&', line));
|
||||||
|
}
|
||||||
|
meta.setLore(list);
|
||||||
|
}
|
||||||
|
item.setItemMeta(meta);
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ItemStack createBorder(Material material) {
|
||||||
|
return createItem(material, " ");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,3 +42,18 @@ social:
|
|||||||
enabled: true
|
enabled: true
|
||||||
# Show player guild tag and level title in chat formatting
|
# Show player guild tag and level title in chat formatting
|
||||||
chat-prefixes: true
|
chat-prefixes: true
|
||||||
|
|
||||||
|
essentials:
|
||||||
|
# Essentials commands (/spawn, /home, /sethome, /delhome, /back, /tpa, /tpaccept, /tpdeny, /rtp, /warp, /playtime)
|
||||||
|
enabled: true
|
||||||
|
# Maximum homes per player
|
||||||
|
max-homes: 5
|
||||||
|
# RTP search radius
|
||||||
|
rtp-radius: 2500
|
||||||
|
|
||||||
|
economy:
|
||||||
|
# Server Economy commands (/balance, /pay, /baltop, /shop, /sell, /market, /orders, /trade, /transactions)
|
||||||
|
enabled: true
|
||||||
|
# Currency symbol
|
||||||
|
currency-symbol: "$"
|
||||||
|
|
||||||
@@ -4,3 +4,70 @@ main: net.scopenet.paper.ScopenetPlugin
|
|||||||
api-version: '1.20'
|
api-version: '1.20'
|
||||||
description: SCOPENET private authentication, access control and activity reporting.
|
description: SCOPENET private authentication, access control and activity reporting.
|
||||||
load: STARTUP
|
load: STARTUP
|
||||||
|
|
||||||
|
commands:
|
||||||
|
scopenet:
|
||||||
|
description: SCOPENET server commands, status, and help
|
||||||
|
aliases: [sn]
|
||||||
|
permission: scopenet.use
|
||||||
|
spawn:
|
||||||
|
description: Teleport to the server spawn point
|
||||||
|
aliases: [hub, lobby]
|
||||||
|
home:
|
||||||
|
description: Teleport to one of your homes or open the homes GUI
|
||||||
|
aliases: [homes]
|
||||||
|
sethome:
|
||||||
|
description: Set a new home location
|
||||||
|
aliases: [createshome]
|
||||||
|
delhome:
|
||||||
|
description: Delete an existing home
|
||||||
|
aliases: [rmhome]
|
||||||
|
back:
|
||||||
|
description: Return to your previous location or death point
|
||||||
|
aliases: [return]
|
||||||
|
tpa:
|
||||||
|
description: Request to teleport to another player
|
||||||
|
tpaccept:
|
||||||
|
description: Accept an incoming teleport request
|
||||||
|
aliases: [tpyes]
|
||||||
|
tpdeny:
|
||||||
|
description: Deny an incoming teleport request
|
||||||
|
aliases: [tpno]
|
||||||
|
rtp:
|
||||||
|
description: Teleport to a random safe location in the wilderness
|
||||||
|
aliases: [wild, randomtp]
|
||||||
|
warp:
|
||||||
|
description: Teleport to a server warp or open the warps GUI
|
||||||
|
aliases: [warps]
|
||||||
|
playtime:
|
||||||
|
description: Check total and session playtime
|
||||||
|
aliases: [ontime]
|
||||||
|
balance:
|
||||||
|
description: Check your server economy balance
|
||||||
|
aliases: [bal, money]
|
||||||
|
pay:
|
||||||
|
description: Send money to another player
|
||||||
|
baltop:
|
||||||
|
description: View the richest players on the server
|
||||||
|
aliases: [richest]
|
||||||
|
shop:
|
||||||
|
description: Open the interactive server shop GUI
|
||||||
|
sell:
|
||||||
|
description: Open the item selling GUI
|
||||||
|
market:
|
||||||
|
description: Open the player marketplace GUI
|
||||||
|
aliases: [ah, auction]
|
||||||
|
orders:
|
||||||
|
description: View active marketplace orders
|
||||||
|
trade:
|
||||||
|
description: Request a secure trade with another player
|
||||||
|
transactions:
|
||||||
|
description: View recent economy transactions
|
||||||
|
guild:
|
||||||
|
description: Guild management, members, claims, and relations
|
||||||
|
aliases: [g, clan]
|
||||||
|
claim:
|
||||||
|
description: Claim the current chunk for your guild
|
||||||
|
unclaim:
|
||||||
|
description: Unclaim the current chunk
|
||||||
|
|
||||||
@@ -7,9 +7,9 @@ use crate::state::{build, AppState};
|
|||||||
use crate::updater;
|
use crate::updater;
|
||||||
use scopenet_core::ping::ServerStatus;
|
use scopenet_core::ping::ServerStatus;
|
||||||
use scopenet_shared::{
|
use scopenet_shared::{
|
||||||
Achievement, DirectMessage, FriendInfo, GameInvite, Guild, GuildClaim, GuildMember, GuildPost,
|
Achievement, BaltopEntry, DirectMessage, EconomyTransaction, FriendInfo, GameInvite, Guild,
|
||||||
LauncherManifest, PlayerProfile, SkinProfile, UserLevelInfo, UserPost, UserProfileView,
|
GuildClaim, GuildMember, GuildPost, LauncherManifest, MemberProfile, PlayerProfile,
|
||||||
UserQuest,
|
ServerEconomyBalance, SkinProfile, UserLevelInfo, UserPost, UserProfileView, UserQuest,
|
||||||
};
|
};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tauri::{AppHandle, Manager, State};
|
use tauri::{AppHandle, Manager, State};
|
||||||
@@ -550,14 +550,14 @@ pub async fn get_my_quests(state: State<'_, AppState>) -> Res<Vec<UserQuest>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn claim_quest(state: State<'_, AppState>, quest_id: String) -> Res<UserQuest> {
|
pub async fn claim_quest(state: State<'_, AppState>, quest_id: String) -> Res<UserLevelInfo> {
|
||||||
let req = account_api(&state, reqwest::Method::POST, &format!("/quests/{quest_id}/claim")).await?;
|
let req = account_api(&state, reqwest::Method::POST, &format!("/quests/{quest_id}/claim")).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() {
|
||||||
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 claim quest").to_string());
|
return Err(body["error"].as_str().unwrap_or("unable to claim quest").to_string());
|
||||||
}
|
}
|
||||||
resp.json().await.map_err(err)
|
get_my_level(state).await
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -738,9 +738,14 @@ pub async fn get_friends(state: State<'_, AppState>) -> Res<Vec<FriendInfo>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn send_friend_request(state: State<'_, AppState>, username: String) -> Res<()> {
|
pub async fn send_friend_request(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
friend_username: Option<String>,
|
||||||
|
username: Option<String>,
|
||||||
|
) -> Res<()> {
|
||||||
|
let target = friend_username.or(username).ok_or("username is required")?;
|
||||||
let req = account_api(&state, reqwest::Method::POST, "/friends/request").await?;
|
let req = account_api(&state, reqwest::Method::POST, "/friends/request").await?;
|
||||||
let resp = req.json(&serde_json::json!({ "username": username })).send().await.map_err(err)?;
|
let resp = req.json(&serde_json::json!({ "username": target })).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 send friend request").to_string());
|
return Err(body["error"].as_str().unwrap_or("unable to send friend request").to_string());
|
||||||
@@ -749,9 +754,15 @@ pub async fn send_friend_request(state: State<'_, AppState>, username: String) -
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn respond_friend_request(state: State<'_, AppState>, target_uuid: String, accept: bool) -> Res<()> {
|
pub async fn respond_friend_request(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
friend_uuid: Option<String>,
|
||||||
|
target_uuid: Option<String>,
|
||||||
|
accept: bool,
|
||||||
|
) -> Res<()> {
|
||||||
|
let target = friend_uuid.or(target_uuid).ok_or("target_uuid is required")?;
|
||||||
let req = account_api(&state, reqwest::Method::POST, "/friends/respond").await?;
|
let req = account_api(&state, reqwest::Method::POST, "/friends/respond").await?;
|
||||||
let resp = req.json(&serde_json::json!({ "target_uuid": target_uuid, "accept": accept })).send().await.map_err(err)?;
|
let resp = req.json(&serde_json::json!({ "target_uuid": target, "accept": accept })).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 friend request").to_string());
|
return Err(body["error"].as_str().unwrap_or("unable to respond to friend request").to_string());
|
||||||
@@ -760,8 +771,13 @@ pub async fn respond_friend_request(state: State<'_, AppState>, target_uuid: Str
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn remove_friend(state: State<'_, AppState>, target_uuid: String) -> Res<()> {
|
pub async fn remove_friend(
|
||||||
let req = account_api(&state, reqwest::Method::DELETE, &format!("/friends/{target_uuid}")).await?;
|
state: State<'_, AppState>,
|
||||||
|
friend_uuid: Option<String>,
|
||||||
|
target_uuid: Option<String>,
|
||||||
|
) -> Res<()> {
|
||||||
|
let target = friend_uuid.or(target_uuid).ok_or("target_uuid is required")?;
|
||||||
|
let req = account_api(&state, reqwest::Method::DELETE, &format!("/friends/{target}")).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() {
|
||||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||||
@@ -770,6 +786,46 @@ pub async fn remove_friend(state: State<'_, AppState>, target_uuid: String) -> R
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn search_members(state: State<'_, AppState>, query: String) -> Res<Vec<MemberProfile>> {
|
||||||
|
let req = account_api(&state, reqwest::Method::GET, &format!("/social/members?q={query}")).await?;
|
||||||
|
let resp = req.send().await.map_err(err)?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err("unable to search members".into());
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_economy_balances(state: State<'_, AppState>) -> Res<Vec<ServerEconomyBalance>> {
|
||||||
|
let req = account_api(&state, reqwest::Method::GET, "/economy/me").await?;
|
||||||
|
let resp = req.send().await.map_err(err)?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err("unable to load economy balances".into());
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_server_baltop(state: State<'_, AppState>, server_id: i64) -> Res<Vec<BaltopEntry>> {
|
||||||
|
let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/economy/baltop")).await?;
|
||||||
|
let resp = req.send().await.map_err(err)?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err("unable to load baltop leaderboard".into());
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_transactions(state: State<'_, AppState>) -> Res<Vec<EconomyTransaction>> {
|
||||||
|
let req = account_api(&state, reqwest::Method::GET, "/economy/transactions").await?;
|
||||||
|
let resp = req.send().await.map_err(err)?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err("unable to load transactions".into());
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(err)
|
||||||
|
}
|
||||||
|
|
||||||
#[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(state: State<'_, AppState>, friend_uuid: String) -> Res<Vec<DirectMessage>> {
|
||||||
let req = account_api(&state, reqwest::Method::GET, &format!("/messages/{friend_uuid}")).await?;
|
let req = account_api(&state, reqwest::Method::GET, &format!("/messages/{friend_uuid}")).await?;
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ pub fn run() {
|
|||||||
commands::send_friend_request,
|
commands::send_friend_request,
|
||||||
commands::respond_friend_request,
|
commands::respond_friend_request,
|
||||||
commands::remove_friend,
|
commands::remove_friend,
|
||||||
|
commands::search_members,
|
||||||
|
commands::get_economy_balances,
|
||||||
|
commands::get_server_baltop,
|
||||||
|
commands::get_transactions,
|
||||||
commands::get_direct_messages,
|
commands::get_direct_messages,
|
||||||
commands::send_direct_message,
|
commands::send_direct_message,
|
||||||
commands::get_game_invites,
|
commands::get_game_invites,
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export interface UserLevelInfo {
|
|||||||
next_level_xp: number;
|
next_level_xp: number;
|
||||||
progress_pct: number;
|
progress_pct: number;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
|
rank?: number | null;
|
||||||
badges: string[];
|
badges: string[];
|
||||||
rewards: LevelReward[];
|
rewards: LevelReward[];
|
||||||
}
|
}
|
||||||
@@ -316,3 +317,44 @@ export interface UserProfileView {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MemberProfile {
|
||||||
|
uuid: string;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
status: string;
|
||||||
|
skin_url: string | null;
|
||||||
|
global_level: number;
|
||||||
|
title: string | null;
|
||||||
|
playtime_secs: number;
|
||||||
|
last_seen: string | null;
|
||||||
|
online: boolean;
|
||||||
|
is_friend: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerEconomyBalance {
|
||||||
|
server_id: number;
|
||||||
|
server_name: string;
|
||||||
|
balance: number;
|
||||||
|
currency_symbol: string;
|
||||||
|
last_updated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EconomyTransaction {
|
||||||
|
id: number;
|
||||||
|
server_id: number;
|
||||||
|
server_name?: string;
|
||||||
|
sender_uuid: string;
|
||||||
|
recipient_uuid: string;
|
||||||
|
amount: number;
|
||||||
|
tx_type: string;
|
||||||
|
description: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaltopEntry {
|
||||||
|
rank: number;
|
||||||
|
uuid: string;
|
||||||
|
username: string;
|
||||||
|
balance: number;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -51,7 +51,8 @@
|
|||||||
levelInfo = updatedLevel;
|
levelInfo = updatedLevel;
|
||||||
// Mark as claimed locally
|
// Mark as claimed locally
|
||||||
userQuests = userQuests.map((uq) => {
|
userQuests = userQuests.map((uq) => {
|
||||||
if (uq.quest.id === questId) {
|
const id = uq.quest?.id || (uq as any).id;
|
||||||
|
if (id === questId) {
|
||||||
return { ...uq, claimed: true };
|
return { ...uq, claimed: true };
|
||||||
}
|
}
|
||||||
return uq;
|
return uq;
|
||||||
@@ -66,18 +67,29 @@
|
|||||||
|
|
||||||
const filteredQuests = $derived(
|
const filteredQuests = $derived(
|
||||||
userQuests.filter((uq) => {
|
userQuests.filter((uq) => {
|
||||||
if (uq.quest.quest_type !== activeTab) return false;
|
const q = uq.quest || uq;
|
||||||
if (selectedCategory !== 'all' && uq.quest.category !== selectedCategory) return false;
|
const qType = q.quest_type || (q as any).period || 'daily';
|
||||||
|
if (qType !== activeTab) return false;
|
||||||
|
const cat = q.category || 'mining';
|
||||||
|
if (selectedCategory !== 'all' && cat !== selectedCategory) return false;
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const completedCount = $derived(
|
const completedCount = $derived(
|
||||||
userQuests.filter((uq) => uq.quest.quest_type === activeTab && uq.completed).length
|
userQuests.filter((uq) => {
|
||||||
|
const q = uq.quest || uq;
|
||||||
|
const qType = q.quest_type || (q as any).period || 'daily';
|
||||||
|
return qType === activeTab && uq.completed;
|
||||||
|
}).length
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalTabQuests = $derived(
|
const totalTabQuests = $derived(
|
||||||
userQuests.filter((uq) => uq.quest.quest_type === activeTab).length
|
userQuests.filter((uq) => {
|
||||||
|
const q = uq.quest || uq;
|
||||||
|
const qType = q.quest_type || (q as any).period || 'daily';
|
||||||
|
return qType === activeTab;
|
||||||
|
}).length
|
||||||
);
|
);
|
||||||
|
|
||||||
const unlockedAchievementsCount = $derived(
|
const unlockedAchievementsCount = $derived(
|
||||||
@@ -128,11 +140,19 @@
|
|||||||
<span class="lvl-label">LVL</span>
|
<span class="lvl-label">LVL</span>
|
||||||
<span class="lvl-number">{levelInfo.level}</span>
|
<span class="lvl-number">{levelInfo.level}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{#if levelInfo.rank}
|
||||||
|
<div class="rank-badge-pill">
|
||||||
|
<Trophy size={11} /> #{levelInfo.rank}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="level-details">
|
<div class="level-details">
|
||||||
<div class="level-title-row">
|
<div class="level-title-row">
|
||||||
<div class="title-meta">
|
<div class="title-meta">
|
||||||
<span class="level-name">SCOPENET Global Level</span>
|
<span class="level-name">SCOPENET Progression</span>
|
||||||
|
{#if levelInfo.rank}
|
||||||
|
<span class="badge rank-title-badge"><Trophy size={12} /> Global Rank #{levelInfo.rank}</span>
|
||||||
|
{/if}
|
||||||
{#if levelInfo.title}
|
{#if levelInfo.title}
|
||||||
<span class="badge title-badge"><Award size={12} /> {levelInfo.title}</span>
|
<span class="badge title-badge"><Award size={12} /> {levelInfo.title}</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -146,7 +166,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="level-footer">
|
<div class="level-footer">
|
||||||
<span class="muted tiny">
|
<span class="muted tiny">
|
||||||
Playing on any SCOPENET server earns Global XP. Unlocks exclusive titles, badges, and cosmetic items.
|
{#if levelInfo.rank}
|
||||||
|
You are currently ranked #{levelInfo.rank} globally! Earn XP across all servers to climb the leaderboard.
|
||||||
|
{:else}
|
||||||
|
Playing on any SCOPENET server earns Global XP. Unlocks exclusive titles, badges, and cosmetic items.
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{#if levelInfo.badges && levelInfo.badges.length > 0}
|
{#if levelInfo.badges && levelInfo.badges.length > 0}
|
||||||
<div class="badges-row">
|
<div class="badges-row">
|
||||||
@@ -242,9 +266,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="quests-grid">
|
<div class="quests-grid">
|
||||||
{#each filteredQuests as uq (uq.quest.id)}
|
{#each filteredQuests as uq ((uq.quest?.id || (uq as any).id))}
|
||||||
{@const pct = Math.min(100, Math.round((uq.current_count / Math.max(1, uq.quest.target_count)) * 100))}
|
{@const q = uq.quest || uq}
|
||||||
{@const IconComponent = categoryIcons[uq.quest.category] || Target}
|
{@const currentCount = uq.current_count ?? (uq as any).progress ?? 0}
|
||||||
|
{@const targetCount = q.target_count || (q as any).target_count || 1}
|
||||||
|
{@const pct = Math.min(100, Math.round((currentCount / Math.max(1, targetCount)) * 100))}
|
||||||
|
{@const IconComponent = categoryIcons[q.category] || Target}
|
||||||
|
{@const qId = q.id || (uq as any).id}
|
||||||
|
|
||||||
<div class="quest-card glass" class:completed={uq.completed} class:claimed={uq.claimed}>
|
<div class="quest-card glass" class:completed={uq.completed} class:claimed={uq.claimed}>
|
||||||
<div class="quest-top">
|
<div class="quest-top">
|
||||||
@@ -253,19 +281,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="quest-meta">
|
<div class="quest-meta">
|
||||||
<div class="quest-title-row">
|
<div class="quest-title-row">
|
||||||
<span class="quest-title">{uq.quest.title}</span>
|
<span class="quest-title">{q.title}</span>
|
||||||
<span class="badge cat-badge">{uq.quest.category}</span>
|
<span class="badge cat-badge">{q.category}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="quest-desc">{uq.quest.description}</p>
|
<p class="quest-desc">{q.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="quest-progress-wrap">
|
<div class="quest-progress-wrap">
|
||||||
<div class="progress-labels">
|
<div class="progress-labels">
|
||||||
<span class="progress-ratio">
|
<span class="progress-ratio">
|
||||||
<strong>{uq.current_count}</strong> / {uq.quest.target_count} ({pct}%)
|
<strong>{currentCount}</strong> / {targetCount} ({pct}%)
|
||||||
</span>
|
</span>
|
||||||
<span class="xp-badge">+{uq.quest.xp_reward} XP</span>
|
<span class="xp-badge">+{q.xp_reward} XP</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress-bar-bg">
|
<div class="progress-bar-bg">
|
||||||
<div class="progress-fill" style:width="{pct}%"></div>
|
<div class="progress-fill" style:width="{pct}%"></div>
|
||||||
@@ -278,15 +306,15 @@
|
|||||||
{:else if uq.completed}
|
{:else if uq.completed}
|
||||||
<button
|
<button
|
||||||
class="primary claim-btn"
|
class="primary claim-btn"
|
||||||
onclick={() => handleClaimQuest(uq.quest.id)}
|
onclick={() => handleClaimQuest(qId)}
|
||||||
disabled={claimingQuestId === uq.quest.id}
|
disabled={claimingQuestId === qId}
|
||||||
>
|
>
|
||||||
{#if claimingQuestId === uq.quest.id}
|
{#if claimingQuestId === qId}
|
||||||
<LoaderCircle size={14} class="spin" />
|
<LoaderCircle size={14} class="spin" />
|
||||||
{:else}
|
{:else}
|
||||||
<Gift size={14} />
|
<Gift size={14} />
|
||||||
{/if}
|
{/if}
|
||||||
Claim +{uq.quest.xp_reward} XP
|
Claim +{q.xp_reward} XP
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="badge in-progress-badge">In Progress</span>
|
<span class="badge in-progress-badge">In Progress</span>
|
||||||
@@ -369,6 +397,26 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
.rank-badge-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
background: color-mix(in srgb, #f59e0b 25%, transparent);
|
||||||
|
color: #f59e0b;
|
||||||
|
border: 1px solid color-mix(in srgb, #f59e0b 50%, transparent);
|
||||||
|
border-radius: 99rem;
|
||||||
|
padding: 2px 7px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: -6px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.rank-title-badge {
|
||||||
|
background: color-mix(in srgb, #f59e0b 20%, transparent);
|
||||||
|
color: #f59e0b;
|
||||||
|
border: 1px solid color-mix(in srgb, #f59e0b 45%, transparent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
.level-details {
|
.level-details {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -2,15 +2,16 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import {
|
import {
|
||||||
Users, UserPlus, MessageSquare, Gamepad2, Send, Check, X, Trash2,
|
Users, UserPlus, MessageSquare, Gamepad2, Send, Check, X, Trash2,
|
||||||
Clock, ExternalLink, LoaderCircle, RefreshCw, Circle, UserX, Sparkles
|
Clock, ExternalLink, LoaderCircle, RefreshCw, Circle, UserX, Sparkles,
|
||||||
|
Search, Trophy, Shield
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import Avatar from '../components/Avatar.svelte';
|
import Avatar from '../components/Avatar.svelte';
|
||||||
import Modal from '../components/Modal.svelte';
|
import Modal from '../components/Modal.svelte';
|
||||||
import { activeAccount, app, instances, toast } from '../lib/store.svelte';
|
import { activeAccount, app, instances, toast } from '../lib/store.svelte';
|
||||||
import { invoke } from '../lib/tauri';
|
import { invoke } from '../lib/tauri';
|
||||||
import type { FriendInfo, DirectMessage, GameInvite } from '../lib/types';
|
import type { FriendInfo, DirectMessage, GameInvite, MemberProfile } from '../lib/types';
|
||||||
|
|
||||||
let activeSection = $state<'friends' | 'messages' | 'invites'>('friends');
|
let activeSection = $state<'friends' | 'members' | 'messages' | 'invites'>('friends');
|
||||||
let friendsFilter = $state<'all' | 'online' | 'pending'>('all');
|
let friendsFilter = $state<'all' | 'online' | 'pending'>('all');
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
|
|
||||||
@@ -25,6 +26,12 @@
|
|||||||
let addFriendUsername = $state('');
|
let addFriendUsername = $state('');
|
||||||
let sendingFriendReq = $state(false);
|
let sendingFriendReq = $state(false);
|
||||||
|
|
||||||
|
// Members Directory / Search
|
||||||
|
let memberSearchQuery = $state('');
|
||||||
|
let membersList = $state<MemberProfile[]>([]);
|
||||||
|
let loadingMembers = $state(false);
|
||||||
|
let addingFriendUuid = $state<string | null>(null);
|
||||||
|
|
||||||
// Send Game Invite Modal
|
// Send Game Invite Modal
|
||||||
let inviteModalOpen = $state(false);
|
let inviteModalOpen = $state(false);
|
||||||
let inviteTargetFriend = $state<FriendInfo | null>(null);
|
let inviteTargetFriend = $state<FriendInfo | null>(null);
|
||||||
@@ -47,14 +54,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function searchMembers() {
|
||||||
|
loadingMembers = true;
|
||||||
|
try {
|
||||||
|
membersList = await invoke<MemberProfile[]>('search_members', { query: memberSearchQuery.trim() });
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(e?.message ?? 'Failed to search members', 'error');
|
||||||
|
} finally {
|
||||||
|
loadingMembers = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddMemberAsFriend(username: string, uuid: string) {
|
||||||
|
addingFriendUuid = uuid;
|
||||||
|
try {
|
||||||
|
await invoke('send_friend_request', { username, friendUsername: username });
|
||||||
|
toast(`Friend request sent to ${username}!`, 'ok');
|
||||||
|
await searchMembers();
|
||||||
|
await loadData();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(e?.message ?? 'Failed to send friend request', 'error');
|
||||||
|
} finally {
|
||||||
|
addingFriendUuid = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSendFriendRequest() {
|
async function handleSendFriendRequest() {
|
||||||
if (!addFriendUsername.trim()) return;
|
if (!addFriendUsername.trim()) return;
|
||||||
sendingFriendReq = true;
|
sendingFriendReq = true;
|
||||||
|
const target = addFriendUsername.trim();
|
||||||
try {
|
try {
|
||||||
await invoke('send_friend_request', { friendUsername: addFriendUsername.trim() });
|
await invoke('send_friend_request', { username: target, friendUsername: target });
|
||||||
toast(`Friend request sent to ${addFriendUsername.trim()}!`, 'ok');
|
toast(`Friend request sent to ${target}!`, 'ok');
|
||||||
addFriendUsername = '';
|
addFriendUsername = '';
|
||||||
await loadData();
|
await loadData();
|
||||||
|
if (activeSection === 'members') {
|
||||||
|
await searchMembers();
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast(e?.message ?? 'Failed to send friend request', 'error');
|
toast(e?.message ?? 'Failed to send friend request', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -64,7 +100,7 @@
|
|||||||
|
|
||||||
async function handleRespondRequest(friendUuid: string, accept: boolean) {
|
async function handleRespondRequest(friendUuid: string, accept: boolean) {
|
||||||
try {
|
try {
|
||||||
await invoke('respond_friend_request', { friendUuid, accept });
|
await invoke('respond_friend_request', { targetUuid: friendUuid, friendUuid, accept });
|
||||||
toast(accept ? 'Friend request accepted!' : 'Request declined.', 'ok');
|
toast(accept ? 'Friend request accepted!' : 'Request declined.', 'ok');
|
||||||
await loadData();
|
await loadData();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -74,7 +110,7 @@
|
|||||||
|
|
||||||
async function handleRemoveFriend(friendUuid: string) {
|
async function handleRemoveFriend(friendUuid: string) {
|
||||||
try {
|
try {
|
||||||
await invoke('remove_friend', { friendUuid });
|
await invoke('remove_friend', { targetUuid: friendUuid, friendUuid });
|
||||||
friends = friends.filter((f) => f.uuid !== friendUuid);
|
friends = friends.filter((f) => f.uuid !== friendUuid);
|
||||||
if (activeChatFriend?.uuid === friendUuid) {
|
if (activeChatFriend?.uuid === friendUuid) {
|
||||||
activeChatFriend = null;
|
activeChatFriend = null;
|
||||||
@@ -191,6 +227,9 @@
|
|||||||
<button class:active={activeSection === 'friends'} onclick={() => (activeSection = 'friends')}>
|
<button class:active={activeSection === 'friends'} onclick={() => (activeSection = 'friends')}>
|
||||||
<Users size={15} /> Friends ({acceptedFriends.length})
|
<Users size={15} /> Friends ({acceptedFriends.length})
|
||||||
</button>
|
</button>
|
||||||
|
<button class:active={activeSection === 'members'} onclick={() => { activeSection = 'members'; if (!membersList.length) searchMembers(); }}>
|
||||||
|
<Sparkles size={15} /> Find Players
|
||||||
|
</button>
|
||||||
<button class:active={activeSection === 'messages'} onclick={() => (activeSection = 'messages')}>
|
<button class:active={activeSection === 'messages'} onclick={() => (activeSection = 'messages')}>
|
||||||
<MessageSquare size={15} /> Direct Messages
|
<MessageSquare size={15} /> Direct Messages
|
||||||
</button>
|
</button>
|
||||||
@@ -346,6 +385,100 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{:else if activeSection === 'members'}
|
||||||
|
<!-- Members Directory Section -->
|
||||||
|
<div class="members-layout">
|
||||||
|
<div class="members-search-bar glass">
|
||||||
|
<Search size={18} class="muted" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search all SCOPENET network players by username..."
|
||||||
|
bind:value={memberSearchQuery}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && searchMembers()}
|
||||||
|
class="members-search-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="primary sm"
|
||||||
|
onclick={searchMembers}
|
||||||
|
disabled={loadingMembers}
|
||||||
|
>
|
||||||
|
{#if loadingMembers}<LoaderCircle size={14} class="spin" />{:else}<Search size={14} /> Search{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loadingMembers && !membersList.length}
|
||||||
|
<div class="center-state"><LoaderCircle class="spin" size={32} /></div>
|
||||||
|
{:else if membersList.length === 0}
|
||||||
|
<div class="empty-state glass">
|
||||||
|
<Users size={36} class="muted" />
|
||||||
|
<p class="muted">No players found matching "{memberSearchQuery}". Try another username!</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="members-grid">
|
||||||
|
{#each membersList as member (member.uuid)}
|
||||||
|
{@const isMe = member.uuid === activeAccount()?.uuid}
|
||||||
|
<div class="member-card glass" class:is-me={isMe}>
|
||||||
|
<div class="member-card-top">
|
||||||
|
<div class="avatar-wrap" role="button" tabindex="0" onclick={() => (app.viewProfileUuid = member.uuid)} onkeydown={(e) => e.key === 'Enter' && (app.viewProfileUuid = member.uuid)}>
|
||||||
|
<Avatar uuid={member.uuid} name={member.username} size={2.8} />
|
||||||
|
<span class="status-dot" class:online={member.online}></span>
|
||||||
|
</div>
|
||||||
|
<div class="member-meta">
|
||||||
|
<div class="name-row">
|
||||||
|
<strong class="member-name" onclick={() => (app.viewProfileUuid = member.uuid)}>{member.username}</strong>
|
||||||
|
{#if isMe}
|
||||||
|
<span class="badge me-tag">You</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="member-sub-info">
|
||||||
|
<span class="badge level-pill">Lvl {member.global_level}</span>
|
||||||
|
{#if member.title}
|
||||||
|
<span class="badge title-pill">{member.title}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="online-status tiny muted">
|
||||||
|
{member.online ? 'Online on Network' : member.last_seen ? `Active ${new Date(member.last_seen).toLocaleDateString()}` : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="member-card-actions">
|
||||||
|
{#if isMe}
|
||||||
|
<button class="ghost sm w-full" onclick={() => (app.viewProfileUuid = member.uuid)}>
|
||||||
|
View My Profile
|
||||||
|
</button>
|
||||||
|
{:else if member.is_friend}
|
||||||
|
<button class="secondary sm" onclick={() => {
|
||||||
|
const friend = friends.find(f => f.uuid === member.uuid) || { uuid: member.uuid, username: member.username, status: 'accepted', is_online: member.online, playing_on: null };
|
||||||
|
selectChatFriend(friend as FriendInfo);
|
||||||
|
}}>
|
||||||
|
<MessageSquare size={13} /> Message
|
||||||
|
</button>
|
||||||
|
<button class="ghost sm icon" onclick={() => (app.viewProfileUuid = member.uuid)} title="View Profile">
|
||||||
|
<ExternalLink size={13} />
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="primary sm"
|
||||||
|
onclick={() => handleAddMemberAsFriend(member.username, member.uuid)}
|
||||||
|
disabled={addingFriendUuid === member.uuid}
|
||||||
|
>
|
||||||
|
{#if addingFriendUuid === member.uuid}
|
||||||
|
<LoaderCircle size={13} class="spin" />
|
||||||
|
{:else}
|
||||||
|
<UserPlus size={13} /> Add Friend
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button class="ghost sm icon" onclick={() => (app.viewProfileUuid = member.uuid)} title="View Profile">
|
||||||
|
<ExternalLink size={13} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{:else if activeSection === 'messages'}
|
{:else if activeSection === 'messages'}
|
||||||
<!-- Direct Messages Split View -->
|
<!-- Direct Messages Split View -->
|
||||||
<div class="messages-split glass">
|
<div class="messages-split glass">
|
||||||
@@ -910,4 +1043,102 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
|
.members-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.members-search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.members-search-input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.members-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.member-card {
|
||||||
|
padding: 1.1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.9rem;
|
||||||
|
transition: transform 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.member-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
|
||||||
|
}
|
||||||
|
.member-card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
.avatar-wrap {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.member-meta {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.member-name {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.member-name:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.member-sub-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.level-pill {
|
||||||
|
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.title-pill {
|
||||||
|
background: color-mix(in srgb, #f59e0b 20%, transparent);
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
.me-tag {
|
||||||
|
background: color-mix(in srgb, var(--line) 40%, transparent);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.member-card-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--line) 40%, transparent);
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.w-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -2,14 +2,23 @@
|
|||||||
import {
|
import {
|
||||||
Trophy, Swords, Skull, Pickaxe, Clock, Box, MessageSquare, LogIn,
|
Trophy, Swords, Skull, Pickaxe, Clock, Box, MessageSquare, LogIn,
|
||||||
RefreshCw, LoaderCircle, Users, Server, Flame, Sparkles, ChevronDown, Check,
|
RefreshCw, LoaderCircle, Users, Server, Flame, Sparkles, ChevronDown, Check,
|
||||||
|
Coins, DollarSign, Wallet, ArrowUpRight, ArrowDownLeft, TrendingUp, History,
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import Avatar from '../components/Avatar.svelte';
|
import Avatar from '../components/Avatar.svelte';
|
||||||
import { activeAccount, app, toast } from '../lib/store.svelte';
|
import { activeAccount, app, toast } from '../lib/store.svelte';
|
||||||
import { invoke } from '../lib/tauri';
|
import { invoke } from '../lib/tauri';
|
||||||
|
import type { ServerEconomyBalance, BaltopEntry, EconomyTransaction } from '../lib/types';
|
||||||
|
|
||||||
let activeTab = $state<'personal' | 'leaderboard'>('personal');
|
let activeTab = $state<'personal' | 'economy' | 'leaderboard'>('personal');
|
||||||
let loadingPersonal = $state(false);
|
let loadingPersonal = $state(false);
|
||||||
let loadingLeaderboard = $state(false);
|
let loadingLeaderboard = $state(false);
|
||||||
|
let loadingEconomy = $state(false);
|
||||||
|
|
||||||
|
// Economy state
|
||||||
|
let economyBalances = $state<ServerEconomyBalance[]>([]);
|
||||||
|
let selectedEconomyServerId = $state<number | null>(null);
|
||||||
|
let baltopData = $state<BaltopEntry[]>([]);
|
||||||
|
let transactions = $state<EconomyTransaction[]>([]);
|
||||||
|
|
||||||
// Personal stats data
|
// Personal stats data
|
||||||
let personalData = $state<{
|
let personalData = $state<{
|
||||||
@@ -142,11 +151,47 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadEconomy() {
|
||||||
|
loadingEconomy = true;
|
||||||
|
try {
|
||||||
|
const [balances, txs] = await Promise.all([
|
||||||
|
invoke<ServerEconomyBalance[]>('get_economy_balances').catch(() => []),
|
||||||
|
invoke<EconomyTransaction[]>('get_transactions').catch(() => []),
|
||||||
|
]);
|
||||||
|
economyBalances = balances;
|
||||||
|
transactions = txs;
|
||||||
|
if (!selectedEconomyServerId && balances.length > 0) {
|
||||||
|
selectedEconomyServerId = balances[0].server_id;
|
||||||
|
}
|
||||||
|
if (selectedEconomyServerId) {
|
||||||
|
await loadBaltop(selectedEconomyServerId);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(e?.message ?? 'Failed to load economy data', 'error');
|
||||||
|
} finally {
|
||||||
|
loadingEconomy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBaltop(serverId: number) {
|
||||||
|
try {
|
||||||
|
baltopData = await invoke<BaltopEntry[]>('get_server_baltop', { serverId });
|
||||||
|
} catch (e) {
|
||||||
|
baltopData = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEconomyServerChange(serverId: number) {
|
||||||
|
selectedEconomyServerId = serverId;
|
||||||
|
loadBaltop(serverId);
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (app.view === 'stats') {
|
if (app.view === 'stats') {
|
||||||
loadPersonalStats();
|
loadPersonalStats();
|
||||||
loadServers();
|
loadServers();
|
||||||
loadLeaderboard();
|
loadLeaderboard();
|
||||||
|
loadEconomy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -178,6 +223,9 @@
|
|||||||
<button class:active={activeTab === 'personal'} onclick={() => (activeTab = 'personal')}>
|
<button class:active={activeTab === 'personal'} onclick={() => (activeTab = 'personal')}>
|
||||||
My Stats
|
My Stats
|
||||||
</button>
|
</button>
|
||||||
|
<button class:active={activeTab === 'economy'} onclick={() => { activeTab = 'economy'; loadEconomy(); }}>
|
||||||
|
<Coins size={14} /> Server Economy
|
||||||
|
</button>
|
||||||
<button class:active={activeTab === 'leaderboard'} onclick={() => (activeTab = 'leaderboard')}>
|
<button class:active={activeTab === 'leaderboard'} onclick={() => (activeTab = 'leaderboard')}>
|
||||||
Leaderboards
|
Leaderboards
|
||||||
</button>
|
</button>
|
||||||
@@ -333,6 +381,149 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else if activeTab === 'economy'}
|
||||||
|
<!-- Economy & Balances Tab -->
|
||||||
|
<div class="economy-view">
|
||||||
|
{#if loadingEconomy && !economyBalances.length}
|
||||||
|
<div class="center-state"><LoaderCircle size={28} class="spin" /></div>
|
||||||
|
{:else if !me || me.kind === 'offline'}
|
||||||
|
<div class="card glass empty-state">
|
||||||
|
<Coins size={36} class="muted" />
|
||||||
|
<h3>Server account required</h3>
|
||||||
|
<p class="muted small">In-game economy balances and transactions are synced to your SCOPENET server account.</p>
|
||||||
|
<button class="sm primary" onclick={() => (app.addAccount = true)}>Sign in with Server Account</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Balances Section -->
|
||||||
|
<div class="section-card card glass">
|
||||||
|
<div class="row justify-between align-center">
|
||||||
|
<div>
|
||||||
|
<strong class="section-title">My Server Balances</strong>
|
||||||
|
<p class="muted tiny">Synced in real-time from SCOPENET economy plugins and mods.</p>
|
||||||
|
</div>
|
||||||
|
<button class="ghost sm icon" onclick={loadEconomy} title="Refresh balances">
|
||||||
|
<RefreshCw size={14} class={loadingEconomy ? 'spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if economyBalances.length === 0}
|
||||||
|
<div class="empty-inline muted tiny">
|
||||||
|
No active currency balances found yet. Join a SCOPENET server to start earning money!
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="econ-balances-grid">
|
||||||
|
{#each economyBalances as bal (bal.server_id)}
|
||||||
|
<div
|
||||||
|
class="econ-balance-card glass"
|
||||||
|
class:selected={selectedEconomyServerId === bal.server_id}
|
||||||
|
onclick={() => handleEconomyServerChange(bal.server_id)}
|
||||||
|
>
|
||||||
|
<div class="econ-card-top row justify-between align-center">
|
||||||
|
<span class="server-badge">{bal.server_name}</span>
|
||||||
|
<Wallet size={16} class="icon-accent" />
|
||||||
|
</div>
|
||||||
|
<div class="econ-balance-val">
|
||||||
|
<span class="currency-sym">{bal.currency_symbol || '$'}</span>
|
||||||
|
<span>{bal.balance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
|
||||||
|
</div>
|
||||||
|
<div class="econ-card-footer tiny muted">
|
||||||
|
Updated {formatDate(bal.last_updated)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Server Baltop Leaderboard Section -->
|
||||||
|
<div class="section-card card glass">
|
||||||
|
<div class="row justify-between align-center">
|
||||||
|
<div>
|
||||||
|
<strong class="section-title">Server Rich List (Baltop)</strong>
|
||||||
|
<p class="muted tiny">Top richest players on the selected game server.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if servers.length > 0}
|
||||||
|
<div class="row align-center gap-xs">
|
||||||
|
<span class="tiny muted">Server:</span>
|
||||||
|
<select
|
||||||
|
class="filter-select"
|
||||||
|
value={selectedEconomyServerId}
|
||||||
|
onchange={(e) => handleEconomyServerChange(Number(e.currentTarget.value))}
|
||||||
|
>
|
||||||
|
{#each servers as s (s.id)}
|
||||||
|
<option value={s.id}>{s.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if baltopData.length === 0}
|
||||||
|
<div class="empty-inline muted tiny">
|
||||||
|
No baltop entries recorded for this server yet.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="baltop-list">
|
||||||
|
{#each baltopData as row}
|
||||||
|
{@const isMe = row.uuid === me?.uuid}
|
||||||
|
<div class="baltop-row" class:is-me={isMe}>
|
||||||
|
<div class="row align-center gap-sm">
|
||||||
|
<span class="baltop-rank" class:gold={row.rank === 1} class:silver={row.rank === 2} class:bronze={row.rank === 3}>
|
||||||
|
#{row.rank}
|
||||||
|
</span>
|
||||||
|
<Avatar uuid={row.uuid} name={row.username} size={2.2} />
|
||||||
|
<strong class="baltop-name">{row.username}</strong>
|
||||||
|
{#if isMe}<span class="badge you">You</span>{/if}
|
||||||
|
</div>
|
||||||
|
<span class="baltop-val highlight">
|
||||||
|
${row.balance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transaction History Section -->
|
||||||
|
<div class="section-card card glass">
|
||||||
|
<div class="row justify-between align-center">
|
||||||
|
<div>
|
||||||
|
<strong class="section-title">Recent Transactions</strong>
|
||||||
|
<p class="muted tiny">Payments, player trades, market listings, and server shop logs.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if transactions.length === 0}
|
||||||
|
<div class="empty-inline muted tiny">
|
||||||
|
No recorded economy transactions yet.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="tx-table-wrap">
|
||||||
|
<div class="tx-header">
|
||||||
|
<span>Type</span>
|
||||||
|
<span>Description</span>
|
||||||
|
<span>Date</span>
|
||||||
|
<span class="text-right">Amount</span>
|
||||||
|
</div>
|
||||||
|
{#each transactions.slice(0, 15) as tx (tx.id)}
|
||||||
|
{@const isSender = tx.sender_uuid === me?.uuid}
|
||||||
|
<div class="tx-row">
|
||||||
|
<span class="tx-type-pill badge" class:send={isSender} class:receive={!isSender}>
|
||||||
|
{#if isSender}<ArrowUpRight size={12} /> Sent{:else}<ArrowDownLeft size={12} /> Received{/if}
|
||||||
|
</span>
|
||||||
|
<span class="tx-desc" title={tx.description}>{tx.description || tx.tx_type}</span>
|
||||||
|
<span class="tx-date tiny muted">{new Date(tx.created_at).toLocaleString()}</span>
|
||||||
|
<span class="tx-amount text-right" class:negative={isSender} class:positive={!isSender}>
|
||||||
|
{isSender ? '-' : '+'}${Math.abs(tx.amount).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Leaderboards Tab -->
|
<!-- Leaderboards Tab -->
|
||||||
<div class="filters-bar card glass row justify-between align-center">
|
<div class="filters-bar card glass row justify-between align-center">
|
||||||
@@ -500,4 +691,32 @@
|
|||||||
.table-header, .table-row { grid-template-columns: 2.5rem 1fr 7rem; }
|
.table-header, .table-row { grid-template-columns: 2.5rem 1fr 7rem; }
|
||||||
.col-extra { display: none; }
|
.col-extra { display: none; }
|
||||||
}
|
}
|
||||||
|
.economy-view { display: flex; flex-direction: column; gap: 1.2rem; }
|
||||||
|
.section-title { font-size: 1.05rem; font-weight: 700; }
|
||||||
|
.empty-inline { padding: 1.2rem 0; }
|
||||||
|
.econ-balances-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); gap: 1rem; margin-top: 0.5rem; }
|
||||||
|
.econ-balance-card { display: flex; flex-direction: column; gap: 0.6rem; padding: 1.1rem 1.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: transform 0.15s, border-color 0.15s; }
|
||||||
|
.econ-balance-card:hover { transform: translateY(-2px); }
|
||||||
|
.econ-balance-card.selected { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
|
||||||
|
.server-badge { font-size: 0.82rem; font-weight: 700; color: var(--accent); }
|
||||||
|
.econ-balance-val { font-size: 1.6rem; font-weight: 800; display: flex; align-items: baseline; gap: 0.2rem; font-variant-numeric: tabular-nums; }
|
||||||
|
.currency-sym { font-size: 1.1rem; color: var(--muted); }
|
||||||
|
.baltop-list { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 0.5rem; }
|
||||||
|
.baltop-row { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--bg) 50%, transparent); border: 1px solid var(--line); }
|
||||||
|
.baltop-row.is-me { background: color-mix(in srgb, var(--accent) 10%, transparent); border-color: color-mix(in srgb, var(--accent) 30%, transparent); }
|
||||||
|
.baltop-rank { font-weight: 800; font-size: 0.95rem; min-width: 2rem; font-variant-numeric: tabular-nums; }
|
||||||
|
.baltop-rank.gold { color: #f59e0b; }
|
||||||
|
.baltop-rank.silver { color: #94a3b8; }
|
||||||
|
.baltop-rank.bronze { color: #d97706; }
|
||||||
|
.baltop-name { font-size: 0.95rem; }
|
||||||
|
.baltop-val { font-size: 1.05rem; }
|
||||||
|
.tx-table-wrap { display: flex; flex-direction: column; margin-top: 0.5rem; border: 1px solid var(--line); border-radius: var(--radius-sm); overflow: hidden; }
|
||||||
|
.tx-header { display: grid; grid-template-columns: 7rem 1fr 12rem 8rem; padding: 0.65rem 1rem; font-size: 0.78rem; text-transform: uppercase; font-weight: 600; color: var(--muted); border-bottom: 1px solid var(--line); }
|
||||||
|
.tx-row { display: grid; grid-template-columns: 7rem 1fr 12rem 8rem; align-items: center; padding: 0.7rem 1rem; border-bottom: 1px solid color-mix(in srgb, var(--line) 40%, transparent); font-size: 0.85rem; }
|
||||||
|
.tx-type-pill.send { background: color-mix(in srgb, var(--danger) 15%, transparent); color: var(--danger); }
|
||||||
|
.tx-type-pill.receive { background: color-mix(in srgb, var(--success) 15%, transparent); color: var(--success); }
|
||||||
|
.tx-desc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.tx-amount { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||||
|
.tx-amount.negative { color: var(--danger); }
|
||||||
|
.tx-amount.positive { color: var(--success); }
|
||||||
</style>
|
</style>
|
||||||
@@ -438,6 +438,44 @@ const MIGRATIONS: &[&str] = &[
|
|||||||
);
|
);
|
||||||
CREATE INDEX user_posts_user ON user_posts(user_uuid, id DESC);
|
CREATE INDEX user_posts_user ON user_posts(user_uuid, id DESC);
|
||||||
"#,
|
"#,
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS server_economy (
|
||||||
|
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
||||||
|
uuid TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
balance REAL NOT NULL DEFAULT 1000.0,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (server_id, uuid)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS economy_user ON server_economy(uuid);
|
||||||
|
CREATE INDEX IF NOT EXISTS economy_bal ON server_economy(server_id, balance DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS economy_transactions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
||||||
|
from_uuid TEXT NOT NULL,
|
||||||
|
from_name TEXT NOT NULL,
|
||||||
|
to_uuid TEXT NOT NULL,
|
||||||
|
to_name TEXT NOT NULL,
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS econ_tx_user ON economy_transactions(server_id, from_uuid, to_uuid);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS server_market (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
||||||
|
seller_uuid TEXT NOT NULL,
|
||||||
|
seller_name TEXT NOT NULL,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
item_name TEXT NOT NULL,
|
||||||
|
amount INTEGER NOT NULL DEFAULT 1,
|
||||||
|
price REAL NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS market_srv ON server_market(server_id, id DESC);
|
||||||
|
"#,
|
||||||
];
|
];
|
||||||
|
|
||||||
pub async fn connect(data_dir: &Path) -> Result<SqlitePool> {
|
pub async fn connect(data_dir: &Path) -> Result<SqlitePool> {
|
||||||
|
|||||||
@@ -78,12 +78,15 @@ pub async fn get_achievements_for_user(
|
|||||||
title,
|
title,
|
||||||
description: desc,
|
description: desc,
|
||||||
category: cat,
|
category: cat,
|
||||||
icon_frame: frame,
|
icon_frame: frame.clone(),
|
||||||
|
frame_type: Some(frame),
|
||||||
icon_item: item,
|
icon_item: item,
|
||||||
icon_bg: bg,
|
icon_bg: bg,
|
||||||
icon_border: border,
|
icon_border: border,
|
||||||
xp_reward: xp,
|
xp_reward: xp,
|
||||||
secret,
|
secret,
|
||||||
|
stat_type: None,
|
||||||
|
target_count: None,
|
||||||
unlocked,
|
unlocked,
|
||||||
unlocked_at,
|
unlocked_at,
|
||||||
})
|
})
|
||||||
@@ -105,6 +108,8 @@ pub struct AdminAchievementRow {
|
|||||||
pub requirement_type: String,
|
pub requirement_type: String,
|
||||||
pub requirement_key: String,
|
pub requirement_key: String,
|
||||||
pub requirement_value: i64,
|
pub requirement_value: i64,
|
||||||
|
pub stat_type: String,
|
||||||
|
pub target_count: i64,
|
||||||
pub total_unlocked: i64,
|
pub total_unlocked: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,18 +171,23 @@ pub async fn admin_list_achievements(
|
|||||||
title,
|
title,
|
||||||
description: desc,
|
description: desc,
|
||||||
category: cat,
|
category: cat,
|
||||||
icon_frame: frame,
|
icon_frame: frame.clone(),
|
||||||
|
frame_type: Some(frame),
|
||||||
icon_item: item,
|
icon_item: item,
|
||||||
icon_bg: bg,
|
icon_bg: bg,
|
||||||
icon_border: border,
|
icon_border: border,
|
||||||
xp_reward: xp,
|
xp_reward: xp,
|
||||||
secret,
|
secret,
|
||||||
|
stat_type: Some(req_key.clone()),
|
||||||
|
target_count: Some(req_val),
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
unlocked_at: None,
|
unlocked_at: None,
|
||||||
},
|
},
|
||||||
requirement_type: req_type,
|
requirement_type: req_type,
|
||||||
requirement_key: req_key,
|
requirement_key: req_key.clone(),
|
||||||
requirement_value: req_val,
|
requirement_value: req_val,
|
||||||
|
stat_type: req_key,
|
||||||
|
target_count: req_val,
|
||||||
total_unlocked,
|
total_unlocked,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -193,13 +203,17 @@ pub struct AchievementPayload {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub category: String,
|
pub category: String,
|
||||||
|
#[serde(alias = "frame_type")]
|
||||||
pub icon_frame: String,
|
pub icon_frame: String,
|
||||||
pub icon_item: String,
|
pub icon_item: String,
|
||||||
pub icon_bg: String,
|
pub icon_bg: String,
|
||||||
pub icon_border: String,
|
pub icon_border: String,
|
||||||
pub requirement_type: String,
|
#[serde(default)]
|
||||||
pub requirement_key: String,
|
pub requirement_type: Option<String>,
|
||||||
pub requirement_value: i64,
|
#[serde(default, alias = "stat_type")]
|
||||||
|
pub requirement_key: Option<String>,
|
||||||
|
#[serde(default, alias = "target_count")]
|
||||||
|
pub requirement_value: Option<i64>,
|
||||||
pub xp_reward: i64,
|
pub xp_reward: i64,
|
||||||
pub secret: Option<bool>,
|
pub secret: Option<bool>,
|
||||||
}
|
}
|
||||||
@@ -215,6 +229,9 @@ pub async fn admin_create_achievement(
|
|||||||
.filter(|s| !s.trim().is_empty())
|
.filter(|s| !s.trim().is_empty())
|
||||||
.unwrap_or_else(|| format!("ach_{}", uuid::Uuid::new_v4().simple()));
|
.unwrap_or_else(|| format!("ach_{}", uuid::Uuid::new_v4().simple()));
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
let req_type = payload.requirement_type.unwrap_or_else(|| "stat".into());
|
||||||
|
let req_key = payload.requirement_key.unwrap_or_else(|| "blocks_broken".into());
|
||||||
|
let req_val = payload.requirement_value.unwrap_or(50);
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO achievements (id, title, description, category, icon_frame, icon_item, icon_bg, icon_border, requirement_type, requirement_key, requirement_value, xp_reward, secret, created_at)
|
"INSERT INTO achievements (id, title, description, category, icon_frame, icon_item, icon_bg, icon_border, requirement_type, requirement_key, requirement_value, xp_reward, secret, created_at)
|
||||||
@@ -228,9 +245,9 @@ pub async fn admin_create_achievement(
|
|||||||
.bind(payload.icon_item)
|
.bind(payload.icon_item)
|
||||||
.bind(payload.icon_bg)
|
.bind(payload.icon_bg)
|
||||||
.bind(payload.icon_border)
|
.bind(payload.icon_border)
|
||||||
.bind(payload.requirement_type)
|
.bind(req_type)
|
||||||
.bind(payload.requirement_key)
|
.bind(req_key)
|
||||||
.bind(payload.requirement_value)
|
.bind(req_val)
|
||||||
.bind(payload.xp_reward)
|
.bind(payload.xp_reward)
|
||||||
.bind(payload.secret.unwrap_or(false))
|
.bind(payload.secret.unwrap_or(false))
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
@@ -247,6 +264,10 @@ 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());
|
||||||
|
let req_key = payload.requirement_key.unwrap_or_else(|| "blocks_broken".into());
|
||||||
|
let req_val = payload.requirement_value.unwrap_or(50);
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE achievements SET
|
"UPDATE achievements SET
|
||||||
title = ?,
|
title = ?,
|
||||||
@@ -270,9 +291,9 @@ pub async fn admin_update_achievement(
|
|||||||
.bind(payload.icon_item)
|
.bind(payload.icon_item)
|
||||||
.bind(payload.icon_bg)
|
.bind(payload.icon_bg)
|
||||||
.bind(payload.icon_border)
|
.bind(payload.icon_border)
|
||||||
.bind(payload.requirement_type)
|
.bind(req_type)
|
||||||
.bind(payload.requirement_key)
|
.bind(req_key)
|
||||||
.bind(payload.requirement_value)
|
.bind(req_val)
|
||||||
.bind(payload.xp_reward)
|
.bind(payload.xp_reward)
|
||||||
.bind(payload.secret)
|
.bind(payload.secret)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
|
|||||||
@@ -0,0 +1,483 @@
|
|||||||
|
//! Per-server economy system, marketplace, transactions, and baltop.
|
||||||
|
|
||||||
|
use crate::auth::AuthUser;
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::routes::servers::GameServer;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::Json;
|
||||||
|
use scopenet_shared::{BaltopEntry, EconomyTransaction, MarketListing, ServerEconomyBalance};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Launcher & Public Economy API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Get current user's economy balances across all connected game servers.
|
||||||
|
pub async fn get_my_balances(
|
||||||
|
auth: AuthUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> AppResult<Json<Vec<ServerEconomyBalance>>> {
|
||||||
|
let rows: Vec<(i64, String, f64)> = sqlx::query_as(
|
||||||
|
"SELECT se.server_id, gs.name, se.balance
|
||||||
|
FROM server_economy se
|
||||||
|
JOIN game_servers gs ON gs.id = se.server_id
|
||||||
|
WHERE se.uuid = ?
|
||||||
|
ORDER BY se.balance DESC",
|
||||||
|
)
|
||||||
|
.bind(&auth.uuid)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let list = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(sid, sname, bal)| ServerEconomyBalance {
|
||||||
|
server_id: sid,
|
||||||
|
server_name: sname,
|
||||||
|
balance: bal,
|
||||||
|
currency_symbol: "$".into(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current user's recent transactions across all servers.
|
||||||
|
pub async fn get_my_transactions(
|
||||||
|
auth: AuthUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> AppResult<Json<Vec<EconomyTransaction>>> {
|
||||||
|
let rows: Vec<(i64, i64, String, String, String, String, String, f64, String, String)> = sqlx::query_as(
|
||||||
|
"SELECT et.id, et.server_id, gs.name, et.from_uuid, et.from_name, et.to_uuid, et.to_name, et.amount, et.description, et.created_at
|
||||||
|
FROM economy_transactions et
|
||||||
|
JOIN game_servers gs ON gs.id = et.server_id
|
||||||
|
WHERE et.from_uuid = ? OR et.to_uuid = ?
|
||||||
|
ORDER BY et.id DESC
|
||||||
|
LIMIT 50",
|
||||||
|
)
|
||||||
|
.bind(&auth.uuid)
|
||||||
|
.bind(&auth.uuid)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let list = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, sid, sname, fuuid, fname, tuuid, tname, amount, desc, created)| EconomyTransaction {
|
||||||
|
id,
|
||||||
|
server_id: sid,
|
||||||
|
server_name: Some(sname),
|
||||||
|
from_uuid: fuuid,
|
||||||
|
from_name: fname,
|
||||||
|
to_uuid: tuuid,
|
||||||
|
to_name: tname,
|
||||||
|
amount,
|
||||||
|
description: desc,
|
||||||
|
created_at: created,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get top richest players for a specific server.
|
||||||
|
pub async fn get_server_baltop(
|
||||||
|
Path(server_id): Path<i64>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> AppResult<Json<Vec<BaltopEntry>>> {
|
||||||
|
let rows: Vec<(String, String, f64)> = sqlx::query_as(
|
||||||
|
"SELECT uuid, username, balance
|
||||||
|
FROM server_economy
|
||||||
|
WHERE server_id = ?
|
||||||
|
ORDER BY balance DESC
|
||||||
|
LIMIT 25",
|
||||||
|
)
|
||||||
|
.bind(server_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let list = rows
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, (uuid, username, balance))| BaltopEntry {
|
||||||
|
rank: i + 1,
|
||||||
|
uuid,
|
||||||
|
username,
|
||||||
|
balance,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get active marketplace listings for a server.
|
||||||
|
pub async fn get_server_market(
|
||||||
|
Path(server_id): Path<i64>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> AppResult<Json<Vec<MarketListing>>> {
|
||||||
|
let rows: Vec<(i64, i64, String, String, String, String, i32, f64, String)> = sqlx::query_as(
|
||||||
|
"SELECT id, server_id, seller_uuid, seller_name, item_id, item_name, amount, price, created_at
|
||||||
|
FROM server_market
|
||||||
|
WHERE server_id = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 100",
|
||||||
|
)
|
||||||
|
.bind(server_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let list = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, sid, suuid, sname, iid, iname, amt, price, created)| MarketListing {
|
||||||
|
id,
|
||||||
|
server_id: sid,
|
||||||
|
seller_uuid: suuid,
|
||||||
|
seller_name: sname,
|
||||||
|
item_id: iid,
|
||||||
|
item_name: iname,
|
||||||
|
amount: amt,
|
||||||
|
price,
|
||||||
|
created_at: created,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Server Integration Relay Endpoints
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ServerBalanceQuery {
|
||||||
|
pub uuid: String,
|
||||||
|
pub username: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn server_get_balance(
|
||||||
|
GameServer(server): GameServer,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<ServerBalanceQuery>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
let username = payload.username.unwrap_or_else(|| "Player".into());
|
||||||
|
|
||||||
|
let bal_opt: Option<f64> = sqlx::query_scalar(
|
||||||
|
"SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?",
|
||||||
|
)
|
||||||
|
.bind(server.id)
|
||||||
|
.bind(&payload.uuid)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let balance = match bal_opt {
|
||||||
|
Some(b) => b,
|
||||||
|
None => {
|
||||||
|
let starting_balance = 1000.0;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO server_economy (server_id, uuid, username, balance, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(server_id, uuid) DO UPDATE SET username = excluded.username",
|
||||||
|
)
|
||||||
|
.bind(server.id)
|
||||||
|
.bind(&payload.uuid)
|
||||||
|
.bind(&username)
|
||||||
|
.bind(starting_balance)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
starting_balance
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"server_id": server.id,
|
||||||
|
"uuid": payload.uuid,
|
||||||
|
"balance": balance,
|
||||||
|
"currency_symbol": "$"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ServerTransferPayload {
|
||||||
|
pub from_uuid: String,
|
||||||
|
pub from_name: String,
|
||||||
|
pub to_uuid: String,
|
||||||
|
pub to_name: String,
|
||||||
|
pub amount: f64,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn server_transfer(
|
||||||
|
GameServer(server): GameServer,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<ServerTransferPayload>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
if payload.amount <= 0.0 {
|
||||||
|
return Err(AppError::bad_request("Transfer amount must be positive"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tx = state.db.begin().await?;
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
// Check sender balance
|
||||||
|
let from_bal: Option<f64> = sqlx::query_scalar(
|
||||||
|
"SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?",
|
||||||
|
)
|
||||||
|
.bind(server.id)
|
||||||
|
.bind(&payload.from_uuid)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let current_from_bal = from_bal.unwrap_or(0.0);
|
||||||
|
if current_from_bal < payload.amount {
|
||||||
|
return Err(AppError::bad_request("Insufficient funds"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_from_bal = current_from_bal - payload.amount;
|
||||||
|
sqlx::query("UPDATE server_economy SET balance = ?, updated_at = ? WHERE server_id = ? AND uuid = ?")
|
||||||
|
.bind(new_from_bal)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(server.id)
|
||||||
|
.bind(&payload.from_uuid)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Update receiver
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO server_economy (server_id, uuid, username, balance, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (server_id, uuid) DO UPDATE SET
|
||||||
|
balance = balance + excluded.balance,
|
||||||
|
username = excluded.username,
|
||||||
|
updated_at = excluded.updated_at",
|
||||||
|
)
|
||||||
|
.bind(server.id)
|
||||||
|
.bind(&payload.to_uuid)
|
||||||
|
.bind(&payload.to_name)
|
||||||
|
.bind(payload.amount)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let desc = payload.description.unwrap_or_else(|| format!("Payment to {}", payload.to_name));
|
||||||
|
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(&payload.from_uuid)
|
||||||
|
.bind(&payload.from_name)
|
||||||
|
.bind(&payload.to_uuid)
|
||||||
|
.bind(&payload.to_name)
|
||||||
|
.bind(payload.amount)
|
||||||
|
.bind(&desc)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"ok": true,
|
||||||
|
"from_balance": new_from_bal
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ServerSyncBalancePayload {
|
||||||
|
pub uuid: String,
|
||||||
|
pub username: String,
|
||||||
|
pub new_balance: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn server_sync_balance(
|
||||||
|
GameServer(server): GameServer,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<ServerSyncBalancePayload>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO server_economy (server_id, uuid, username, balance, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(server_id, uuid) DO UPDATE SET
|
||||||
|
balance = excluded.balance,
|
||||||
|
username = excluded.username,
|
||||||
|
updated_at = excluded.updated_at",
|
||||||
|
)
|
||||||
|
.bind(server.id)
|
||||||
|
.bind(&payload.uuid)
|
||||||
|
.bind(&payload.username)
|
||||||
|
.bind(payload.new_balance)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "ok": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn server_baltop(
|
||||||
|
GameServer(server): GameServer,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> AppResult<Json<Vec<BaltopEntry>>> {
|
||||||
|
let rows: Vec<(String, String, f64)> = sqlx::query_as(
|
||||||
|
"SELECT uuid, username, balance
|
||||||
|
FROM server_economy
|
||||||
|
WHERE server_id = ?
|
||||||
|
ORDER BY balance DESC
|
||||||
|
LIMIT 20",
|
||||||
|
)
|
||||||
|
.bind(server.id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let list = rows
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, (uuid, username, balance))| BaltopEntry {
|
||||||
|
rank: i + 1,
|
||||||
|
uuid,
|
||||||
|
username,
|
||||||
|
balance,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct MarketListPayload {
|
||||||
|
pub seller_uuid: String,
|
||||||
|
pub seller_name: String,
|
||||||
|
pub item_id: String,
|
||||||
|
pub item_name: String,
|
||||||
|
pub amount: i32,
|
||||||
|
pub price: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn server_market_list(
|
||||||
|
GameServer(server): GameServer,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<MarketListPayload>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
let id: i64 = sqlx::query_scalar(
|
||||||
|
"INSERT INTO server_market (server_id, seller_uuid, seller_name, item_id, item_name, amount, price, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(server.id)
|
||||||
|
.bind(payload.seller_uuid)
|
||||||
|
.bind(payload.seller_name)
|
||||||
|
.bind(payload.item_id)
|
||||||
|
.bind(payload.item_name)
|
||||||
|
.bind(payload.amount)
|
||||||
|
.bind(payload.price)
|
||||||
|
.bind(&now)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "id": id, "ok": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct MarketBuyPayload {
|
||||||
|
pub listing_id: i64,
|
||||||
|
pub buyer_uuid: String,
|
||||||
|
pub buyer_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn server_market_buy(
|
||||||
|
GameServer(server): GameServer,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<MarketBuyPayload>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let listing: Option<(String, String, String, String, i32, f64)> = sqlx::query_as(
|
||||||
|
"SELECT seller_uuid, seller_name, item_id, item_name, amount, price
|
||||||
|
FROM server_market
|
||||||
|
WHERE id = ? AND server_id = ?",
|
||||||
|
)
|
||||||
|
.bind(payload.listing_id)
|
||||||
|
.bind(server.id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some((seller_uuid, seller_name, item_id, item_name, amount, price)) = listing else {
|
||||||
|
return Err(AppError::not_found("Listing not found"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut tx = state.db.begin().await?;
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
// Deduct buyer
|
||||||
|
let buyer_bal: f64 = sqlx::query_scalar(
|
||||||
|
"SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?",
|
||||||
|
)
|
||||||
|
.bind(server.id)
|
||||||
|
.bind(&payload.buyer_uuid)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
|
||||||
|
if buyer_bal < price {
|
||||||
|
return Err(AppError::bad_request("Insufficient funds"));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("UPDATE server_economy SET balance = balance - ?, updated_at = ? WHERE server_id = ? AND uuid = ?")
|
||||||
|
.bind(price)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(server.id)
|
||||||
|
.bind(&payload.buyer_uuid)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Pay seller
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO server_economy (server_id, uuid, username, balance, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (server_id, uuid) DO UPDATE SET
|
||||||
|
balance = balance + excluded.balance,
|
||||||
|
username = excluded.username,
|
||||||
|
updated_at = excluded.updated_at",
|
||||||
|
)
|
||||||
|
.bind(server.id)
|
||||||
|
.bind(&seller_uuid)
|
||||||
|
.bind(&seller_name)
|
||||||
|
.bind(price)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Delete listing
|
||||||
|
sqlx::query("DELETE FROM server_market WHERE id = ?")
|
||||||
|
.bind(payload.listing_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Log transaction
|
||||||
|
let desc = format!("Market purchase: {}x {} from {}", amount, item_name, seller_name);
|
||||||
|
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(&payload.buyer_uuid)
|
||||||
|
.bind(&payload.buyer_name)
|
||||||
|
.bind(&seller_uuid)
|
||||||
|
.bind(&seller_name)
|
||||||
|
.bind(price)
|
||||||
|
.bind(&desc)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"ok": true,
|
||||||
|
"item_id": item_id,
|
||||||
|
"item_name": item_name,
|
||||||
|
"amount": amount,
|
||||||
|
"price": price
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Guilds system, in-game relay, roles, posts, and land-claiming.
|
//! Guilds system, in-game relay, roles, posts, and land-claiming.
|
||||||
|
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::{AdminUser, AuthUser};
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::routes::servers::GameServer;
|
use crate::routes::servers::GameServer;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@@ -710,10 +710,11 @@ pub async fn unclaim_by_id(
|
|||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ChunkGridQuery {
|
pub struct ChunkGridQuery {
|
||||||
pub server_id: i64,
|
pub server_id: Option<i64>,
|
||||||
|
pub instance_id: Option<String>,
|
||||||
pub dimension: Option<String>,
|
pub dimension: Option<String>,
|
||||||
pub center_x: i32,
|
pub center_x: Option<i32>,
|
||||||
pub center_z: i32,
|
pub center_z: Option<i32>,
|
||||||
pub radius: Option<i32>,
|
pub radius: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -723,23 +724,31 @@ pub async fn get_chunk_grid(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> AppResult<Json<Vec<GuildClaim>>> {
|
) -> AppResult<Json<Vec<GuildClaim>>> {
|
||||||
let dim = query.dimension.unwrap_or_else(|| "minecraft:overworld".into());
|
let dim = query.dimension.unwrap_or_else(|| "minecraft:overworld".into());
|
||||||
let r = query.radius.unwrap_or(8).clamp(2, 24);
|
let inst_id = query.instance_id.unwrap_or_default();
|
||||||
let min_x = query.center_x - r;
|
let cx = query.center_x.unwrap_or(0);
|
||||||
let max_x = query.center_x + r;
|
let cz = query.center_z.unwrap_or(0);
|
||||||
let min_z = query.center_z - r;
|
let r = query.radius.unwrap_or(32).clamp(2, 1024);
|
||||||
let max_z = query.center_z + r;
|
let min_x = cx - r;
|
||||||
|
let max_x = cx + r;
|
||||||
|
let min_z = cz - r;
|
||||||
|
let max_z = cz + r;
|
||||||
|
|
||||||
let rows: Vec<(i64, String, String, String, i64, String, i32, i32, String, String)> = sqlx::query_as(
|
let rows: Vec<(i64, String, String, String, Option<i64>, String, i32, i32, String, String)> = sqlx::query_as(
|
||||||
"SELECT gc.id, gc.guild_id, g.name, g.tag, gc.server_id, gc.dimension,
|
"SELECT gc.id, gc.guild_id, g.name, g.tag, gc.server_id, gc.dimension,
|
||||||
gc.chunk_x, gc.chunk_z, gc.claimed_by_uuid, gc.claimed_at
|
gc.chunk_x, gc.chunk_z, gc.claimed_by_uuid, gc.claimed_at
|
||||||
FROM guild_claims gc
|
FROM guild_claims gc
|
||||||
JOIN guilds g ON g.id = gc.guild_id
|
JOIN guilds g ON g.id = gc.guild_id
|
||||||
WHERE gc.server_id = ? AND gc.dimension = ?
|
WHERE (? IS NULL OR gc.server_id = ?)
|
||||||
|
AND (? = '' OR g.instance_id = ?)
|
||||||
|
AND gc.dimension = ?
|
||||||
AND gc.chunk_x BETWEEN ? AND ?
|
AND gc.chunk_x BETWEEN ? AND ?
|
||||||
AND gc.chunk_z BETWEEN ? AND ?
|
AND gc.chunk_z BETWEEN ? AND ?
|
||||||
LIMIT 500",
|
LIMIT 1000",
|
||||||
)
|
)
|
||||||
.bind(query.server_id)
|
.bind(query.server_id)
|
||||||
|
.bind(query.server_id)
|
||||||
|
.bind(&inst_id)
|
||||||
|
.bind(&inst_id)
|
||||||
.bind(&dim)
|
.bind(&dim)
|
||||||
.bind(min_x)
|
.bind(min_x)
|
||||||
.bind(max_x)
|
.bind(max_x)
|
||||||
@@ -756,7 +765,7 @@ pub async fn get_chunk_grid(
|
|||||||
guild_id: gid,
|
guild_id: gid,
|
||||||
guild_name: gname,
|
guild_name: gname,
|
||||||
guild_tag: gtag,
|
guild_tag: gtag,
|
||||||
server_id: sid,
|
server_id: sid.unwrap_or(0),
|
||||||
dimension: gdim,
|
dimension: gdim,
|
||||||
chunk_x: cx,
|
chunk_x: cx,
|
||||||
chunk_z: cz,
|
chunk_z: cz,
|
||||||
@@ -769,6 +778,106 @@ pub async fn get_chunk_grid(
|
|||||||
Ok(Json(list))
|
Ok(Json(list))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Admin list all guilds.
|
||||||
|
pub async fn admin_list_guilds(
|
||||||
|
_admin: AdminUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> AppResult<Json<Vec<Guild>>> {
|
||||||
|
let rows: Vec<(
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
i64,
|
||||||
|
i64,
|
||||||
|
i64,
|
||||||
|
String,
|
||||||
|
i64,
|
||||||
|
i64,
|
||||||
|
)> = sqlx::query_as(
|
||||||
|
"SELECT g.id, g.instance_id, g.name, g.tag, g.description, g.motd, g.leader_uuid,
|
||||||
|
g.icon_url, g.banner_url, g.level, g.xp, g.max_claims, g.created_at,
|
||||||
|
(SELECT COUNT(*) FROM guild_members gm WHERE gm.guild_id = g.id) as member_count,
|
||||||
|
(SELECT COUNT(*) FROM guild_claims gc WHERE gc.guild_id = g.id) as claims_count
|
||||||
|
FROM guilds g
|
||||||
|
ORDER BY g.level DESC, member_count DESC",
|
||||||
|
)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let list = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(
|
||||||
|
|(
|
||||||
|
id,
|
||||||
|
inst_id,
|
||||||
|
name,
|
||||||
|
tag,
|
||||||
|
desc,
|
||||||
|
motd,
|
||||||
|
leader,
|
||||||
|
icon,
|
||||||
|
banner,
|
||||||
|
lvl,
|
||||||
|
xp,
|
||||||
|
max_c,
|
||||||
|
created,
|
||||||
|
members,
|
||||||
|
claims,
|
||||||
|
)| Guild {
|
||||||
|
id,
|
||||||
|
instance_id: inst_id,
|
||||||
|
name,
|
||||||
|
tag,
|
||||||
|
description: desc,
|
||||||
|
motd,
|
||||||
|
leader_uuid: leader,
|
||||||
|
icon_url: icon,
|
||||||
|
banner_url: banner,
|
||||||
|
level: lvl,
|
||||||
|
xp,
|
||||||
|
max_claims: max_c,
|
||||||
|
member_count: members,
|
||||||
|
claims_count: claims,
|
||||||
|
created_at: created,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admin delete a guild.
|
||||||
|
pub async fn admin_delete_guild(
|
||||||
|
_admin: AdminUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
sqlx::query("DELETE FROM guild_claims WHERE guild_id = ?")
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM guild_members WHERE guild_id = ?")
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM guild_posts WHERE guild_id = ?")
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM guilds WHERE id = ?")
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "ok": true })))
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Server Integration / Plugin In-Game Land Claim Relay
|
// Server Integration / Plugin In-Game Land Claim Relay
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -827,3 +936,307 @@ pub async fn server_check_chunk(
|
|||||||
"message": if is_member { None } else { Some(format!("This land is claimed by [{guild_tag}] {guild_name}")) }
|
"message": if is_member { None } else { Some(format!("This land is claimed by [{guild_tag}] {guild_name}")) }
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ServerClaimChunkPayload {
|
||||||
|
pub uuid: String,
|
||||||
|
pub dimension: String,
|
||||||
|
pub chunk_x: i32,
|
||||||
|
pub chunk_z: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn server_claim_chunk(
|
||||||
|
GameServer(server): GameServer,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<ServerClaimChunkPayload>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let member: Option<(String, String)> = sqlx::query_as(
|
||||||
|
"SELECT guild_id, role FROM guild_members WHERE uuid = ?",
|
||||||
|
)
|
||||||
|
.bind(&payload.uuid)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some((guild_id, _role)) = member else {
|
||||||
|
return Err(AppError::bad_request("You must be in a guild to claim land. Create one with /guild create <name> <tag>"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let max_claims: i64 = sqlx::query_scalar("SELECT max_claims FROM guilds WHERE id = ?")
|
||||||
|
.bind(&guild_id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or(16);
|
||||||
|
|
||||||
|
let current_claims: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ?")
|
||||||
|
.bind(&guild_id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
if current_claims >= max_claims {
|
||||||
|
return Err(AppError::bad_request(format!("Guild reached its max claim limit of {max_claims} chunks")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
let res = sqlx::query(
|
||||||
|
"INSERT INTO guild_claims (guild_id, server_id, dimension, chunk_x, chunk_z, claimed_by_uuid, claimed_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&guild_id)
|
||||||
|
.bind(server.id)
|
||||||
|
.bind(&payload.dimension)
|
||||||
|
.bind(payload.chunk_x)
|
||||||
|
.bind(payload.chunk_z)
|
||||||
|
.bind(&payload.uuid)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = res {
|
||||||
|
tracing::warn!("server claim chunk failed: {e}");
|
||||||
|
return Err(AppError::bad_request("Chunk is already claimed by another guild"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let guild_name: String = sqlx::query_scalar("SELECT name FROM guilds WHERE id = ?")
|
||||||
|
.bind(&guild_id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| "Guild".into());
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"ok": true,
|
||||||
|
"guild_id": guild_id,
|
||||||
|
"guild_name": guild_name,
|
||||||
|
"chunk_x": payload.chunk_x,
|
||||||
|
"chunk_z": payload.chunk_z
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn server_unclaim_chunk(
|
||||||
|
GameServer(server): GameServer,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<ServerClaimChunkPayload>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let claim: Option<(i64, String)> = sqlx::query_as(
|
||||||
|
"SELECT id, guild_id FROM guild_claims WHERE server_id = ? AND dimension = ? AND chunk_x = ? AND chunk_z = ?",
|
||||||
|
)
|
||||||
|
.bind(server.id)
|
||||||
|
.bind(&payload.dimension)
|
||||||
|
.bind(payload.chunk_x)
|
||||||
|
.bind(payload.chunk_z)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some((claim_id, guild_id)) = claim else {
|
||||||
|
return Err(AppError::not_found("This chunk is not claimed"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let member: Option<(String,)> = sqlx::query_as(
|
||||||
|
"SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?",
|
||||||
|
)
|
||||||
|
.bind(&guild_id)
|
||||||
|
.bind(&payload.uuid)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if member.is_none() {
|
||||||
|
return Err(AppError::forbidden("You cannot unclaim land belonging to another guild"));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM guild_claims WHERE id = ?")
|
||||||
|
.bind(claim_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "ok": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ServerGuildPlayerQuery {
|
||||||
|
pub uuid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn server_get_player_guild(
|
||||||
|
GameServer(_server): GameServer,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<ServerGuildPlayerQuery>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let member_opt: Option<(String, String)> = sqlx::query_as(
|
||||||
|
"SELECT guild_id, role FROM guild_members WHERE uuid = ?",
|
||||||
|
)
|
||||||
|
.bind(&payload.uuid)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some((guild_id, player_role)) = member_opt else {
|
||||||
|
return Ok(Json(serde_json::json!({ "in_guild": false })));
|
||||||
|
};
|
||||||
|
|
||||||
|
let guild_opt: Option<(String, String, String, String, String, i64, i64, i64)> = sqlx::query_as(
|
||||||
|
"SELECT name, tag, description, motd, leader_uuid, level, xp, max_claims FROM guilds WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(&guild_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some((name, tag, desc, motd, leader_uuid, level, xp, max_claims)) = guild_opt else {
|
||||||
|
return Ok(Json(serde_json::json!({ "in_guild": false })));
|
||||||
|
};
|
||||||
|
|
||||||
|
let member_rows: Vec<(String, String, String)> = sqlx::query_as(
|
||||||
|
"SELECT uuid, name, role FROM guild_members WHERE guild_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&guild_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let claims_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ?")
|
||||||
|
.bind(&guild_id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let members_val: Vec<Value> = member_rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(u, n, r)| serde_json::json!({ "uuid": u, "name": n, "role": r }))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"in_guild": true,
|
||||||
|
"guild": {
|
||||||
|
"id": guild_id,
|
||||||
|
"name": name,
|
||||||
|
"tag": tag,
|
||||||
|
"description": desc,
|
||||||
|
"motd": motd,
|
||||||
|
"leader_uuid": leader_uuid,
|
||||||
|
"role": player_role,
|
||||||
|
"level": level,
|
||||||
|
"xp": xp,
|
||||||
|
"claims_count": claims_count,
|
||||||
|
"max_claims": max_claims,
|
||||||
|
"members": members_val
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ServerCreateGuildPayload {
|
||||||
|
pub uuid: String,
|
||||||
|
pub username: String,
|
||||||
|
pub name: String,
|
||||||
|
pub tag: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn server_create_guild(
|
||||||
|
GameServer(_server): GameServer,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<ServerCreateGuildPayload>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let name = payload.name.trim();
|
||||||
|
let tag = payload.tag.trim();
|
||||||
|
if name.len() < 3 || name.len() > 32 {
|
||||||
|
return Err(AppError::bad_request("Guild name must be between 3 and 32 characters"));
|
||||||
|
}
|
||||||
|
if tag.len() < 2 || tag.len() > 6 {
|
||||||
|
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 = ?)")
|
||||||
|
.bind(&payload.uuid)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if in_guild {
|
||||||
|
return Err(AppError::bad_request("You are already in a guild. Leave your current guild first"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let guild_id = format!("guild_{}", uuid::Uuid::new_v4().simple());
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
let mut tx = state.db.begin().await?;
|
||||||
|
|
||||||
|
let res = sqlx::query(
|
||||||
|
"INSERT INTO guilds (id, instance_id, name, tag, description, motd, leader_uuid, created_at)
|
||||||
|
VALUES (?, '', ?, ?, '', 'Welcome to the guild!', ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&guild_id)
|
||||||
|
.bind(name)
|
||||||
|
.bind(tag)
|
||||||
|
.bind(&payload.uuid)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = res {
|
||||||
|
tracing::warn!("server_create_guild failed: {e}");
|
||||||
|
return Err(AppError::bad_request("A guild with that name or tag already exists"));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO guild_members (guild_id, uuid, name, role, joined_at)
|
||||||
|
VALUES (?, ?, ?, 'leader', ?)",
|
||||||
|
)
|
||||||
|
.bind(&guild_id)
|
||||||
|
.bind(&payload.uuid)
|
||||||
|
.bind(&payload.username)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"ok": true,
|
||||||
|
"guild_id": guild_id,
|
||||||
|
"name": name,
|
||||||
|
"tag": tag
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ServerGuildLeavePayload {
|
||||||
|
pub uuid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn server_guild_leave(
|
||||||
|
GameServer(_server): GameServer,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<ServerGuildLeavePayload>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let member_opt: Option<(String, String)> = sqlx::query_as(
|
||||||
|
"SELECT guild_id, role FROM guild_members WHERE uuid = ?",
|
||||||
|
)
|
||||||
|
.bind(&payload.uuid)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some((guild_id, role)) = member_opt else {
|
||||||
|
return Err(AppError::bad_request("You are not in a guild"));
|
||||||
|
};
|
||||||
|
|
||||||
|
if role == "leader" {
|
||||||
|
let member_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_members WHERE guild_id = ?")
|
||||||
|
.bind(&guild_id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or(1);
|
||||||
|
|
||||||
|
if member_count > 1 {
|
||||||
|
return Err(AppError::bad_request("Guild leader cannot leave without transferring leadership"));
|
||||||
|
} else {
|
||||||
|
sqlx::query("DELETE FROM guild_claims WHERE guild_id = ?").bind(&guild_id).execute(&state.db).await?;
|
||||||
|
sqlx::query("DELETE FROM guild_members WHERE guild_id = ?").bind(&guild_id).execute(&state.db).await?;
|
||||||
|
sqlx::query("DELETE FROM guilds WHERE id = ?").bind(&guild_id).execute(&state.db).await?;
|
||||||
|
return Ok(Json(serde_json::json!({ "ok": true, "disbanded": true })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM guild_members WHERE guild_id = ? AND uuid = ?")
|
||||||
|
.bind(&guild_id)
|
||||||
|
.bind(&payload.uuid)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "ok": true, "disbanded": false })))
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ pub struct LandingBlock {
|
|||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
pub subtitle: Option<String>,
|
pub subtitle: Option<String>,
|
||||||
|
pub options: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for LandingBlock {
|
impl Default for LandingBlock {
|
||||||
@@ -73,6 +74,7 @@ impl Default for LandingBlock {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
title: None,
|
title: None,
|
||||||
subtitle: None,
|
subtitle: None,
|
||||||
|
options: serde_json::json!({}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,15 +117,15 @@ impl Default for LandingConfig {
|
|||||||
hosted_downloads: vec![],
|
hosted_downloads: vec![],
|
||||||
external_download_url: None,
|
external_download_url: None,
|
||||||
blocks: vec![
|
blocks: vec![
|
||||||
LandingBlock { id: "b_hero".into(), block_type: "hero".into(), enabled: true, title: None, subtitle: None },
|
LandingBlock { id: "b_hero".into(), block_type: "hero".into(), enabled: true, title: None, subtitle: None, options: serde_json::json!({}) },
|
||||||
LandingBlock { id: "b_download".into(), block_type: "download".into(), enabled: true, title: Some("Get the Launcher".into()), subtitle: Some("Available on Windows, macOS, and Linux".into()) },
|
LandingBlock { id: "b_download".into(), block_type: "download".into(), enabled: true, title: Some("Get the Launcher".into()), subtitle: Some("Available on Windows, macOS, and Linux".into()), options: serde_json::json!({}) },
|
||||||
LandingBlock { id: "b_servers".into(), block_type: "servers".into(), enabled: true, title: Some("Live Servers".into()), subtitle: Some("Check our current status and join the game".into()) },
|
LandingBlock { id: "b_servers".into(), block_type: "servers".into(), enabled: true, title: Some("Live Servers".into()), subtitle: Some("Check our current status and join the game".into()), options: serde_json::json!({}) },
|
||||||
LandingBlock { id: "b_stats".into(), block_type: "stats".into(), enabled: true, title: Some("Community By Numbers".into()), subtitle: Some("Every block placed, every second played".into()) },
|
LandingBlock { id: "b_stats".into(), block_type: "stats".into(), enabled: true, title: Some("Community By Numbers".into()), subtitle: Some("Every block placed, every second played".into()), options: serde_json::json!({}) },
|
||||||
LandingBlock { id: "b_leaderboard".into(), block_type: "leaderboard".into(), enabled: true, title: Some("Top Players".into()), subtitle: Some("The champions of our network".into()) },
|
LandingBlock { id: "b_leaderboard".into(), block_type: "leaderboard".into(), enabled: true, title: Some("Top Players".into()), subtitle: Some("The champions of our network".into()), options: serde_json::json!({}) },
|
||||||
LandingBlock { id: "b_instances".into(), block_type: "instances".into(), enabled: true, title: Some("Featured Instances".into()), subtitle: Some("Hand-crafted modpacks ready to launch".into()) },
|
LandingBlock { id: "b_instances".into(), block_type: "instances".into(), enabled: true, title: Some("Featured Instances".into()), subtitle: Some("Hand-crafted modpacks ready to launch".into()), options: serde_json::json!({}) },
|
||||||
LandingBlock { id: "b_news".into(), block_type: "news".into(), enabled: true, title: Some("Latest News".into()), subtitle: Some("Updates, events, and patch notes".into()) },
|
LandingBlock { id: "b_news".into(), block_type: "news".into(), enabled: true, title: Some("Latest News".into()), subtitle: Some("Updates, events, and patch notes".into()), options: serde_json::json!({}) },
|
||||||
LandingBlock { id: "b_faq".into(), block_type: "faq".into(), enabled: true, title: Some("Frequently Asked Questions".into()), subtitle: Some("Everything you need to know".into()) },
|
LandingBlock { id: "b_faq".into(), block_type: "faq".into(), enabled: true, title: Some("Frequently Asked Questions".into()), subtitle: Some("Everything you need to know".into()), options: serde_json::json!({}) },
|
||||||
LandingBlock { id: "b_socials".into(), block_type: "socials".into(), enabled: true, title: Some("Join Our Community".into()), subtitle: Some("Connect with thousands of players".into()) },
|
LandingBlock { id: "b_socials".into(), block_type: "socials".into(), enabled: true, title: Some("Join Our Community".into()), subtitle: Some("Connect with thousands of players".into()), options: serde_json::json!({}) },
|
||||||
],
|
],
|
||||||
faqs: vec![
|
faqs: vec![
|
||||||
FaqItem {
|
FaqItem {
|
||||||
|
|||||||
@@ -103,16 +103,56 @@ pub async fn get_user_levels_by_uuid(state: &AppState, uuid: &str) -> AppResult<
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// Query rewards
|
||||||
|
let rewards_rows: Vec<(i64, i64, String, String, String)> = sqlx::query_as(
|
||||||
|
"SELECT id, level_req, reward_type, reward_name, reward_data
|
||||||
|
FROM level_rewards
|
||||||
|
ORDER BY level_req ASC",
|
||||||
|
)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let rewards = rewards_rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, req, rtype, rname, rdata)| {
|
||||||
|
let data: Value = serde_json::from_str(&rdata).unwrap_or_default();
|
||||||
|
let desc = data.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
let icon = data.get("icon").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||||
|
scopenet_shared::LevelReward {
|
||||||
|
id,
|
||||||
|
level: req,
|
||||||
|
reward_type: rtype,
|
||||||
|
reward_value: rname,
|
||||||
|
description: desc,
|
||||||
|
icon,
|
||||||
|
server_id: None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let rank_opt: Option<i64> = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) + 1 FROM user_levels WHERE global_xp > ?",
|
||||||
|
)
|
||||||
|
.bind(global_xp)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or(Some(1));
|
||||||
|
|
||||||
Ok(UserLevelInfo {
|
Ok(UserLevelInfo {
|
||||||
uuid: uuid.to_string(),
|
uuid: uuid.to_string(),
|
||||||
global_xp,
|
global_xp,
|
||||||
|
current_xp: global_xp,
|
||||||
global_level,
|
global_level,
|
||||||
|
level: global_level,
|
||||||
current_level_xp,
|
current_level_xp,
|
||||||
next_level_xp,
|
next_level_xp,
|
||||||
progress_pct,
|
progress_pct,
|
||||||
title,
|
title,
|
||||||
badges,
|
badges,
|
||||||
server_levels,
|
server_levels,
|
||||||
|
rank: rank_opt.map(|r| r as usize),
|
||||||
|
rewards,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,13 +208,32 @@ pub async fn list_rewards(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, ltype, sid, req, rtype, name, data, created)| {
|
.map(|(id, ltype, sid, req, rtype, name, data, created)| {
|
||||||
let parsed_data: Value = serde_json::from_str(&data).unwrap_or_default();
|
let parsed_data: Value = serde_json::from_str(&data).unwrap_or_default();
|
||||||
|
let desc = parsed_data.get("description")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let icon = parsed_data.get("icon")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(match rtype.as_str() {
|
||||||
|
"title" => "👑",
|
||||||
|
"profile_badge" | "badge" => "⭐",
|
||||||
|
"cosmetic" => "✨",
|
||||||
|
"item" => "🗡️",
|
||||||
|
_ => "🎁",
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"id": id,
|
"id": id,
|
||||||
|
"level": req,
|
||||||
|
"level_req": req,
|
||||||
"level_type": ltype,
|
"level_type": ltype,
|
||||||
"server_id": sid,
|
"server_id": sid,
|
||||||
"level_req": req,
|
|
||||||
"reward_type": rtype,
|
"reward_type": rtype,
|
||||||
|
"reward_value": name,
|
||||||
"reward_name": name,
|
"reward_name": name,
|
||||||
|
"description": if desc.is_empty() { format!("Granted at Level {req}") } else { desc },
|
||||||
|
"icon": icon,
|
||||||
"reward_data": parsed_data,
|
"reward_data": parsed_data,
|
||||||
"created_at": created
|
"created_at": created
|
||||||
})
|
})
|
||||||
@@ -185,12 +244,17 @@ pub async fn list_rewards(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct CreateRewardPayload {
|
pub struct RewardPayload {
|
||||||
pub level_type: String,
|
pub id: Option<i64>,
|
||||||
|
pub level_type: Option<String>,
|
||||||
pub server_id: Option<i64>,
|
pub server_id: Option<i64>,
|
||||||
pub level_req: i64,
|
#[serde(alias = "level")]
|
||||||
|
pub level_req: Option<i64>,
|
||||||
pub reward_type: String,
|
pub reward_type: String,
|
||||||
pub reward_name: String,
|
#[serde(alias = "reward_value")]
|
||||||
|
pub reward_name: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub icon: Option<String>,
|
||||||
pub reward_data: Option<Value>,
|
pub reward_data: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,21 +262,36 @@ pub struct CreateRewardPayload {
|
|||||||
pub async fn admin_create_reward(
|
pub async fn admin_create_reward(
|
||||||
_admin: AdminUser,
|
_admin: AdminUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(payload): Json<CreateRewardPayload>,
|
Json(payload): Json<RewardPayload>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
let data_str = serde_json::to_string(&payload.reward_data.unwrap_or_default()).unwrap_or_default();
|
let level_req = payload.level_req.unwrap_or(2);
|
||||||
|
let reward_name = payload.reward_name.unwrap_or_else(|| "Reward".into());
|
||||||
|
let level_type = payload.level_type.unwrap_or_else(|| "global".into());
|
||||||
|
|
||||||
|
let mut data_obj = match payload.reward_data {
|
||||||
|
Some(Value::Object(map)) => map,
|
||||||
|
_ => serde_json::Map::new(),
|
||||||
|
};
|
||||||
|
if let Some(desc) = payload.description {
|
||||||
|
data_obj.insert("description".into(), Value::String(desc));
|
||||||
|
}
|
||||||
|
if let Some(ico) = payload.icon {
|
||||||
|
data_obj.insert("icon".into(), Value::String(ico));
|
||||||
|
}
|
||||||
|
data_obj.insert("title".into(), Value::String(reward_name.clone()));
|
||||||
|
let data_str = serde_json::to_string(&data_obj).unwrap_or_default();
|
||||||
|
|
||||||
let id: i64 = sqlx::query_scalar(
|
let id: i64 = sqlx::query_scalar(
|
||||||
"INSERT INTO level_rewards (level_type, server_id, level_req, reward_type, reward_name, reward_data, created_at)
|
"INSERT INTO level_rewards (level_type, server_id, level_req, reward_type, reward_name, reward_data, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
RETURNING id",
|
RETURNING id",
|
||||||
)
|
)
|
||||||
.bind(payload.level_type)
|
.bind(level_type)
|
||||||
.bind(payload.server_id)
|
.bind(payload.server_id)
|
||||||
.bind(payload.level_req)
|
.bind(level_req)
|
||||||
.bind(payload.reward_type)
|
.bind(payload.reward_type)
|
||||||
.bind(payload.reward_name)
|
.bind(reward_name)
|
||||||
.bind(data_str)
|
.bind(data_str)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
.fetch_one(&state.db)
|
.fetch_one(&state.db)
|
||||||
@@ -221,6 +300,48 @@ pub async fn admin_create_reward(
|
|||||||
Ok(Json(serde_json::json!({ "id": id, "ok": true })))
|
Ok(Json(serde_json::json!({ "id": id, "ok": true })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Admin update reward.
|
||||||
|
pub async fn admin_update_reward(
|
||||||
|
_admin: AdminUser,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<RewardPayload>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let level_req = payload.level_req.unwrap_or(2);
|
||||||
|
let reward_name = payload.reward_name.unwrap_or_else(|| "Reward".into());
|
||||||
|
|
||||||
|
let mut data_obj = match payload.reward_data {
|
||||||
|
Some(Value::Object(map)) => map,
|
||||||
|
_ => serde_json::Map::new(),
|
||||||
|
};
|
||||||
|
if let Some(desc) = payload.description {
|
||||||
|
data_obj.insert("description".into(), Value::String(desc));
|
||||||
|
}
|
||||||
|
if let Some(ico) = payload.icon {
|
||||||
|
data_obj.insert("icon".into(), Value::String(ico));
|
||||||
|
}
|
||||||
|
data_obj.insert("title".into(), Value::String(reward_name.clone()));
|
||||||
|
let data_str = serde_json::to_string(&data_obj).unwrap_or_default();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE level_rewards SET
|
||||||
|
level_req = ?,
|
||||||
|
reward_type = ?,
|
||||||
|
reward_name = ?,
|
||||||
|
reward_data = ?
|
||||||
|
WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(level_req)
|
||||||
|
.bind(payload.reward_type)
|
||||||
|
.bind(reward_name)
|
||||||
|
.bind(data_str)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "id": id, "ok": true })))
|
||||||
|
}
|
||||||
|
|
||||||
/// Admin delete reward.
|
/// Admin delete reward.
|
||||||
pub async fn admin_delete_reward(
|
pub async fn admin_delete_reward(
|
||||||
_admin: AdminUser,
|
_admin: AdminUser,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ pub mod account;
|
|||||||
pub mod achievements;
|
pub mod achievements;
|
||||||
pub mod activity;
|
pub mod activity;
|
||||||
pub mod admin;
|
pub mod admin;
|
||||||
|
pub mod economy;
|
||||||
pub mod guilds;
|
pub mod guilds;
|
||||||
pub mod landing;
|
pub mod landing;
|
||||||
pub mod leveling;
|
pub mod leveling;
|
||||||
@@ -23,6 +24,7 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
|||||||
.route("/launcher/manifest", get(public::manifest))
|
.route("/launcher/manifest", get(public::manifest))
|
||||||
.route("/launcher/instances/{id}", get(public::instance_manifest))
|
.route("/launcher/instances/{id}", get(public::instance_manifest))
|
||||||
.route("/launcher/events", post(public::event))
|
.route("/launcher/events", post(public::event))
|
||||||
|
.route("/instances", get(admin::list_instances))
|
||||||
.route("/auth/login", post(public::login))
|
.route("/auth/login", post(public::login))
|
||||||
.route("/auth/register", post(public::register))
|
.route("/auth/register", post(public::register))
|
||||||
.route("/auth/me", get(public::me))
|
.route("/auth/me", get(public::me))
|
||||||
@@ -65,7 +67,13 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
|||||||
.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))
|
||||||
// Social: Friends, DMs, Invites, Profiles, Posts
|
// Economy
|
||||||
|
.route("/economy/me", get(economy::get_my_balances))
|
||||||
|
.route("/economy/balances", get(economy::get_my_balances))
|
||||||
|
.route("/economy/transactions", get(economy::get_my_transactions))
|
||||||
|
.route("/servers/{id}/economy/baltop", get(economy::get_server_baltop))
|
||||||
|
.route("/servers/{id}/economy/market", get(economy::get_server_market))
|
||||||
|
// Social: Friends, DMs, Invites, Profiles, Posts, Members Search
|
||||||
.route("/friends", get(social::list_friends))
|
.route("/friends", get(social::list_friends))
|
||||||
.route("/friends/request", post(social::send_friend_request))
|
.route("/friends/request", post(social::send_friend_request))
|
||||||
.route("/friends/respond", post(social::respond_friend_request))
|
.route("/friends/respond", post(social::respond_friend_request))
|
||||||
@@ -87,6 +95,8 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
|||||||
.route("/social/profile/{uuid}", get(social::get_player_profile))
|
.route("/social/profile/{uuid}", get(social::get_player_profile))
|
||||||
.route("/social/posts", post(social::create_user_post))
|
.route("/social/posts", post(social::create_user_post))
|
||||||
.route("/social/posts/{id}/like", post(social::like_user_post))
|
.route("/social/posts/{id}/like", post(social::like_user_post))
|
||||||
|
.route("/social/members", get(social::search_members))
|
||||||
|
.route("/members/search", get(social::search_members))
|
||||||
.layer(DefaultBodyLimit::max(4 * 1024 * 1024));
|
.layer(DefaultBodyLimit::max(4 * 1024 * 1024));
|
||||||
|
|
||||||
let admin = Router::new()
|
let admin = Router::new()
|
||||||
@@ -124,13 +134,17 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
|||||||
.route("/modrinth/project/{id}/versions", get(meta::modrinth_versions))
|
.route("/modrinth/project/{id}/versions", get(meta::modrinth_versions))
|
||||||
.route("/curseforge/search", get(meta::curseforge_search))
|
.route("/curseforge/search", get(meta::curseforge_search))
|
||||||
.route("/curseforge/mod/{id}/files", get(meta::curseforge_files))
|
.route("/curseforge/mod/{id}/files", get(meta::curseforge_files))
|
||||||
// Admin Quests & Achievements & Rewards
|
// Admin Quests & Achievements & Rewards & Guilds
|
||||||
.route("/rewards", post(leveling::admin_create_reward))
|
.route("/rewards", get(leveling::list_rewards).post(leveling::admin_create_reward))
|
||||||
.route("/rewards/{id}", delete(leveling::admin_delete_reward))
|
.route("/rewards/{id}", put(leveling::admin_update_reward).delete(leveling::admin_delete_reward))
|
||||||
|
.route("/levels/rewards", get(leveling::list_rewards).post(leveling::admin_create_reward))
|
||||||
|
.route("/levels/rewards/{id}", put(leveling::admin_update_reward).delete(leveling::admin_delete_reward))
|
||||||
.route("/quests", get(quests::admin_list_quests).post(quests::admin_create_quest))
|
.route("/quests", get(quests::admin_list_quests).post(quests::admin_create_quest))
|
||||||
.route("/quests/{id}", put(quests::admin_update_quest).delete(quests::admin_delete_quest))
|
.route("/quests/{id}", put(quests::admin_update_quest).delete(quests::admin_delete_quest))
|
||||||
.route("/achievements", get(achievements::admin_list_achievements).post(achievements::admin_create_achievement))
|
.route("/achievements", get(achievements::admin_list_achievements).post(achievements::admin_create_achievement))
|
||||||
.route("/achievements/{id}", put(achievements::admin_update_achievement).delete(achievements::admin_delete_achievement))
|
.route("/achievements/{id}", put(achievements::admin_update_achievement).delete(achievements::admin_delete_achievement))
|
||||||
|
.route("/guilds", get(guilds::admin_list_guilds))
|
||||||
|
.route("/guilds/{id}", delete(guilds::admin_delete_guild))
|
||||||
.layer(DefaultBodyLimit::max(upload_limit));
|
.layer(DefaultBodyLimit::max(upload_limit));
|
||||||
|
|
||||||
// Called by the SCOPENET Paper plugin and Fabric/Forge mods.
|
// Called by the SCOPENET Paper plugin and Fabric/Forge mods.
|
||||||
@@ -139,6 +153,18 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
|||||||
.route("/login", post(servers::login))
|
.route("/login", post(servers::login))
|
||||||
.route("/sync", post(servers::sync))
|
.route("/sync", post(servers::sync))
|
||||||
.route("/guilds/check-chunk", post(guilds::server_check_chunk))
|
.route("/guilds/check-chunk", post(guilds::server_check_chunk))
|
||||||
|
.route("/guilds/claim", post(guilds::server_claim_chunk))
|
||||||
|
.route("/guilds/unclaim", post(guilds::server_unclaim_chunk))
|
||||||
|
.route("/guilds/player", post(guilds::server_get_player_guild))
|
||||||
|
.route("/guilds/create", post(guilds::server_create_guild))
|
||||||
|
.route("/guilds/leave", post(guilds::server_guild_leave))
|
||||||
|
.route("/economy/balance", post(economy::server_get_balance))
|
||||||
|
.route("/economy/pay", post(economy::server_transfer))
|
||||||
|
.route("/economy/transfer", post(economy::server_transfer))
|
||||||
|
.route("/economy/sync-balance", post(economy::server_sync_balance))
|
||||||
|
.route("/economy/baltop", post(economy::server_baltop))
|
||||||
|
.route("/economy/market/list", post(economy::server_market_list))
|
||||||
|
.route("/economy/market/buy", post(economy::server_market_buy))
|
||||||
.layer(DefaultBodyLimit::max(2 * 1024 * 1024));
|
.layer(DefaultBodyLimit::max(2 * 1024 * 1024));
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
|
|||||||
@@ -92,18 +92,23 @@ pub async fn get_my_quests(
|
|||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
description: desc,
|
description: desc,
|
||||||
period,
|
period: period.clone(),
|
||||||
category: cat,
|
category: cat,
|
||||||
target_stat: stat,
|
target_stat: stat.clone(),
|
||||||
target_count: target,
|
target_count: target,
|
||||||
xp_reward: xp,
|
xp_reward: xp,
|
||||||
icon,
|
icon,
|
||||||
enabled,
|
enabled,
|
||||||
|
quest_type: Some(period),
|
||||||
|
stat_type: Some(stat),
|
||||||
|
active: Some(enabled),
|
||||||
},
|
},
|
||||||
progress,
|
progress,
|
||||||
|
current_count: progress,
|
||||||
completed,
|
completed,
|
||||||
claimed,
|
claimed,
|
||||||
period_key: pkey,
|
period_key: pkey,
|
||||||
|
expires_at: Some(chrono::Utc::now().to_rfc3339()),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -259,13 +264,16 @@ pub async fn admin_list_quests(
|
|||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
description: desc,
|
description: desc,
|
||||||
period,
|
period: period.clone(),
|
||||||
category: cat,
|
category: cat,
|
||||||
target_stat: stat,
|
target_stat: stat.clone(),
|
||||||
target_count: target,
|
target_count: target,
|
||||||
xp_reward: xp,
|
xp_reward: xp,
|
||||||
icon,
|
icon,
|
||||||
enabled,
|
enabled,
|
||||||
|
quest_type: Some(period),
|
||||||
|
stat_type: Some(stat),
|
||||||
|
active: Some(enabled),
|
||||||
},
|
},
|
||||||
total_completions: completions,
|
total_completions: completions,
|
||||||
}
|
}
|
||||||
@@ -281,12 +289,15 @@ pub struct QuestPayload {
|
|||||||
pub id: Option<String>,
|
pub id: Option<String>,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub period: String,
|
#[serde(alias = "quest_type")]
|
||||||
|
pub period: Option<String>,
|
||||||
pub category: String,
|
pub category: String,
|
||||||
pub target_stat: String,
|
#[serde(alias = "stat_type")]
|
||||||
|
pub target_stat: Option<String>,
|
||||||
pub target_count: i64,
|
pub target_count: i64,
|
||||||
pub xp_reward: i64,
|
pub xp_reward: i64,
|
||||||
pub icon: Option<String>,
|
pub icon: Option<String>,
|
||||||
|
#[serde(alias = "active")]
|
||||||
pub enabled: Option<bool>,
|
pub enabled: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,10 +307,12 @@ pub async fn admin_create_quest(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(payload): Json<QuestPayload>,
|
Json(payload): Json<QuestPayload>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
|
let period = payload.period.unwrap_or_else(|| "daily".into());
|
||||||
|
let target_stat = payload.target_stat.unwrap_or_else(|| "blocks_broken".into());
|
||||||
let id = payload
|
let id = payload
|
||||||
.id
|
.id
|
||||||
.filter(|s| !s.trim().is_empty())
|
.filter(|s| !s.trim().is_empty())
|
||||||
.unwrap_or_else(|| format!("q_{}_{}", payload.period, uuid::Uuid::new_v4().simple()));
|
.unwrap_or_else(|| format!("q_{}_{}", period, uuid::Uuid::new_v4().simple()));
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -309,12 +322,12 @@ pub async fn admin_create_quest(
|
|||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(payload.title)
|
.bind(payload.title)
|
||||||
.bind(payload.description)
|
.bind(payload.description)
|
||||||
.bind(payload.period)
|
.bind(period)
|
||||||
.bind(payload.category)
|
.bind(payload.category)
|
||||||
.bind(payload.target_stat)
|
.bind(target_stat)
|
||||||
.bind(payload.target_count)
|
.bind(payload.target_count)
|
||||||
.bind(payload.xp_reward)
|
.bind(payload.xp_reward)
|
||||||
.bind(payload.icon.unwrap_or_else(|| "star".into()))
|
.bind(payload.icon.unwrap_or_else(|| "⛏️".into()))
|
||||||
.bind(payload.enabled.unwrap_or(true))
|
.bind(payload.enabled.unwrap_or(true))
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
@@ -330,6 +343,9 @@ pub async fn admin_update_quest(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(payload): Json<QuestPayload>,
|
Json(payload): Json<QuestPayload>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
|
let period = payload.period.unwrap_or_else(|| "daily".into());
|
||||||
|
let target_stat = payload.target_stat.unwrap_or_else(|| "blocks_broken".into());
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE quests SET
|
"UPDATE quests SET
|
||||||
title = ?,
|
title = ?,
|
||||||
@@ -345,9 +361,9 @@ pub async fn admin_update_quest(
|
|||||||
)
|
)
|
||||||
.bind(payload.title)
|
.bind(payload.title)
|
||||||
.bind(payload.description)
|
.bind(payload.description)
|
||||||
.bind(payload.period)
|
.bind(period)
|
||||||
.bind(payload.category)
|
.bind(payload.category)
|
||||||
.bind(payload.target_stat)
|
.bind(target_stat)
|
||||||
.bind(payload.target_count)
|
.bind(payload.target_count)
|
||||||
.bind(payload.xp_reward)
|
.bind(payload.xp_reward)
|
||||||
.bind(payload.icon)
|
.bind(payload.icon)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use scopenet_shared::{DirectMessage, FriendInfo, GameInvite, UserPost, UserProfileView};
|
use scopenet_shared::{DirectMessage, FriendInfo, GameInvite, UserPost, UserProfileView};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -621,3 +621,72 @@ pub async fn like_user_post(
|
|||||||
|
|
||||||
Ok(Json(serde_json::json!({ "ok": true })))
|
Ok(Json(serde_json::json!({ "ok": true })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Members Search & Discovery
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SearchMembersQuery {
|
||||||
|
pub q: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_members(
|
||||||
|
auth: AuthUser,
|
||||||
|
Query(query): Query<SearchMembersQuery>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> AppResult<Json<Vec<scopenet_shared::MemberProfile>>> {
|
||||||
|
let q = query.q.unwrap_or_default().trim().to_lowercase();
|
||||||
|
let pattern = format!("%{q}%");
|
||||||
|
|
||||||
|
let rows: Vec<(String, String, String, String, Option<String>, Option<i64>, Option<String>, i64, Option<String>, Option<String>)> = sqlx::query_as(
|
||||||
|
"SELECT u.uuid, u.username, u.role, u.status, u.skin_url,
|
||||||
|
ul.global_level, ul.title, u.playtime_secs, u.last_seen_ingame,
|
||||||
|
f.status as friendship_status
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_levels ul ON ul.uuid = u.uuid
|
||||||
|
LEFT JOIN friendships f ON (f.user_uuid = ? AND f.friend_uuid = u.uuid)
|
||||||
|
WHERE (? = '' OR LOWER(u.username) LIKE ?)
|
||||||
|
ORDER BY (CASE WHEN LOWER(u.username) = ? THEN 1 ELSE 2 END), u.playtime_secs DESC
|
||||||
|
LIMIT 60",
|
||||||
|
)
|
||||||
|
.bind(&auth.uuid)
|
||||||
|
.bind(&q)
|
||||||
|
.bind(&pattern)
|
||||||
|
.bind(&q)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let now_ts = chrono::Utc::now().timestamp();
|
||||||
|
|
||||||
|
let list = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(uuid, username, role, status, skin_url, glvl, title, playtime, last_seen, friend_status)| {
|
||||||
|
let online = if let Some(ref ls) = last_seen {
|
||||||
|
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ls) {
|
||||||
|
(now_ts - dt.timestamp()).abs() < 180
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
let is_friend = friend_status.as_deref() == Some("accepted");
|
||||||
|
scopenet_shared::MemberProfile {
|
||||||
|
uuid,
|
||||||
|
username,
|
||||||
|
role,
|
||||||
|
status,
|
||||||
|
skin_url,
|
||||||
|
global_level: glvl.unwrap_or(1),
|
||||||
|
title,
|
||||||
|
playtime_secs: playtime,
|
||||||
|
last_seen,
|
||||||
|
online,
|
||||||
|
is_friend,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(list))
|
||||||
|
}
|
||||||
@@ -163,6 +163,7 @@ export interface LandingBlock {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
|
options?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LandingConfig {
|
export interface LandingConfig {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { Plus, Pencil, Trash2, Trophy, Sparkles, Shield, Swords, Pickaxe, Compass, Users } from '@lucide/svelte';
|
import { Plus, Pencil, Trash2, Trophy, Sparkles, Shield, Swords, Pickaxe, Compass, Users } from '@lucide/svelte';
|
||||||
import Modal from '../components/Modal.svelte';
|
import Modal from '../components/Modal.svelte';
|
||||||
import { get, post, put, del } from '../lib/api';
|
import { get, post, put, del } from '../lib/api';
|
||||||
@@ -61,7 +62,21 @@
|
|||||||
async function loadAchievements() {
|
async function loadAchievements() {
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
achievements = await get<Achievement[]>('/api/admin/achievements');
|
const res = await get<any[]>('/api/admin/achievements');
|
||||||
|
achievements = (res || []).map((a: any) => ({
|
||||||
|
id: a.id || '',
|
||||||
|
title: a.title || '',
|
||||||
|
description: a.description || '',
|
||||||
|
category: a.category || 'progression',
|
||||||
|
xp_reward: a.xp_reward || 0,
|
||||||
|
frame_type: a.frame_type || a.icon_frame || 'task',
|
||||||
|
icon_item: a.icon_item || 'diamond_pickaxe',
|
||||||
|
icon_bg: a.icon_bg || 'deepslate',
|
||||||
|
icon_border: a.icon_border || 'gold',
|
||||||
|
stat_type: a.stat_type || a.requirement_key || 'blocks_broken',
|
||||||
|
target_count: a.target_count || a.requirement_value || 1,
|
||||||
|
secret: !!a.secret,
|
||||||
|
}));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toastError(e);
|
toastError(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -69,7 +84,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
onMount(() => {
|
||||||
loadAchievements();
|
loadAchievements();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { Shield, Users, MapPin, Trash2, Search, ExternalLink, Flag } from '@lucide/svelte';
|
import { Shield, Users, MapPin, Trash2, Search, ExternalLink, Flag } from '@lucide/svelte';
|
||||||
import Modal from '../components/Modal.svelte';
|
import Modal from '../components/Modal.svelte';
|
||||||
import { get, del } from '../lib/api';
|
import { get, del } from '../lib/api';
|
||||||
@@ -17,11 +18,28 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
const [g, inst] = await Promise.all([
|
const [g, inst] = await Promise.all([
|
||||||
get<Guild[]>('/api/admin/guilds'),
|
get<any[]>('/api/admin/guilds').catch(() => []),
|
||||||
get<Instance[]>('/api/v1/instances'),
|
get<Instance[]>('/api/admin/instances').catch(() => []),
|
||||||
]);
|
]);
|
||||||
guilds = g;
|
guilds = (g || []).map((x: any) => ({
|
||||||
instances = inst;
|
id: x.id || '',
|
||||||
|
instance_id: x.instance_id || '',
|
||||||
|
name: x.name || 'Unnamed Guild',
|
||||||
|
tag: x.tag || '',
|
||||||
|
description: x.description || '',
|
||||||
|
motd: x.motd || '',
|
||||||
|
leader_uuid: x.leader_uuid || '',
|
||||||
|
icon_url: x.icon_url || null,
|
||||||
|
banner_url: x.banner_url || null,
|
||||||
|
level: x.level || 1,
|
||||||
|
xp: x.xp || 0,
|
||||||
|
max_claims: x.max_claims || 10,
|
||||||
|
member_count: x.member_count ?? 1,
|
||||||
|
max_members: x.max_members || 50,
|
||||||
|
claims_count: x.claims_count ?? 0,
|
||||||
|
created_at: x.created_at || new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
instances = inst || [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toastError(e);
|
toastError(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -29,7 +47,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
onMount(() => {
|
||||||
loadData();
|
loadData();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Save, LoaderCircle, Plus, Trash2, ArrowUp, ArrowDown, ExternalLink,
|
Save, LoaderCircle, Plus, Trash2, ArrowUp, ArrowDown, ExternalLink,
|
||||||
Upload, Globe, Sparkles, HelpCircle, Server, Trophy, BarChart3,
|
Upload, Globe, Sparkles, HelpCircle, Server, Trophy, BarChart3,
|
||||||
Boxes, Newspaper, MessageSquare, Download, Check, Eye, Code,
|
Boxes, Newspaper, MessageSquare, Download, Check, Eye, Code,
|
||||||
|
ChevronDown, ChevronUp, Sliders,
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import Toggle from '../components/Toggle.svelte';
|
import Toggle from '../components/Toggle.svelte';
|
||||||
import { api, get, put, formatBytes } from '../lib/api';
|
import { api, get, put, formatBytes } from '../lib/api';
|
||||||
@@ -14,6 +15,11 @@
|
|||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let uploading = $state(false);
|
let uploading = $state(false);
|
||||||
let activeTab = $state<'blocks' | 'downloads' | 'faq' | 'css'>('blocks');
|
let activeTab = $state<'blocks' | 'downloads' | 'faq' | 'css'>('blocks');
|
||||||
|
let openBlocks = $state<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
function toggleBlockOptions(id: string) {
|
||||||
|
openBlocks[id] = !openBlocks[id];
|
||||||
|
}
|
||||||
|
|
||||||
const blockMeta: Record<BlockType, { label: string; icon: any; desc: string }> = {
|
const blockMeta: Record<BlockType, { label: string; icon: any; desc: string }> = {
|
||||||
hero: { label: 'Hero Header', icon: Sparkles, desc: 'Top visual headline with CTA buttons and particle effects' },
|
hero: { label: 'Hero Header', icon: Sparkles, desc: 'Top visual headline with CTA buttons and particle effects' },
|
||||||
@@ -200,39 +206,222 @@
|
|||||||
{#each config.blocks as block, idx (block.id)}
|
{#each config.blocks as block, idx (block.id)}
|
||||||
{@const meta = blockMeta[block.type as BlockType] || { label: block.type, icon: Sparkles, desc: '' }}
|
{@const meta = blockMeta[block.type as BlockType] || { label: block.type, icon: Sparkles, desc: '' }}
|
||||||
{@const Icon = meta.icon}
|
{@const Icon = meta.icon}
|
||||||
<div class="block-row" class:disabled={!block.enabled}>
|
{@const isOpen = !!openBlocks[block.id]}
|
||||||
<div class="block-reorder">
|
<div class="block-item-container" class:disabled={!block.enabled}>
|
||||||
<button class="icon-btn" disabled={idx === 0} onclick={() => moveBlock(idx, -1)} title="Move up">
|
<div class="block-row">
|
||||||
<ArrowUp size={14} />
|
<div class="block-reorder">
|
||||||
</button>
|
<button class="icon-btn" disabled={idx === 0} onclick={() => moveBlock(idx, -1)} title="Move up">
|
||||||
<button class="icon-btn" disabled={idx === config.blocks.length - 1} onclick={() => moveBlock(idx, 1)} title="Move down">
|
<ArrowUp size={14} />
|
||||||
<ArrowDown size={14} />
|
</button>
|
||||||
</button>
|
<button class="icon-btn" disabled={idx === config.blocks.length - 1} onclick={() => moveBlock(idx, 1)} title="Move down">
|
||||||
</div>
|
<ArrowDown size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="block-icon">
|
<div class="block-icon">
|
||||||
<Icon size={18} />
|
<Icon size={18} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="block-info grow">
|
<div class="block-info grow" role="button" tabindex="0" onclick={() => toggleBlockOptions(block.id)} onkeydown={(e) => e.key === 'Enter' && toggleBlockOptions(block.id)}>
|
||||||
<div class="row align-center gap-xs">
|
<div class="row align-center gap-xs">
|
||||||
<strong>{meta.label}</strong>
|
<strong>{meta.label}</strong>
|
||||||
<span class="block-type-tag tiny muted">{block.type}</span>
|
<span class="block-type-tag tiny muted">{block.type}</span>
|
||||||
|
{#if block.title}
|
||||||
|
<span class="badge-sub small muted">"{block.title}"</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="tiny muted">{meta.desc}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="block-inputs row gap-sm align-center">
|
||||||
|
<button class="btn secondary sm" onclick={() => toggleBlockOptions(block.id)} title="Configure Module Settings">
|
||||||
|
<Sliders size={13} /> {isOpen ? 'Hide Options' : 'Configure'}
|
||||||
|
{#if isOpen}<ChevronUp size={13} />{:else}<ChevronDown size={13} />{/if}
|
||||||
|
</button>
|
||||||
|
<Toggle bind:checked={block.enabled} label="" />
|
||||||
</div>
|
</div>
|
||||||
<span class="tiny muted">{meta.desc}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="block-inputs row gap-sm align-center">
|
{#if isOpen}
|
||||||
{#if block.type !== 'hero'}
|
<div class="block-config-pane">
|
||||||
<input
|
{#if !block.options}
|
||||||
bind:value={block.title}
|
{((block.options = {}), '')}
|
||||||
placeholder="Section title"
|
{/if}
|
||||||
class="sm-input"
|
|
||||||
title="Custom section title"
|
<div class="grid-2">
|
||||||
/>
|
<label class="field">
|
||||||
{/if}
|
<span>Section Title</span>
|
||||||
<Toggle bind:checked={block.enabled} label="" />
|
<input bind:value={block.title} placeholder="Custom Section Title" />
|
||||||
</div>
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Section Subtitle</span>
|
||||||
|
<input bind:value={block.subtitle} placeholder="Sub-heading or explanatory text" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if block.type === 'hero'}
|
||||||
|
<div class="module-fields grid-2">
|
||||||
|
<label class="field">
|
||||||
|
<span>Headline Override</span>
|
||||||
|
<input bind:value={block.options.headline} placeholder="Defaults to General Hero Headline" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Top Badge Text</span>
|
||||||
|
<input bind:value={block.options.badge_text} placeholder="e.g. Season 2 Now Live!" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Primary CTA Button Text</span>
|
||||||
|
<input bind:value={block.options.cta_text} placeholder="e.g. Download Launcher" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Custom CTA URL (Overrides auto-download)</span>
|
||||||
|
<input bind:value={block.options.cta_url} placeholder="https://..." />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Show Server IP Copy Button</span>
|
||||||
|
<Toggle bind:checked={block.options.show_ip_copy} label="Enable 1-Click Server IP Copy" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{:else if block.type === 'download'}
|
||||||
|
<div class="module-fields grid-2">
|
||||||
|
<label class="field">
|
||||||
|
<span>Display Layout</span>
|
||||||
|
<select bind:value={block.options.layout}>
|
||||||
|
<option value="cards">Feature Cards (Default)</option>
|
||||||
|
<option value="compact">Compact List</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Badge Highlight Note</span>
|
||||||
|
<input bind:value={block.options.badge_note} placeholder="e.g. Java 21 Bundled · Auto-Sync" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Show System Requirements</span>
|
||||||
|
<Toggle bind:checked={block.options.show_specs} label="Display OS requirements info" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{:else if block.type === 'servers'}
|
||||||
|
<div class="module-fields grid-2">
|
||||||
|
<label class="field">
|
||||||
|
<span>Show Offline Servers</span>
|
||||||
|
<Toggle bind:checked={block.options.show_offline} label="List servers even when offline" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Show Version / Software Badges</span>
|
||||||
|
<Toggle bind:checked={block.options.show_badges} label="Display MC version and loader" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Action Button Text</span>
|
||||||
|
<input bind:value={block.options.cta_label} placeholder="e.g. Copy Server IP" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{:else if block.type === 'leaderboard'}
|
||||||
|
<div class="module-fields grid-2">
|
||||||
|
<label class="field">
|
||||||
|
<span>Default Metric</span>
|
||||||
|
<select bind:value={block.options.default_metric}>
|
||||||
|
<option value="playtime_secs">Playtime</option>
|
||||||
|
<option value="global_level">Global Level</option>
|
||||||
|
<option value="player_kills">Player Kills</option>
|
||||||
|
<option value="mob_kills">Mob Kills</option>
|
||||||
|
<option value="blocks_broken">Blocks Mined</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Leaderboard Size</span>
|
||||||
|
<select bind:value={block.options.limit}>
|
||||||
|
<option value={5}>Top 5</option>
|
||||||
|
<option value={10}>Top 10 (Default)</option>
|
||||||
|
<option value={20}>Top 20</option>
|
||||||
|
<option value={50}>Top 50</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Show Top 3 Podium</span>
|
||||||
|
<Toggle bind:checked={block.options.show_podium} label="Highlight Gold, Silver, and Bronze winners" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{:else if block.type === 'stats'}
|
||||||
|
<div class="module-fields grid-2">
|
||||||
|
<label class="field">
|
||||||
|
<span>Show Players Online Counter</span>
|
||||||
|
<Toggle bind:checked={block.options.show_players} label="Display total registered / online players" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Show Total Playtime Counter</span>
|
||||||
|
<Toggle bind:checked={block.options.show_playtime} label="Display cumulative playtime" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Show Guilds Counter</span>
|
||||||
|
<Toggle bind:checked={block.options.show_guilds} label="Display active guilds count" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Custom Stat (Label & Value)</span>
|
||||||
|
<div class="row gap-xs">
|
||||||
|
<input bind:value={block.options.custom_stat1_label} placeholder="e.g. Blocks Placed" />
|
||||||
|
<input bind:value={block.options.custom_stat1_value} placeholder="e.g. 1.2M+" style="max-width: 100px" />
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{:else if block.type === 'instances'}
|
||||||
|
<div class="module-fields grid-2">
|
||||||
|
<label class="field">
|
||||||
|
<span>Grid Columns</span>
|
||||||
|
<select bind:value={block.options.columns}>
|
||||||
|
<option value={2}>2 Columns</option>
|
||||||
|
<option value={3}>3 Columns (Default)</option>
|
||||||
|
<option value={4}>4 Columns</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Show Mod Loader Badges</span>
|
||||||
|
<Toggle bind:checked={block.options.show_loaders} label="Display Fabric / Forge / NeoForge pills" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{:else if block.type === 'news'}
|
||||||
|
<div class="module-fields grid-2">
|
||||||
|
<label class="field">
|
||||||
|
<span>Max Articles Shown</span>
|
||||||
|
<select bind:value={block.options.max_items}>
|
||||||
|
<option value={3}>3 Articles</option>
|
||||||
|
<option value={5}>5 Articles (Default)</option>
|
||||||
|
<option value={10}>10 Articles</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Show Date Badges</span>
|
||||||
|
<Toggle bind:checked={block.options.show_dates} label="Show publication date on posts" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{:else if block.type === 'faq'}
|
||||||
|
<div class="module-fields grid-2">
|
||||||
|
<label class="field">
|
||||||
|
<span>Allow Multiple Open Questions</span>
|
||||||
|
<Toggle bind:checked={block.options.allow_multiple} label="Visitors can expand multiple FAQs at once" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{:else if block.type === 'socials'}
|
||||||
|
<div class="module-fields grid-2">
|
||||||
|
<label class="field">
|
||||||
|
<span>Discord Invite URL</span>
|
||||||
|
<input bind:value={block.options.discord_url} placeholder="https://discord.gg/..." />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Server Webstore URL</span>
|
||||||
|
<input bind:value={block.options.store_url} placeholder="https://store.myserver.com" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>YouTube / Twitch URL</span>
|
||||||
|
<input bind:value={block.options.media_url} placeholder="https://youtube.com/@..." />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Twitter / X or Bluesky URL</span>
|
||||||
|
<input bind:value={block.options.social_url} placeholder="https://x.com/..." />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -378,11 +567,17 @@
|
|||||||
.field input, .field textarea { background: var(--bg); border: 1px solid var(--line); color: var(--text); padding: 8px 12px; border-radius: var(--radius-sm); font-size: 14px; }
|
.field input, .field textarea { background: var(--bg); border: 1px solid var(--line); color: var(--text); padding: 8px 12px; border-radius: var(--radius-sm); font-size: 14px; }
|
||||||
.field input:focus, .field textarea:focus { border-color: var(--accent); outline: none; }
|
.field input:focus, .field textarea:focus { border-color: var(--accent); outline: none; }
|
||||||
.blocks-list { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
|
.blocks-list { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
|
||||||
.block-row { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: var(--radius-sm); background: var(--bg); border: 1px solid var(--line); transition: opacity 0.15s, border-color 0.15s; }
|
.block-item-container { border-radius: var(--radius-sm); border: 1px solid var(--line); background: var(--bg); overflow: hidden; transition: opacity 0.15s, border-color 0.15s; }
|
||||||
.block-row.disabled { opacity: 0.55; }
|
.block-item-container.disabled { opacity: 0.55; }
|
||||||
|
.block-row { display: flex; align-items: center; gap: 12px; padding: 12px 14px; }
|
||||||
|
.block-config-pane { border-top: 1px dashed var(--line); padding: 16px; background: color-mix(in srgb, var(--surface) 50%, transparent); display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.module-fields { border-top: 1px solid var(--line); padding-top: 12px; }
|
||||||
|
.badge-sub { font-style: italic; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
select { background: var(--bg); border: 1px solid var(--line); color: var(--text); padding: 8px 12px; border-radius: var(--radius-sm); font-size: 14px; outline: none; }
|
||||||
|
select:focus { border-color: var(--accent); }
|
||||||
.block-reorder { display: flex; flex-direction: column; gap: 2px; }
|
.block-reorder { display: flex; flex-direction: column; gap: 2px; }
|
||||||
.block-icon { width: 34px; height: 34px; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--accent) 15%, transparent); color: var(--accent); display: grid; place-items: center; flex-shrink: 0; }
|
.block-icon { width: 34px; height: 34px; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--accent) 15%, transparent); color: var(--accent); display: grid; place-items: center; flex-shrink: 0; }
|
||||||
.block-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
.block-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; cursor: pointer; }
|
||||||
.block-type-tag { text-transform: uppercase; font-size: 10px; letter-spacing: 0.05em; }
|
.block-type-tag { text-transform: uppercase; font-size: 10px; letter-spacing: 0.05em; }
|
||||||
.sm-input { width: 180px; padding: 6px 10px; font-size: 12px; }
|
.sm-input { width: 180px; padding: 6px 10px; font-size: 12px; }
|
||||||
.downloads-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; margin-top: 6px; }
|
.downloads-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; margin-top: 6px; }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { Sparkles, Trophy, Plus, Pencil, Trash2, Award, Shield, Crown } from '@lucide/svelte';
|
import { Sparkles, Trophy, Plus, Pencil, Trash2, Award, Shield, Crown } from '@lucide/svelte';
|
||||||
import Modal from '../components/Modal.svelte';
|
import Modal from '../components/Modal.svelte';
|
||||||
import { get, post, put, del } from '../lib/api';
|
import { get, post, put, del } from '../lib/api';
|
||||||
@@ -26,11 +27,18 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
const [r, lb] = await Promise.all([
|
const [r, lb] = await Promise.all([
|
||||||
get<LevelReward[]>('/api/v1/levels/rewards'),
|
get<any[]>('/api/v1/levels/rewards'),
|
||||||
get<UserLevelInfo[]>('/api/v1/levels/leaderboard?limit=10'),
|
get<UserLevelInfo[]>('/api/v1/levels/leaderboard?limit=10'),
|
||||||
]);
|
]);
|
||||||
rewards = r.sort((a, b) => a.level - b.level);
|
rewards = (r || []).map((x: any) => ({
|
||||||
leaderboard = lb;
|
id: x.id,
|
||||||
|
level: Number(x.level ?? x.level_req ?? 1),
|
||||||
|
reward_type: x.reward_type || 'title',
|
||||||
|
reward_value: x.reward_value ?? x.reward_name ?? '',
|
||||||
|
description: x.description ?? '',
|
||||||
|
icon: x.icon ?? '🎁',
|
||||||
|
})).sort((a, b) => a.level - b.level);
|
||||||
|
leaderboard = lb || [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toastError(e);
|
toastError(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -38,7 +46,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
onMount(() => {
|
||||||
loadData();
|
loadData();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import {
|
import {
|
||||||
Download, Server, Users, Trophy, Sparkles, HelpCircle, Newspaper,
|
Download, Server, Users, Trophy, Sparkles, HelpCircle, Newspaper,
|
||||||
Copy, Check, ExternalLink, ChevronDown, ChevronUp, Swords, Flame,
|
Copy, Check, ExternalLink, ChevronDown, ChevronUp, Swords, Flame,
|
||||||
Pickaxe, Clock, Box, Shield, Zap, ArrowRight, LogIn, Crown,
|
Pickaxe, Clock, Box, Shield, Zap, ArrowRight, LogIn, Crown, MessageSquare,
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { get, formatBytes } from '../lib/api';
|
import { get, formatBytes } from '../lib/api';
|
||||||
import { session } from '../lib/session.svelte';
|
import { session } from '../lib/session.svelte';
|
||||||
@@ -171,18 +171,25 @@
|
|||||||
<div class="container hero-content">
|
<div class="container hero-content">
|
||||||
<div class="hero-badge">
|
<div class="hero-badge">
|
||||||
<span class="hero-badge-dot"></span>
|
<span class="hero-badge-dot"></span>
|
||||||
<span>{totalOnline} Players Online Now</span>
|
<span>{block.options?.badge_text || `${totalOnline} Players Online Now`}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="hero-title">{config.hero_title}</h1>
|
<h1 class="hero-title">{block.options?.headline || config.hero_title}</h1>
|
||||||
<p class="hero-sub">{config.hero_subtitle}</p>
|
<p class="hero-sub">{block.options?.subtitle || config.hero_subtitle}</p>
|
||||||
|
|
||||||
<div class="hero-cta-group">
|
<div class="hero-cta-group">
|
||||||
{#if primaryDownload}
|
{#if block.options?.cta_url}
|
||||||
|
<a href={block.options.cta_url} target="_blank" class="btn-hero-primary">
|
||||||
|
<Download size={20} />
|
||||||
|
<div class="cta-text-group">
|
||||||
|
<span class="cta-main">{block.options?.cta_text || config.hero_cta_text}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{:else if primaryDownload}
|
||||||
<a href={primaryDownload.file_url} class="btn-hero-primary" download>
|
<a href={primaryDownload.file_url} class="btn-hero-primary" download>
|
||||||
<Download size={20} />
|
<Download size={20} />
|
||||||
<div class="cta-text-group">
|
<div class="cta-text-group">
|
||||||
<span class="cta-main">{config.hero_cta_text}</span>
|
<span class="cta-main">{block.options?.cta_text || config.hero_cta_text}</span>
|
||||||
<span class="cta-sub">for {primaryDownload.label} ({formatBytes(primaryDownload.size)})</span>
|
<span class="cta-sub">for {primaryDownload.label} ({formatBytes(primaryDownload.size)})</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -190,18 +197,18 @@
|
|||||||
<a href={config.external_download_url} target="_blank" class="btn-hero-primary">
|
<a href={config.external_download_url} target="_blank" class="btn-hero-primary">
|
||||||
<Download size={20} />
|
<Download size={20} />
|
||||||
<div class="cta-text-group">
|
<div class="cta-text-group">
|
||||||
<span class="cta-main">{config.hero_cta_text}</span>
|
<span class="cta-main">{block.options?.cta_text || config.hero_cta_text}</span>
|
||||||
<span class="cta-sub">Download Latest Release</span>
|
<span class="cta-sub">Download Latest Release</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<button type="button" class="btn-hero-primary" onclick={() => scrollToSection('download')}>
|
<button type="button" class="btn-hero-primary" onclick={() => scrollToSection('download')}>
|
||||||
<Download size={20} />
|
<Download size={20} />
|
||||||
<span class="cta-main">{config.hero_cta_text}</span>
|
<span class="cta-main">{block.options?.cta_text || config.hero_cta_text}</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if config.server_ip}
|
{#if block.options?.show_ip_copy !== false && config.server_ip}
|
||||||
<button class="btn-hero-secondary" onclick={() => copyIp(config?.server_ip ?? '')}>
|
<button class="btn-hero-secondary" onclick={() => copyIp(config?.server_ip ?? '')}>
|
||||||
{#if copied}<Check size={18} /> Copied Server IP{:else}<Copy size={18} /> Copy Server IP{/if}
|
{#if copied}<Check size={18} /> Copied Server IP{:else}<Copy size={18} /> Copy Server IP{/if}
|
||||||
</button>
|
</button>
|
||||||
@@ -217,9 +224,12 @@
|
|||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>{block.title || 'Get the Launcher'}</h2>
|
<h2>{block.title || 'Get the Launcher'}</h2>
|
||||||
<p class="muted">{block.subtitle || 'Custom modpack synchronisation, fast Java auto-updates, and HD skin wardrobe.'}</p>
|
<p class="muted">{block.subtitle || 'Custom modpack synchronisation, fast Java auto-updates, and HD skin wardrobe.'}</p>
|
||||||
|
{#if block.options?.badge_note}
|
||||||
|
<div class="badge-note-pill">{block.options.badge_note}</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="downloads-cards-grid">
|
<div class="downloads-cards-grid" class:compact-list={block.options?.layout === 'compact'}>
|
||||||
{#each config.hosted_downloads as d}
|
{#each config.hosted_downloads as d}
|
||||||
<div class="download-card" class:recommended={d.platform === detectedOS}>
|
<div class="download-card" class:recommended={d.platform === detectedOS}>
|
||||||
{#if d.platform === detectedOS}
|
{#if d.platform === detectedOS}
|
||||||
@@ -260,16 +270,16 @@
|
|||||||
<p class="muted">{block.subtitle || 'Connect to our high-performance Minecraft worlds.'}</p>
|
<p class="muted">{block.subtitle || 'Connect to our high-performance Minecraft worlds.'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if publicServers.length}
|
{#if publicServers.filter((s) => (block.options?.show_offline === false ? s.online : true)).length}
|
||||||
<div class="servers-grid">
|
<div class="servers-grid">
|
||||||
{#each publicServers as s}
|
{#each publicServers.filter((s) => (block.options?.show_offline === false ? s.online : true)) as s}
|
||||||
<div class="server-card">
|
<div class="server-card">
|
||||||
<div class="server-card-top">
|
<div class="server-card-top">
|
||||||
<div class="server-status-pill" class:online={s.online}>
|
<div class="server-status-pill" class:online={s.online}>
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
<span>{s.online ? `${s.players_online} / ${s.players_max} Online` : 'Offline'}</span>
|
<span>{s.online ? `${s.players_online} / ${s.players_max} Online` : 'Offline'}</span>
|
||||||
</div>
|
</div>
|
||||||
{#if s.mc_version}
|
{#if block.options?.show_badges !== false && s.mc_version}
|
||||||
<span class="mc-ver-tag tiny">{s.mc_version}</span>
|
<span class="mc-ver-tag tiny">{s.mc_version}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -277,7 +287,7 @@
|
|||||||
<h3 class="server-card-title">{s.name}</h3>
|
<h3 class="server-card-title">{s.name}</h3>
|
||||||
<div class="server-card-bottom">
|
<div class="server-card-bottom">
|
||||||
<button class="server-copy-btn" onclick={() => copyIp(config?.server_ip ?? '')}>
|
<button class="server-copy-btn" onclick={() => copyIp(config?.server_ip ?? '')}>
|
||||||
<span>{config?.server_ip || 'play.server.com'}</span>
|
<span>{block.options?.cta_label || config?.server_ip || 'play.server.com'}</span>
|
||||||
<Copy size={13} />
|
<Copy size={13} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -442,22 +452,34 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats-counter-grid">
|
<div class="stats-counter-grid">
|
||||||
<div class="counter-card">
|
{#if block.options?.show_players !== false}
|
||||||
<span class="counter-num">{formatNumber(totalPlayers)}</span>
|
<div class="counter-card">
|
||||||
<span class="counter-label">Total Players</span>
|
<span class="counter-num">{formatNumber(totalPlayers)}</span>
|
||||||
</div>
|
<span class="counter-label">Total Players</span>
|
||||||
<div class="counter-card">
|
</div>
|
||||||
<span class="counter-num">{formatTime(totalPlaytime)}</span>
|
{/if}
|
||||||
<span class="counter-label">Time Explored</span>
|
{#if block.options?.show_playtime !== false}
|
||||||
</div>
|
<div class="counter-card">
|
||||||
|
<span class="counter-num">{formatTime(totalPlaytime)}</span>
|
||||||
|
<span class="counter-label">Time Explored</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if block.options?.show_guilds !== false && publicGuilds.length > 0}
|
||||||
|
<div class="counter-card">
|
||||||
|
<span class="counter-num">{publicGuilds.length}</span>
|
||||||
|
<span class="counter-label">Active Guilds</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="counter-card">
|
<div class="counter-card">
|
||||||
<span class="counter-num">{publicServers.length}</span>
|
<span class="counter-num">{publicServers.length}</span>
|
||||||
<span class="counter-label">Connected Servers</span>
|
<span class="counter-label">Connected Servers</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="counter-card">
|
{#if block.options?.custom_stat1_label && block.options?.custom_stat1_value}
|
||||||
<span class="counter-num">{instances.length}</span>
|
<div class="counter-card">
|
||||||
<span class="counter-label">Modpack Instances</span>
|
<span class="counter-num">{block.options.custom_stat1_value}</span>
|
||||||
</div>
|
<span class="counter-label">{block.options.custom_stat1_label}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -542,6 +564,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{:else if block.type === 'socials'}
|
||||||
|
<!-- Socials Block -->
|
||||||
|
<section class="section" id="socials">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>{block.title || 'Join Our Community'}</h2>
|
||||||
|
<p class="muted">{block.subtitle || 'Connect with players, join our Discord, and follow news.'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="socials-buttons-grid">
|
||||||
|
{#if block.options?.discord_url}
|
||||||
|
<a href={block.options.discord_url} target="_blank" rel="noreferrer" class="social-btn discord">
|
||||||
|
<MessageSquare size={18} /> Join our Discord
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{#if block.options?.store_url}
|
||||||
|
<a href={block.options.store_url} target="_blank" rel="noreferrer" class="social-btn store">
|
||||||
|
<Sparkles size={18} /> Visit Webstore
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{#if block.options?.media_url}
|
||||||
|
<a href={block.options.media_url} target="_blank" rel="noreferrer" class="social-btn media">
|
||||||
|
<Flame size={18} /> YouTube / Twitch
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{#if block.options?.social_url}
|
||||||
|
<a href={block.options.social_url} target="_blank" rel="noreferrer" class="social-btn twitter">
|
||||||
|
<ExternalLink size={18} /> Follow on Twitter / X
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -670,6 +726,15 @@
|
|||||||
.footer { border-top: 1px solid rgba(255, 255, 255, 0.08); padding: 40px 0; margin-top: auto; }
|
.footer { border-top: 1px solid rgba(255, 255, 255, 0.08); padding: 40px 0; margin-top: auto; }
|
||||||
.footer-content { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; }
|
.footer-content { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; }
|
||||||
.empty-state-box { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 40px; }
|
.empty-state-box { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 40px; }
|
||||||
|
.badge-note-pill { display: inline-flex; align-items: center; padding: 4px 12px; border-radius: 99rem; background: rgba(109, 106, 245, 0.15); color: #a5a3ff; font-size: 13px; font-weight: 600; margin-top: 10px; }
|
||||||
|
.downloads-cards-grid.compact-list { grid-template-columns: 1fr; }
|
||||||
|
.socials-buttons-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-top: 24px; }
|
||||||
|
.social-btn { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 16px 20px; border-radius: 12px; font-weight: 600; text-decoration: none; transition: transform 0.15s, opacity 0.15s; color: #fff; font-size: 15px; }
|
||||||
|
.social-btn:hover { transform: translateY(-2px); opacity: 0.95; }
|
||||||
|
.social-btn.discord { background: #5865F2; }
|
||||||
|
.social-btn.store { background: #8b5cf6; }
|
||||||
|
.social-btn.media { background: #ef4444; }
|
||||||
|
.social-btn.twitter { background: #0284c7; }
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.nav-links { display: none; }
|
.nav-links { display: none; }
|
||||||
.hero-title { font-size: 34px; }
|
.hero-title { font-size: 34px; }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { Search, Plus, Pencil, Trash2, CheckCircle2, XCircle, Award, Sparkles, Filter } from '@lucide/svelte';
|
import { Search, Plus, Pencil, Trash2, CheckCircle2, XCircle, Award, Sparkles, Filter } from '@lucide/svelte';
|
||||||
import Modal from '../components/Modal.svelte';
|
import Modal from '../components/Modal.svelte';
|
||||||
import { get, post, put, del } from '../lib/api';
|
import { get, post, put, del } from '../lib/api';
|
||||||
@@ -33,7 +34,19 @@
|
|||||||
async function loadQuests() {
|
async function loadQuests() {
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
quests = await get<Quest[]>('/api/admin/quests');
|
const res = await get<any[]>('/api/admin/quests');
|
||||||
|
quests = (res || []).map((q: any) => ({
|
||||||
|
id: q.id || '',
|
||||||
|
title: q.title || '',
|
||||||
|
description: q.description || '',
|
||||||
|
quest_type: q.quest_type || q.period || 'daily',
|
||||||
|
category: q.category || 'mining',
|
||||||
|
xp_reward: q.xp_reward || 0,
|
||||||
|
stat_type: q.stat_type || q.target_stat || 'blocks_broken',
|
||||||
|
target_count: q.target_count || 1,
|
||||||
|
icon: q.icon || '⛏️',
|
||||||
|
active: q.active !== undefined ? q.active : (q.enabled !== undefined ? q.enabled : true),
|
||||||
|
}));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toastError(e);
|
toastError(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -41,7 +54,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
onMount(() => {
|
||||||
loadQuests();
|
loadQuests();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user