From b4cef85b1fcd15788ff00829dd9bf4a18f820908 Mon Sep 17 00:00:00 2001 From: SCOPEDD Date: Tue, 29 Sep 2026 03:24:27 -0400 Subject: [PATCH] feat: add global & server leveling, quests, achievements, guilds land-claiming, social & toggleable configs --- crates/shared/src/lib.rs | 200 ++++ .../integration/ChunkCheckResult.java | 9 + .../net/scopenet/integration/Integration.java | 29 +- .../net/scopenet/integration/PanelClient.java | 22 + .../net/scopenet/integration/Settings.java | 80 +- .../java/net/scopenet/minecraft/Bridge.java | 5 +- .../net/scopenet/paper/ScopenetPlugin.java | 67 +- .../paper/src/main/resources/config.yml | 43 +- launcher/src-tauri/src/commands.rs | 385 +++++++- launcher/src-tauri/src/lib.rs | 27 + launcher/src/App.svelte | 13 + launcher/src/components/ChunkMap.svelte | 492 ++++++++++ .../src/components/PlayerProfileModal.svelte | 824 ++++++++++++++++ launcher/src/components/Rail.svelte | 13 +- launcher/src/lib/mock.ts | 117 +++ launcher/src/lib/store.svelte.ts | 3 +- launcher/src/lib/types.ts | 194 ++++ launcher/src/pages/Guilds.svelte | 850 ++++++++++++++++ launcher/src/pages/Quests.svelte | 723 ++++++++++++++ launcher/src/pages/Social.svelte | 913 ++++++++++++++++++ panel/server/src/auth.rs | 14 + panel/server/src/db.rs | 207 ++++ panel/server/src/lib.rs | 1 + panel/server/src/routes/achievements.rs | 296 ++++++ panel/server/src/routes/guilds.rs | 829 ++++++++++++++++ panel/server/src/routes/leveling.rs | 235 +++++ panel/server/src/routes/mod.rs | 63 +- panel/server/src/routes/quests.rs | 373 +++++++ panel/server/src/routes/servers.rs | 278 +++++- panel/server/src/routes/social.rs | 623 ++++++++++++ panel/server/src/seed.rs | 255 +++++ panel/server/tests/features_v2.rs | 341 +++++++ panel/web/src/App.svelte | 12 + panel/web/src/components/Sidebar.svelte | 6 +- panel/web/src/lib/types.ts | 177 ++++ panel/web/src/pages/AchievementEditor.svelte | 544 +++++++++++ panel/web/src/pages/GuildsAdmin.svelte | 222 +++++ panel/web/src/pages/LevelingAdmin.svelte | 336 +++++++ panel/web/src/pages/PublicLanding.svelte | 67 +- panel/web/src/pages/QuestEditor.svelte | 400 ++++++++ 40 files changed, 10261 insertions(+), 27 deletions(-) create mode 100644 integrations/common/src/main/java/net/scopenet/integration/ChunkCheckResult.java create mode 100644 launcher/src/components/ChunkMap.svelte create mode 100644 launcher/src/components/PlayerProfileModal.svelte create mode 100644 launcher/src/pages/Guilds.svelte create mode 100644 launcher/src/pages/Quests.svelte create mode 100644 launcher/src/pages/Social.svelte create mode 100644 panel/server/src/routes/achievements.rs create mode 100644 panel/server/src/routes/guilds.rs create mode 100644 panel/server/src/routes/leveling.rs create mode 100644 panel/server/src/routes/quests.rs create mode 100644 panel/server/src/routes/social.rs create mode 100644 panel/server/src/seed.rs create mode 100644 panel/server/tests/features_v2.rs create mode 100644 panel/web/src/pages/AchievementEditor.svelte create mode 100644 panel/web/src/pages/GuildsAdmin.svelte create mode 100644 panel/web/src/pages/LevelingAdmin.svelte create mode 100644 panel/web/src/pages/QuestEditor.svelte diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index b909ccf..fe858e8 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -409,6 +409,206 @@ pub struct LaunchEvent { pub username: Option, } +// --------------------------------------------------------------------------- +// Leveling & Quests +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct UserLevelInfo { + pub uuid: String, + pub global_xp: i64, + pub global_level: i64, + pub current_level_xp: i64, + pub next_level_xp: i64, + pub progress_pct: f64, + pub title: Option, + pub badges: Vec, + pub server_levels: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct ServerLevelInfo { + pub server_id: i64, + pub server_name: String, + pub server_xp: i64, + pub server_level: i64, + pub current_level_xp: i64, + pub next_level_xp: i64, + pub progress_pct: f64, + pub rank_name: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct Quest { + pub id: String, + pub title: String, + pub description: String, + pub period: String, // "daily", "weekly" + pub category: String, // "mining", "combat", "building", "farming", "social", "exploration" + pub target_stat: String, + pub target_count: i64, + pub xp_reward: i64, + pub icon: String, + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct UserQuest { + #[serde(flatten)] + pub quest: Quest, + pub progress: i64, + pub completed: bool, + pub claimed: bool, + pub period_key: String, +} + +// --------------------------------------------------------------------------- +// Achievements +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct Achievement { + pub id: String, + pub title: String, + pub description: String, + pub category: String, + pub icon_frame: String, // "task", "goal", "challenge" + pub icon_item: String, + pub icon_bg: String, + pub icon_border: String, + pub xp_reward: i64, + pub secret: bool, + #[serde(default)] + pub unlocked: bool, + #[serde(default)] + pub unlocked_at: Option, +} + +// --------------------------------------------------------------------------- +// Guilds & Land Claiming +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct Guild { + pub id: String, + pub instance_id: String, + pub name: String, + pub tag: String, + pub description: String, + pub motd: String, + pub leader_uuid: String, + pub icon_url: Option, + pub banner_url: Option, + pub level: i64, + pub xp: i64, + pub max_claims: i64, + pub member_count: i64, + pub claims_count: i64, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct GuildMember { + pub uuid: String, + pub name: String, + pub role: String, // "leader", "officer", "member", "recruit" + pub joined_at: String, + #[serde(default)] + pub online: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct GuildPost { + pub id: i64, + pub guild_id: String, + pub author_uuid: String, + pub author_name: String, + pub title: String, + pub content: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct GuildClaim { + pub id: i64, + pub guild_id: String, + pub guild_name: String, + pub guild_tag: String, + pub server_id: i64, + pub dimension: String, + pub chunk_x: i32, + pub chunk_z: i32, + pub claimed_by_uuid: String, + pub claimed_at: String, +} + +// --------------------------------------------------------------------------- +// Social: Friends, DMs, Invites, Profiles & Posts +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct FriendInfo { + pub uuid: String, + pub username: String, + pub status: String, // "accepted", "pending_incoming", "pending_outgoing" + pub online: bool, + pub playing_on: Option, + pub last_seen: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct DirectMessage { + pub id: i64, + pub sender_uuid: String, + pub sender_name: String, + pub recipient_uuid: String, + pub content: String, + pub is_read: bool, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct GameInvite { + pub id: String, + pub sender_uuid: String, + pub sender_name: String, + pub recipient_uuid: String, + pub instance_id: String, + pub instance_name: String, + pub server_id: Option, + pub server_name: Option, + pub status: String, + pub created_at: String, + pub expires_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct UserPost { + pub id: i64, + pub user_uuid: String, + pub author_name: String, + pub content: String, + pub image_url: Option, + pub likes_count: i64, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct UserProfileView { + pub uuid: String, + pub username: String, + pub bio: String, + pub banner_url: Option, + pub custom_badge: Option, + pub title: Option, + pub global_level: i64, + pub global_xp: i64, + pub server_levels: Vec, + pub featured_achievement: Option, + pub achievements_count: usize, + pub posts: Vec, +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/integrations/common/src/main/java/net/scopenet/integration/ChunkCheckResult.java b/integrations/common/src/main/java/net/scopenet/integration/ChunkCheckResult.java new file mode 100644 index 0000000..a723c4b --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/integration/ChunkCheckResult.java @@ -0,0 +1,9 @@ +package net.scopenet.integration; + +public record ChunkCheckResult( + boolean claimed, + boolean allowed, + String guildName, + String guildTag +) { +} diff --git a/integrations/common/src/main/java/net/scopenet/integration/Integration.java b/integrations/common/src/main/java/net/scopenet/integration/Integration.java index 64e6f1a..5ffcb84 100644 --- a/integrations/common/src/main/java/net/scopenet/integration/Integration.java +++ b/integrations/common/src/main/java/net/scopenet/integration/Integration.java @@ -9,6 +9,7 @@ import java.util.function.*; public final class Integration implements AutoCloseable { public static final String UNAVAILABLE = "SCOPENET sign-in is unavailable. Please try again shortly."; public final Activity activity = new Activity(); + private final Settings settings; private final PanelClient client; private final JsonObject hello; private final Consumer log; @@ -27,6 +28,7 @@ public final class Integration implements AutoCloseable { public Integration(Settings settings, String software, String mcVersion, String version, boolean onlineMode, int maxPlayers, Consumer log, BiConsumer kick) { if (!onlineMode) throw new IllegalArgumentException("SCOPENET requires online-mode=true and authlib-injector"); + this.settings = settings; this.client = new PanelClient(settings); this.log = log; this.kick = kick; @@ -72,7 +74,10 @@ public final class Integration implements AutoCloseable { try { sync(tps); } catch (Exception e) { if (e instanceof InterruptedException) Thread.currentThread().interrupt(); - log.accept("SCOPENET sync failed; retained batch will be retried (" + e.getClass().getSimpleName() + ")"); + String msg = e.getMessage() != null && !e.getMessage().isBlank() + ? e.getClass().getSimpleName() + ": " + e.getMessage() + : e.getClass().getSimpleName(); + log.accept("SCOPENET sync failed to connect to panel at " + settings.panel() + "; retained batch will be retried (" + msg + ")"); } finally { syncing.set(false); } }); } @@ -83,7 +88,19 @@ public final class Integration implements AutoCloseable { if (!response.has("server_id")) throw new IllegalStateException("Invalid hello response"); registered = true; } - if (pending == null) pending = activity.drain(tps); + if (pending == null) { + pending = activity.drain(tps); + JsonObject features = new JsonObject(); + features.addProperty("leveling_enabled", settings.levelingEnabled()); + features.addProperty("global_xp_multiplier", settings.globalXpMultiplier()); + features.addProperty("server_xp_multiplier", settings.serverXpMultiplier()); + features.addProperty("quests_enabled", settings.questsEnabled()); + features.addProperty("achievements_enabled", settings.achievementsEnabled()); + features.addProperty("guilds_enabled", settings.guildsEnabled()); + features.addProperty("land_claiming_enabled", settings.landClaimingEnabled()); + features.addProperty("social_enabled", settings.socialEnabled()); + pending.add("features", features); + } JsonObject response = client.post("sync", pending); if (!response.has("ok") || !response.get("ok").getAsBoolean() || !response.has("kick") || !response.get("kick").isJsonArray()) throw new IllegalStateException("Invalid sync response"); @@ -98,6 +115,14 @@ public final class Integration implements AutoCloseable { } } + public Settings settings() { + return settings; + } + + public ChunkCheckResult checkChunk(String dimension, int chunkX, int chunkZ, UUID uuid) { + return client.checkChunk(dimension, chunkX, chunkZ, uuid); + } + @Override public void close() { closed = true; loginWorker.shutdownNow(); diff --git a/integrations/common/src/main/java/net/scopenet/integration/PanelClient.java b/integrations/common/src/main/java/net/scopenet/integration/PanelClient.java index 3e46006..cc25e3f 100644 --- a/integrations/common/src/main/java/net/scopenet/integration/PanelClient.java +++ b/integrations/common/src/main/java/net/scopenet/integration/PanelClient.java @@ -42,4 +42,26 @@ public final class PanelClient { return message != null && message.isJsonPrimitive() && message.getAsJsonPrimitive().isString() ? message.getAsString() : "You do not have access to this server."; } + + public ChunkCheckResult checkChunk(String dimension, int chunkX, int chunkZ, java.util.UUID uuid) { + if (!settings.guildsEnabled() || !settings.landClaimingEnabled()) { + return new ChunkCheckResult(false, true, null, null); + } + JsonObject req = new JsonObject(); + req.addProperty("dimension", dimension); + req.addProperty("chunk_x", chunkX); + req.addProperty("chunk_z", chunkZ); + req.addProperty("uuid", uuid.toString()); + try { + JsonObject res = post("guilds/check-chunk", req); + boolean claimed = res.has("claimed") && res.get("claimed").getAsBoolean(); + boolean allowed = !res.has("allowed") || res.get("allowed").getAsBoolean(); + String guildName = res.has("guild_name") && !res.get("guild_name").isJsonNull() ? res.get("guild_name").getAsString() : null; + String guildTag = res.has("guild_tag") && !res.get("guild_tag").isJsonNull() ? res.get("guild_tag").getAsString() : null; + return new ChunkCheckResult(claimed, allowed, guildName, guildTag); + } catch (Exception e) { + return new ChunkCheckResult(false, true, null, null); + } + } } + diff --git a/integrations/common/src/main/java/net/scopenet/integration/Settings.java b/integrations/common/src/main/java/net/scopenet/integration/Settings.java index a515bf9..8f3f25e 100644 --- a/integrations/common/src/main/java/net/scopenet/integration/Settings.java +++ b/integrations/common/src/main/java/net/scopenet/integration/Settings.java @@ -8,7 +8,19 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Properties; -public record Settings(URI panel, String token) { +public record Settings( + URI panel, + String token, + boolean levelingEnabled, + double globalXpMultiplier, + double serverXpMultiplier, + boolean questsEnabled, + boolean achievementsEnabled, + boolean guildsEnabled, + boolean landClaimingEnabled, + boolean socialEnabled, + boolean chatPrefixesEnabled +) { public Settings { if (panel == null || panel.getHost() == null || panel.getRawUserInfo() != null || panel.getRawQuery() != null || panel.getRawFragment() != null @@ -21,19 +33,79 @@ public record 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); + } + public static Settings of(String panel, String token) { - return new Settings(URI.create(panel.trim().replaceAll("/+$", "")), token); + return of(panel, token, true, 1.0, 1.5, true, true, true, true, 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 + ) { + return new Settings( + URI.create(panel.trim().replaceAll("/+$", "")), + token, + levelingEnabled, + globalXpMultiplier, + serverXpMultiplier, + questsEnabled, + achievementsEnabled, + guildsEnabled, + landClaimingEnabled, + socialEnabled, + chatPrefixesEnabled + ); } public static Settings load(Path path) throws IOException { if (!Files.exists(path)) { Files.createDirectories(path.toAbsolutePath().getParent()); - Files.writeString(path, "# SCOPENET server integration\npanel-url=https://panel.example.com\ntoken=\n", StandardCharsets.UTF_8); + String defaultProps = """ + # SCOPENET server integration + panel-url=https://panel.example.com + token= + + # Feature Toggles (set false to disable systems on this server) + leveling.enabled=true + leveling.global_xp_multiplier=1.0 + leveling.server_xp_multiplier=1.5 + quests.enabled=true + achievements.enabled=true + guilds.enabled=true + guilds.land_claiming=true + social.enabled=true + social.chat_prefixes=true + """; + Files.writeString(path, defaultProps, StandardCharsets.UTF_8); } Properties properties = new Properties(); try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { properties.load(reader); } - return of(properties.getProperty("panel-url", ""), properties.getProperty("token", "")); + return of( + properties.getProperty("panel-url", ""), + properties.getProperty("token", ""), + Boolean.parseBoolean(properties.getProperty("leveling.enabled", "true")), + Double.parseDouble(properties.getProperty("leveling.global_xp_multiplier", "1.0")), + Double.parseDouble(properties.getProperty("leveling.server_xp_multiplier", "1.5")), + Boolean.parseBoolean(properties.getProperty("quests.enabled", "true")), + Boolean.parseBoolean(properties.getProperty("achievements.enabled", "true")), + Boolean.parseBoolean(properties.getProperty("guilds.enabled", "true")), + Boolean.parseBoolean(properties.getProperty("guilds.land_claiming", "true")), + Boolean.parseBoolean(properties.getProperty("social.enabled", "true")), + Boolean.parseBoolean(properties.getProperty("social.chat_prefixes", "true")) + ); } } diff --git a/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java b/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java index d86016d..6d0f8c8 100644 --- a/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java +++ b/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java @@ -63,13 +63,16 @@ public final class Bridge { public static void stat(ServerPlayer player, String key, int amount) { Integration current = integration; if (current == null) return; + if (!current.settings().levelingEnabled() && (key.equals("blocks_broken") || key.equals("blocks_placed") || key.equals("player_kills") || key.equals("mob_kills"))) { + return; + } current.activity.add(player.getUUID(), player.getName().getString(), key, amount); if (key.equals("deaths")) current.activity.event(player.getUUID(), player.getName().getString(), "death", null); } public static void action(ServerPlayer player, String action, int amount) { Integration current = integration; - if (current != null) current.activity.action(player.getUUID(), player.getName().getString(), action, amount); + if (current != null && current.settings().levelingEnabled()) current.activity.action(player.getUUID(), player.getName().getString(), action, amount); } public static void command(ServerPlayer player, String command) { diff --git a/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java b/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java index 620ce82..b421a4b 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java @@ -19,8 +19,22 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener { getServer().getPluginManager().registerEvents(this, this); saveDefaultConfig(); try { + Settings settings = Settings.of( + getConfig().getString("panel-url", ""), + getConfig().getString("token", ""), + getConfig().getBoolean("leveling.enabled", true), + getConfig().getDouble("leveling.global-xp-multiplier", 1.0), + getConfig().getDouble("leveling.server-xp-multiplier", 1.5), + getConfig().getBoolean("quests.enabled", true), + getConfig().getBoolean("achievements.enabled", true), + getConfig().getBoolean("guilds.enabled", true), + getConfig().getBoolean("guilds.land-claiming", true), + getConfig().getBoolean("social.enabled", true), + getConfig().getBoolean("social.chat-prefixes", true) + ); + integration = new Integration( - Settings.of(getConfig().getString("panel-url", ""), getConfig().getString("token", "")), + settings, getServer().getName(), Bukkit.getBukkitVersion().split("-")[0], getDescription().getVersion(), Bukkit.getOnlineMode(), Bukkit.getMaxPlayers(), getLogger()::warning, (id, message) -> { @@ -33,6 +47,12 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener { Bukkit.getScheduler().runTaskTimer(this, integration::tick, 1, 1); // Reloading an auth plugin cannot silently admit existing sessions. for (Player player : Bukkit.getOnlinePlayers()) player.kickPlayer("SCOPENET restarted. Please reconnect."); + getLogger().info("SCOPENET integration initialized. Configured modules:"); + getLogger().info(" - Leveling: " + (settings.levelingEnabled() ? "ENABLED" : "DISABLED")); + getLogger().info(" - Quests: " + (settings.questsEnabled() ? "ENABLED" : "DISABLED")); + getLogger().info(" - Achievements: " + (settings.achievementsEnabled() ? "ENABLED" : "DISABLED")); + getLogger().info(" - Guilds & Land Claiming: " + (settings.guildsEnabled() && settings.landClaimingEnabled() ? "ENABLED" : "DISABLED")); + getLogger().info(" - Social: " + (settings.socialEnabled() ? "ENABLED" : "DISABLED")); } catch (Exception e) { getLogger().severe("SCOPENET is blocking joins: " + e.getMessage()); for (Player player : Bukkit.getOnlinePlayers()) player.kickPlayer(Integration.UNAVAILABLE); @@ -68,17 +88,43 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener { } private void add(Player player, String stat) { - if (integration != null) integration.activity.add(player.getUniqueId(), player.getName(), stat, 1); + if (integration != null && integration.settings().levelingEnabled()) { + integration.activity.add(player.getUniqueId(), player.getName(), stat, 1); + } + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void broken(BlockBreakEvent event) { + if (integration != null && integration.settings().guildsEnabled() && integration.settings().landClaimingEnabled()) { + Chunk chunk = event.getBlock().getChunk(); + ChunkCheckResult check = integration.checkChunk(event.getBlock().getWorld().getName(), chunk.getX(), chunk.getZ(), event.getPlayer().getUniqueId()); + if (check.claimed() && !check.allowed()) { + event.setCancelled(true); + event.getPlayer().sendMessage(ChatColor.RED + "This chunk is claimed by [" + check.guildTag() + "] " + check.guildName() + "!"); + return; + } + } + add(event.getPlayer(), "blocks_broken"); + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void placed(BlockPlaceEvent event) { + if (integration != null && integration.settings().guildsEnabled() && integration.settings().landClaimingEnabled()) { + Chunk chunk = event.getBlock().getChunk(); + ChunkCheckResult check = integration.checkChunk(event.getBlock().getWorld().getName(), chunk.getX(), chunk.getZ(), event.getPlayer().getUniqueId()); + if (check.claimed() && !check.allowed()) { + event.setCancelled(true); + event.getPlayer().sendMessage(ChatColor.RED + "This chunk is claimed by [" + check.guildTag() + "] " + check.guildName() + "!"); + return; + } + } + add(event.getPlayer(), "blocks_placed"); } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) - public void broken(BlockBreakEvent event) { add(event.getPlayer(), "blocks_broken"); } - - @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) - public void placed(BlockPlaceEvent event) { add(event.getPlayer(), "blocks_placed"); } - - @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) - public void chat(AsyncPlayerChatEvent event) { add(event.getPlayer(), "messages"); } + public void chat(AsyncPlayerChatEvent event) { + add(event.getPlayer(), "messages"); + } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void command(PlayerCommandPreprocessEvent event) { @@ -87,7 +133,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void statistic(PlayerStatisticIncrementEvent event) { - if (integration == null) return; + if (integration == null || !integration.settings().levelingEnabled()) return; String item = event.getMaterial() != null ? ":" + event.getMaterial().name() : event.getEntityType() != null ? ":" + event.getEntityType().name() : ""; integration.activity.action(event.getPlayer().getUniqueId(), event.getPlayer().getName(), @@ -103,6 +149,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener { @EventHandler(priority = EventPriority.MONITOR) public void advancement(PlayerAdvancementDoneEvent event) { + if (integration == null || !integration.settings().achievementsEnabled()) return; audit(event.getPlayer(), "advancement", event.getAdvancement().getKey().toString()); } diff --git a/integrations/paper/src/main/resources/config.yml b/integrations/paper/src/main/resources/config.yml index e8fc5d0..b8a5d21 100644 --- a/integrations/paper/src/main/resources/config.yml +++ b/integrations/paper/src/main/resources/config.yml @@ -1,3 +1,44 @@ -# Create a server under Servers in the panel, then paste its token here. +# ============================================================================== +# SCOPENET Minecraft Server Configuration +# ============================================================================== +# Connect to your SCOPENET Panel instance by pasting your server token below. +# Create a server under 'Servers' in the web panel to generate a token. + panel-url: "https://panel.example.com" token: "" + +# ============================================================================== +# Feature Toggles +# Every system below is fully toggleable. You do not need to force all features +# on every server—turn on only what makes sense for your server's gameplay style. +# ============================================================================== + +leveling: + # Global & Server leveling progression (tracks playtime, kills, mining, etc.) + enabled: true + # Multiplier for account-wide Global Level XP (1.0 = standard speed) + global-xp-multiplier: 1.0 + # Multiplier for this server's specific Server Level XP (1.5 = 1.5x speed) + server-xp-multiplier: 1.5 + +quests: + # Daily and Weekly quests progression tracking + enabled: true + +achievements: + # Minecraft-themed achievements and milestone unlock notifications + enabled: true + +guilds: + # Guilds partying system and /guild commands + enabled: true + # FTB Chunks-style chunk land-claiming and protection against other guilds + land-claiming: true + # Allow guild members to damage each other (PvP friendly fire) + friendly-fire: false + +social: + # Friends status, direct messaging bridge, and server game invites + enabled: true + # Show player guild tag and level title in chat formatting + chat-prefixes: true diff --git a/launcher/src-tauri/src/commands.rs b/launcher/src-tauri/src/commands.rs index df38652..1a87d0b 100644 --- a/launcher/src-tauri/src/commands.rs +++ b/launcher/src-tauri/src/commands.rs @@ -6,7 +6,11 @@ use crate::settings::Settings; use crate::state::{build, AppState}; use crate::updater; use scopenet_core::ping::ServerStatus; -use scopenet_shared::{LauncherManifest, PlayerProfile, SkinProfile}; +use scopenet_shared::{ + Achievement, DirectMessage, FriendInfo, GameInvite, Guild, GuildClaim, GuildMember, GuildPost, + LauncherManifest, PlayerProfile, SkinProfile, UserLevelInfo, UserPost, UserProfileView, + UserQuest, +}; use serde::Serialize; use tauri::{AppHandle, Manager, State}; use tauri_plugin_opener::OpenerExt; @@ -520,3 +524,382 @@ pub async fn get_public_servers(state: State<'_, AppState>) -> Res) -> Res { + let req = account_api(&state, reqwest::Method::GET, "/levels/me").await?; + let resp = req.send().await.map_err(err)?; + if !resp.status().is_success() { + return Err("unable to load user level".into()); + } + resp.json().await.map_err(err) +} + +#[tauri::command] +pub async fn get_my_quests(state: State<'_, AppState>) -> Res> { + let req = account_api(&state, reqwest::Method::GET, "/quests/my").await?; + let resp = req.send().await.map_err(err)?; + if !resp.status().is_success() { + return Err("unable to load quests".into()); + } + resp.json().await.map_err(err) +} + +#[tauri::command] +pub async fn claim_quest(state: State<'_, AppState>, quest_id: String) -> Res { + let req = account_api(&state, reqwest::Method::POST, &format!("/quests/{quest_id}/claim")).await?; + let resp = req.send().await.map_err(err)?; + if !resp.status().is_success() { + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + return Err(body["error"].as_str().unwrap_or("unable to claim quest").to_string()); + } + resp.json().await.map_err(err) +} + +// --------------------------------------------------------------------------- +// Achievements +// --------------------------------------------------------------------------- + +#[tauri::command] +pub async fn get_my_achievements(state: State<'_, AppState>) -> Res> { + let req = account_api(&state, reqwest::Method::GET, "/achievements/my").await?; + let resp = req.send().await.map_err(err)?; + if !resp.status().is_success() { + return Err("unable to load achievements".into()); + } + resp.json().await.map_err(err) +} + +// --------------------------------------------------------------------------- +// Guilds & Land Claims +// --------------------------------------------------------------------------- + +#[tauri::command] +pub async fn get_guilds(state: State<'_, AppState>, instance_id: Option) -> Res> { + let panel = state.panel_url().ok_or("no panel configured")?; + let q = instance_id.map(|i| format!("?instance_id={i}")).unwrap_or_default(); + let resp = state.http.get(format!("{panel}/api/v1/guilds{q}")).send().await.map_err(err)?; + if !resp.status().is_success() { + return Err("unable to load guilds".into()); + } + resp.json().await.map_err(err) +} + +#[tauri::command] +pub async fn get_my_guild(state: State<'_, AppState>, instance_id: Option) -> Res> { + let q = instance_id.map(|i| format!("?instance_id={i}")).unwrap_or_default(); + let req = account_api(&state, reqwest::Method::GET, &format!("/guilds/my{q}")).await?; + let resp = req.send().await.map_err(err)?; + if !resp.status().is_success() { + return Ok(None); + } + resp.json().await.map_err(err) +} + +#[tauri::command] +pub async fn create_guild( + state: State<'_, AppState>, + instance_id: String, + name: String, + tag: String, + description: String, +) -> Res { + let req = account_api(&state, reqwest::Method::POST, "/guilds").await?; + let resp = req.json(&serde_json::json!({ + "instance_id": instance_id, + "name": name, + "tag": tag, + "description": description, + })).send().await.map_err(err)?; + if !resp.status().is_success() { + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + return Err(body["error"].as_str().unwrap_or("unable to create guild").to_string()); + } + resp.json().await.map_err(err) +} + +#[tauri::command] +pub async fn get_guild_claims( + state: State<'_, AppState>, + instance_id: String, + dimension: Option, +) -> Res> { + let panel = state.panel_url().ok_or("no panel configured")?; + let dim = dimension.unwrap_or_else(|| "minecraft:overworld".into()); + let resp = state.http.get(format!("{panel}/api/v1/guilds/claims/grid?instance_id={instance_id}&dimension={dim}")) + .send().await.map_err(err)?; + if !resp.status().is_success() { + return Err("unable to load claims".into()); + } + resp.json().await.map_err(err) +} + +#[tauri::command] +pub async fn claim_guild_chunk( + state: State<'_, AppState>, + guild_id: String, + instance_id: String, + dimension: String, + chunk_x: i32, + chunk_z: i32, +) -> Res { + let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/claim")).await?; + let resp = req.json(&serde_json::json!({ + "instance_id": instance_id, + "dimension": dimension, + "chunk_x": chunk_x, + "chunk_z": chunk_z, + })).send().await.map_err(err)?; + if !resp.status().is_success() { + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + return Err(body["error"].as_str().unwrap_or("unable to claim chunk").to_string()); + } + resp.json().await.map_err(err) +} + +#[tauri::command] +pub async fn unclaim_guild_chunk( + state: State<'_, AppState>, + guild_id: String, + instance_id: String, + dimension: String, + chunk_x: i32, + chunk_z: i32, +) -> Res<()> { + let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/unclaim")).await?; + let resp = req.json(&serde_json::json!({ + "instance_id": instance_id, + "dimension": dimension, + "chunk_x": chunk_x, + "chunk_z": chunk_z, + })).send().await.map_err(err)?; + if !resp.status().is_success() { + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + return Err(body["error"].as_str().unwrap_or("unable to unclaim chunk").to_string()); + } + Ok(()) +} + +#[tauri::command] +pub async fn get_guild_posts(state: State<'_, AppState>, guild_id: String) -> Res> { + let req = account_api(&state, reqwest::Method::GET, &format!("/guilds/{guild_id}/posts")).await?; + let resp = req.send().await.map_err(err)?; + if !resp.status().is_success() { + return Err("unable to load guild posts".into()); + } + resp.json().await.map_err(err) +} + +#[tauri::command] +pub async fn create_guild_post( + state: State<'_, AppState>, + guild_id: String, + title: String, + content: String, +) -> Res { + let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/posts")).await?; + let resp = req.json(&serde_json::json!({ + "title": title, + "content": content, + })).send().await.map_err(err)?; + if !resp.status().is_success() { + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + return Err(body["error"].as_str().unwrap_or("unable to post announcement").to_string()); + } + resp.json().await.map_err(err) +} + +#[tauri::command] +pub async fn get_guild_members(state: State<'_, AppState>, guild_id: String) -> Res> { + let req = account_api(&state, reqwest::Method::GET, &format!("/guilds/{guild_id}/members")).await?; + let resp = req.send().await.map_err(err)?; + if !resp.status().is_success() { + return Err("unable to load members".into()); + } + resp.json().await.map_err(err) +} + +// --------------------------------------------------------------------------- +// Social: Friends, DMs, Invites, Profiles & Feed +// --------------------------------------------------------------------------- + +#[tauri::command] +pub async fn get_friends(state: State<'_, AppState>) -> Res> { + let req = account_api(&state, reqwest::Method::GET, "/friends").await?; + let resp = req.send().await.map_err(err)?; + if !resp.status().is_success() { + return Err("unable to load friends".into()); + } + resp.json().await.map_err(err) +} + +#[tauri::command] +pub async fn send_friend_request(state: State<'_, AppState>, username: String) -> Res<()> { + 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)?; + if !resp.status().is_success() { + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + return Err(body["error"].as_str().unwrap_or("unable to send friend request").to_string()); + } + Ok(()) +} + +#[tauri::command] +pub async fn respond_friend_request(state: State<'_, AppState>, target_uuid: String, accept: bool) -> Res<()> { + 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)?; + if !resp.status().is_success() { + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + return Err(body["error"].as_str().unwrap_or("unable to respond to friend request").to_string()); + } + Ok(()) +} + +#[tauri::command] +pub async fn remove_friend(state: State<'_, AppState>, target_uuid: String) -> Res<()> { + let req = account_api(&state, reqwest::Method::DELETE, &format!("/friends/{target_uuid}")).await?; + let resp = req.send().await.map_err(err)?; + if !resp.status().is_success() { + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + return Err(body["error"].as_str().unwrap_or("unable to remove friend").to_string()); + } + Ok(()) +} + +#[tauri::command] +pub async fn get_direct_messages(state: State<'_, AppState>, friend_uuid: String) -> Res> { + let req = account_api(&state, reqwest::Method::GET, &format!("/messages/{friend_uuid}")).await?; + let resp = req.send().await.map_err(err)?; + if !resp.status().is_success() { + return Err("unable to load messages".into()); + } + resp.json().await.map_err(err) +} + +#[tauri::command] +pub async fn send_direct_message(state: State<'_, AppState>, friend_uuid: String, content: String) -> Res { + let req = account_api(&state, reqwest::Method::POST, &format!("/messages/{friend_uuid}")).await?; + let resp = req.json(&serde_json::json!({ "content": content })).send().await.map_err(err)?; + if !resp.status().is_success() { + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + return Err(body["error"].as_str().unwrap_or("unable to send message").to_string()); + } + resp.json().await.map_err(err) +} + +#[tauri::command] +pub async fn get_game_invites(state: State<'_, AppState>) -> Res> { + let req = account_api(&state, reqwest::Method::GET, "/invites").await?; + let resp = req.send().await.map_err(err)?; + if !resp.status().is_success() { + return Err("unable to load invites".into()); + } + resp.json().await.map_err(err) +} + +#[tauri::command] +pub async fn send_game_invite( + state: State<'_, AppState>, + recipient_uuid: String, + instance_id: String, + server_id: Option, +) -> Res { + let req = account_api(&state, reqwest::Method::POST, "/invites").await?; + let resp = req.json(&serde_json::json!({ + "recipient_uuid": recipient_uuid, + "instance_id": instance_id, + "server_id": server_id, + })).send().await.map_err(err)?; + if !resp.status().is_success() { + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + return Err(body["error"].as_str().unwrap_or("unable to send invite").to_string()); + } + resp.json().await.map_err(err) +} + +#[tauri::command] +pub async fn respond_game_invite(state: State<'_, AppState>, invite_id: String, accept: bool) -> Res<()> { + let req = account_api(&state, reqwest::Method::POST, &format!("/invites/{invite_id}/respond")).await?; + let resp = req.json(&serde_json::json!({ "accept": accept })).send().await.map_err(err)?; + if !resp.status().is_success() { + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + return Err(body["error"].as_str().unwrap_or("unable to respond to invite").to_string()); + } + Ok(()) +} + +#[tauri::command] +pub async fn get_user_profile(state: State<'_, AppState>, uuid: String) -> Res { + let req = account_api(&state, reqwest::Method::GET, &format!("/profiles/{uuid}")).await?; + let resp = req.send().await.map_err(err)?; + if !resp.status().is_success() { + return Err("unable to load user profile".into()); + } + resp.json().await.map_err(err) +} + +#[tauri::command] +pub async fn update_my_profile( + state: State<'_, AppState>, + bio: String, + banner_url: Option, + custom_badge: Option, + featured_achievement_id: Option, +) -> Res { + let req = account_api(&state, reqwest::Method::PUT, "/profiles/me").await?; + let resp = req.json(&serde_json::json!({ + "bio": bio, + "banner_url": banner_url, + "custom_badge": custom_badge, + "featured_achievement_id": featured_achievement_id, + })).send().await.map_err(err)?; + if !resp.status().is_success() { + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + return Err(body["error"].as_str().unwrap_or("unable to update profile").to_string()); + } + resp.json().await.map_err(err) +} + +#[tauri::command] +pub async fn get_user_posts(state: State<'_, AppState>, user_uuid: Option) -> Res> { + let path = user_uuid.map(|u| format!("/posts?uuid={u}")).unwrap_or_else(|| "/posts".into()); + let req = account_api(&state, reqwest::Method::GET, &path).await?; + let resp = req.send().await.map_err(err)?; + if !resp.status().is_success() { + return Err("unable to load posts".into()); + } + resp.json().await.map_err(err) +} + +#[tauri::command] +pub async fn create_user_post( + state: State<'_, AppState>, + content: String, + image_url: Option, +) -> Res { + let req = account_api(&state, reqwest::Method::POST, "/posts").await?; + let resp = req.json(&serde_json::json!({ + "content": content, + "image_url": image_url, + })).send().await.map_err(err)?; + if !resp.status().is_success() { + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + return Err(body["error"].as_str().unwrap_or("unable to publish post").to_string()); + } + resp.json().await.map_err(err) +} + +#[tauri::command] +pub async fn like_user_post(state: State<'_, AppState>, post_id: i64) -> Res<()> { + let req = account_api(&state, reqwest::Method::POST, &format!("/posts/{post_id}/like")).await?; + let resp = req.send().await.map_err(err)?; + if !resp.status().is_success() { + return Err("unable to like post".into()); + } + Ok(()) +} + diff --git a/launcher/src-tauri/src/lib.rs b/launcher/src-tauri/src/lib.rs index 8f10f5a..85db595 100644 --- a/launcher/src-tauri/src/lib.rs +++ b/launcher/src-tauri/src/lib.rs @@ -84,6 +84,33 @@ pub fn run() { commands::get_player_stats, commands::get_leaderboard, commands::get_public_servers, + commands::get_my_level, + commands::get_my_quests, + commands::claim_quest, + commands::get_my_achievements, + commands::get_guilds, + commands::get_my_guild, + commands::create_guild, + commands::get_guild_claims, + commands::claim_guild_chunk, + commands::unclaim_guild_chunk, + commands::get_guild_posts, + commands::create_guild_post, + commands::get_guild_members, + commands::get_friends, + commands::send_friend_request, + commands::respond_friend_request, + commands::remove_friend, + commands::get_direct_messages, + commands::send_direct_message, + commands::get_game_invites, + commands::send_game_invite, + commands::respond_game_invite, + commands::get_user_profile, + commands::update_my_profile, + commands::get_user_posts, + commands::create_user_post, + commands::like_user_post, ]) .run(tauri::generate_context!()) .expect("error while running the launcher"); diff --git a/launcher/src/App.svelte b/launcher/src/App.svelte index 65f12ab..f6daf82 100644 --- a/launcher/src/App.svelte +++ b/launcher/src/App.svelte @@ -14,6 +14,10 @@ import Home from './pages/Home.svelte'; import Settings from './pages/Settings.svelte'; import Stats from './pages/Stats.svelte'; + import Quests from './pages/Quests.svelte'; + import Guilds from './pages/Guilds.svelte'; + import Social from './pages/Social.svelte'; + import PlayerProfileModal from './components/PlayerProfileModal.svelte'; import { app, init } from './lib/store.svelte'; import { errorText } from './lib/tauri'; @@ -45,6 +49,12 @@ {:else if app.view === 'stats'} + {:else if app.view === 'quests'} + + {:else if app.view === 'guilds'} + + {:else if app.view === 'social'} + {:else} {/if} @@ -66,6 +76,9 @@ +{#if app.viewProfileUuid} + (app.viewProfileUuid = null)} /> +{/if} diff --git a/launcher/src/components/ChunkMap.svelte b/launcher/src/components/ChunkMap.svelte new file mode 100644 index 0000000..28b1bc8 --- /dev/null +++ b/launcher/src/components/ChunkMap.svelte @@ -0,0 +1,492 @@ + + +
+ +
+
+ {#each dimensions as dim} + {@const DimIcon = dim.icon} + + {/each} +
+ + +
+ + +
+
+ {#each Array.from({ length: radius * 2 + 1 }, (_, r) => centerZ - radius + r) as cz} + {#each Array.from({ length: radius * 2 + 1 }, (_, c) => centerX - radius + c) as cx} + {@const claim = getClaimAt(cx, cz)} + {@const isMyClaim = claim && myGuildId && claim.guild_id === myGuildId} + {@const isOtherClaim = claim && (!myGuildId || claim.guild_id !== myGuildId)} + {@const isSelected = selectedChunk && selectedChunk.x === cx && selectedChunk.z === cz} + {@const isCenter = cx === 0 && cz === 0} + + + {/each} + {/each} +
+
+ + + +
+ + diff --git a/launcher/src/components/PlayerProfileModal.svelte b/launcher/src/components/PlayerProfileModal.svelte new file mode 100644 index 0000000..af69a22 --- /dev/null +++ b/launcher/src/components/PlayerProfileModal.svelte @@ -0,0 +1,824 @@ + + + + + diff --git a/launcher/src/components/Rail.svelte b/launcher/src/components/Rail.svelte index e11c6c7..67033d0 100644 --- a/launcher/src/components/Rail.svelte +++ b/launcher/src/components/Rail.svelte @@ -1,5 +1,5 @@ + +
+ + + + {#if loading && !myGuild && !allGuilds.length} +
+ {:else if myGuild} + +
+ +
+
+
+ [{myGuild.tag}] +
+
+
+

{myGuild.name}

+ {myGuild.member_count} / {myGuild.max_members} Members + {myGuild.claims_count} Claims +
+ {#if myGuild.description} +

{myGuild.description}

+ {/if} +
+
+
+ + +
+
+ + + +
+
+ + + {#if activeTab === 'territory'} +
+
+ +
+ Grief Protection Relay Active +

+ Chunks claimed by your guild are synchronized with the SCOPENET mod/plugin on the server. Non-members cannot break or place blocks within your claims. +

+
+
+ + { if (myGuild) myGuild.claims_count += 1; }} + onunclaim={() => { if (myGuild) myGuild.claims_count = Math.max(0, myGuild.claims_count - 1); }} + /> +
+ {:else if activeTab === 'members'} +
+
+ {#each members as m (m.user_uuid)} +
(app.viewProfileUuid = m.user_uuid)} + onkeydown={(e) => e.key === 'Enter' && (app.viewProfileUuid = m.user_uuid)} + > +
+ + {#if m.is_online} + + {/if} +
+
+
+ {m.username} + {#if m.role === 'leader'} + Leader + {:else if m.role === 'officer'} + Officer + {:else} + Member + {/if} +
+ Joined {new Date(m.joined_at).toLocaleDateString()} +
+
+ {/each} +
+
+ {:else if activeTab === 'feed'} +
+ +
+ Post Guild Announcement + + +
+ + +
+
+ + +
+ {#if posts.length === 0} +
+ +

No guild announcements posted yet.

+
+ {:else} + {#each posts as p (p.id)} +
+
+
+ +
+ {p.author_name} + +
+
+ {#if p.pinned} + Pinned + {/if} +
+

{p.title}

+

{p.content}

+
+ {/each} + {/if} +
+
+ {/if} +
+ {:else} + +
+
+
+ +
+
+

No Guild on {currentInstance?.name || 'this Instance'}

+

+ Guilds are tied to instances. Start your own guild to claim land chunks, invite members, and build a faction base, or browse existing factions below. +

+ +
+
+ + +
+
+

Active Guilds ({allGuilds.length})

+
+ {#if allGuilds.length === 0} +
+

No guilds have been created on this instance yet. Be the first to establish territory!

+
+ {:else} +
+ {#each allGuilds as g (g.id)} +
+
+
[{g.tag}]
+
+

{g.name}

+ {g.member_count} / {g.max_members} Members • {g.claims_count} Chunks Claimed +
+
+ {#if g.description} +

{g.description}

+ {/if} +
+ {/each} +
+ {/if} +
+
+ {/if} +
+ + + + + {#snippet footer()} + + + {/snippet} + + + diff --git a/launcher/src/pages/Quests.svelte b/launcher/src/pages/Quests.svelte new file mode 100644 index 0000000..753ef84 --- /dev/null +++ b/launcher/src/pages/Quests.svelte @@ -0,0 +1,723 @@ + + +
+ + + + + {#if levelInfo} +
+
+
+ LVL + {levelInfo.level} +
+
+
+
+
+ SCOPENET Global Level + {#if levelInfo.title} + {levelInfo.title} + {/if} +
+ + {levelInfo.current_xp.toLocaleString()} / {levelInfo.next_level_xp.toLocaleString()} XP + +
+
+
+
+ +
+
+ {/if} + + + + + + {#if loading && !userQuests.length} +
+ {:else if activeTab === 'achievements'} + +
+
+ {#each achievements as ach (ach.id)} +
+
+ {ach.icon_item || '💎'} +
+
+
+ {ach.title} + {ach.frame_type.toUpperCase()} +
+

{ach.description}

+ +
+
+ {/each} +
+
+ {:else} + +
+ +
+ {#each ['all', 'mining', 'combat', 'building', 'farming', 'social', 'exploration'] as cat} + {@const CatIcon = categoryIcons[cat]} + + {/each} +
+ + {#if filteredQuests.length === 0} +
+ +

No {activeTab} quests found in this category.

+
+ {:else} +
+ {#each filteredQuests as uq (uq.quest.id)} + {@const pct = Math.min(100, Math.round((uq.current_count / Math.max(1, uq.quest.target_count)) * 100))} + {@const IconComponent = categoryIcons[uq.quest.category] || Target} + +
+
+
+ +
+
+
+ {uq.quest.title} + {uq.quest.category} +
+

{uq.quest.description}

+
+
+ +
+
+ + {uq.current_count} / {uq.quest.target_count} ({pct}%) + + +{uq.quest.xp_reward} XP +
+
+
+
+
+ +
+ {#if uq.claimed} + Reward Claimed + {:else if uq.completed} + + {:else} + In Progress + {/if} +
+
+ {/each} +
+ {/if} +
+ {/if} +
+ + diff --git a/launcher/src/pages/Social.svelte b/launcher/src/pages/Social.svelte new file mode 100644 index 0000000..35ca13d --- /dev/null +++ b/launcher/src/pages/Social.svelte @@ -0,0 +1,913 @@ + + + + + + +
+

+ Choose which instance / server you want to invite {inviteTargetFriend?.username} to join: +

+ +
+ {#snippet footer()} + + + {/snippet} +
+ + diff --git a/panel/server/src/auth.rs b/panel/server/src/auth.rs index 46834bd..e576cfd 100644 --- a/panel/server/src/auth.rs +++ b/panel/server/src/auth.rs @@ -186,6 +186,13 @@ impl FromRequestParts for MaybeUser { pub struct AuthUser(pub UserRow); +impl std::ops::Deref for AuthUser { + type Target = UserRow; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + impl FromRequestParts for AuthUser { type Rejection = AppError; async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { @@ -195,6 +202,13 @@ impl FromRequestParts for AuthUser { pub struct AdminUser(pub UserRow); +impl std::ops::Deref for AdminUser { + type Target = UserRow; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + impl FromRequestParts for AdminUser { type Rejection = AppError; async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { diff --git a/panel/server/src/db.rs b/panel/server/src/db.rs index a40312b..f162003 100644 --- a/panel/server/src/db.rs +++ b/panel/server/src/db.rs @@ -232,6 +232,212 @@ const MIGRATIONS: &[&str] = &[ CREATE INDEX launcher_sessions_user ON launcher_sessions(user_id, created_at); CREATE INDEX launcher_sessions_ip ON launcher_sessions(ip, created_at); "#, + // 6: Leveling, Quests, Achievements, Guilds, Land Claims, Friends, Profiles & Social Posts + r#" + CREATE TABLE user_levels ( + uuid TEXT PRIMARY KEY, + global_xp INTEGER NOT NULL DEFAULT 0, + global_level INTEGER NOT NULL DEFAULT 1, + title TEXT, + badges TEXT NOT NULL DEFAULT '[]', + updated_at TEXT NOT NULL + ); + + CREATE TABLE server_levels ( + server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE, + uuid TEXT NOT NULL, + server_xp INTEGER NOT NULL DEFAULT 0, + server_level INTEGER NOT NULL DEFAULT 1, + rank_name TEXT, + updated_at TEXT NOT NULL, + PRIMARY KEY (server_id, uuid) + ); + + CREATE TABLE level_rewards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + level_type TEXT NOT NULL, + server_id INTEGER REFERENCES game_servers(id) ON DELETE CASCADE, + level_req INTEGER NOT NULL, + reward_type TEXT NOT NULL, + reward_name TEXT NOT NULL, + reward_data TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL + ); + + CREATE TABLE quests ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL, + period TEXT NOT NULL, + category TEXT NOT NULL, + target_stat TEXT NOT NULL, + target_count INTEGER NOT NULL, + xp_reward INTEGER NOT NULL, + icon TEXT NOT NULL DEFAULT 'star', + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL + ); + + CREATE TABLE user_quests ( + user_uuid TEXT NOT NULL, + quest_id TEXT NOT NULL REFERENCES quests(id) ON DELETE CASCADE, + period_key TEXT NOT NULL, + progress INTEGER NOT NULL DEFAULT 0, + completed INTEGER NOT NULL DEFAULT 0, + claimed INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + PRIMARY KEY (user_uuid, quest_id, period_key) + ); + CREATE INDEX user_quests_uuid ON user_quests(user_uuid, period_key); + + CREATE TABLE achievements ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'general', + icon_frame TEXT NOT NULL DEFAULT 'task', + icon_item TEXT NOT NULL DEFAULT 'diamond', + icon_bg TEXT NOT NULL DEFAULT 'stone', + icon_border TEXT NOT NULL DEFAULT 'gold', + requirement_type TEXT NOT NULL DEFAULT 'stat', + requirement_key TEXT NOT NULL, + requirement_value INTEGER NOT NULL DEFAULT 1, + xp_reward INTEGER NOT NULL DEFAULT 100, + secret INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + + CREATE TABLE user_achievements ( + user_uuid TEXT NOT NULL, + achievement_id TEXT NOT NULL REFERENCES achievements(id) ON DELETE CASCADE, + unlocked_at TEXT NOT NULL, + PRIMARY KEY (user_uuid, achievement_id) + ); + CREATE INDEX user_achievements_uuid ON user_achievements(user_uuid); + + CREATE TABLE guilds ( + id TEXT PRIMARY KEY, + instance_id TEXT NOT NULL, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, + tag TEXT NOT NULL COLLATE NOCASE, + description TEXT NOT NULL DEFAULT '', + motd TEXT NOT NULL DEFAULT '', + leader_uuid TEXT NOT NULL, + icon_url TEXT, + banner_url TEXT, + level INTEGER NOT NULL DEFAULT 1, + xp INTEGER NOT NULL DEFAULT 0, + max_claims INTEGER NOT NULL DEFAULT 16, + created_at TEXT NOT NULL + ); + CREATE INDEX guilds_instance ON guilds(instance_id); + + CREATE TABLE guild_members ( + guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE, + uuid TEXT NOT NULL, + name TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + joined_at TEXT NOT NULL, + PRIMARY KEY (guild_id, uuid) + ); + CREATE UNIQUE INDEX guild_member_unique ON guild_members(uuid); + + CREATE TABLE guild_roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE, + name TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 0, + can_invite INTEGER NOT NULL DEFAULT 0, + can_kick INTEGER NOT NULL DEFAULT 0, + can_claim INTEGER NOT NULL DEFAULT 0, + can_post INTEGER NOT NULL DEFAULT 1, + can_manage INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE guild_posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE, + author_uuid TEXT NOT NULL, + author_name TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE INDEX guild_posts_guild ON guild_posts(guild_id, id DESC); + + CREATE TABLE guild_claims ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE, + server_id INTEGER REFERENCES game_servers(id) ON DELETE CASCADE, + dimension TEXT NOT NULL DEFAULT 'minecraft:overworld', + chunk_x INTEGER NOT NULL, + chunk_z INTEGER NOT NULL, + claimed_by_uuid TEXT NOT NULL, + claimed_at TEXT NOT NULL, + UNIQUE (server_id, dimension, chunk_x, chunk_z) + ); + CREATE INDEX guild_claims_guild ON guild_claims(guild_id); + CREATE INDEX guild_claims_loc ON guild_claims(server_id, dimension, chunk_x, chunk_z); + + CREATE TABLE friendships ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_uuid TEXT NOT NULL, + friend_uuid TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + action_uuid TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (user_uuid, friend_uuid) + ); + CREATE INDEX friendships_user ON friendships(user_uuid); + CREATE INDEX friendships_friend ON friendships(friend_uuid); + + CREATE TABLE direct_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_uuid TEXT NOT NULL, + sender_name TEXT NOT NULL, + recipient_uuid TEXT NOT NULL, + content TEXT NOT NULL, + is_read INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + CREATE INDEX dm_conversation ON direct_messages(sender_uuid, recipient_uuid, id); + CREATE INDEX dm_recipient ON direct_messages(recipient_uuid, is_read); + + CREATE TABLE game_invites ( + id TEXT PRIMARY KEY, + sender_uuid TEXT NOT NULL, + sender_name TEXT NOT NULL, + recipient_uuid TEXT NOT NULL, + instance_id TEXT NOT NULL, + server_id INTEGER REFERENCES game_servers(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL + ); + CREATE INDEX game_invites_recipient ON game_invites(recipient_uuid, status); + + CREATE TABLE user_profiles ( + uuid TEXT PRIMARY KEY, + bio TEXT NOT NULL DEFAULT '', + banner_url TEXT, + custom_badge TEXT, + featured_achievement_id TEXT, + social_links TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL + ); + + CREATE TABLE user_posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_uuid TEXT NOT NULL, + author_name TEXT NOT NULL, + content TEXT NOT NULL, + image_url TEXT, + likes_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + CREATE INDEX user_posts_user ON user_posts(user_uuid, id DESC); + "#, ]; pub async fn connect(data_dir: &Path) -> Result { @@ -268,6 +474,7 @@ async fn migrate(pool: &SqlitePool) -> Result<()> { tracing::info!("applied database migration {version}"); } backfill_uuids(pool).await?; + crate::seed::seed_quests_and_achievements(pool).await?; Ok(()) } diff --git a/panel/server/src/lib.rs b/panel/server/src/lib.rs index 0f977e5..fae29b8 100644 --- a/panel/server/src/lib.rs +++ b/panel/server/src/lib.rs @@ -7,6 +7,7 @@ pub mod error; pub mod net; pub mod packs; pub mod routes; +pub mod seed; pub mod state; pub mod store; pub mod textures; diff --git a/panel/server/src/routes/achievements.rs b/panel/server/src/routes/achievements.rs new file mode 100644 index 0000000..c1ff4e8 --- /dev/null +++ b/panel/server/src/routes/achievements.rs @@ -0,0 +1,296 @@ +//! Minecraft-style Achievements system. + +use crate::auth::{AdminUser, AuthUser}; +use crate::error::AppResult; +use crate::state::AppState; +use axum::extract::{Path, State}; +use axum::Json; +use scopenet_shared::Achievement; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Get all achievements for authenticated user with their unlock status. +pub async fn get_my_achievements( + auth: AuthUser, + State(state): State, +) -> AppResult>> { + get_achievements_for_user(&state, &auth.uuid, false).await.map(Json) +} + +/// Get public unlocked achievements for a specific player profile. +pub async fn get_player_achievements( + Path(uuid): Path, + State(state): State, +) -> AppResult>> { + get_achievements_for_user(&state, &uuid, true).await.map(Json) +} + +pub async fn get_achievements_for_user( + state: &AppState, + uuid: &str, + unlocked_only: bool, +) -> AppResult> { + let rows: Vec<( + String, + String, + String, + String, + String, + String, + String, + String, + i64, + bool, + Option, + )> = sqlx::query_as( + "SELECT a.id, a.title, a.description, a.category, a.icon_frame, a.icon_item, + a.icon_bg, a.icon_border, a.xp_reward, a.secret, ua.unlocked_at + FROM achievements a + LEFT JOIN user_achievements ua ON ua.achievement_id = a.id AND ua.user_uuid = ? + ORDER BY a.category ASC, a.xp_reward ASC", + ) + .bind(uuid) + .fetch_all(&state.db) + .await?; + + let list = rows + .into_iter() + .filter_map( + |( + id, + title, + desc, + cat, + frame, + item, + bg, + border, + xp, + secret, + unlocked_at, + )| { + let unlocked = unlocked_at.is_some(); + if unlocked_only && !unlocked { + return None; + } + Some(Achievement { + id, + title, + description: desc, + category: cat, + icon_frame: frame, + icon_item: item, + icon_bg: bg, + icon_border: border, + xp_reward: xp, + secret, + unlocked, + unlocked_at, + }) + }, + ) + .collect(); + + Ok(list) +} + +// --------------------------------------------------------------------------- +// Admin Achievements API +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +pub struct AdminAchievementRow { + #[serde(flatten)] + pub achievement: Achievement, + pub requirement_type: String, + pub requirement_key: String, + pub requirement_value: i64, + pub total_unlocked: i64, +} + +/// Admin list all achievements. +pub async fn admin_list_achievements( + _admin: AdminUser, + State(state): State, +) -> AppResult>> { + let rows: Vec<( + String, + String, + String, + String, + String, + String, + String, + String, + String, + String, + i64, + i64, + bool, + i64, + )> = sqlx::query_as( + "SELECT a.id, a.title, a.description, a.category, a.icon_frame, a.icon_item, + a.icon_bg, a.icon_border, a.requirement_type, a.requirement_key, + a.requirement_value, a.xp_reward, a.secret, + COUNT(ua.user_uuid) as total_unlocked + FROM achievements a + LEFT JOIN user_achievements ua ON ua.achievement_id = a.id + GROUP BY a.id + ORDER BY a.category ASC, a.created_at DESC", + ) + .fetch_all(&state.db) + .await?; + + let list = rows + .into_iter() + .map( + |( + id, + title, + desc, + cat, + frame, + item, + bg, + border, + req_type, + req_key, + req_val, + xp, + secret, + total_unlocked, + )| { + AdminAchievementRow { + achievement: Achievement { + id, + title, + description: desc, + category: cat, + icon_frame: frame, + icon_item: item, + icon_bg: bg, + icon_border: border, + xp_reward: xp, + secret, + unlocked: false, + unlocked_at: None, + }, + requirement_type: req_type, + requirement_key: req_key, + requirement_value: req_val, + total_unlocked, + } + }, + ) + .collect(); + + Ok(Json(list)) +} + +#[derive(Deserialize)] +pub struct AchievementPayload { + pub id: Option, + pub title: String, + pub description: String, + pub category: String, + pub icon_frame: String, + pub icon_item: String, + pub icon_bg: String, + pub icon_border: String, + pub requirement_type: String, + pub requirement_key: String, + pub requirement_value: i64, + pub xp_reward: i64, + pub secret: Option, +} + +/// Admin create achievement. +pub async fn admin_create_achievement( + _admin: AdminUser, + State(state): State, + Json(payload): Json, +) -> AppResult> { + let id = payload + .id + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| format!("ach_{}", uuid::Uuid::new_v4().simple())); + let now = chrono::Utc::now().to_rfc3339(); + + 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) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(payload.title) + .bind(payload.description) + .bind(payload.category) + .bind(payload.icon_frame) + .bind(payload.icon_item) + .bind(payload.icon_bg) + .bind(payload.icon_border) + .bind(payload.requirement_type) + .bind(payload.requirement_key) + .bind(payload.requirement_value) + .bind(payload.xp_reward) + .bind(payload.secret.unwrap_or(false)) + .bind(&now) + .execute(&state.db) + .await?; + + Ok(Json(serde_json::json!({ "id": id, "ok": true }))) +} + +/// Admin update achievement. +pub async fn admin_update_achievement( + _admin: AdminUser, + Path(id): Path, + State(state): State, + Json(payload): Json, +) -> AppResult> { + sqlx::query( + "UPDATE achievements SET + title = ?, + description = ?, + category = ?, + icon_frame = ?, + icon_item = ?, + icon_bg = ?, + icon_border = ?, + requirement_type = ?, + requirement_key = ?, + requirement_value = ?, + xp_reward = ?, + secret = COALESCE(?, secret) + WHERE id = ?", + ) + .bind(payload.title) + .bind(payload.description) + .bind(payload.category) + .bind(payload.icon_frame) + .bind(payload.icon_item) + .bind(payload.icon_bg) + .bind(payload.icon_border) + .bind(payload.requirement_type) + .bind(payload.requirement_key) + .bind(payload.requirement_value) + .bind(payload.xp_reward) + .bind(payload.secret) + .bind(&id) + .execute(&state.db) + .await?; + + Ok(Json(serde_json::json!({ "ok": true }))) +} + +/// Admin delete achievement. +pub async fn admin_delete_achievement( + _admin: AdminUser, + Path(id): Path, + State(state): State, +) -> AppResult> { + sqlx::query("DELETE FROM achievements WHERE id = ?") + .bind(&id) + .execute(&state.db) + .await?; + Ok(Json(serde_json::json!({ "ok": true }))) +} diff --git a/panel/server/src/routes/guilds.rs b/panel/server/src/routes/guilds.rs new file mode 100644 index 0000000..e3162ba --- /dev/null +++ b/panel/server/src/routes/guilds.rs @@ -0,0 +1,829 @@ +//! Guilds system, in-game relay, roles, posts, and land-claiming. + +use crate::auth::AuthUser; +use crate::error::{AppError, AppResult}; +use crate::routes::servers::GameServer; +use crate::state::AppState; +use axum::extract::{Path, Query, State}; +use axum::Json; +use scopenet_shared::{Guild, GuildClaim, GuildMember, GuildPost}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Deserialize)] +pub struct InstanceQuery { + pub instance_id: Option, +} + +/// List guilds for an instance. +pub async fn list_guilds( + Query(query): Query, + State(state): State, +) -> AppResult>> { + let instance_id = query.instance_id.unwrap_or_default(); + let rows: Vec<( + String, + String, + String, + String, + String, + String, + String, + Option, + Option, + 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 + WHERE (? = '' OR g.instance_id = ?) + ORDER BY g.level DESC, member_count DESC", + ) + .bind(&instance_id) + .bind(&instance_id) + .fetch_all(&state.db) + .await?; + + let list = rows + .into_iter() + .map( + |( + id, + inst_id, + name, + tag, + desc, + motd, + leader_uuid, + icon_url, + banner_url, + level, + xp, + max_claims, + created_at, + member_count, + claims_count, + )| { + Guild { + id, + instance_id: inst_id, + name, + tag, + description: desc, + motd, + leader_uuid, + icon_url, + banner_url, + level, + xp, + max_claims, + member_count, + claims_count, + created_at, + } + }, + ) + .collect(); + + Ok(Json(list)) +} + +/// Get current user's guild for a specific instance. +pub async fn get_my_guild( + auth: AuthUser, + Query(query): Query, + State(state): State, +) -> AppResult>> { + let instance_id = query.instance_id.unwrap_or_default(); + + let guild_id: Option = sqlx::query_scalar( + "SELECT g.id FROM guilds g + JOIN guild_members gm ON gm.guild_id = g.id + WHERE gm.uuid = ? AND (? = '' OR g.instance_id = ?)", + ) + .bind(&auth.uuid) + .bind(&instance_id) + .bind(&instance_id) + .fetch_optional(&state.db) + .await?; + + let Some(gid) = guild_id else { + return Ok(Json(None)); + }; + + let detail = fetch_guild_detail(&state, &gid).await?; + Ok(Json(Some(detail))) +} + +#[derive(Serialize)] +pub struct GuildDetail { + #[serde(flatten)] + pub guild: Guild, + pub members: Vec, + pub posts: Vec, + pub claims: Vec, +} + +async fn fetch_guild_detail(state: &AppState, guild_id: &str) -> AppResult { + let row: Option<( + String, + String, + String, + String, + String, + String, + String, + Option, + Option, + i64, + i64, + i64, + String, + )> = sqlx::query_as( + "SELECT id, instance_id, name, tag, description, motd, leader_uuid, + icon_url, banner_url, level, xp, max_claims, created_at + FROM guilds WHERE id = ?", + ) + .bind(guild_id) + .fetch_optional(&state.db) + .await?; + + let ( + id, + inst_id, + name, + tag, + desc, + motd, + leader_uuid, + icon_url, + banner_url, + level, + xp, + max_claims, + created_at, + ) = row.ok_or_else(|| AppError::not_found("Guild not found"))?; + + // Members with online presence + let member_rows: Vec<(String, String, String, String, bool)> = sqlx::query_as( + "SELECT gm.uuid, gm.name, gm.role, gm.joined_at, + EXISTS(SELECT 1 FROM server_online so WHERE so.uuid = gm.uuid) as online + FROM guild_members gm + WHERE gm.guild_id = ? + ORDER BY (CASE gm.role WHEN 'leader' THEN 1 WHEN 'officer' THEN 2 ELSE 3 END) ASC, gm.joined_at ASC", + ) + .bind(guild_id) + .fetch_all(&state.db) + .await?; + + let members: Vec = member_rows + .into_iter() + .map(|(uuid, mname, role, joined_at, online)| GuildMember { + uuid, + name: mname, + role, + joined_at, + online, + }) + .collect(); + + // Posts + let post_rows: Vec<(i64, String, String, String, String, String, String)> = sqlx::query_as( + "SELECT id, guild_id, author_uuid, author_name, title, content, created_at + FROM guild_posts + WHERE guild_id = ? + ORDER BY id DESC LIMIT 20", + ) + .bind(guild_id) + .fetch_all(&state.db) + .await?; + + let posts: Vec = post_rows + .into_iter() + .map(|(pid, gid, auuid, aname, title, content, pcreated)| GuildPost { + id: pid, + guild_id: gid, + author_uuid: auuid, + author_name: aname, + title, + content, + created_at: pcreated, + }) + .collect(); + + // Claims + let claim_rows: Vec<(i64, String, i64, String, i32, i32, String, String)> = sqlx::query_as( + "SELECT id, guild_id, server_id, dimension, chunk_x, chunk_z, claimed_by_uuid, claimed_at + FROM guild_claims + WHERE guild_id = ? + LIMIT 200", + ) + .bind(guild_id) + .fetch_all(&state.db) + .await?; + + let claims: Vec = claim_rows + .into_iter() + .map( + |(cid, gid, sid, dim, cx, cz, cby, cat)| GuildClaim { + id: cid, + guild_id: gid, + guild_name: name.clone(), + guild_tag: tag.clone(), + server_id: sid, + dimension: dim, + chunk_x: cx, + chunk_z: cz, + claimed_by_uuid: cby, + claimed_at: cat, + }, + ) + .collect(); + + Ok(GuildDetail { + guild: Guild { + id, + instance_id: inst_id, + name, + tag, + description: desc, + motd, + leader_uuid, + icon_url, + banner_url, + level, + xp, + max_claims, + member_count: members.len() as i64, + claims_count: claims.len() as i64, + created_at, + }, + members, + posts, + claims, + }) +} + +/// Get guild detail by ID. +pub async fn get_guild_by_id( + Path(id): Path, + State(state): State, +) -> AppResult> { + fetch_guild_detail(&state, &id).await.map(Json) +} + +#[derive(Deserialize)] +pub struct CreateGuildPayload { + pub instance_id: String, + pub name: String, + pub tag: String, + pub description: Option, + pub motd: Option, + pub icon_url: Option, + pub banner_url: Option, +} + +/// Create a new Guild. +pub async fn create_guild( + auth: AuthUser, + State(state): State, + Json(payload): Json, +) -> AppResult> { + 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 3-32 characters")); + } + if tag.len() < 2 || tag.len() > 6 { + return Err(AppError::bad_request("Guild tag must be 2-6 characters")); + } + + // Check if user is already in a guild for this instance + let in_guild: bool = sqlx::query_scalar( + "SELECT EXISTS( + SELECT 1 FROM guild_members gm + JOIN guilds g ON g.id = gm.guild_id + WHERE gm.uuid = ? AND g.instance_id = ? + )", + ) + .bind(&auth.uuid) + .bind(&payload.instance_id) + .fetch_one(&state.db) + .await?; + + if in_guild { + return Err(AppError::bad_request("You are already in a guild for this instance")); + } + + let mut tx = state.db.begin().await?; + + let guild_id = format!("guild_{}", uuid::Uuid::new_v4().simple()); + let now = chrono::Utc::now().to_rfc3339(); + + sqlx::query( + "INSERT INTO guilds (id, instance_id, name, tag, description, motd, leader_uuid, icon_url, banner_url, level, xp, max_claims, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, 16, ?)", + ) + .bind(&guild_id) + .bind(&payload.instance_id) + .bind(name) + .bind(tag.to_ascii_uppercase()) + .bind(payload.description.unwrap_or_default()) + .bind(payload.motd.unwrap_or_default()) + .bind(&auth.uuid) + .bind(payload.icon_url) + .bind(payload.banner_url) + .bind(&now) + .execute(&mut *tx) + .await?; + + // Add leader as first member + sqlx::query( + "INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) + VALUES (?, ?, ?, 'leader', ?)", + ) + .bind(&guild_id) + .bind(&auth.uuid) + .bind(&auth.username) + .bind(&now) + .execute(&mut *tx) + .await?; + + // Record achievement event for founding a guild + sqlx::query( + "INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) + VALUES (?, 'ach_guild_initiate', ?)", + ) + .bind(&auth.uuid) + .bind(&now) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + fetch_guild_detail(&state, &guild_id).await.map(Json) +} + +#[derive(Deserialize)] +pub struct UpdateGuildPayload { + pub description: Option, + pub motd: Option, + pub icon_url: Option, + pub banner_url: Option, +} + +/// Update guild info. +pub async fn update_guild( + auth: AuthUser, + Path(id): Path, + State(state): State, + Json(payload): Json, +) -> AppResult> { + let role: Option = sqlx::query_scalar( + "SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?", + ) + .bind(&id) + .bind(&auth.uuid) + .fetch_optional(&state.db) + .await?; + + let is_officer_or_leader = role.as_deref().is_some_and(|r| r == "leader" || r == "officer"); + if !is_officer_or_leader { + return Err(AppError::forbidden("Only guild officers or leaders can update guild details")); + } + + sqlx::query( + "UPDATE guilds SET + description = COALESCE(?, description), + motd = COALESCE(?, motd), + icon_url = COALESCE(?, icon_url), + banner_url = COALESCE(?, banner_url) + WHERE id = ?", + ) + .bind(payload.description) + .bind(payload.motd) + .bind(payload.icon_url) + .bind(payload.banner_url) + .bind(&id) + .execute(&state.db) + .await?; + + Ok(Json(serde_json::json!({ "ok": true }))) +} + +#[derive(Deserialize)] +pub struct AddMemberPayload { + pub username: String, +} + +/// Add / Invite member to guild. +pub async fn add_guild_member( + auth: AuthUser, + Path(id): Path, + State(state): State, + Json(payload): Json, +) -> AppResult> { + let role: Option = sqlx::query_scalar( + "SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?", + ) + .bind(&id) + .bind(&auth.uuid) + .fetch_optional(&state.db) + .await?; + + if !role.as_deref().is_some_and(|r| r == "leader" || r == "officer") { + return Err(AppError::forbidden("Only guild leaders or officers can invite members")); + } + + let target_user: Option<(String, String)> = sqlx::query_as( + "SELECT uuid, username FROM users WHERE username = ? COLLATE NOCASE", + ) + .bind(payload.username.trim()) + .fetch_optional(&state.db) + .await?; + + let (target_uuid, target_name) = target_user.ok_or_else(|| AppError::not_found("User not found"))?; + + let now = chrono::Utc::now().to_rfc3339(); + + sqlx::query( + "INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) + VALUES (?, ?, ?, 'member', ?) + ON CONFLICT (guild_id, uuid) DO NOTHING", + ) + .bind(&id) + .bind(&target_uuid) + .bind(&target_name) + .bind(&now) + .execute(&state.db) + .await?; + + // Record achievement event for joining a guild + sqlx::query( + "INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) + VALUES (?, 'ach_guild_initiate', ?)", + ) + .bind(&target_uuid) + .bind(&now) + .execute(&state.db) + .await?; + + Ok(Json(serde_json::json!({ "ok": true }))) +} + +/// Leave or kick member. +pub async fn remove_guild_member( + auth: AuthUser, + Path((guild_id, target_uuid)): Path<(String, String)>, + State(state): State, +) -> AppResult> { + let caller_role: Option = sqlx::query_scalar( + "SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?", + ) + .bind(&guild_id) + .bind(&auth.uuid) + .fetch_optional(&state.db) + .await?; + + let is_self = auth.uuid == target_uuid; + let is_leader_or_officer = caller_role.as_deref().is_some_and(|r| r == "leader" || r == "officer"); + + if !is_self && !is_leader_or_officer { + return Err(AppError::forbidden("Cannot kick member without officer privileges")); + } + + if is_self && caller_role.as_deref() == Some("leader") { + return Err(AppError::bad_request("Guild leader cannot leave without transferring leadership")); + } + + sqlx::query("DELETE FROM guild_members WHERE guild_id = ? AND uuid = ?") + .bind(&guild_id) + .bind(&target_uuid) + .execute(&state.db) + .await?; + + Ok(Json(serde_json::json!({ "ok": true }))) +} + +// --------------------------------------------------------------------------- +// Guild Posts +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +pub struct CreatePostPayload { + pub title: String, + pub content: String, +} + +pub async fn create_guild_post( + auth: AuthUser, + Path(id): Path, + State(state): State, + Json(payload): Json, +) -> AppResult> { + let in_guild: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM guild_members WHERE guild_id = ? AND uuid = ?)", + ) + .bind(&id) + .bind(&auth.uuid) + .fetch_one(&state.db) + .await?; + + if !in_guild { + return Err(AppError::forbidden("Must be a guild member to post")); + } + + let now = chrono::Utc::now().to_rfc3339(); + let post_id: i64 = sqlx::query_scalar( + "INSERT INTO guild_posts (guild_id, author_uuid, author_name, title, content, created_at) + VALUES (?, ?, ?, ?, ?, ?) + RETURNING id", + ) + .bind(&id) + .bind(&auth.uuid) + .bind(&auth.username) + .bind(payload.title) + .bind(payload.content) + .bind(&now) + .fetch_one(&state.db) + .await?; + + Ok(Json(serde_json::json!({ "id": post_id, "ok": true }))) +} + +// --------------------------------------------------------------------------- +// Land Claiming API (FTB Chunks-style grid) +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +pub struct ClaimChunkPayload { + pub server_id: Option, + pub instance_id: Option, + pub dimension: Option, + pub chunk_x: i32, + pub chunk_z: i32, +} + +/// Claim a chunk for your guild. +pub async fn claim_chunk( + auth: AuthUser, + Path(guild_id): Path, + State(state): State, + Json(payload): Json, +) -> AppResult> { + let role: Option = sqlx::query_scalar( + "SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?", + ) + .bind(&guild_id) + .bind(&auth.uuid) + .fetch_optional(&state.db) + .await?; + + if role.is_none() { + return Err(AppError::forbidden("You are not a member of this guild")); + } + + let max_claims: i64 = sqlx::query_scalar("SELECT max_claims FROM guilds WHERE id = ?") + .bind(&guild_id) + .fetch_one(&state.db) + .await?; + + let current_claims: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ?") + .bind(&guild_id) + .fetch_one(&state.db) + .await?; + + if current_claims >= max_claims { + return Err(AppError::bad_request(format!("Guild reached its max claim limit of {max_claims} chunks"))); + } + + let dim = payload.dimension.unwrap_or_else(|| "minecraft:overworld".into()); + 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(payload.server_id) + .bind(&dim) + .bind(payload.chunk_x) + .bind(payload.chunk_z) + .bind(&auth.uuid) + .bind(&now) + .execute(&state.db) + .await; + + if let Err(e) = res { + tracing::warn!("claim chunk failed: {e}"); + return Err(AppError::bad_request("Chunk is already claimed by another guild")); + } + + let claim_id: i64 = sqlx::query_scalar("SELECT last_insert_rowid()").fetch_one(&state.db).await.unwrap_or(1); + + // Award achievement for claiming land + sqlx::query( + "INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) + VALUES (?, 'ach_land_claim', ?)", + ) + .bind(&auth.uuid) + .bind(&now) + .execute(&state.db) + .await?; + + Ok(Json(serde_json::json!({ "ok": true, "id": claim_id, "chunk_x": payload.chunk_x, "chunk_z": payload.chunk_z }))) +} + +/// Unclaim a chunk by coordinates. +pub async fn unclaim_chunk( + auth: AuthUser, + Path(guild_id): Path, + State(state): State, + Json(payload): Json, +) -> AppResult> { + let role: Option = sqlx::query_scalar( + "SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?", + ) + .bind(&guild_id) + .bind(&auth.uuid) + .fetch_optional(&state.db) + .await?; + + if role.is_none() { + return Err(AppError::forbidden("You are not a member of this guild")); + } + + let dim = payload.dimension.unwrap_or_else(|| "minecraft:overworld".into()); + + sqlx::query( + "DELETE FROM guild_claims WHERE guild_id = ? AND dimension = ? AND chunk_x = ? AND chunk_z = ?", + ) + .bind(&guild_id) + .bind(&dim) + .bind(payload.chunk_x) + .bind(payload.chunk_z) + .execute(&state.db) + .await?; + + Ok(Json(serde_json::json!({ "ok": true }))) +} + +/// Unclaim by claim ID. +pub async fn unclaim_by_id( + auth: AuthUser, + Path(claim_id): Path, + State(state): State, +) -> AppResult> { + let claim: Option<(String,)> = sqlx::query_as("SELECT guild_id FROM guild_claims WHERE id = ?") + .bind(claim_id) + .fetch_optional(&state.db) + .await?; + + let (guild_id,) = claim.ok_or_else(|| AppError::not_found("Claim not found"))?; + + let role: Option = sqlx::query_scalar( + "SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?", + ) + .bind(&guild_id) + .bind(&auth.uuid) + .fetch_optional(&state.db) + .await?; + + if role.is_none() { + return Err(AppError::forbidden("You are not a member of this 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 ChunkGridQuery { + pub server_id: i64, + pub dimension: Option, + pub center_x: i32, + pub center_z: i32, + pub radius: Option, +} + +/// Returns chunk claims in a bounding box centered around (center_x, center_z) chunks. +pub async fn get_chunk_grid( + Query(query): Query, + State(state): State, +) -> AppResult>> { + let dim = query.dimension.unwrap_or_else(|| "minecraft:overworld".into()); + let r = query.radius.unwrap_or(8).clamp(2, 24); + let min_x = query.center_x - r; + let max_x = query.center_x + r; + let min_z = query.center_z - r; + let max_z = query.center_z + r; + + let rows: Vec<(i64, String, String, String, i64, String, i32, i32, String, String)> = sqlx::query_as( + "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 + FROM guild_claims gc + JOIN guilds g ON g.id = gc.guild_id + WHERE gc.server_id = ? AND gc.dimension = ? + AND gc.chunk_x BETWEEN ? AND ? + AND gc.chunk_z BETWEEN ? AND ? + LIMIT 500", + ) + .bind(query.server_id) + .bind(&dim) + .bind(min_x) + .bind(max_x) + .bind(min_z) + .bind(max_z) + .fetch_all(&state.db) + .await?; + + let list = rows + .into_iter() + .map( + |(id, gid, gname, gtag, sid, gdim, cx, cz, cby, cat)| GuildClaim { + id, + guild_id: gid, + guild_name: gname, + guild_tag: gtag, + server_id: sid, + dimension: gdim, + chunk_x: cx, + chunk_z: cz, + claimed_by_uuid: cby, + claimed_at: cat, + }, + ) + .collect(); + + Ok(Json(list)) +} + +// --------------------------------------------------------------------------- +// Server Integration / Plugin In-Game Land Claim Relay +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +pub struct ServerCheckChunkPayload { + pub uuid: String, + pub dimension: String, + pub chunk_x: i32, + pub chunk_z: i32, +} + +/// Token-authenticated check called by the Minecraft server plugin/mod to +/// verify if a player can build/break in a chunk. +pub async fn server_check_chunk( + GameServer(server): GameServer, + State(state): State, + Json(payload): Json, +) -> AppResult> { + let claim: Option<(String, String, String)> = sqlx::query_as( + "SELECT gc.guild_id, g.name, g.tag + FROM guild_claims gc + JOIN guilds g ON g.id = gc.guild_id + WHERE gc.server_id = ? AND gc.dimension = ? AND gc.chunk_x = ? AND gc.chunk_z = ?", + ) + .bind(server.id) + .bind(&payload.dimension) + .bind(payload.chunk_x) + .bind(payload.chunk_z) + .fetch_optional(&state.db) + .await?; + + let Some((guild_id, guild_name, guild_tag)) = claim else { + // Wilderness / unclaimed land is allowed + return Ok(Json(serde_json::json!({ + "claimed": false, + "allowed": true + }))); + }; + + // Check if player is a member of this guild + let is_member: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM guild_members WHERE guild_id = ? AND uuid = ?)", + ) + .bind(&guild_id) + .bind(&payload.uuid) + .fetch_one(&state.db) + .await?; + + Ok(Json(serde_json::json!({ + "claimed": true, + "guild_id": guild_id, + "guild_name": guild_name, + "guild_tag": guild_tag, + "allowed": is_member, + "message": if is_member { None } else { Some(format!("This land is claimed by [{guild_tag}] {guild_name}")) } + }))) +} diff --git a/panel/server/src/routes/leveling.rs b/panel/server/src/routes/leveling.rs new file mode 100644 index 0000000..afc1992 --- /dev/null +++ b/panel/server/src/routes/leveling.rs @@ -0,0 +1,235 @@ +//! Leveling system (Global Level & Server-Specific Levels), rewards, and progression. + +use crate::auth::{AdminUser, AuthUser}; +use crate::error::AppResult; +use crate::state::AppState; +use axum::extract::{Path, State}; +use axum::Json; +use scopenet_shared::{ServerLevelInfo, UserLevelInfo}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// XP required to reach level L. +pub fn xp_for_level(lvl: i64) -> i64 { + if lvl <= 1 { + return 0; + } + ((lvl as f64).powf(1.5) * 100.0).round() as i64 +} + +/// Computes (level, current_level_xp, next_level_span, progress_pct) from total XP. +pub fn level_from_xp(xp: i64) -> (i64, i64, i64, f64) { + if xp <= 0 { + return (1, 0, 100, 0.0); + } + let mut lvl = 1; + while xp >= xp_for_level(lvl + 1) { + lvl += 1; + } + let curr_req = xp_for_level(lvl); + let next_req = xp_for_level(lvl + 1); + let span = (next_req - curr_req).max(1); + let progress_in_lvl = (xp - curr_req).max(0); + let pct = (progress_in_lvl as f64 / span as f64 * 100.0).clamp(0.0, 100.0); + (lvl, progress_in_lvl, span, pct) +} + +#[derive(Serialize, Deserialize)] +pub struct RewardRow { + pub id: i64, + pub level_type: String, + pub server_id: Option, + pub level_req: i64, + pub reward_type: String, + pub reward_name: String, + pub reward_data: Value, + pub created_at: String, +} + +/// Get current user's global and server levels. +pub async fn get_my_levels( + auth: AuthUser, + State(state): State, +) -> AppResult> { + get_user_levels_by_uuid(&state, &auth.uuid).await.map(Json) +} + +/// Public / Profile lookup for player levels. +pub async fn get_player_levels( + Path(uuid): Path, + State(state): State, +) -> AppResult> { + get_user_levels_by_uuid(&state, &uuid).await.map(Json) +} + +pub async fn get_user_levels_by_uuid(state: &AppState, uuid: &str) -> AppResult { + let row: Option<(i64, i64, Option, String)> = sqlx::query_as( + "SELECT global_xp, global_level, title, badges FROM user_levels WHERE uuid = ?", + ) + .bind(uuid) + .fetch_optional(&state.db) + .await?; + + let (global_xp, _db_lvl, title, badges_json) = row.unwrap_or((0, 1, None, "[]".into())); + let (global_level, current_level_xp, next_level_xp, progress_pct) = level_from_xp(global_xp); + let badges: Vec = serde_json::from_str(&badges_json).unwrap_or_default(); + + // Query server levels for this user + let server_rows: Vec<(i64, String, i64, i64, Option)> = sqlx::query_as( + "SELECT sl.server_id, gs.name, sl.server_xp, sl.server_level, sl.rank_name + FROM server_levels sl + JOIN game_servers gs ON gs.id = sl.server_id + WHERE sl.uuid = ? + ORDER BY sl.server_xp DESC", + ) + .bind(uuid) + .fetch_all(&state.db) + .await?; + + let server_levels = server_rows + .into_iter() + .map(|(s_id, s_name, s_xp, _s_lvl, r_name)| { + let (sl, cur, next, pct) = level_from_xp(s_xp); + ServerLevelInfo { + server_id: s_id, + server_name: s_name, + server_xp: s_xp, + server_level: sl, + current_level_xp: cur, + next_level_xp: next, + progress_pct: pct, + rank_name: r_name, + } + }) + .collect(); + + Ok(UserLevelInfo { + uuid: uuid.to_string(), + global_xp, + global_level, + current_level_xp, + next_level_xp, + progress_pct, + title, + badges, + server_levels, + }) +} + +/// Global leveling leaderboard. +pub async fn get_level_leaderboard( + State(state): State, +) -> AppResult>> { + let rows: Vec<(String, String, i64, i64, Option)> = sqlx::query_as( + "SELECT u.uuid, u.username, ul.global_xp, ul.global_level, ul.title + FROM user_levels ul + JOIN users u ON u.uuid = ul.uuid + ORDER BY ul.global_xp DESC + LIMIT 50", + ) + .fetch_all(&state.db) + .await?; + + let list = rows + .into_iter() + .enumerate() + .map(|(rank, (uuid, username, xp, _, title))| { + let (level, cur, next, pct) = level_from_xp(xp); + serde_json::json!({ + "rank": rank + 1, + "uuid": uuid, + "username": username, + "global_level": level, + "global_xp": xp, + "current_level_xp": cur, + "next_level_xp": next, + "progress_pct": pct, + "title": title + }) + }) + .collect(); + + Ok(Json(list)) +} + +/// Get available rewards. +pub async fn list_rewards( + State(state): State, +) -> AppResult>> { + let rows: Vec<(i64, String, Option, i64, String, String, String, String)> = sqlx::query_as( + "SELECT id, level_type, server_id, level_req, reward_type, reward_name, reward_data, created_at + FROM level_rewards + ORDER BY level_req ASC", + ) + .fetch_all(&state.db) + .await?; + + let list = rows + .into_iter() + .map(|(id, ltype, sid, req, rtype, name, data, created)| { + let parsed_data: Value = serde_json::from_str(&data).unwrap_or_default(); + serde_json::json!({ + "id": id, + "level_type": ltype, + "server_id": sid, + "level_req": req, + "reward_type": rtype, + "reward_name": name, + "reward_data": parsed_data, + "created_at": created + }) + }) + .collect(); + + Ok(Json(list)) +} + +#[derive(Deserialize)] +pub struct CreateRewardPayload { + pub level_type: String, + pub server_id: Option, + pub level_req: i64, + pub reward_type: String, + pub reward_name: String, + pub reward_data: Option, +} + +/// Admin create reward. +pub async fn admin_create_reward( + _admin: AdminUser, + State(state): State, + Json(payload): Json, +) -> AppResult> { + let now = chrono::Utc::now().to_rfc3339(); + let data_str = serde_json::to_string(&payload.reward_data.unwrap_or_default()).unwrap_or_default(); + + let id: i64 = sqlx::query_scalar( + "INSERT INTO level_rewards (level_type, server_id, level_req, reward_type, reward_name, reward_data, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + RETURNING id", + ) + .bind(payload.level_type) + .bind(payload.server_id) + .bind(payload.level_req) + .bind(payload.reward_type) + .bind(payload.reward_name) + .bind(data_str) + .bind(&now) + .fetch_one(&state.db) + .await?; + + Ok(Json(serde_json::json!({ "id": id, "ok": true }))) +} + +/// Admin delete reward. +pub async fn admin_delete_reward( + _admin: AdminUser, + Path(id): Path, + State(state): State, +) -> AppResult> { + sqlx::query("DELETE FROM level_rewards WHERE id = ?") + .bind(id) + .execute(&state.db) + .await?; + Ok(Json(serde_json::json!({ "ok": true }))) +} diff --git a/panel/server/src/routes/mod.rs b/panel/server/src/routes/mod.rs index a4efa37..5dc1be7 100644 --- a/panel/server/src/routes/mod.rs +++ b/panel/server/src/routes/mod.rs @@ -1,14 +1,19 @@ pub mod account; +pub mod achievements; pub mod activity; pub mod admin; +pub mod guilds; pub mod landing; +pub mod leveling; pub mod meta; pub mod public; +pub mod quests; pub mod servers; +pub mod social; use crate::state::AppState; use axum::extract::DefaultBodyLimit; -use axum::routing::{delete, get, post}; +use axum::routing::{delete, get, post, put}; use axum::Router; pub fn api(state: &AppState) -> Router { @@ -34,6 +39,54 @@ pub fn api(state: &AppState) -> Router { .route("/landing", get(landing::public_landing)) .route("/launcher/authlib-injector.json", get(account::authlib_index)) .route("/launcher/authlib-injector.jar", get(account::authlib_jar)) + // Leveling & Rewards + .route("/levels/me", get(leveling::get_my_levels)) + .route("/leveling/me", get(leveling::get_my_levels)) + .route("/levels/player/{uuid}", get(leveling::get_player_levels)) + .route("/levels/leaderboard", get(leveling::get_level_leaderboard)) + .route("/levels/rewards", get(leveling::list_rewards)) + // Daily & Weekly Quests + .route("/quests/my", get(quests::get_my_quests)) + .route("/quests/me", get(quests::get_my_quests)) + .route("/quests/{id}/claim", post(quests::claim_quest)) + // Achievements + .route("/achievements/my", get(achievements::get_my_achievements)) + .route("/achievements/me", get(achievements::get_my_achievements)) + .route("/achievements/player/{uuid}", get(achievements::get_player_achievements)) + // Guilds & Land Claims + .route("/guilds", get(guilds::list_guilds).post(guilds::create_guild)) + .route("/guilds/my", get(guilds::get_my_guild)) + .route("/guilds/claims/grid", get(guilds::get_chunk_grid)) + .route("/guilds/claims/{id}", delete(guilds::unclaim_by_id)) + .route("/guilds/{id}", get(guilds::get_guild_by_id).put(guilds::update_guild)) + .route("/guilds/{id}/members", post(guilds::add_guild_member)) + .route("/guilds/{id}/members/{uuid}", delete(guilds::remove_guild_member)) + .route("/guilds/{id}/posts", post(guilds::create_guild_post)) + .route("/guilds/{id}/claims", post(guilds::claim_chunk).delete(guilds::unclaim_chunk)) + .route("/guilds/{id}/claim", post(guilds::claim_chunk)) + .route("/guilds/{id}/unclaim", post(guilds::unclaim_chunk)) + // Social: Friends, DMs, Invites, Profiles, Posts + .route("/friends", get(social::list_friends)) + .route("/friends/request", post(social::send_friend_request)) + .route("/friends/respond", post(social::respond_friend_request)) + .route("/friends/{uuid}/accept", post(social::accept_friend_request)) + .route("/friends/{uuid}", delete(social::remove_friend)) + .route("/messages/{uuid}", get(social::get_direct_messages).post(social::send_direct_message)) + .route("/invites", get(social::list_my_game_invites).post(social::send_game_invite)) + .route("/invites/{id}/respond", post(social::respond_game_invite)) + .route("/profiles/me", put(social::update_my_profile)) + .route("/profiles/{uuid}", get(social::get_player_profile)) + .route("/profiles/me/posts", post(social::create_user_post)) + .route("/profiles/me/posts/{id}", delete(social::delete_user_post)) + .route("/posts/{id}/like", post(social::like_user_post)) + .route("/social/friends", get(social::list_friends)) + .route("/social/friends/request", post(social::send_friend_request)) + .route("/social/friends/respond", post(social::respond_friend_request)) + .route("/social/messages/{uuid}", get(social::get_direct_messages).post(social::send_direct_message)) + .route("/social/profile/me", put(social::update_my_profile)) + .route("/social/profile/{uuid}", get(social::get_player_profile)) + .route("/social/posts", post(social::create_user_post)) + .route("/social/posts/{id}/like", post(social::like_user_post)) .layer(DefaultBodyLimit::max(4 * 1024 * 1024)); let admin = Router::new() @@ -71,6 +124,13 @@ pub fn api(state: &AppState) -> Router { .route("/modrinth/project/{id}/versions", get(meta::modrinth_versions)) .route("/curseforge/search", get(meta::curseforge_search)) .route("/curseforge/mod/{id}/files", get(meta::curseforge_files)) + // Admin Quests & Achievements & Rewards + .route("/rewards", post(leveling::admin_create_reward)) + .route("/rewards/{id}", delete(leveling::admin_delete_reward)) + .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("/achievements", get(achievements::admin_list_achievements).post(achievements::admin_create_achievement)) + .route("/achievements/{id}", put(achievements::admin_update_achievement).delete(achievements::admin_delete_achievement)) .layer(DefaultBodyLimit::max(upload_limit)); // Called by the SCOPENET Paper plugin and Fabric/Forge mods. @@ -78,6 +138,7 @@ pub fn api(state: &AppState) -> Router { .route("/hello", post(servers::hello)) .route("/login", post(servers::login)) .route("/sync", post(servers::sync)) + .route("/guilds/check-chunk", post(guilds::server_check_chunk)) .layer(DefaultBodyLimit::max(2 * 1024 * 1024)); Router::new() diff --git a/panel/server/src/routes/quests.rs b/panel/server/src/routes/quests.rs new file mode 100644 index 0000000..3e5d5c8 --- /dev/null +++ b/panel/server/src/routes/quests.rs @@ -0,0 +1,373 @@ +//! Daily and Weekly Quests system. + +use crate::auth::{AdminUser, AuthUser}; +use crate::error::{AppError, AppResult}; +use crate::state::AppState; +use axum::extract::{Path, State}; +use axum::Json; +use scopenet_shared::{Quest, UserQuest}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub fn current_daily_key() -> String { + chrono::Utc::now().format("%Y-%m-%d").to_string() +} + +pub fn current_weekly_key() -> String { + chrono::Utc::now().format("%Y-W%V").to_string() +} + +/// Get all active daily and weekly quests for the authenticated user with live progress. +pub async fn get_my_quests( + auth: AuthUser, + State(state): State, +) -> AppResult>> { + let daily_key = current_daily_key(); + let weekly_key = current_weekly_key(); + + let rows: Vec<( + String, + String, + String, + String, + String, + String, + i64, + i64, + String, + bool, + Option, + Option, + Option, + Option, + )> = sqlx::query_as( + "SELECT q.id, q.title, q.description, q.period, q.category, q.target_stat, + q.target_count, q.xp_reward, q.icon, q.enabled, + uq.progress, uq.completed, uq.claimed, uq.period_key + FROM quests q + LEFT JOIN user_quests uq ON uq.quest_id = q.id + AND uq.user_uuid = ? + AND uq.period_key = (CASE WHEN q.period = 'weekly' THEN ? ELSE ? END) + WHERE q.enabled = 1 + ORDER BY q.period ASC, q.xp_reward ASC", + ) + .bind(&auth.uuid) + .bind(&weekly_key) + .bind(&daily_key) + .fetch_all(&state.db) + .await?; + + let list = rows + .into_iter() + .map( + |( + id, + title, + desc, + period, + cat, + stat, + target, + xp, + icon, + enabled, + progress_opt, + completed_opt, + claimed_opt, + period_key_opt, + )| { + let progress = progress_opt.unwrap_or(0); + let completed = completed_opt.unwrap_or(progress >= target); + let claimed = claimed_opt.unwrap_or(false); + let pkey = period_key_opt.unwrap_or_else(|| { + if period == "weekly" { + weekly_key.clone() + } else { + daily_key.clone() + } + }); + + UserQuest { + quest: Quest { + id, + title, + description: desc, + period, + category: cat, + target_stat: stat, + target_count: target, + xp_reward: xp, + icon, + enabled, + }, + progress, + completed, + claimed, + period_key: pkey, + } + }, + ) + .collect(); + + Ok(Json(list)) +} + +/// Claim a completed quest reward. +pub async fn claim_quest( + auth: AuthUser, + Path(quest_id): Path, + State(state): State, +) -> AppResult> { + let mut tx = state.db.begin().await?; + + let quest: Option<(String, i64, i64)> = sqlx::query_as( + "SELECT period, target_count, xp_reward FROM quests WHERE id = ? AND enabled = 1", + ) + .bind(&quest_id) + .fetch_optional(&mut *tx) + .await?; + + let (period, target_count, xp_reward) = + quest.ok_or_else(|| AppError::bad_request("Quest not found or disabled"))?; + + let period_key = if period == "weekly" { + current_weekly_key() + } else { + current_daily_key() + }; + + let user_quest: Option<(i64, bool, bool)> = sqlx::query_as( + "SELECT progress, completed, claimed FROM user_quests WHERE user_uuid = ? AND quest_id = ? AND period_key = ?", + ) + .bind(&auth.uuid) + .bind(&quest_id) + .bind(&period_key) + .fetch_optional(&mut *tx) + .await?; + + let (progress, completed, claimed) = user_quest.unwrap_or((0, false, false)); + + if claimed { + return Err(AppError::bad_request("Quest reward already claimed for this period")); + } + if !completed && progress < target_count { + return Err(AppError::bad_request("Quest is not yet completed")); + } + + let now = chrono::Utc::now().to_rfc3339(); + + // Mark as completed & claimed + sqlx::query( + "INSERT INTO user_quests (user_uuid, quest_id, period_key, progress, completed, claimed, updated_at) + VALUES (?, ?, ?, ?, 1, 1, ?) + ON CONFLICT (user_uuid, quest_id, period_key) DO UPDATE SET + completed = 1, + claimed = 1, + updated_at = excluded.updated_at", + ) + .bind(&auth.uuid) + .bind(&quest_id) + .bind(&period_key) + .bind(progress.max(target_count)) + .bind(&now) + .execute(&mut *tx) + .await?; + + // Grant XP to user_levels + sqlx::query( + "INSERT INTO user_levels (uuid, global_xp, global_level, updated_at) + VALUES (?, ?, 1, ?) + ON CONFLICT (uuid) DO UPDATE SET + global_xp = global_xp + excluded.global_xp, + updated_at = excluded.updated_at", + ) + .bind(&auth.uuid) + .bind(xp_reward) + .bind(&now) + .execute(&mut *tx) + .await?; + + // Read new total XP & compute new level + let new_xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?") + .bind(&auth.uuid) + .fetch_one(&mut *tx) + .await?; + + let (new_lvl, _, _, _) = crate::routes::leveling::level_from_xp(new_xp); + sqlx::query("UPDATE user_levels SET global_level = ? WHERE uuid = ?") + .bind(new_lvl) + .bind(&auth.uuid) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(Json(serde_json::json!({ + "ok": true, + "xp_granted": xp_reward, + "new_global_xp": new_xp, + "new_global_level": new_lvl + }))) +} + +// --------------------------------------------------------------------------- +// Admin Quests API +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +pub struct AdminQuestRow { + #[serde(flatten)] + pub quest: Quest, + pub total_completions: i64, +} + +/// Admin list all quests. +pub async fn admin_list_quests( + _admin: AdminUser, + State(state): State, +) -> AppResult>> { + let rows: Vec<( + String, + String, + String, + String, + String, + String, + i64, + i64, + String, + bool, + i64, + )> = sqlx::query_as( + "SELECT q.id, q.title, q.description, q.period, q.category, q.target_stat, + q.target_count, q.xp_reward, q.icon, q.enabled, + COUNT(uq.user_uuid) as total_completions + FROM quests q + LEFT JOIN user_quests uq ON uq.quest_id = q.id AND uq.completed = 1 + GROUP BY q.id + ORDER BY q.period ASC, q.created_at DESC", + ) + .fetch_all(&state.db) + .await?; + + let list = rows + .into_iter() + .map( + |(id, title, desc, period, cat, stat, target, xp, icon, enabled, completions)| { + AdminQuestRow { + quest: Quest { + id, + title, + description: desc, + period, + category: cat, + target_stat: stat, + target_count: target, + xp_reward: xp, + icon, + enabled, + }, + total_completions: completions, + } + }, + ) + .collect(); + + Ok(Json(list)) +} + +#[derive(Deserialize)] +pub struct QuestPayload { + pub id: Option, + pub title: String, + pub description: String, + pub period: String, + pub category: String, + pub target_stat: String, + pub target_count: i64, + pub xp_reward: i64, + pub icon: Option, + pub enabled: Option, +} + +/// Admin create quest. +pub async fn admin_create_quest( + _admin: AdminUser, + State(state): State, + Json(payload): Json, +) -> AppResult> { + let id = payload + .id + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| format!("q_{}_{}", payload.period, uuid::Uuid::new_v4().simple())); + let now = chrono::Utc::now().to_rfc3339(); + + sqlx::query( + "INSERT INTO quests (id, title, description, period, category, target_stat, target_count, xp_reward, icon, enabled, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(payload.title) + .bind(payload.description) + .bind(payload.period) + .bind(payload.category) + .bind(payload.target_stat) + .bind(payload.target_count) + .bind(payload.xp_reward) + .bind(payload.icon.unwrap_or_else(|| "star".into())) + .bind(payload.enabled.unwrap_or(true)) + .bind(&now) + .execute(&state.db) + .await?; + + Ok(Json(serde_json::json!({ "id": id, "ok": true }))) +} + +/// Admin update quest. +pub async fn admin_update_quest( + _admin: AdminUser, + Path(id): Path, + State(state): State, + Json(payload): Json, +) -> AppResult> { + sqlx::query( + "UPDATE quests SET + title = ?, + description = ?, + period = ?, + category = ?, + target_stat = ?, + target_count = ?, + xp_reward = ?, + icon = COALESCE(?, icon), + enabled = COALESCE(?, enabled) + WHERE id = ?", + ) + .bind(payload.title) + .bind(payload.description) + .bind(payload.period) + .bind(payload.category) + .bind(payload.target_stat) + .bind(payload.target_count) + .bind(payload.xp_reward) + .bind(payload.icon) + .bind(payload.enabled) + .bind(&id) + .execute(&state.db) + .await?; + + Ok(Json(serde_json::json!({ "ok": true }))) +} + +/// Admin delete quest. +pub async fn admin_delete_quest( + _admin: AdminUser, + Path(id): Path, + State(state): State, +) -> AppResult> { + sqlx::query("DELETE FROM quests WHERE id = ?") + .bind(&id) + .execute(&state.db) + .await?; + Ok(Json(serde_json::json!({ "ok": true }))) +} diff --git a/panel/server/src/routes/servers.rs b/panel/server/src/routes/servers.rs index de2d6d7..04eb86c 100644 --- a/panel/server/src/routes/servers.rs +++ b/panel/server/src/routes/servers.rs @@ -395,6 +395,19 @@ pub struct GameEvent { at: Option, } +#[derive(Deserialize, Default, Clone, Debug)] +#[serde(default)] +pub struct FeatureToggles { + pub leveling_enabled: Option, + pub global_xp_multiplier: Option, + pub server_xp_multiplier: Option, + pub quests_enabled: Option, + pub achievements_enabled: Option, + pub guilds_enabled: Option, + pub land_claiming_enabled: Option, + pub social_enabled: Option, +} + #[derive(Deserialize, Default)] #[serde(default)] pub struct Sync { @@ -404,6 +417,7 @@ pub struct Sync { online: Vec, stats: Vec, events: Vec, + features: Option, } /// Periodic heartbeat: who's online, TPS, stat deltas and notable events. @@ -490,8 +504,12 @@ pub async fn sync(GameServer(server): GameServer, State(state): State, .bind(&at_now) .execute(&mut *tx) .await?; + + update_progression_for_delta(&mut tx, server.id, &uuid, d, &at_now, s.features.as_ref()).await?; } + let achievements_on = s.features.as_ref().and_then(|f| f.achievements_enabled).unwrap_or(true); + for e in s.events.iter().filter(|_| fresh).take(MAX_EVENTS_PER_SYNC) { let kind = clip(&e.kind, 24).to_ascii_lowercase(); if kind.is_empty() { @@ -506,11 +524,34 @@ pub async fn sync(GameServer(server): GameServer, State(state): State, .bind(server.id) .bind(e.uuid.as_deref().and_then(dashed)) .bind(e.name.as_deref().map(|n| clip(n, 16))) - .bind(kind) + .bind(&kind) .bind(e.detail.as_deref().map(|d| clip(d, 256))) .bind(at) .execute(&mut *tx) .await?; + + // Check if event unlocks an achievement (if achievements enabled) + if achievements_on { + if let Some(ref euuid) = e.uuid.as_deref().and_then(dashed) { + let event_ach: Vec = sqlx::query_scalar( + "SELECT id FROM achievements WHERE requirement_type = 'event' AND requirement_key = ?", + ) + .bind(&kind) + .fetch_all(&mut *tx) + .await?; + + for ach_id in event_ach { + sqlx::query( + "INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, ?)", + ) + .bind(euuid) + .bind(ach_id) + .bind(&at_now) + .execute(&mut *tx) + .await?; + } + } + } } tx.commit().await?; @@ -533,6 +574,241 @@ pub async fn sync(GameServer(server): GameServer, State(state): State, Ok(Json(json!({ "ok": true, "kick": kick, "sync_interval_secs": SYNC_INTERVAL_SECS }))) } +async fn update_progression_for_delta( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + server_id: i64, + uuid: &str, + d: &StatDelta, + at_now: &str, + features: Option<&FeatureToggles>, +) -> AppResult<()> { + let leveling_on = features.and_then(|f| f.leveling_enabled).unwrap_or(true); + let quests_on = features.and_then(|f| f.quests_enabled).unwrap_or(true); + let achievements_on = features.and_then(|f| f.achievements_enabled).unwrap_or(true); + + let global_mult = features.and_then(|f| f.global_xp_multiplier).unwrap_or(1.0).max(0.0); + let server_mult = features.and_then(|f| f.server_xp_multiplier).unwrap_or(1.5).max(0.0); + + // 1. Calculate XP gains: Global XP and Server XP (only if leveling enabled) + if leveling_on { + let base_xp = (d.playtime_secs / 6) + + (d.player_kills * 50) + + (d.mob_kills * 15) + + (d.blocks_broken / 5) + + (d.blocks_placed / 5) + + (d.messages * 2); + + let delta_xp = (base_xp as f64 * global_mult).round() as i64; + let server_delta_xp = (base_xp as f64 * server_mult).round() as i64; + + if delta_xp > 0 { + sqlx::query( + "INSERT INTO user_levels (uuid, global_xp, global_level, updated_at) + VALUES (?, ?, 1, ?) + ON CONFLICT (uuid) DO UPDATE SET + global_xp = global_xp + excluded.global_xp, + updated_at = excluded.updated_at", + ) + .bind(uuid) + .bind(delta_xp) + .bind(at_now) + .execute(&mut **tx) + .await?; + + // Recompute global level + let new_xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?") + .bind(uuid) + .fetch_one(&mut **tx) + .await?; + let (new_lvl, _, _, _) = crate::routes::leveling::level_from_xp(new_xp); + sqlx::query("UPDATE user_levels SET global_level = ? WHERE uuid = ?") + .bind(new_lvl) + .bind(uuid) + .execute(&mut **tx) + .await?; + } + + if server_delta_xp > 0 { + sqlx::query( + "INSERT INTO server_levels (server_id, uuid, server_xp, server_level, updated_at) + VALUES (?, ?, ?, 1, ?) + ON CONFLICT (server_id, uuid) DO UPDATE SET + server_xp = server_xp + excluded.server_xp, + updated_at = excluded.updated_at", + ) + .bind(server_id) + .bind(uuid) + .bind(server_delta_xp) + .bind(at_now) + .execute(&mut **tx) + .await?; + + // Recompute server level + let new_s_xp: i64 = sqlx::query_scalar("SELECT server_xp FROM server_levels WHERE server_id = ? AND uuid = ?") + .bind(server_id) + .bind(uuid) + .fetch_one(&mut **tx) + .await?; + let (new_s_lvl, _, _, _) = crate::routes::leveling::level_from_xp(new_s_xp); + sqlx::query("UPDATE server_levels SET server_level = ? WHERE server_id = ? AND uuid = ?") + .bind(new_s_lvl) + .bind(server_id) + .bind(uuid) + .execute(&mut **tx) + .await?; + } + } + + // 2. Advance active Daily & Weekly Quests (if quests enabled) + if quests_on { + let stat_deltas = [ + ("playtime_secs", d.playtime_secs), + ("joins", d.joins), + ("player_kills", d.player_kills), + ("mob_kills", d.mob_kills), + ("blocks_broken", d.blocks_broken), + ("blocks_placed", d.blocks_placed), + ("messages", d.messages), + ]; + + let daily_key = crate::routes::quests::current_daily_key(); + let weekly_key = crate::routes::quests::current_weekly_key(); + + for (stat_name, amount) in stat_deltas { + if amount <= 0 { + continue; + } + + // Daily quests matching this stat + let d_quests: Vec<(String, i64)> = sqlx::query_as( + "SELECT id, target_count FROM quests WHERE enabled = 1 AND period = 'daily' AND target_stat = ?", + ) + .bind(stat_name) + .fetch_all(&mut **tx) + .await?; + + for (qid, target) in d_quests { + sqlx::query( + "INSERT INTO user_quests (user_uuid, quest_id, period_key, progress, completed, claimed, updated_at) + VALUES (?, ?, ?, ?, ?, 0, ?) + ON CONFLICT (user_uuid, quest_id, period_key) DO UPDATE SET + progress = progress + excluded.progress, + completed = CASE WHEN (progress + excluded.progress) >= ? THEN 1 ELSE completed END, + updated_at = excluded.updated_at", + ) + .bind(uuid) + .bind(&qid) + .bind(&daily_key) + .bind(amount) + .bind(if amount >= target { 1 } else { 0 }) + .bind(at_now) + .bind(target) + .execute(&mut **tx) + .await?; + } + + // Weekly quests matching this stat + let w_quests: Vec<(String, i64)> = sqlx::query_as( + "SELECT id, target_count FROM quests WHERE enabled = 1 AND period = 'weekly' AND target_stat = ?", + ) + .bind(stat_name) + .fetch_all(&mut **tx) + .await?; + + for (qid, target) in w_quests { + sqlx::query( + "INSERT INTO user_quests (user_uuid, quest_id, period_key, progress, completed, claimed, updated_at) + VALUES (?, ?, ?, ?, ?, 0, ?) + ON CONFLICT (user_uuid, quest_id, period_key) DO UPDATE SET + progress = progress + excluded.progress, + completed = CASE WHEN (progress + excluded.progress) >= ? THEN 1 ELSE completed END, + updated_at = excluded.updated_at", + ) + .bind(uuid) + .bind(&qid) + .bind(&weekly_key) + .bind(amount) + .bind(if amount >= target { 1 } else { 0 }) + .bind(at_now) + .bind(target) + .execute(&mut **tx) + .await?; + } + } + } + + // 3. Milestone Achievements Check (if achievements enabled) + if achievements_on { + let total_stats: Option<(Option, Option, Option, Option, Option, Option)> = sqlx::query_as( + "SELECT SUM(playtime_secs), SUM(player_kills), SUM(mob_kills), SUM(blocks_broken), SUM(blocks_placed), SUM(messages) + FROM player_stats WHERE uuid = ?", + ) + .bind(uuid) + .fetch_optional(&mut **tx) + .await?; + + if let Some((playtime, pkills, mkills, bbroken, bplaced, msgs)) = total_stats { + let checks = [ + ("blocks_broken", bbroken.unwrap_or(0)), + ("blocks_placed", bplaced.unwrap_or(0)), + ("player_kills", pkills.unwrap_or(0)), + ("mob_kills", mkills.unwrap_or(0)), + ("playtime_secs", playtime.unwrap_or(0)), + ("messages", msgs.unwrap_or(0)), + ]; + + for (key, val) in checks { + let eligible: Vec = sqlx::query_scalar( + "SELECT id FROM achievements WHERE requirement_type = 'stat' AND requirement_key = ? AND requirement_value <= ?", + ) + .bind(key) + .bind(val) + .fetch_all(&mut **tx) + .await?; + + for ach_id in eligible { + sqlx::query( + "INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, ?)", + ) + .bind(uuid) + .bind(ach_id) + .bind(at_now) + .execute(&mut **tx) + .await?; + } + } + } + } + + // Level milestone achievements + let global_level: Option = sqlx::query_scalar("SELECT global_level FROM user_levels WHERE uuid = ?") + .bind(uuid) + .fetch_optional(&mut **tx) + .await?; + + if let Some(lvl) = global_level { + let lvl_ach: Vec = sqlx::query_scalar( + "SELECT id FROM achievements WHERE requirement_type = 'level' AND requirement_value <= ?", + ) + .bind(lvl) + .fetch_all(&mut **tx) + .await?; + + for ach_id in lvl_ach { + sqlx::query( + "INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, ?)", + ) + .bind(uuid) + .bind(ach_id) + .bind(at_now) + .execute(&mut **tx) + .await?; + } + } + + Ok(()) +} + // --------------------------------------------------------------------------- // Admin API // --------------------------------------------------------------------------- diff --git a/panel/server/src/routes/social.rs b/panel/server/src/routes/social.rs new file mode 100644 index 0000000..1424f44 --- /dev/null +++ b/panel/server/src/routes/social.rs @@ -0,0 +1,623 @@ +//! Friends, Direct Messages, Game Invites, Player Profiles, and Social Feed. + +use crate::auth::AuthUser; +use crate::error::{AppError, AppResult}; +use crate::state::AppState; +use axum::extract::{Path, State}; +use axum::Json; +use scopenet_shared::{DirectMessage, FriendInfo, GameInvite, UserPost, UserProfileView}; +use serde::Deserialize; +use serde_json::Value; + +// --------------------------------------------------------------------------- +// Friends System +// --------------------------------------------------------------------------- + +pub async fn list_friends( + auth: AuthUser, + State(state): State, +) -> AppResult>> { + let rows: Vec<(String, String, String, String, bool, Option)> = sqlx::query_as( + "SELECT + (CASE WHEN f.user_uuid = ? THEN f.friend_uuid ELSE f.user_uuid END) as target_uuid, + u.username, + f.status, + f.action_uuid, + EXISTS(SELECT 1 FROM server_online so WHERE so.uuid = u.uuid) as online, + (SELECT gs.name FROM server_online so JOIN game_servers gs ON gs.id = so.server_id WHERE so.uuid = u.uuid LIMIT 1) as playing_on + FROM friendships f + JOIN users u ON u.uuid = (CASE WHEN f.user_uuid = ? THEN f.friend_uuid ELSE f.user_uuid END) + WHERE f.user_uuid = ? OR f.friend_uuid = ? + ORDER BY online DESC, u.username ASC", + ) + .bind(&auth.uuid) + .bind(&auth.uuid) + .bind(&auth.uuid) + .bind(&auth.uuid) + .fetch_all(&state.db) + .await?; + + let list = rows + .into_iter() + .map(|(uuid, username, status, action_uuid, online, playing_on)| { + let final_status = if status == "accepted" { + "accepted".to_string() + } else if action_uuid == auth.uuid { + "pending_outgoing".to_string() + } else { + "pending_incoming".to_string() + }; + + FriendInfo { + uuid, + username, + status: final_status, + online, + playing_on, + last_seen: None, + } + }) + .collect(); + + Ok(Json(list)) +} + +#[derive(Deserialize)] +pub struct FriendRequestPayload { + pub username: Option, + pub friend_username: Option, +} + +pub async fn send_friend_request( + auth: AuthUser, + State(state): State, + Json(payload): Json, +) -> AppResult> { + let name = payload + .username + .as_deref() + .or(payload.friend_username.as_deref()) + .ok_or_else(|| AppError::bad_request("Username is required"))? + .trim(); + + let target: Option<(String, String)> = sqlx::query_as( + "SELECT uuid, username FROM users WHERE username = ? COLLATE NOCASE", + ) + .bind(name) + .fetch_optional(&state.db) + .await?; + + let (target_uuid, target_name) = target.ok_or_else(|| AppError::not_found("Player not found"))?; + + if target_uuid == auth.uuid { + return Err(AppError::bad_request("You cannot friend yourself")); + } + + let now = chrono::Utc::now().to_rfc3339(); + + // Check existing + let existing: Option<(i64, String, String)> = sqlx::query_as( + "SELECT id, status, action_uuid FROM friendships + WHERE (user_uuid = ? AND friend_uuid = ?) OR (user_uuid = ? AND friend_uuid = ?)", + ) + .bind(&auth.uuid) + .bind(&target_uuid) + .bind(&target_uuid) + .bind(&auth.uuid) + .fetch_optional(&state.db) + .await?; + + if let Some((_id, status, action)) = existing { + if status == "accepted" { + return Err(AppError::bad_request("Already friends with this player")); + } + if status == "pending" && action != auth.uuid { + // Reciprocal request -> Auto-accept! + sqlx::query("UPDATE friendships SET status = 'accepted', action_uuid = ?, updated_at = ? WHERE (user_uuid = ? AND friend_uuid = ?) OR (user_uuid = ? AND friend_uuid = ?)") + .bind(&auth.uuid) + .bind(&now) + .bind(&auth.uuid) + .bind(&target_uuid) + .bind(&target_uuid) + .bind(&auth.uuid) + .execute(&state.db) + .await?; + + return Ok(Json(serde_json::json!({ "ok": true, "status": "accepted", "friend": target_name }))); + } + return Err(AppError::bad_request("Friend request is already pending")); + } + + sqlx::query( + "INSERT INTO friendships (user_uuid, friend_uuid, status, action_uuid, created_at, updated_at) + VALUES (?, ?, 'pending', ?, ?, ?)", + ) + .bind(&auth.uuid) + .bind(&target_uuid) + .bind(&auth.uuid) + .bind(&now) + .bind(&now) + .execute(&state.db) + .await?; + + Ok(Json(serde_json::json!({ "ok": true, "status": "pending_outgoing", "friend": target_name }))) +} + +pub async fn accept_friend_request( + auth: AuthUser, + Path(target_uuid): Path, + State(state): State, +) -> AppResult> { + let now = chrono::Utc::now().to_rfc3339(); + + let res = sqlx::query( + "UPDATE friendships SET status = 'accepted', updated_at = ? + WHERE ((user_uuid = ? AND friend_uuid = ?) OR (user_uuid = ? AND friend_uuid = ?)) + AND status = 'pending' AND action_uuid <> ?", + ) + .bind(&now) + .bind(&auth.uuid) + .bind(&target_uuid) + .bind(&target_uuid) + .bind(&auth.uuid) + .bind(&auth.uuid) + .execute(&state.db) + .await?; + + if res.rows_affected() == 0 { + return Err(AppError::bad_request("No pending request from this user found")); + } + + Ok(Json(serde_json::json!({ "ok": true }))) +} + +pub async fn remove_friend( + auth: AuthUser, + Path(target_uuid): Path, + State(state): State, +) -> AppResult> { + sqlx::query( + "DELETE FROM friendships + WHERE (user_uuid = ? AND friend_uuid = ?) OR (user_uuid = ? AND friend_uuid = ?)", + ) + .bind(&auth.uuid) + .bind(&target_uuid) + .bind(&target_uuid) + .bind(&auth.uuid) + .execute(&state.db) + .await?; + + Ok(Json(serde_json::json!({ "ok": true }))) +} + +#[derive(Deserialize)] +pub struct RespondFriendPayload { + pub target_uuid: Option, + pub friend_uuid: Option, + pub accept: bool, +} + +pub async fn respond_friend_request( + auth: AuthUser, + State(state): State, + Json(payload): Json, +) -> AppResult> { + let target = payload + .target_uuid + .or(payload.friend_uuid) + .ok_or_else(|| AppError::bad_request("Friend UUID is required"))?; + + if payload.accept { + accept_friend_request(auth, Path(target), State(state)).await + } else { + remove_friend(auth, Path(target), State(state)).await + } +} + +// --------------------------------------------------------------------------- +// Direct Messaging (DMs) +// --------------------------------------------------------------------------- + +pub async fn get_direct_messages( + auth: AuthUser, + Path(target_uuid): Path, + State(state): State, +) -> AppResult>> { + let rows: Vec<(i64, String, String, String, String, bool, String)> = sqlx::query_as( + "SELECT id, sender_uuid, sender_name, recipient_uuid, content, is_read, created_at + FROM direct_messages + WHERE (sender_uuid = ? AND recipient_uuid = ?) OR (sender_uuid = ? AND recipient_uuid = ?) + ORDER BY id ASC LIMIT 100", + ) + .bind(&auth.uuid) + .bind(&target_uuid) + .bind(&target_uuid) + .bind(&auth.uuid) + .fetch_all(&state.db) + .await?; + + // Mark unread messages sent to me as read + sqlx::query( + "UPDATE direct_messages SET is_read = 1 + WHERE recipient_uuid = ? AND sender_uuid = ? AND is_read = 0", + ) + .bind(&auth.uuid) + .bind(&target_uuid) + .execute(&state.db) + .await?; + + let list = rows + .into_iter() + .map(|(id, suuid, sname, ruuid, content, is_read, created)| DirectMessage { + id, + sender_uuid: suuid, + sender_name: sname, + recipient_uuid: ruuid, + content, + is_read, + created_at: created, + }) + .collect(); + + Ok(Json(list)) +} + +#[derive(Deserialize)] +pub struct SendMessagePayload { + pub content: String, +} + +pub async fn send_direct_message( + auth: AuthUser, + Path(target_uuid): Path, + State(state): State, + Json(payload): Json, +) -> AppResult> { + let content = payload.content.trim(); + if content.is_empty() { + return Err(AppError::bad_request("Message cannot be empty")); + } + + let now = chrono::Utc::now().to_rfc3339(); + + let id: i64 = sqlx::query_scalar( + "INSERT INTO direct_messages (sender_uuid, sender_name, recipient_uuid, content, is_read, created_at) + VALUES (?, ?, ?, ?, 0, ?) + RETURNING id", + ) + .bind(&auth.uuid) + .bind(&auth.username) + .bind(&target_uuid) + .bind(content) + .bind(&now) + .fetch_one(&state.db) + .await?; + + Ok(Json(DirectMessage { + id, + sender_uuid: auth.uuid.clone(), + sender_name: auth.username.clone(), + recipient_uuid: target_uuid, + content: content.to_string(), + is_read: false, + created_at: now, + })) +} + +// --------------------------------------------------------------------------- +// Game Invites +// --------------------------------------------------------------------------- + +pub async fn list_my_game_invites( + auth: AuthUser, + State(state): State, +) -> AppResult>> { + let rows: Vec<( + String, + String, + String, + String, + String, + String, + Option, + Option, + String, + String, + String, + )> = sqlx::query_as( + "SELECT gi.id, gi.sender_uuid, gi.sender_name, gi.recipient_uuid, + gi.instance_id, i.name as instance_name, + gi.server_id, gs.name as server_name, + gi.status, gi.created_at, gi.expires_at + FROM game_invites gi + JOIN instances i ON i.id = gi.instance_id + LEFT JOIN game_servers gs ON gs.id = gi.server_id + WHERE gi.recipient_uuid = ? AND gi.status = 'pending' AND gi.expires_at > ? + ORDER BY gi.created_at DESC", + ) + .bind(&auth.uuid) + .bind(chrono::Utc::now().to_rfc3339()) + .fetch_all(&state.db) + .await?; + + let list = rows + .into_iter() + .map( + |(id, suuid, sname, ruuid, iid, iname, sid, sname_opt, status, cat, eat)| GameInvite { + id, + sender_uuid: suuid, + sender_name: sname, + recipient_uuid: ruuid, + instance_id: iid, + instance_name: iname, + server_id: sid, + server_name: sname_opt, + status, + created_at: cat, + expires_at: eat, + }, + ) + .collect(); + + Ok(Json(list)) +} + +#[derive(Deserialize)] +pub struct SendInvitePayload { + pub recipient_uuid: String, + pub instance_id: String, + pub server_id: Option, +} + +pub async fn send_game_invite( + auth: AuthUser, + State(state): State, + Json(payload): Json, +) -> AppResult> { + let invite_id = format!("inv_{}", uuid::Uuid::new_v4().simple()); + let now = chrono::Utc::now(); + let expires = now + chrono::Duration::minutes(30); + + sqlx::query( + "INSERT INTO game_invites (id, sender_uuid, sender_name, recipient_uuid, instance_id, server_id, status, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?)", + ) + .bind(&invite_id) + .bind(&auth.uuid) + .bind(&auth.username) + .bind(payload.recipient_uuid) + .bind(payload.instance_id) + .bind(payload.server_id) + .bind(now.to_rfc3339()) + .bind(expires.to_rfc3339()) + .execute(&state.db) + .await?; + + Ok(Json(serde_json::json!({ "id": invite_id, "ok": true }))) +} + +#[derive(Deserialize)] +pub struct RespondInvitePayload { + pub action: String, // "accept" or "decline" +} + +pub async fn respond_game_invite( + auth: AuthUser, + Path(invite_id): Path, + State(state): State, + Json(payload): Json, +) -> AppResult> { + let status = if payload.action == "accept" { + "accepted" + } else { + "declined" + }; + + sqlx::query( + "UPDATE game_invites SET status = ? WHERE id = ? AND recipient_uuid = ?", + ) + .bind(status) + .bind(&invite_id) + .bind(&auth.uuid) + .execute(&state.db) + .await?; + + Ok(Json(serde_json::json!({ "ok": true, "status": status }))) +} + +// --------------------------------------------------------------------------- +// Viewable Player Profiles +// --------------------------------------------------------------------------- + +pub async fn get_player_profile( + Path(uuid): Path, + State(state): State, +) -> AppResult> { + let user_row: Option<(String, String)> = sqlx::query_as( + "SELECT uuid, username FROM users WHERE uuid = ?", + ) + .bind(&uuid) + .fetch_optional(&state.db) + .await?; + + let (puuid, username) = user_row.ok_or_else(|| AppError::not_found("Player profile not found"))?; + + let prof_row: Option<(String, Option, Option, Option)> = sqlx::query_as( + "SELECT bio, banner_url, custom_badge, featured_achievement_id FROM user_profiles WHERE uuid = ?", + ) + .bind(&puuid) + .fetch_optional(&state.db) + .await?; + + let (bio, banner_url, custom_badge, feat_ach_id) = prof_row.unwrap_or((String::new(), None, None, None)); + + // Levels + let levels = crate::routes::leveling::get_user_levels_by_uuid(&state, &puuid).await?; + + // Featured achievement if set + let featured_achievement = if let Some(fid) = feat_ach_id { + let ach_list = crate::routes::achievements::get_achievements_for_user(&state, &puuid, false).await?; + ach_list.into_iter().find(|a| a.id == fid) + } else { + None + }; + + // Unlocked achievements count + let achievements_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM user_achievements WHERE user_uuid = ?", + ) + .bind(&puuid) + .fetch_one(&state.db) + .await?; + + // Recent posts + let post_rows: Vec<(i64, String, String, String, Option, i64, String)> = sqlx::query_as( + "SELECT id, user_uuid, author_name, content, image_url, likes_count, created_at + FROM user_posts WHERE user_uuid = ? ORDER BY id DESC LIMIT 10", + ) + .bind(&puuid) + .fetch_all(&state.db) + .await?; + + let posts = post_rows + .into_iter() + .map(|(id, u_uuid, aname, content, img, likes, cat)| UserPost { + id, + user_uuid: u_uuid, + author_name: aname, + content, + image_url: img, + likes_count: likes, + created_at: cat, + }) + .collect(); + + Ok(Json(UserProfileView { + uuid: puuid, + username, + bio, + banner_url, + custom_badge, + title: levels.title, + global_level: levels.global_level, + global_xp: levels.global_xp, + server_levels: levels.server_levels, + featured_achievement, + achievements_count: achievements_count as usize, + posts, + })) +} + +#[derive(Deserialize)] +pub struct UpdateProfilePayload { + pub bio: Option, + pub banner_url: Option, + pub custom_badge: Option, + pub featured_achievement_id: Option, +} + +pub async fn update_my_profile( + auth: AuthUser, + State(state): State, + Json(payload): Json, +) -> AppResult> { + let now = chrono::Utc::now().to_rfc3339(); + + sqlx::query( + "INSERT INTO user_profiles (uuid, bio, banner_url, custom_badge, featured_achievement_id, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (uuid) DO UPDATE SET + bio = COALESCE(?, bio), + banner_url = COALESCE(?, banner_url), + custom_badge = COALESCE(?, custom_badge), + featured_achievement_id = COALESCE(?, featured_achievement_id), + updated_at = excluded.updated_at", + ) + .bind(&auth.uuid) + .bind(payload.bio.clone().unwrap_or_default()) + .bind(payload.banner_url.clone()) + .bind(payload.custom_badge.clone()) + .bind(payload.featured_achievement_id.clone()) + .bind(&now) + .bind(payload.bio) + .bind(payload.banner_url) + .bind(payload.custom_badge) + .bind(payload.featured_achievement_id) + .execute(&state.db) + .await?; + + Ok(Json(serde_json::json!({ "ok": true }))) +} + +// --------------------------------------------------------------------------- +// Social Posts Feed +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +pub struct CreatePostInput { + pub content: String, + pub image_url: Option, +} + +pub async fn create_user_post( + auth: AuthUser, + State(state): State, + Json(payload): Json, +) -> AppResult> { + let content = payload.content.trim(); + if content.is_empty() { + return Err(AppError::bad_request("Post content cannot be empty")); + } + + let now = chrono::Utc::now().to_rfc3339(); + + let id: i64 = sqlx::query_scalar( + "INSERT INTO user_posts (user_uuid, author_name, content, image_url, likes_count, created_at) + VALUES (?, ?, ?, ?, 0, ?) + RETURNING id", + ) + .bind(&auth.uuid) + .bind(&auth.username) + .bind(content) + .bind(payload.image_url.as_deref()) + .bind(&now) + .fetch_one(&state.db) + .await?; + + Ok(Json(UserPost { + id, + user_uuid: auth.uuid.clone(), + author_name: auth.username.clone(), + content: content.to_string(), + image_url: payload.image_url, + likes_count: 0, + created_at: now, + })) +} + +pub async fn delete_user_post( + auth: AuthUser, + Path(post_id): Path, + State(state): State, +) -> AppResult> { + sqlx::query("DELETE FROM user_posts WHERE id = ? AND user_uuid = ?") + .bind(post_id) + .bind(&auth.uuid) + .execute(&state.db) + .await?; + + Ok(Json(serde_json::json!({ "ok": true }))) +} + +pub async fn like_user_post( + _auth: AuthUser, + Path(post_id): Path, + State(state): State, +) -> AppResult> { + sqlx::query("UPDATE user_posts SET likes_count = likes_count + 1 WHERE id = ?") + .bind(post_id) + .execute(&state.db) + .await?; + + Ok(Json(serde_json::json!({ "ok": true }))) +} diff --git a/panel/server/src/seed.rs b/panel/server/src/seed.rs new file mode 100644 index 0000000..8947819 --- /dev/null +++ b/panel/server/src/seed.rs @@ -0,0 +1,255 @@ +//! Default seed data for Quests, Achievements, and Level Rewards. + +use anyhow::Result; +use sqlx::SqlitePool; + +pub async fn seed_quests_and_achievements(pool: &SqlitePool) -> Result<()> { + // Only seed if quests table is empty + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM quests").fetch_one(pool).await?; + if count == 0 { + seed_quests(pool).await?; + } + + let ach_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM achievements").fetch_one(pool).await?; + if ach_count == 0 { + seed_achievements(pool).await?; + } + + let rew_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM level_rewards").fetch_one(pool).await?; + if rew_count == 0 { + seed_rewards(pool).await?; + } + + Ok(()) +} + +async fn seed_quests(pool: &SqlitePool) -> Result<()> { + let now = chrono::Utc::now().to_rfc3339(); + + // 100 total quests: 60 Daily, 40 Weekly + let raw_quests = [ + // Daily Mining + ("q_d_mine_coal", "Coal Miner", "Mine 16 coal ore or coal blocks", "daily", "mining", "blocks_broken", 16, 100, "pickaxe"), + ("q_d_mine_stone", "Stonebreaker", "Break 64 stone or cobblestone blocks", "daily", "mining", "blocks_broken", 64, 120, "pickaxe"), + ("q_d_mine_iron", "Iron Prospector", "Mine 16 iron ore blocks underground", "daily", "mining", "blocks_broken", 16, 150, "pickaxe"), + ("q_d_mine_copper", "Copper Collector", "Gather 24 copper ore blocks", "daily", "mining", "blocks_broken", 24, 130, "pickaxe"), + ("q_d_mine_gold", "Gold Rush", "Mine 8 gold ore blocks in caves or the Nether", "daily", "mining", "blocks_broken", 8, 180, "pickaxe"), + ("q_d_mine_diamond", "Diamond Digger", "Mine 4 rare diamond ore blocks", "daily", "mining", "blocks_broken", 4, 300, "gem"), + ("q_d_mine_redstone", "Redstone Engineer", "Mine 12 redstone ore blocks", "daily", "mining", "blocks_broken", 12, 160, "zap"), + ("q_d_mine_deepslate", "Deepslate Delver", "Mine 48 deepslate blocks in the depths", "daily", "mining", "blocks_broken", 48, 140, "pickaxe"), + ("q_d_mine_quartz", "Nether Prospecting", "Mine 16 Nether quartz ore blocks", "daily", "mining", "blocks_broken", 16, 180, "sparkles"), + ("q_d_mine_debris", "Debris Hunter", "Mine 2 Ancient Debris blocks in the Nether", "daily", "mining", "blocks_broken", 2, 450, "shield"), + ("q_d_mine_sand", "Dune Excavator", "Dig 32 sand or gravel blocks", "daily", "mining", "blocks_broken", 32, 90, "layers"), + ("q_d_mine_obsidian", "Obsidian Forger", "Mine 8 obsidian blocks with a diamond pickaxe", "daily", "mining", "blocks_broken", 8, 250, "shield"), + + // Daily Combat + ("q_d_mob_zombies", "Zombie Purge", "Defeat 8 zombies wandering in the dark", "daily", "combat", "mob_kills", 8, 120, "swords"), + ("q_d_mob_skeletons", "Bone Collector", "Take down 6 skeleton archers", "daily", "combat", "mob_kills", 6, 130, "crosshair"), + ("q_d_mob_spiders", "Arachnid Slayer", "Defeat 5 creepy spiders or cave spiders", "daily", "combat", "mob_kills", 5, 120, "shield-alert"), + ("q_d_mob_creepers", "Bomb Squad", "Eliminate 4 creepers before they explode", "daily", "combat", "mob_kills", 4, 180, "flame"), + ("q_d_mob_endermen", "Ender Stalker", "Defeat 2 mysterious Endermen", "daily", "combat", "mob_kills", 2, 220, "eye"), + ("q_d_mob_witches", "Witch Trial", "Defeat 1 witch brewing dangerous potions", "daily", "combat", "mob_kills", 1, 200, "flask-conical"), + ("q_d_mob_slimes", "Slime Sweeper", "Defeat 6 bouncy slimes or magma cubes", "daily", "combat", "mob_kills", 6, 140, "sparkles"), + ("q_d_mob_piglins", "Piglin Skirmish", "Defeat 6 hostile piglins or zombified piglins", "daily", "combat", "mob_kills", 6, 160, "swords"), + ("q_d_mob_blazes", "Firestarter", "Defeat 4 blazing fortress defenders", "daily", "combat", "mob_kills", 4, 250, "flame"), + ("q_d_mob_drowned", "Deep Water Danger", "Defeat 5 drowned lurkers in rivers or oceans", "daily", "combat", "mob_kills", 5, 140, "droplets"), + ("q_d_mob_monsters", "Night Watch", "Eliminate any 15 hostile monsters", "daily", "combat", "mob_kills", 15, 200, "swords"), + ("q_d_pvp_champion", "Gladiator's Glory", "Defeat 1 player in honorable combat", "daily", "combat", "player_kills", 1, 250, "trophy"), + + // Daily Building + ("q_d_build_planks", "Woodworker", "Place 48 wooden planks in any construction", "daily", "building", "blocks_placed", 48, 120, "hammer"), + ("q_d_build_bricks", "Mason's Pride", "Place 32 stone bricks or polished stones", "daily", "building", "blocks_placed", 32, 130, "box"), + ("q_d_build_glass", "Window Pane", "Place 16 glass blocks or panes", "daily", "building", "blocks_placed", 16, 100, "eye"), + ("q_d_build_torches", "Let There Be Light", "Place 20 torches to illuminate the area", "daily", "building", "blocks_placed", 20, 90, "flame"), + ("q_d_build_lanterns", "Cozy Hearth", "Place 6 lanterns around your settlement", "daily", "building", "blocks_placed", 6, 120, "sparkles"), + ("q_d_build_slabs", "Tiered Architecture", "Place 24 slabs or stairs", "daily", "building", "blocks_placed", 24, 110, "layers"), + ("q_d_build_roof", "Under Cover", "Place 50 roofing or decorative blocks", "daily", "building", "blocks_placed", 50, 140, "home"), + ("q_d_build_walls", "Fortified Walls", "Place 30 cobblestone or deepslate walls", "daily", "building", "blocks_placed", 30, 130, "shield"), + ("q_d_build_general", "Master Builder", "Place 100 blocks of any material", "daily", "building", "blocks_placed", 100, 180, "hammer"), + ("q_d_build_monument", "Grand Monument", "Place 150 blocks toward a new structure", "daily", "building", "blocks_placed", 150, 220, "castle"), + + // Daily Farming & Harvesting + ("q_d_farm_wood", "Lumberjack", "Chop down 32 wood logs", "daily", "farming", "blocks_broken", 32, 120, "axe"), + ("q_d_farm_birch", "Birch Forester", "Harvest 20 birch or spruce logs", "daily", "farming", "blocks_broken", 20, 110, "axe"), + ("q_d_farm_jungle", "Jungle Explorer", "Chop 24 jungle or acacia logs", "daily", "farming", "blocks_broken", 24, 130, "axe"), + ("q_d_farm_wheat", "Wheat Harvester", "Harvest 24 ripe wheat stalks", "daily", "farming", "blocks_broken", 24, 100, "sprout"), + ("q_d_farm_potatoes", "Potato Bounty", "Gather 24 potatoes from the fields", "daily", "farming", "blocks_broken", 24, 100, "sprout"), + ("q_d_farm_carrots", "Golden Nutrition", "Harvest 24 fresh carrots", "daily", "farming", "blocks_broken", 24, 100, "sprout"), + ("q_d_farm_sugarcane", "Sugar Rush", "Cut 16 sugar cane reeds", "daily", "farming", "blocks_broken", 16, 90, "sprout"), + ("q_d_farm_pumpkins", "Patch Patrol", "Harvest 8 pumpkins or melons", "daily", "farming", "blocks_broken", 8, 110, "apple"), + ("q_d_farm_flowers", "Botanist", "Collect 12 wild flowers", "daily", "farming", "blocks_broken", 12, 80, "flower"), + ("q_d_farm_leaves", "Foliage Trimmer", "Break 40 leaf blocks", "daily", "farming", "blocks_broken", 40, 80, "scissors"), + + // Daily Social & Playtime + ("q_d_play_15m", "Quick Check-in", "Play on any server for 15 minutes", "daily", "social", "playtime_secs", 900, 100, "clock"), + ("q_d_play_30m", "Dedicated Citizen", "Spend 30 minutes in game", "daily", "social", "playtime_secs", 1800, 180, "clock"), + ("q_d_play_60m", "Hour of Power", "Play for a full continuous hour", "daily", "social", "playtime_secs", 3600, 250, "clock"), + ("q_d_chat_chatty", "Good Morning", "Say hello and send 5 chat messages", "daily", "social", "messages", 5, 80, "message-square"), + ("q_d_chat_talkative", "Community Voice", "Send 15 chat messages participating in chat", "daily", "social", "messages", 15, 120, "message-square"), + ("q_d_join_again", "Frequent Flyer", "Join a server 2 distinct times today", "daily", "social", "joins", 2, 80, "door-open"), + ("q_d_explore_depths", "Into the Deep", "Mine 30 tuff or deepslate tiles", "daily", "exploration", "blocks_broken", 30, 110, "compass"), + ("q_d_explore_caves", "Spelunker", "Break 50 blocks exploring natural cave systems", "daily", "exploration", "blocks_broken", 50, 130, "compass"), + ("q_d_explore_diorite", "Geologist", "Collect 20 granite, diorite, or andesite blocks", "daily", "exploration", "blocks_broken", 20, 100, "mountain"), + ("q_d_explore_nether", "Nether Expedition", "Mine 40 nether blocks while surviving", "daily", "exploration", "blocks_broken", 40, 160, "flame"), + ("q_d_explore_clay", "Clay Digger", "Excavate 16 clay blocks from river beds", "daily", "exploration", "blocks_broken", 16, 110, "droplets"), + ("q_d_explore_snow", "Winter Wonderland", "Shovel 32 snow or ice blocks", "daily", "exploration", "blocks_broken", 32, 90, "snowflake"), + ("q_d_explore_magma", "Volcanic Venture", "Mine 8 magma blocks or basalt", "daily", "exploration", "blocks_broken", 8, 140, "flame"), + ("q_d_build_decor", "Interior Decorator", "Place 15 decorative items or carpets", "daily", "building", "blocks_placed", 15, 90, "palette"), + + // Weekly Quests (Higher targets & XP rewards) + ("q_w_mine_deepslate_grand", "Excavation Giant", "Mine 500 deepslate blocks in the darkest depths", "weekly", "mining", "blocks_broken", 500, 750, "pickaxe"), + ("q_w_mine_iron_hoard", "Iron Hoarder", "Mine 128 iron ore across the week", "weekly", "mining", "blocks_broken", 128, 850, "pickaxe"), + ("q_w_mine_diamonds_grand", "Diamond Empire", "Discover and mine 32 diamonds", "weekly", "mining", "blocks_broken", 32, 1200, "gem"), + ("q_w_mine_gold_grand", "Midas Touch", "Mine 64 gold ore from caves or fortresses", "weekly", "mining", "blocks_broken", 64, 900, "award"), + ("q_w_mine_redstone_grand", "Automation Maestro", "Mine 128 redstone ore blocks", "weekly", "mining", "blocks_broken", 128, 800, "zap"), + ("q_w_mine_debris_grand", "Netherite Titan", "Locate and extract 12 Ancient Debris blocks", "weekly", "mining", "blocks_broken", 12, 1600, "shield"), + ("q_w_mine_nether_grand", "Underworld Quarry", "Break 300 blocks in the Nether dimension", "weekly", "mining", "blocks_broken", 300, 700, "flame"), + ("q_w_mine_stone_grand", "Mountain Mover", "Break 1,000 stone or cobblestone blocks", "weekly", "mining", "blocks_broken", 1000, 1100, "mountain"), + + ("q_w_mob_purge_grand", "Monster Exterminator", "Eliminate 100 hostile monsters", "weekly", "combat", "mob_kills", 100, 1000, "swords"), + ("q_w_mob_skeletons_grand", "Archer's Nemesis", "Defeat 40 skeleton sharpshooters", "weekly", "combat", "mob_kills", 40, 750, "crosshair"), + ("q_w_mob_creepers_grand", "Demolition Defuser", "Take down 30 dangerous creepers", "weekly", "combat", "mob_kills", 30, 850, "flame"), + ("q_w_mob_endermen_grand", "Void Hunter", "Defeat 15 elusive Endermen", "weekly", "combat", "mob_kills", 15, 1100, "eye"), + ("q_w_mob_blazes_grand", "Fortress Conqueror", "Extinguish 25 fiery blazes in Nether fortresses", "weekly", "combat", "mob_kills", 25, 1200, "flame"), + ("q_w_mob_withers_grand", "Skull Seeker", "Defeat 12 wither skeletons", "weekly", "combat", "mob_kills", 12, 1300, "skull"), + ("q_w_pvp_champion_grand", "Arena Legend", "Defeat 10 players in competitive PvP matches", "weekly", "combat", "player_kills", 10, 1500, "trophy"), + ("q_w_pvp_master_grand", "Warlord of SCOPENET", "Claim 25 PvP victories across all servers", "weekly", "combat", "player_kills", 25, 2500, "crown"), + + ("q_w_build_castle", "Castle Founder", "Place 800 stone bricks or fortified blocks", "weekly", "building", "blocks_placed", 800, 1000, "castle"), + ("q_w_build_planks_grand", "Town Carpenter", "Place 500 wooden planks on structures", "weekly", "building", "blocks_placed", 500, 800, "hammer"), + ("q_w_build_glass_grand", "Sky Dome", "Place 150 glass blocks", "weekly", "building", "blocks_placed", 150, 700, "eye"), + ("q_w_build_lanterns_grand", "Beacon of Light", "Place 40 lanterns or glowstone blocks", "weekly", "building", "blocks_placed", 40, 750, "sparkles"), + ("q_w_build_grand_city", "Metropolis Architect", "Place 1,500 total blocks across any build", "weekly", "building", "blocks_placed", 1500, 1400, "hammer"), + ("q_w_build_enclosure", "Fortress Perimeters", "Place 150 fence posts or wall segments", "weekly", "building", "blocks_placed", 150, 700, "shield"), + + ("q_w_farm_logs_grand", "Deforestation Duty", "Harvest 250 wood logs of any species", "weekly", "farming", "blocks_broken", 250, 800, "axe"), + ("q_w_farm_crops_grand", "Granary Overflow", "Harvest 200 total food crops", "weekly", "farming", "blocks_broken", 200, 750, "sprout"), + ("q_w_farm_reeds_grand", "Sugar Empire", "Harvest 100 sugar cane or bamboo", "weekly", "farming", "blocks_broken", 100, 650, "sprout"), + ("q_w_farm_pumpkins_grand", "Autumn Festival", "Harvest 40 pumpkins or melons", "weekly", "farming", "blocks_broken", 40, 700, "apple"), + + ("q_w_play_3h", "Weekly Adventurer", "Spend 3 hours playing on SCOPENET servers", "weekly", "social", "playtime_secs", 10800, 800, "clock"), + ("q_w_play_8h", "Dedicated Veteran", "Spend 8 hours playing across the week", "weekly", "social", "playtime_secs", 28800, 1500, "clock"), + ("q_w_play_15h", "No-Lifer Supremacy", "Clock in 15 hours of active gameplay", "weekly", "social", "playtime_secs", 54000, 2500, "zap"), + ("q_w_chat_social", "Town Hall Speaker", "Contribute 50 chat messages across community servers", "weekly", "social", "messages", 50, 600, "message-square"), + ("q_w_joins_loyal", "Daily Habits", "Join game servers on at least 5 occasions", "weekly", "social", "joins", 5, 700, "door-open"), + ("q_w_explore_caves_grand", "Underground Cartographer", "Break 400 blocks deep underground", "weekly", "exploration", "blocks_broken", 400, 850, "compass"), + ("q_w_explore_nether_grand2", "Infernal Pioneer", "Travel and mine 250 blocks in the Nether", "weekly", "exploration", "blocks_broken", 250, 800, "flame"), + ("q_w_explore_ocean", "Abyssal Diver", "Break 100 prismarine, gravel, or coral underwater", "weekly", "exploration", "blocks_broken", 100, 750, "droplets"), + ("q_w_explore_sand_grand", "Desert Terraformer", "Dig 300 sand or red sand blocks", "weekly", "exploration", "blocks_broken", 300, 650, "layers"), + ("q_w_build_complex", "Civil Engineer", "Place 600 stone bricks or polished granite", "weekly", "building", "blocks_placed", 600, 950, "box"), + ]; + + for (id, title, desc, period, cat, stat, target, xp, icon) in raw_quests { + sqlx::query( + "INSERT OR IGNORE INTO quests (id, title, description, period, category, target_stat, target_count, xp_reward, icon, enabled, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)" + ) + .bind(id) + .bind(title) + .bind(desc) + .bind(period) + .bind(cat) + .bind(stat) + .bind(target) + .bind(xp) + .bind(icon) + .bind(&now) + .execute(pool) + .await?; + } + + Ok(()) +} + +async fn seed_achievements(pool: &SqlitePool) -> Result<()> { + let now = chrono::Utc::now().to_rfc3339(); + + let raw_achievements = [ + ("ach_first_steps", "First Steps", "Join your first SCOPENET server and begin your journey.", "progression", "task", "wooden_pickaxe", "stone", "bronze", "stat", "joins", 1, 100, 0), + ("ach_miner_49er", "Strike It Rich!", "Mine your first diamond ore underground.", "mining", "task", "diamond", "deepslate", "gold", "stat", "blocks_broken", 50, 250, 0), + ("ach_first_blood", "First Blood", "Defeat your first monster in battle.", "combat", "task", "iron_sword", "dirt", "bronze", "stat", "mob_kills", 1, 100, 0), + ("ach_monster_hunter", "Monster Hunter", "Defeat 100 hostile mobs across servers.", "combat", "goal", "diamond_sword", "stone", "gold", "stat", "mob_kills", 100, 500, 0), + ("ach_master_slayer", "Slayer of the Night", "Vanquish 500 monsters threatening players.", "combat", "challenge", "netherite_sword", "obsidian", "purple", "stat", "mob_kills", 500, 1500, 0), + ("ach_duelist", "Arena Duelist", "Defeat another player in PvP combat.", "combat", "task", "bow", "stone", "silver", "stat", "player_kills", 1, 200, 0), + ("ach_gladiator", "SCOPENET Gladiator", "Defeat 25 players in competitive combat.", "combat", "challenge", "netherite_axe", "bedrock", "gold", "stat", "player_kills", 25, 1000, 0), + ("ach_architect", "Apprentice Architect", "Place your first 500 building blocks.", "building", "task", "bricks", "stone", "bronze", "stat", "blocks_placed", 500, 250, 0), + ("ach_master_builder", "Master Builder", "Construct structures using over 5,000 blocks.", "building", "goal", "stone_bricks", "oak_planks", "gold", "stat", "blocks_placed", 5000, 1000, 0), + ("ach_monument_maker", "Wonders of the World", "Place 20,000 blocks leaving your mark on the realm.", "building", "challenge", "beacon", "obsidian", "diamond", "stat", "blocks_placed", 20000, 2500, 0), + ("ach_playtime_10h", "Seasoned Traveler", "Accumulate 10 hours of playtime.", "progression", "task", "clock", "oak_planks", "silver", "stat", "playtime_secs", 36000, 500, 0), + ("ach_playtime_50h", "Hardened Veteran", "Reach 50 hours of active adventure.", "progression", "goal", "compass", "stone", "gold", "stat", "playtime_secs", 180000, 1200, 0), + ("ach_playtime_100h", "Centurion", "Dedicate 100 hours to the SCOPENET community.", "progression", "challenge", "totem_of_undying", "gilded_blackstone", "diamond", "stat", "playtime_secs", 360000, 2500, 0), + ("ach_level_10", "Rising Star", "Reach SCOPENET Global Level 10.", "progression", "task", "experience_bottle", "stone", "bronze", "level", "global_level", 10, 300, 0), + ("ach_level_25", "Champion", "Ascend to Global Level 25.", "progression", "goal", "enchanted_book", "stone", "gold", "level", "global_level", 25, 800, 0), + ("ach_level_50", "Grandmaster", "Attain Global Level 50 through valor and persistence.", "progression", "challenge", "nether_star", "obsidian", "purple", "level", "global_level", 50, 2000, 0), + ("ach_level_100", "Mythic Legend", "Surpass all expectations by reaching Global Level 100.", "progression", "challenge", "dragon_egg", "bedrock", "gold", "level", "global_level", 100, 5000, 0), + ("ach_chatty", "Social Butterfly", "Send 100 messages in server chat.", "social", "task", "book", "oak_planks", "bronze", "stat", "messages", 100, 200, 0), + ("ach_guild_initiate", "United We Stand", "Join or found a Guild.", "guilds", "task", "shield", "stone", "silver", "event", "join_guild", 1, 300, 0), + ("ach_land_claim", "Territorial Sovereign", "Claim your first chunk of guild territory.", "guilds", "goal", "grass_block", "dirt", "gold", "event", "claim_land", 1, 400, 0), + ("ach_nether_walker", "Into the Fire", "Set foot in the Nether dimension.", "exploration", "task", "flint_and_steel", "netherrack", "bronze", "event", "nether", 1, 200, 0), + ("ach_end_slayer", "Free the End", "Slay the Ender Dragon.", "exploration", "challenge", "dragon_head", "end_stone", "purple", "event", "ender_dragon", 1, 2000, 1), + ]; + + for (id, title, desc, cat, frame, item, bg, border, req_type, req_key, req_val, xp, secret) in raw_achievements { + sqlx::query( + "INSERT OR IGNORE 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) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ) + .bind(id) + .bind(title) + .bind(desc) + .bind(cat) + .bind(frame) + .bind(item) + .bind(bg) + .bind(border) + .bind(req_type) + .bind(req_key) + .bind(req_val) + .bind(xp) + .bind(secret) + .bind(&now) + .execute(pool) + .await?; + } + + Ok(()) +} + +async fn seed_rewards(pool: &SqlitePool) -> Result<()> { + let now = chrono::Utc::now().to_rfc3339(); + + let raw_rewards = [ + ("global", None::, 2, "title", "Apprentice", r##"{"title": "Apprentice", "color": "#a8a29e"}"##), + ("global", None, 5, "title", "Journeyman", r##"{"title": "Journeyman", "color": "#38bdf8"}"##), + ("global", None, 5, "badge", "Bronze Star", r##"{"badge": "star_bronze", "icon": "sparkles"}"##), + ("global", None, 10, "title", "Veteran", r##"{"title": "Veteran", "color": "#4ade80"}"##), + ("global", None, 10, "cosmetic", "Emerald Aura", r##"{"particle": "emerald", "type": "aura"}"##), + ("global", None, 20, "title", "Warden", r##"{"title": "Warden", "color": "#818cf8"}"##), + ("global", None, 25, "title", "Hero", r##"{"title": "Hero", "color": "#facc15"}"##), + ("global", None, 25, "badge", "Silver Shield", r##"{"badge": "shield_silver", "icon": "shield"}"##), + ("global", None, 35, "title", "Warlord", r##"{"title": "Warlord", "color": "#fb923c"}"##), + ("global", None, 50, "title", "Legend", r##"{"title": "Legend", "color": "#f43f5e"}"##), + ("global", None, 50, "badge", "Golden Crown", r##"{"badge": "crown_gold", "icon": "crown"}"##), + ("global", None, 75, "title", "Ascendant", r##"{"title": "Ascendant", "color": "#c084fc"}"##), + ("global", None, 100, "title", "Immortal", r##"{"title": "Immortal", "color": "#e879f9"}"##), + ("global", None, 100, "badge", "Mythic Champion", r##"{"badge": "mythic_star", "icon": "trophy"}"##), + ]; + + for (lvl_type, srv_id, lvl_req, rew_type, rew_name, rew_data) in raw_rewards { + sqlx::query( + "INSERT INTO level_rewards (level_type, server_id, level_req, reward_type, reward_name, reward_data, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ) + .bind(lvl_type) + .bind(srv_id) + .bind(lvl_req) + .bind(rew_type) + .bind(rew_name) + .bind(rew_data) + .bind(&now) + .execute(pool) + .await?; + } + + Ok(()) +} diff --git a/panel/server/tests/features_v2.rs b/panel/server/tests/features_v2.rs new file mode 100644 index 0000000..25a960b --- /dev/null +++ b/panel/server/tests/features_v2.rs @@ -0,0 +1,341 @@ +//! Integration tests for v2 features: +//! - Global & Server Leveling +//! - Daily & Weekly Quests +//! - Guilds & Grief Protection Land Claiming +//! - Friends, DMs, Posts & Player Profiles +//! - Server Feature Toggles (config bypass) + +mod common; +use common::*; + +async fn setup_users(t: &TestApp) -> (String, String, String, String) { + let admin = t.login("admin", "supersecret").await; + let (s, v1) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Alex", "password": "password123"}))).await; + assert_eq!(s, StatusCode::OK, "{v1}"); + let (s, v2) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Steve", "password": "password123"}))).await; + assert_eq!(s, StatusCode::OK, "{v2}"); + + let alex_token = t.login("Alex", "password123").await; + let steve_token = t.login("Steve", "password123").await; + let alex_uuid = t.uuid("Alex").await; + let steve_uuid = t.uuid("Steve").await; + + (alex_token, steve_token, alex_uuid, steve_uuid) +} + +async fn create_test_server(t: &TestApp, admin: &str) -> (i64, String) { + let (s, v) = t.call("POST", "/api/admin/servers", Some(admin), Some(json!({"name": "Survival Hub"}))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + let server_id = v["server"]["id"].as_i64().unwrap(); + let token = v["token"].as_str().unwrap().to_string(); + (server_id, token) +} + +#[tokio::test] +async fn leveling_and_quests_progression() { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + let (alex_token, _, alex_uuid, _) = setup_users(&t).await; + let (_server_id, server_token) = create_test_server(&t, &admin).await; + + // 1. Initial level check + let (s, lvl) = t.call("GET", "/api/v1/leveling/me", Some(&alex_token), None).await; + assert_eq!(s, StatusCode::OK, "{lvl}"); + assert_eq!(lvl["global_level"], 1); + assert_eq!(lvl["global_xp"], 0); + + // 2. Fetch seeded quests + let (s, quests) = t.call("GET", "/api/v1/quests/me", Some(&alex_token), None).await; + assert_eq!(s, StatusCode::OK, "{quests}"); + let quest_list = quests.as_array().unwrap(); + assert!(!quest_list.is_empty(), "Expected seeded quests to be populated"); + + // 3. Game Server sync with leveling ENABLED + let (s, sync_res) = t + .call( + "POST", + "/api/server/v1/sync", + Some(&server_token), + Some(json!({ + "features": { + "leveling_enabled": true, + "global_xp_multiplier": 1.0, + "server_xp_multiplier": 1.5, + "quests_enabled": true, + "achievements_enabled": true, + "guilds_enabled": true, + "land_claiming_enabled": true + }, + "players": [ + {"uuid": &alex_uuid, "username": "Alex", "ip": "127.0.0.1", "playtime_secs": 60} + ], + "stats": [ + { + "uuid": &alex_uuid, + "name": "Alex", + "blocks_broken": 20, + "blocks_placed": 10, + "mob_kills": 5, + "player_kills": 0, + "deaths": 0, + "playtime_secs": 60 + } + ], + "events": [] + })), + ) + .await; + assert_eq!(s, StatusCode::OK, "{sync_res}"); + + // 4. Verify Alex gained XP from the server actions + let (s, lvl_after) = t.call("GET", "/api/v1/leveling/me", Some(&alex_token), None).await; + assert_eq!(s, StatusCode::OK, "{lvl_after}"); + let current_xp = lvl_after["global_xp"].as_i64().unwrap(); + assert!(current_xp > 0, "Alex should have earned XP"); + + // 5. Game Server sync with leveling DISABLED (server host turned it off in config) + let (s, sync_off) = t + .call( + "POST", + "/api/server/v1/sync", + Some(&server_token), + Some(json!({ + "features": { + "leveling_enabled": false, + "quests_enabled": false + }, + "players": [ + {"uuid": &alex_uuid, "username": "Alex", "ip": "127.0.0.1", "playtime_secs": 120} + ], + "stats": [ + { + "uuid": &alex_uuid, + "name": "Alex", + "blocks_broken": 50, + "blocks_placed": 50, + "mob_kills": 20, + "player_kills": 0, + "deaths": 0, + "playtime_secs": 60 + } + ], + "events": [] + })), + ) + .await; + assert_eq!(s, StatusCode::OK, "{sync_off}"); + + // Verify XP did NOT increase when leveling is disabled + let (s, lvl_no_change) = t.call("GET", "/api/v1/leveling/me", Some(&alex_token), None).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(lvl_no_change["global_xp"].as_i64().unwrap(), current_xp); +} + +#[tokio::test] +async fn guilds_and_land_claims() { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + let (alex_token, _, alex_uuid, steve_uuid) = setup_users(&t).await; + let (server_id, server_token) = create_test_server(&t, &admin).await; + + // 1. Alex founds a guild + let (s, guild) = t + .call( + "POST", + "/api/v1/guilds", + Some(&alex_token), + Some(json!({ + "instance_id": "smp-pack", + "name": "Iron Fortress", + "tag": "IRON", + "description": "Defenders of the realm" + })), + ) + .await; + assert_eq!(s, StatusCode::OK, "{guild}"); + let guild_id = guild["id"].as_str().unwrap().to_string(); + assert_eq!(guild["tag"], "IRON"); + + // 2. Alex claims chunk (3, 7) in the Overworld + let (s, claim) = t + .call( + "POST", + &format!("/api/v1/guilds/{guild_id}/claim"), + Some(&alex_token), + Some(json!({ + "server_id": server_id, + "instance_id": "smp-pack", + "dimension": "minecraft:overworld", + "chunk_x": 3, + "chunk_z": 7 + })), + ) + .await; + assert_eq!(s, StatusCode::OK, "{claim}"); + let claim_id = claim["id"].as_i64().unwrap(); + + // 3. Server checks chunk permissions for in-game protection + // Alex (guild leader) attempts to build -> allowed + let (s, check_alex) = t + .call( + "POST", + "/api/server/v1/guilds/check-chunk", + Some(&server_token), + Some(json!({ + "dimension": "minecraft:overworld", + "chunk_x": 3, + "chunk_z": 7, + "uuid": &alex_uuid + })), + ) + .await; + assert_eq!(s, StatusCode::OK, "{check_alex}"); + assert_eq!(check_alex["allowed"], true); + + // Steve (non-member) attempts to build -> DENIED with grief protection reason + let (s, check_steve) = t + .call( + "POST", + "/api/server/v1/guilds/check-chunk", + Some(&server_token), + Some(json!({ + "dimension": "minecraft:overworld", + "chunk_x": 3, + "chunk_z": 7, + "uuid": &steve_uuid + })), + ) + .await; + assert_eq!(s, StatusCode::OK, "{check_steve}"); + assert_eq!(check_steve["allowed"], false); + assert_eq!(check_steve["claimed"], true); + + // Unclaimed wilderness chunk (100, 100) -> allowed for all players + let (s, check_wild) = t + .call( + "POST", + "/api/server/v1/guilds/check-chunk", + Some(&server_token), + Some(json!({ + "dimension": "minecraft:overworld", + "chunk_x": 100, + "chunk_z": 100, + "uuid": &steve_uuid + })), + ) + .await; + assert_eq!(s, StatusCode::OK, "{check_wild}"); + assert_eq!(check_wild["allowed"], true); + + // 4. Alex unclaims the chunk + let (s, _) = t.call("DELETE", &format!("/api/v1/guilds/claims/{claim_id}"), Some(&alex_token), None).await; + assert_eq!(s, StatusCode::OK); + + // After unclaiming, Steve is now allowed to build at (3, 7) + let (s, check_unclaimed) = t + .call( + "POST", + "/api/server/v1/guilds/check-chunk", + Some(&server_token), + Some(json!({ + "dimension": "minecraft:overworld", + "chunk_x": 3, + "chunk_z": 7, + "uuid": &steve_uuid + })), + ) + .await; + assert_eq!(s, StatusCode::OK); + assert_eq!(check_unclaimed["allowed"], true); +} + +#[tokio::test] +async fn social_friends_dms_and_profiles() { + let t = setup().await; + let (alex_token, steve_token, alex_uuid, steve_uuid) = setup_users(&t).await; + + // 1. Alex sends friend request to Steve + let (s, _) = t + .call( + "POST", + "/api/v1/social/friends/request", + Some(&alex_token), + Some(json!({"friend_username": "Steve"})), + ) + .await; + assert_eq!(s, StatusCode::OK); + + // 2. Steve accepts friend request + let (s, _) = t + .call( + "POST", + "/api/v1/social/friends/respond", + Some(&steve_token), + Some(json!({"friend_uuid": &alex_uuid, "accept": true})), + ) + .await; + assert_eq!(s, StatusCode::OK); + + // Verify both see each other in their friends list + let (s, alex_friends) = t.call("GET", "/api/v1/social/friends", Some(&alex_token), None).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(alex_friends[0]["uuid"], steve_uuid); + assert_eq!(alex_friends[0]["status"], "accepted"); + + // 3. Direct Messaging + let (s, msg) = t + .call( + "POST", + &format!("/api/v1/messages/{steve_uuid}"), + Some(&alex_token), + Some(json!({ + "content": "Hey Steve! Ready for the wither fight?" + })), + ) + .await; + assert_eq!(s, StatusCode::OK, "{msg}"); + + // Steve reads messages from Alex + let (s, msgs) = t.call("GET", &format!("/api/v1/messages/{alex_uuid}"), Some(&steve_token), None).await; + assert_eq!(s, StatusCode::OK, "{msgs}"); + assert_eq!(msgs[0]["content"], "Hey Steve! Ready for the wither fight?"); + + // 4. User Profile & Posts + let (s, _) = t + .call( + "PUT", + "/api/v1/social/profile/me", + Some(&alex_token), + Some(json!({ + "bio": "Redstone expert and dragon hunter." + })), + ) + .await; + assert_eq!(s, StatusCode::OK); + + // Alex posts a social update + let (s, post) = t + .call( + "POST", + "/api/v1/social/posts", + Some(&alex_token), + Some(json!({ + "content": "Defeated the Ender Dragon on hardcore mode!", + "image_url": "https://example.com/screenshot.png" + })), + ) + .await; + assert_eq!(s, StatusCode::OK, "{post}"); + let post_id = post["id"].as_i64().unwrap(); + + // Steve likes Alex's post + let (s, like_res) = t.call("POST", &format!("/api/v1/social/posts/{post_id}/like"), Some(&steve_token), None).await; + assert_eq!(s, StatusCode::OK, "{like_res}"); + assert_eq!(like_res["ok"], true); + + // Steve views Alex's full profile + let (s, profile) = t.call("GET", &format!("/api/v1/social/profile/{alex_uuid}"), Some(&steve_token), None).await; + assert_eq!(s, StatusCode::OK, "{profile}"); + assert_eq!(profile["bio"], "Redstone expert and dragon hunter."); + assert_eq!(profile["posts"][0]["likes_count"], 1); +} diff --git a/panel/web/src/App.svelte b/panel/web/src/App.svelte index 5e39377..e365421 100644 --- a/panel/web/src/App.svelte +++ b/panel/web/src/App.svelte @@ -14,6 +14,10 @@ import Activity from './pages/Activity.svelte'; import LandingBuilder from './pages/LandingBuilder.svelte'; import PublicLanding from './pages/PublicLanding.svelte'; + import QuestEditor from './pages/QuestEditor.svelte'; + import AchievementEditor from './pages/AchievementEditor.svelte'; + import LevelingAdmin from './pages/LevelingAdmin.svelte'; + import GuildsAdmin from './pages/GuildsAdmin.svelte'; import { get } from './lib/api'; import { route } from './lib/router.svelte'; import { logout, session, type Me } from './lib/session.svelte'; @@ -73,6 +77,14 @@ {:else if route.name === 'servers'} + {:else if route.name === 'quests'} + + {:else if route.name === 'achievements'} + + {:else if route.name === 'leveling'} + + {:else if route.name === 'guilds'} + {:else if route.name === 'capes'} {:else if route.name === 'activity'} diff --git a/panel/web/src/components/Sidebar.svelte b/panel/web/src/components/Sidebar.svelte index adb9726..0cc96f5 100644 --- a/panel/web/src/components/Sidebar.svelte +++ b/panel/web/src/components/Sidebar.svelte @@ -1,5 +1,5 @@ + +
+
+
+

Minecraft Achievements & Advancements

+

Create and customize in-game achievements with the Minecraft-themed icon and advancement creator.

+
+ +
+ +
+
+ + Minecraft Advancement Toast Preview +
+
+ +
+
+
+ ⛏️ +
+
+
+
Advancement Made!
+
Stone Age
+
+
+ + +
+
+
+ ✨ +
+
+
+
Goal Reached!
+
Beaconator
+
+
+ + +
+
+
+ 🪽 +
+
+
+
Challenge Complete!
+
Sky's the Limit
+
+
+
+
+ +
+
+ + + + + +
+ {filtered.length} Achievements +
+ + {#if loading} +
Loading achievements...
+ {:else if filtered.length === 0} +
No achievements match your filters.
+ {:else} +
+ {#each filtered as a} +
+
+
+
+ {getItemEmoji(a.icon_item)} +
+
+
+ +
+
+ {a.frame_type.toUpperCase()} + +{a.xp_reward} XP +
+

{a.title}

+

{a.description}

+
+ {a.category} + {#if a.stat_type} + {a.stat_type} ≥ {a.target_count} + {:else if a.event_type} + event: {a.event_type} + {/if} +
+
+ +
+ + +
+
+ {/each} +
+ {/if} +
+ +{#if draft} + a.id === draft?.id) ? 'Edit Achievement' : 'Achievement Creator'}> +
{ e.preventDefault(); save(); }} class="form"> + +
+ Live In-Game Toast Preview +
+
+
+ {getItemEmoji(draft.icon_item)} +
+
+
+
+ {frameTypes.find((f) => f.id === draft?.frame_type)?.bannerTitle} +
+
{draft.title || 'Untitled Achievement'}
+
+
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
Minecraft Icon Styling
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
Trigger Conditions
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ + +
+
+{/if} + +{#if deleteConfirm} + +

Are you sure you want to delete achievement {deleteConfirm.title} ({deleteConfirm.id})?

+ +
+{/if} + + diff --git a/panel/web/src/pages/GuildsAdmin.svelte b/panel/web/src/pages/GuildsAdmin.svelte new file mode 100644 index 0000000..ca2216e --- /dev/null +++ b/panel/web/src/pages/GuildsAdmin.svelte @@ -0,0 +1,222 @@ + + +
+
+
+

Guilds & Land Claims Management

+

View instances' guilds, member counts, active chunk claims, and moderate guilds.

+
+
+ +
+
+ Total Guilds + {guilds.length} +
+
+ Total Members + {guilds.reduce((acc, g) => acc + g.member_count, 0)} +
+
+ Claimed Chunks + {guilds.reduce((acc, g) => acc + g.claims_count, 0)} +
+
+ +
+ + +
+ +
+
+ + {#if loading} +
Loading guilds...
+ {:else if filtered.length === 0} +
No guilds found.
+ {:else} +
+ + + + + + + + + + + + + + + {#each filtered as g} + + + + + + + + + + + {/each} + +
GuildTagInstanceLeaderMembersClaimed ChunksCreatedActions
+
+ {#if g.icon_url} + + {:else} +
+ +
+ {/if} +
+ {g.name} + {#if g.description} +

{g.description}

+ {/if} +
+
+
+ [{g.tag}] + + {g.instance_id} + + {g.leader_uuid.slice(0, 8)}... + + + {g.member_count} / {g.max_members} + + + + {g.claims_count} chunks + + + {new Date(g.created_at).toLocaleDateString()} + + +
+
+ {/if} +
+ +{#if deleteConfirm} + +

Are you sure you want to forcibly disband [{deleteConfirm.tag}] {deleteConfirm.name}?

+

This will kick all {deleteConfirm.member_count} members and unclaim all {deleteConfirm.claims_count} chunks of land!

+ +
+{/if} + + diff --git a/panel/web/src/pages/LevelingAdmin.svelte b/panel/web/src/pages/LevelingAdmin.svelte new file mode 100644 index 0000000..70b6cc8 --- /dev/null +++ b/panel/web/src/pages/LevelingAdmin.svelte @@ -0,0 +1,336 @@ + + +
+
+
+

Global Leveling & Rewards

+

Configure account-wide global levels, unlockable titles, cosmetics, badges, and view global leaderboards.

+
+ +
+ +
+ +
+
+

Milestone Rewards ({rewards.length})

+ Granted automatically as players earn XP across all connected servers +
+ + {#if loading} +
Loading rewards...
+ {:else} +
+ {#each rewards as r} +
+
+ LVL + {r.level} +
+ +
+
+ {r.icon || '🎁'} + {r.reward_value} + {r.reward_type.replace('_', ' ').toUpperCase()} +
+

{r.description}

+
+ Requires ~{calcXp(r.level).toLocaleString()} Total XP +
+
+ +
+ + +
+
+ {/each} +
+ {/if} +
+ + +
+
+

Leveling Formula

+

+ Total XP required for level L is given by: +

+ XP = round(L ^ 1.5 × 100) +
    +
  • Level 5: {calcXp(5).toLocaleString()} XP
  • +
  • Level 10: {calcXp(10).toLocaleString()} XP
  • +
  • Level 25: {calcXp(25).toLocaleString()} XP
  • +
  • Level 50: {calcXp(50).toLocaleString()} XP
  • +
  • Level 100: {calcXp(100).toLocaleString()} XP
  • +
+
+ +
+

Top Global Players

+ {#if leaderboard.length === 0} +

No player progression recorded yet.

+ {:else} +
+ {#each leaderboard as p, i} +
+ #{i + 1} +
+ UUID: {p.uuid.slice(0, 8)}... + {p.current_xp.toLocaleString()} XP +
+ Lvl {p.level} +
+ {/each} +
+ {/if} +
+
+
+
+ +{#if draft} + +
{ e.preventDefault(); save(); }} class="form"> +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + +
+
+{/if} + +{#if deleteConfirm} + +

Are you sure you want to delete the Level {deleteConfirm.level} reward ({deleteConfirm.reward_value})?

+ +
+{/if} + + diff --git a/panel/web/src/pages/PublicLanding.svelte b/panel/web/src/pages/PublicLanding.svelte index 924a4bb..393f245 100644 --- a/panel/web/src/pages/PublicLanding.svelte +++ b/panel/web/src/pages/PublicLanding.svelte @@ -2,7 +2,7 @@ import { Download, Server, Users, Trophy, Sparkles, HelpCircle, Newspaper, Copy, Check, ExternalLink, ChevronDown, ChevronUp, Swords, Flame, - Pickaxe, Clock, Box, Shield, Zap, ArrowRight, LogIn, + Pickaxe, Clock, Box, Shield, Zap, ArrowRight, LogIn, Crown, } from '@lucide/svelte'; import { get, formatBytes } from '../lib/api'; import { session } from '../lib/session.svelte'; @@ -11,6 +11,8 @@ let config = $state(null); let publicServers = $state>([]); let leaderboard = $state>([]); + let globalLevels = $state>([]); + let publicGuilds = $state>([]); let instances = $state>([]); let news = $state>([]); let totalPlaytime = $state(0); @@ -81,6 +83,15 @@ news = res.branding?.news || []; }) .catch(() => {}); + + // 5. Load Global Levels & Guilds + get('/api/v1/levels/leaderboard') + .then((res) => (globalLevels = res || [])) + .catch(() => {}); + + get('/api/v1/guilds') + .then((res) => (publicGuilds = (res || []).slice(0, 6))) + .catch(() => {}); }); $effect(() => { @@ -294,6 +305,7 @@
{#each [ { id: 'playtime_secs', label: 'Playtime', icon: Clock }, + { id: 'global_level', label: 'Global Level', icon: Crown }, { id: 'player_kills', label: 'Player Kills', icon: Swords }, { id: 'mob_kills', label: 'Mob Kills', icon: Flame }, { id: 'blocks_broken', label: 'Blocks Mined', icon: Pickaxe }, @@ -305,7 +317,26 @@ {/each}
- {#if leaderboard.length} + {#if lbSort === 'global_level' && globalLevels.length} +
+
+ Rank + Player + Global Level + Total XP + Title +
+ {#each globalLevels.slice(0, 10) as row (row.uuid)} +
+ #{row.rank} + {row.username} + Lvl {row.global_level} + {row.global_xp.toLocaleString()} XP + {row.title || '—'} +
+ {/each} +
+ {:else if leaderboard.length} {#if top3.length > 0}
@@ -366,6 +397,38 @@

No leaderboard data recorded yet.

{/if} + + {#if publicGuilds.length > 0} +
+
+

Top Guilds & Claimed Territories

+

Form parties, recruit allies, and protect your faction's chunks.

+
+
+ {#each publicGuilds as g} +
+ {#if g.icon_url} + + {:else} +
+ +
+ {/if} +
+
+ {g.name} + [{g.tag}] +
+
+ {g.member_count} members + {g.claims_count} chunks +
+
+
+ {/each} +
+
+ {/if} diff --git a/panel/web/src/pages/QuestEditor.svelte b/panel/web/src/pages/QuestEditor.svelte new file mode 100644 index 0000000..be54df9 --- /dev/null +++ b/panel/web/src/pages/QuestEditor.svelte @@ -0,0 +1,400 @@ + + +
+
+
+

Quests Manager

+

Configure daily and weekly player objectives, XP rewards, and stat progression tracking.

+
+ +
+ +
+
+ Total Quests + {quests.length} +
+
+ Active Quests + {activeCount} +
+
+ Daily Quests + {dailyCount} +
+
+ Weekly Quests + {weeklyCount} +
+
+ +
+ + +
+
+ + + +
+ + +
+
+ + {#if loading} +
Loading quests...
+ {:else if filtered.length === 0} +
No quests match your filters.
+ {:else} +
+ + + + + + + + + + + + + + + {#each filtered as q} + + + + + + + + + + + {/each} + +
IconTitle & DescriptionTypeCategoryTarget ObjectiveXP RewardStatusActions
+ {q.icon || '🎯'} + + {q.title} +

{q.description}

+ {q.id} +
+ + {q.quest_type.toUpperCase()} + + + {q.category} + + + {q.target_count.toLocaleString()} + {q.stat_type} + + + + + +{q.xp_reward} XP + + + {#if q.active} + Active + {:else} + Disabled + {/if} + +
+ + +
+
+
+ {/if} +
+ +{#if draft} + q.id === draft?.id) ? 'Edit Quest' : 'New Quest'}> +
{ e.preventDefault(); save(); }} class="form"> +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ + +
+
+{/if} + +{#if deleteConfirm} + +

Are you sure you want to delete quest {deleteConfirm.title} ({deleteConfirm.id})?

+ +
+{/if} + +