feat: add global & server leveling, quests, achievements, guilds land-claiming, social & toggleable configs

This commit is contained in:
SCOPEDD committed 2026-09-29 03:24:27 -04:00
1 parent 4b79f69c20
commit b4cef85b1f
40 files changed
+10261 -27

No files matched your search

+200
View File
@@ -409,6 +409,206 @@ pub struct LaunchEvent {
pub username: Option<String>,
}
// ---------------------------------------------------------------------------
// 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<String>,
pub badges: Vec<String>,
pub server_levels: Vec<ServerLevelInfo>,
}
#[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<String>,
}
#[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<String>,
}
// ---------------------------------------------------------------------------
// 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<String>,
pub banner_url: Option<String>,
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<String>,
pub last_seen: Option<String>,
}
#[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<i64>,
pub server_name: Option<String>,
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<String>,
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<String>,
pub custom_badge: Option<String>,
pub title: Option<String>,
pub global_level: i64,
pub global_xp: i64,
pub server_levels: Vec<ServerLevelInfo>,
pub featured_achievement: Option<Achievement>,
pub achievements_count: usize,
pub posts: Vec<UserPost>,
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -0,0 +1,9 @@
package net.scopenet.integration;
public record ChunkCheckResult(
boolean claimed,
boolean allowed,
String guildName,
String guildTag
) {
}
@@ -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<String> 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<String> log, BiConsumer<UUID, String> 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();
@@ -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);
}
}
}
@@ -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"))
);
}
}
@@ -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) {
@@ -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());
}
@@ -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
+384 -1
View File
@@ -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<serde_json::V
}
resp.json().await.map_err(err)
}
// ---------------------------------------------------------------------------
// Leveling & Quests
// ---------------------------------------------------------------------------
#[tauri::command]
pub async fn get_my_level(state: State<'_, AppState>) -> Res<UserLevelInfo> {
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<Vec<UserQuest>> {
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<UserQuest> {
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<Vec<Achievement>> {
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<String>) -> Res<Vec<Guild>> {
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<String>) -> Res<Option<Guild>> {
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<Guild> {
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<String>,
) -> Res<Vec<GuildClaim>> {
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<GuildClaim> {
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<Vec<GuildPost>> {
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<GuildPost> {
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<Vec<GuildMember>> {
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<Vec<FriendInfo>> {
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<Vec<DirectMessage>> {
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<DirectMessage> {
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<Vec<GameInvite>> {
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<i64>,
) -> Res<GameInvite> {
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<UserProfileView> {
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<String>,
custom_badge: Option<String>,
featured_achievement_id: Option<String>,
) -> Res<UserProfileView> {
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<String>) -> Res<Vec<UserPost>> {
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<String>,
) -> Res<UserPost> {
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(())
}
+27
View File
@@ -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");
+13
View File
@@ -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 @@
<Settings />
{:else if app.view === 'stats'}
<Stats />
{:else if app.view === 'quests'}
<Quests />
{:else if app.view === 'guilds'}
<Guilds />
{:else if app.view === 'social'}
<Social />
{:else}
<Home />
{/if}
@@ -66,6 +76,9 @@
<SignIn />
</Modal>
<InstanceSettings />
{#if app.viewProfileUuid}
<PlayerProfileModal uuid={app.viewProfileUuid} onclose={() => (app.viewProfileUuid = null)} />
{/if}
<CrashDialog />
<Toasts />
+492
View File
@@ -0,0 +1,492 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
Shield, Lock, Plus, Trash2, Compass, ArrowUp, ArrowDown, ArrowLeft, ArrowRight,
MapPin, Globe, LoaderCircle, CheckCircle2, AlertTriangle, RefreshCw
} from '@lucide/svelte';
import { invoke } from '../lib/tauri';
import { toast } from '../lib/store.svelte';
import type { GuildClaim } from '../lib/types';
let {
instanceId,
myGuildId = null,
myGuildTag = '',
onclaim,
onunclaim
}: {
instanceId: string;
myGuildId?: string | null;
myGuildTag?: string;
onclaim?: (claim: GuildClaim) => void;
onunclaim?: (claimId: number) => void;
} = $props();
let dimension = $state<'minecraft:overworld' | 'minecraft:the_nether' | 'minecraft:the_end'>('minecraft:overworld');
let centerX = $state(0);
let centerZ = $state(0);
let radius = $state(5); // 11x11 grid
let claims = $state<GuildClaim[]>([]);
let loading = $state(false);
let claiming = $state(false);
let hoveredChunk = $state<{ x: number; z: number } | null>(null);
let selectedChunk = $state<{ x: number; z: number } | null>(null);
const dimensions = [
{ id: 'minecraft:overworld', label: 'Overworld', icon: Globe },
{ id: 'minecraft:the_nether', label: 'The Nether', icon: Compass },
{ id: 'minecraft:the_end', label: 'The End', icon: Compass }
] as const;
async function loadClaims() {
if (!instanceId) return;
loading = true;
try {
claims = await invoke<GuildClaim[]>('get_guild_claims', {
instanceId,
dimension
});
} catch (e: any) {
toast(e?.message ?? 'Failed to load territory claims', 'error');
} finally {
loading = false;
}
}
function getClaimAt(x: number, z: number): GuildClaim | undefined {
return claims.find((c) => c.chunk_x === x && c.chunk_z === z && c.dimension === dimension);
}
const selectedClaim = $derived(
selectedChunk ? getClaimAt(selectedChunk.x, selectedChunk.z) : null
);
const myClaimsCount = $derived(
myGuildId ? claims.filter((c) => c.guild_id === myGuildId).length : 0
);
async function handleClaim() {
if (!selectedChunk || !myGuildId) return;
claiming = true;
try {
const claim = await invoke<GuildClaim>('claim_guild_chunk', {
instanceId,
dimension,
chunkX: selectedChunk.x,
chunkZ: selectedChunk.z
});
claims = [...claims, claim];
toast(`Successfully claimed chunk [${selectedChunk.x}, ${selectedChunk.z}]!`, 'ok');
onclaim?.(claim);
} catch (e: any) {
toast(e?.message ?? 'Failed to claim chunk', 'error');
} finally {
claiming = false;
}
}
async function handleUnclaim() {
if (!selectedClaim) return;
claiming = true;
try {
await invoke('unclaim_guild_chunk', { claimId: selectedClaim.id });
claims = claims.filter((c) => c.id !== selectedClaim.id);
toast(`Unclaimed chunk [${selectedClaim.chunk_x}, ${selectedClaim.chunk_z}]`, 'ok');
onunclaim?.(selectedClaim.id);
selectedChunk = null;
} catch (e: any) {
toast(e?.message ?? 'Failed to unclaim chunk', 'error');
} finally {
claiming = false;
}
}
function pan(dx: number, dz: number) {
centerX += dx;
centerZ += dz;
}
function resetCenter() {
centerX = 0;
centerZ = 0;
}
$effect(() => {
if (instanceId && dimension) {
loadClaims();
}
});
onMount(() => {
loadClaims();
});
</script>
<div class="chunk-map-container glass">
<!-- Controls Bar -->
<div class="map-controls">
<div class="dim-selector">
{#each dimensions as dim}
{@const DimIcon = dim.icon}
<button
class="dim-btn"
class:active={dimension === dim.id}
onclick={() => { dimension = dim.id; selectedChunk = null; }}
>
<DimIcon size={14} />
<span>{dim.label}</span>
</button>
{/each}
</div>
<div class="nav-controls">
<span class="coord-label">Center: [{centerX}, {centerZ}]</span>
<div class="nav-buttons">
<button class="ghost icon sm" onclick={() => pan(0, -2)} title="Pan North"><ArrowUp size={14} /></button>
<div class="nav-row">
<button class="ghost icon sm" onclick={() => pan(-2, 0)} title="Pan West"><ArrowLeft size={14} /></button>
<button class="ghost icon sm" onclick={resetCenter} title="Center at (0,0)"><MapPin size={14} /></button>
<button class="ghost icon sm" onclick={() => pan(2, 0)} title="Pan East"><ArrowRight size={14} /></button>
</div>
<button class="ghost icon sm" onclick={() => pan(0, 2)} title="Pan South"><ArrowDown size={14} /></button>
</div>
<button class="ghost icon sm refresh-btn" onclick={loadClaims} title="Refresh Claims">
<RefreshCw size={14} class={loading ? 'spin' : ''} />
</button>
</div>
</div>
<!-- Main Chunk Map Canvas / Grid -->
<div class="grid-wrapper">
<div
class="chunk-grid"
style:grid-template-columns="repeat({radius * 2 + 1}, minmax(36px, 1fr))"
style:grid-template-rows="repeat({radius * 2 + 1}, minmax(36px, 1fr))"
>
{#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}
<button
class="chunk-cell"
class:my-claim={isMyClaim}
class:other-claim={isOtherClaim}
class:selected={isSelected}
class:is-center={isCenter}
onmouseenter={() => (hoveredChunk = { x: cx, z: cz })}
onmouseleave={() => (hoveredChunk = null)}
onclick={() => (selectedChunk = { x: cx, z: cz })}
title="Chunk [{cx}, {cz}] (Blocks {cx * 16}..{(cx + 1) * 16 - 1}, {cz * 16}..{(cz + 1) * 16 - 1})"
>
{#if isMyClaim}
<Shield size={16} class="claim-icon mine" />
<span class="cell-tag">{claim.guild_tag || myGuildTag || 'YOU'}</span>
{:else if isOtherClaim}
<Lock size={15} class="claim-icon rival" />
<span class="cell-tag">{claim.guild_tag || 'GUILD'}</span>
{:else if isCenter}
<span class="spawn-dot" title="World Origin"></span>
{/if}
</button>
{/each}
{/each}
</div>
</div>
<!-- Legend & Action Bar -->
<div class="map-footer">
<div class="info-block">
{#if selectedChunk}
<div class="selected-details">
<span class="chunk-title">Selected: Chunk <strong>[{selectedChunk.x}, {selectedChunk.z}]</strong></span>
<span class="block-range muted tiny">
X: {selectedChunk.x * 16}..{(selectedChunk.x + 1) * 16 - 1}, Z: {selectedChunk.z * 16}..{(selectedChunk.z + 1) * 16 - 1}
</span>
{#if selectedClaim}
<div class="claim-status">
{#if myGuildId && selectedClaim.guild_id === myGuildId}
<span class="badge mine"><Shield size={12} /> Claimed by Your Guild</span>
<button
class="danger sm action-btn"
onclick={handleUnclaim}
disabled={claiming}
>
<Trash2 size={13} /> Unclaim
</button>
{:else}
<span class="badge rival"><Lock size={12} /> Claimed by {selectedClaim.guild_name || 'Rival Guild'} [{selectedClaim.guild_tag || '?'}]</span>
<span class="tiny muted protected-note">Grief Protection Active</span>
{/if}
</div>
{:else}
<div class="unclaimed-status">
<span class="badge unclaimed">Wilderness (Unclaimed)</span>
{#if myGuildId}
<button
class="primary sm action-btn"
onclick={handleClaim}
disabled={claiming}
>
{#if claiming}<LoaderCircle size={13} class="spin" />{:else}<Plus size={13} />{/if}
Claim Chunk
</button>
{:else}
<span class="tiny muted">Join or create a guild to claim land</span>
{/if}
</div>
{/if}
</div>
{:else if hoveredChunk}
<div class="hover-info">
<span>Chunk: <strong>[{hoveredChunk.x}, {hoveredChunk.z}]</strong></span>
<span class="muted tiny">Click to view details or claim</span>
</div>
{:else}
<div class="hint-info muted tiny">
<span>Click any grid square to inspect or claim chunks. Claimed chunks are protected in-game against block edits by other players.</span>
</div>
{/if}
</div>
<div class="legend">
<div class="legend-item"><span class="legend-box mine"></span><span>My Guild ({myClaimsCount})</span></div>
<div class="legend-item"><span class="legend-box rival"></span><span>Rival Guild</span></div>
<div class="legend-item"><span class="legend-box wild"></span><span>Wilderness</span></div>
</div>
</div>
</div>
<style>
.chunk-map-container {
display: flex;
flex-direction: column;
gap: 0.8rem;
padding: 1rem;
border-radius: var(--radius);
background: color-mix(in srgb, var(--surface) 80%, transparent);
border: 1px solid var(--line);
user-select: none;
}
.map-controls {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.6rem;
}
.dim-selector {
display: flex;
background: color-mix(in srgb, var(--bg) 60%, transparent);
padding: 0.2rem;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
gap: 0.2rem;
}
.dim-btn {
display: flex;
align-items: center;
gap: 0.35rem;
background: transparent;
border: none;
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
font-weight: 560;
color: var(--muted);
border-radius: calc(var(--radius-sm) - 2px);
transition: all 0.15s;
}
.dim-btn.active {
background: color-mix(in srgb, var(--accent) 15%, transparent);
color: var(--text);
}
.nav-controls {
display: flex;
align-items: center;
gap: 0.6rem;
}
.coord-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.nav-buttons {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
}
.nav-row {
display: flex;
gap: 0.1rem;
}
.refresh-btn {
margin-left: 0.3rem;
}
.grid-wrapper {
overflow: auto;
display: flex;
justify-content: center;
padding: 0.5rem;
background: color-mix(in srgb, var(--bg) 80%, black);
border-radius: var(--radius-sm);
border: 1px solid color-mix(in srgb, var(--line) 60%, transparent);
max-height: 24rem;
}
.chunk-grid {
display: grid;
gap: 2px;
}
.chunk-cell {
width: 38px;
height: 38px;
padding: 0;
border-radius: 4px;
background: color-mix(in srgb, var(--surface) 60%, transparent);
border: 1px solid color-mix(in srgb, var(--line) 40%, transparent);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
cursor: pointer;
transition: transform 0.1s, border-color 0.15s, background 0.15s;
}
.chunk-cell:hover {
transform: scale(1.08);
z-index: 10;
border-color: var(--text);
background: color-mix(in srgb, var(--text) 12%, transparent);
}
.chunk-cell.selected {
border-color: var(--accent);
box-shadow: 0 0 8px var(--accent);
z-index: 15;
}
.chunk-cell.is-center::after {
content: '';
position: absolute;
width: 5px;
height: 5px;
border-radius: 50%;
background: #f59e0b;
}
.chunk-cell.my-claim {
background: color-mix(in srgb, var(--success) 22%, transparent);
border-color: color-mix(in srgb, var(--success) 70%, transparent);
}
.chunk-cell.other-claim {
background: color-mix(in srgb, var(--danger) 22%, transparent);
border-color: color-mix(in srgb, var(--danger) 70%, transparent);
}
:global(.claim-icon.mine) {
color: var(--success);
}
:global(.claim-icon.rival) {
color: var(--danger);
}
.cell-tag {
font-size: 0.55rem;
font-weight: 700;
line-height: 1;
margin-top: 1px;
max-width: 34px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-transform: uppercase;
}
.map-footer {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.8rem;
padding-top: 0.5rem;
border-top: 1px solid var(--line);
}
.info-block {
flex: 1;
min-width: 14rem;
}
.selected-details {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.chunk-title {
font-size: 0.88rem;
}
.claim-status, .unclaimed-status {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
margin-top: 0.2rem;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
padding: 0.35rem 0.75rem;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
font-weight: 600;
padding: 0.18rem 0.55rem;
border-radius: 99rem;
}
.badge.mine {
background: color-mix(in srgb, var(--success) 18%, transparent);
color: var(--success);
}
.badge.rival {
background: color-mix(in srgb, var(--danger) 18%, transparent);
color: var(--danger);
}
.badge.unclaimed {
background: color-mix(in srgb, var(--text) 10%, transparent);
color: var(--muted);
}
.protected-note {
font-style: italic;
}
.legend {
display: flex;
align-items: center;
gap: 1rem;
font-size: 0.76rem;
color: var(--muted);
}
.legend-item {
display: flex;
align-items: center;
gap: 0.35rem;
}
.legend-box {
width: 12px;
height: 12px;
border-radius: 3px;
border: 1px solid transparent;
}
.legend-box.mine {
background: color-mix(in srgb, var(--success) 35%, transparent);
border-color: var(--success);
}
.legend-box.rival {
background: color-mix(in srgb, var(--danger) 35%, transparent);
border-color: var(--danger);
}
.legend-box.wild {
background: color-mix(in srgb, var(--surface) 80%, transparent);
border-color: var(--line);
}
</style>
@@ -0,0 +1,824 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
X, Trophy, Shield, Heart, Image as ImageIcon, Send, Edit3, Check,
Clock, Swords, Flame, Pickaxe, Box, LoaderCircle, Award, Star, MessageSquare
} from '@lucide/svelte';
import Avatar from './Avatar.svelte';
import Modal from './Modal.svelte';
import { activeAccount, app, toast } from '../lib/store.svelte';
import { invoke } from '../lib/tauri';
import type { UserProfileView, UserPost } from '../lib/types';
let {
uuid,
onclose
}: {
uuid: string;
onclose: () => void;
} = $props();
let profile = $state<UserProfileView | null>(null);
let loading = $state(true);
let activeTab = $state<'feed' | 'progression' | 'achievements' | 'stats'>('feed');
// Edit Profile State
let editing = $state(false);
let editBio = $state('');
let editBanner = $state('');
let savingProfile = $state(false);
// New Post State
let newPostContent = $state('');
let newPostImageUrl = $state('');
let posting = $state(false);
const isMe = $derived(activeAccount()?.uuid === uuid);
async function loadProfile() {
loading = true;
try {
profile = await invoke<UserProfileView>('get_user_profile', { uuid });
if (profile) {
editBio = profile.bio || '';
editBanner = profile.banner_url || '';
}
} catch (e: any) {
toast(e?.message ?? 'Failed to load player profile', 'error');
} finally {
loading = false;
}
}
async function handleSaveProfile() {
savingProfile = true;
try {
profile = await invoke<UserProfileView>('update_my_profile', {
bio: editBio.trim() || null,
bannerUrl: editBanner.trim() || null
});
editing = false;
toast('Profile updated successfully!', 'ok');
} catch (e: any) {
toast(e?.message ?? 'Failed to save profile', 'error');
} finally {
savingProfile = false;
}
}
async function handleCreatePost() {
if (!newPostContent.trim()) return;
posting = true;
try {
const post = await invoke<UserPost>('create_user_post', {
content: newPostContent.trim(),
imageUrl: newPostImageUrl.trim() || null
});
if (profile) {
profile.posts = [post, ...profile.posts];
}
newPostContent = '';
newPostImageUrl = '';
toast('Post published!', 'ok');
} catch (e: any) {
toast(e?.message ?? 'Failed to create post', 'error');
} finally {
posting = false;
}
}
async function handleLikePost(post: UserPost) {
try {
const liked = await invoke<boolean>('like_user_post', { postId: post.id });
post.liked_by_me = liked;
post.likes_count = liked ? post.likes_count + 1 : Math.max(0, post.likes_count - 1);
} catch (e: any) {
toast(e?.message ?? 'Failed to update like', 'error');
}
}
function formatPlaytime(secs: number): string {
if (!secs || secs < 60) return `${secs || 0}s`;
const hours = Math.floor(secs / 3600);
const mins = Math.floor((secs % 3600) / 60);
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
}
onMount(() => {
loadProfile();
});
</script>
<div class="profile-backdrop" role="presentation" onclick={(e) => e.target === e.currentTarget && onclose()}>
<div class="profile-modal glass" role="dialog" aria-modal="true" aria-label="Player Profile">
<button class="ghost icon close-btn" onclick={onclose} aria-label="Close Profile">
<X size={18} />
</button>
{#if loading || !profile}
<div class="center-state"><LoaderCircle class="spin" size={32} /></div>
{:else}
<!-- Banner & Profile Header -->
<div
class="profile-banner"
style:background-image={profile.banner_url ? `linear-gradient(rgba(0,0,0,0.4), rgba(0,0,0,0.8)), url(${profile.banner_url})` : undefined}
>
<div class="header-overlay">
<div class="user-main">
<div class="avatar-ring">
<Avatar uuid={profile.uuid} name={profile.username} size={4.2} />
</div>
<div class="user-titles">
<div class="user-row">
<h2>{profile.username}</h2>
{#if profile.global_level?.title}
<span class="badge title-badge"><Award size={12} /> {profile.global_level.title}</span>
{/if}
</div>
<div class="meta-row tiny muted">
<span>Joined {new Date(profile.created_at).toLocaleDateString()}</span>
<span>•</span>
<span>{profile.friends_count} Friends</span>
<span>•</span>
<span class="global-lvl-tag">Level {profile.global_level?.level ?? 1}</span>
</div>
</div>
</div>
{#if isMe}
<button class="ghost sm edit-btn" onclick={() => (editing = !editing)}>
<Edit3 size={14} /> {editing ? 'Cancel' : 'Edit Profile'}
</button>
{/if}
</div>
</div>
<!-- Bio / Edit Form -->
<div class="bio-section">
{#if editing}
<div class="edit-profile-box glass">
<label>
<span class="tiny bold">Bio</span>
<textarea bind:value={editBio} placeholder="Tell other players about yourself..." rows="2"></textarea>
</label>
<label>
<span class="tiny bold">Banner Image URL</span>
<input type="url" bind:value={editBanner} placeholder="https://example.com/banner.jpg" />
</label>
<div class="edit-actions">
<button class="ghost sm" onclick={() => (editing = false)}>Cancel</button>
<button class="primary sm" onclick={handleSaveProfile} disabled={savingProfile}>
{#if savingProfile}<LoaderCircle size={13} class="spin" />{:else}<Check size={13} />{/if}
Save
</button>
</div>
</div>
{:else if profile.bio}
<p class="bio-text">{profile.bio}</p>
{/if}
{#if profile.badges && profile.badges.length > 0}
<div class="badges-list">
{#each profile.badges as badge}
<span class="badge custom-badge"><Star size={11} /> {badge}</span>
{/each}
</div>
{/if}
</div>
<!-- Segmented Profile Tabs -->
<div class="profile-tabs">
<button class:active={activeTab === 'feed'} onclick={() => (activeTab = 'feed')}>
<MessageSquare size={14} /> Posts & Feed
</button>
<button class:active={activeTab === 'progression'} onclick={() => (activeTab = 'progression')}>
<Award size={14} /> Levels & Ranks
</button>
<button class:active={activeTab === 'achievements'} onclick={() => (activeTab = 'achievements')}>
<Trophy size={14} /> Achievements ({profile.achievements.length})
</button>
<button class:active={activeTab === 'stats'} onclick={() => (activeTab = 'stats')}>
<Swords size={14} /> Stats
</button>
</div>
<!-- Tab Content Area -->
<div class="modal-tab-content">
{#if activeTab === 'feed'}
<div class="feed-container">
<!-- Create Post (if viewing self) -->
{#if isMe}
<div class="create-post-box glass">
<textarea
bind:value={newPostContent}
placeholder="Share a milestone, screenshot, or status update..."
rows="2"
class="post-input"
></textarea>
<div class="post-controls">
<div class="image-input-wrap">
<ImageIcon size={14} class="muted" />
<input
type="url"
placeholder="Photo URL (optional)"
bind:value={newPostImageUrl}
class="img-url-input"
/>
</div>
<button
class="primary sm"
onclick={handleCreatePost}
disabled={posting || !newPostContent.trim()}
>
{#if posting}<LoaderCircle size={13} class="spin" />{:else}<Send size={13} />{/if}
Post
</button>
</div>
</div>
{/if}
<!-- User Posts List -->
{#if profile.posts.length === 0}
<div class="empty-state">
<p class="muted">No posts yet from {profile.username}.</p>
</div>
{:else}
<div class="posts-stream">
{#each profile.posts as post (post.id)}
<div class="post-card glass">
<div class="post-top">
<Avatar uuid={profile.uuid} name={profile.username} size={2} />
<div class="post-author-meta">
<span class="author-name">{profile.username}</span>
<span class="post-time tiny muted">{new Date(post.created_at).toLocaleString()}</span>
</div>
</div>
<p class="post-body">{post.content}</p>
{#if post.image_url}
<div class="post-photo-wrap">
<img src={post.image_url} alt="Post Attachment" class="post-photo" />
</div>
{/if}
<div class="post-footer">
<button
class="ghost sm like-btn"
class:liked={post.liked_by_me}
onclick={() => handleLikePost(post)}
>
<Heart size={14} fill={post.liked_by_me ? 'currentColor' : 'none'} />
<span>{post.likes_count}</span>
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
{:else if activeTab === 'progression'}
<!-- Progression Tab -->
<div class="progression-view">
<!-- Global Level Card -->
<div class="prog-card glass">
<div class="prog-header">
<div>
<span class="tiny bold uppercase muted">Account Progression</span>
<h3>SCOPENET Global Level</h3>
</div>
<div class="level-pill">
Level {profile.global_level.level}
</div>
</div>
<div class="xp-bar-wrap">
<div class="xp-numbers tiny">
<span>{profile.global_level.current_xp.toLocaleString()} XP</span>
<span>{profile.global_level.next_level_xp.toLocaleString()} XP</span>
</div>
<div class="xp-bar">
<div class="xp-fill" style:width="{profile.global_level.progress_pct}%"></div>
</div>
</div>
</div>
<!-- Server Levels List -->
{#if profile.server_levels && profile.server_levels.length > 0}
<h4 class="subhead">Server Specific Levels</h4>
<div class="servers-levels-grid">
{#each profile.server_levels as sl}
<div class="server-lvl-card glass">
<div class="sl-top">
<div>
<strong>{sl.server_name}</strong>
{#if sl.rank_title}
<span class="badge rank-badge">{sl.rank_title}</span>
{/if}
</div>
<span class="lvl-badge">Lv. {sl.level}</span>
</div>
<div class="xp-bar">
<div class="xp-fill" style:width="{sl.progress_pct}%"></div>
</div>
<span class="tiny muted">{sl.current_xp} / {sl.next_level_xp} XP</span>
</div>
{/each}
</div>
{:else}
<p class="muted tiny">No server levels recorded yet.</p>
{/if}
</div>
{:else if activeTab === 'achievements'}
<!-- Achievements Tab -->
<div class="achievements-view">
{#if profile.achievements.length === 0}
<div class="empty-state">
<Trophy size={32} class="muted" />
<p class="muted">No achievements unlocked yet.</p>
</div>
{:else}
<div class="ach-grid">
{#each profile.achievements as ach (ach.id)}
<div class="ach-item glass" class:frame-challenge={ach.frame_type === 'challenge'}>
<div class="ach-icon-box" style:background={ach.icon_bg || 'rgba(0,0,0,0.5)'}>
<span>{ach.icon_item || '🏆'}</span>
</div>
<div class="ach-meta">
<span class="ach-name">{ach.title}</span>
<p class="ach-sub tiny muted">{ach.description}</p>
<span class="tiny xp-label">+{ach.xp_reward} XP</span>
</div>
</div>
{/each}
</div>
{/if}
</div>
{:else if activeTab === 'stats'}
<!-- Stats Tab -->
<div class="stats-view">
{#if profile.stats}
<div class="stats-cards-grid">
<div class="stat-mini-card glass">
<Clock size={16} class="muted" />
<span class="tiny muted">Playtime</span>
<strong>{formatPlaytime(profile.stats.playtime_secs)}</strong>
</div>
<div class="stat-mini-card glass">
<Swords size={16} class="accent" />
<span class="tiny muted">Player Kills</span>
<strong>{profile.stats.player_kills.toLocaleString()}</strong>
</div>
<div class="stat-mini-card glass">
<Flame size={16} class="warn" />
<span class="tiny muted">Mob Kills</span>
<strong>{profile.stats.mob_kills.toLocaleString()}</strong>
</div>
<div class="stat-mini-card glass">
<Pickaxe size={16} class="success" />
<span class="tiny muted">Blocks Mined</span>
<strong>{profile.stats.blocks_broken.toLocaleString()}</strong>
</div>
<div class="stat-mini-card glass">
<Box size={16} class="muted" />
<span class="tiny muted">Blocks Placed</span>
<strong>{profile.stats.blocks_placed.toLocaleString()}</strong>
</div>
<div class="stat-mini-card glass">
<Award size={16} class="accent" />
<span class="tiny muted">Server Joins</span>
<strong>{profile.stats.joins.toLocaleString()}</strong>
</div>
</div>
{:else}
<div class="empty-state">
<p class="muted">No statistics recorded yet for this player.</p>
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
</div>
<style>
.profile-backdrop {
position: fixed;
inset: 0;
z-index: 85;
display: grid;
place-items: center;
padding: 1.5rem;
background: color-mix(in srgb, var(--bg) 75%, transparent);
animation: fade 0.15s ease;
}
.profile-modal {
width: 100%;
max-width: 36rem;
max-height: calc(100vh - 4rem);
overflow-y: auto;
background: var(--surface);
border: 1px solid var(--line-strong);
border-radius: var(--radius);
position: relative;
box-shadow: 0 1rem 3rem -1rem rgba(0, 0, 0, 0.6);
}
.close-btn {
position: absolute;
top: 0.8rem;
right: 0.8rem;
z-index: 10;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
}
.profile-banner {
height: 9rem;
background-color: color-mix(in srgb, var(--accent) 25%, black);
background-size: cover;
background-position: center;
position: relative;
display: flex;
align-items: flex-end;
}
.header-overlay {
width: 100%;
padding: 1rem 1.4rem;
display: flex;
justify-content: space-between;
align-items: flex-end;
background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, transparent 100%);
}
.user-main {
display: flex;
align-items: flex-end;
gap: 1rem;
}
.avatar-ring {
border: 3px solid var(--surface);
border-radius: 28%;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
background: var(--surface);
}
.user-titles {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.user-row {
display: flex;
align-items: center;
gap: 0.6rem;
}
.user-row h2 {
font-size: 1.3rem;
font-weight: 750;
margin: 0;
}
.title-badge {
background: color-mix(in srgb, #f59e0b 25%, transparent);
color: #f59e0b;
}
.meta-row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.global-lvl-tag {
color: var(--accent);
font-weight: 650;
}
.edit-btn {
display: flex;
align-items: center;
gap: 0.35rem;
background: rgba(0, 0, 0, 0.5);
}
.bio-section {
padding: 0.9rem 1.4rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
border-bottom: 1px solid var(--line);
}
.bio-text {
font-size: 0.88rem;
color: color-mix(in srgb, var(--text) 90%, transparent);
line-height: 1.4;
margin: 0;
}
.edit-profile-box {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.9rem;
border-radius: var(--radius-sm);
}
.edit-profile-box textarea, .edit-profile-box input {
background: color-mix(in srgb, var(--bg) 60%, transparent);
border: 1px solid var(--line);
color: var(--text);
padding: 0.4rem 0.6rem;
border-radius: var(--radius-sm);
font-size: 0.82rem;
}
.edit-actions {
display: flex;
justify-content: flex-end;
gap: 0.4rem;
}
.badges-list {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.custom-badge {
background: color-mix(in srgb, var(--text) 8%, transparent);
font-size: 0.72rem;
}
.profile-tabs {
display: flex;
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--surface) 90%, black);
padding: 0 1rem;
gap: 0.3rem;
}
.profile-tabs button {
display: flex;
align-items: center;
gap: 0.35rem;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
padding: 0.65rem 0.9rem;
font-size: 0.82rem;
font-weight: 560;
color: var(--muted);
border-radius: 0;
transition: all 0.15s;
}
.profile-tabs button.active {
color: var(--text);
border-bottom-color: var(--accent);
}
.modal-tab-content {
padding: 1.2rem 1.4rem;
}
/* Feed Tab */
.feed-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.create-post-box {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.9rem;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
}
.post-input {
background: color-mix(in srgb, var(--bg) 50%, transparent);
border: 1px solid var(--line);
color: var(--text);
padding: 0.5rem 0.7rem;
border-radius: var(--radius-sm);
font-size: 0.85rem;
resize: vertical;
}
.post-controls {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.6rem;
}
.image-input-wrap {
flex: 1;
display: flex;
align-items: center;
gap: 0.4rem;
background: color-mix(in srgb, var(--bg) 50%, transparent);
border: 1px solid var(--line);
padding: 0.25rem 0.6rem;
border-radius: var(--radius-sm);
}
.img-url-input {
flex: 1;
background: transparent;
border: none;
color: var(--text);
font-size: 0.8rem;
outline: none;
}
.posts-stream {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.post-card {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 1rem;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
}
.post-top {
display: flex;
align-items: center;
gap: 0.6rem;
}
.author-name {
font-size: 0.85rem;
font-weight: 650;
display: block;
}
.post-body {
font-size: 0.86rem;
line-height: 1.4;
margin: 0;
}
.post-photo-wrap {
border-radius: var(--radius-sm);
overflow: hidden;
max-height: 16rem;
}
.post-photo {
width: 100%;
height: 100%;
object-fit: cover;
}
.post-footer {
display: flex;
justify-content: flex-start;
}
.like-btn {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
color: var(--muted);
}
.like-btn.liked {
color: #e11d48;
}
/* Progression */
.progression-view {
display: flex;
flex-direction: column;
gap: 1rem;
}
.prog-card {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 1.1rem;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
}
.prog-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.prog-header h3 {
margin: 0.1rem 0 0;
font-size: 1.1rem;
}
.level-pill {
padding: 0.3rem 0.7rem;
border-radius: 99rem;
background: color-mix(in srgb, var(--accent) 20%, transparent);
color: var(--accent);
font-weight: 700;
font-size: 0.88rem;
}
.xp-bar-wrap {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.xp-numbers {
display: flex;
justify-content: space-between;
color: var(--muted);
}
.xp-bar {
width: 100%;
height: 0.45rem;
background: color-mix(in srgb, var(--text) 10%, transparent);
border-radius: 99rem;
overflow: hidden;
}
.xp-fill {
height: 100%;
background: var(--accent);
border-radius: 99rem;
}
.subhead {
font-size: 0.88rem;
font-weight: 700;
}
.servers-levels-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
gap: 0.8rem;
}
.server-lvl-card {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.8rem;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
}
.sl-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.lvl-badge {
font-weight: 700;
color: var(--accent);
font-size: 0.82rem;
}
.rank-badge {
background: color-mix(in srgb, #f59e0b 20%, transparent);
color: #f59e0b;
font-size: 0.65rem;
}
/* Achievements */
.ach-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
gap: 0.8rem;
}
.ach-item {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.75rem;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
}
.ach-icon-box {
width: 2.2rem;
height: 2.2rem;
border-radius: 4px;
display: grid;
place-items: center;
font-size: 1.1rem;
flex-shrink: 0;
}
.ach-meta {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.ach-name {
font-size: 0.84rem;
font-weight: 700;
}
.ach-sub {
margin: 0.1rem 0;
line-height: 1.25;
}
.xp-label {
color: var(--accent);
font-weight: 700;
}
/* Stats Grid */
.stats-cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
gap: 0.8rem;
}
.stat-mini-card {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.8rem;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
}
.stat-mini-card strong {
font-size: 1.1rem;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
font-weight: 600;
padding: 0.16rem 0.5rem;
border-radius: 99rem;
}
.center-state, .empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.8rem;
padding: 2.5rem 1rem;
text-align: center;
}
</style>
+11 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Settings, Plus, Check, LogOut, Home, Trophy } from '@lucide/svelte';
import { Settings, Plus, Check, LogOut, Home, Trophy, Target, Shield, Users } from '@lucide/svelte';
import Avatar from './Avatar.svelte';
import { abs, activeAccount, app, instances, removeAccount, selectInstance, switchAccount } from '../lib/store.svelte';
@@ -27,9 +27,18 @@
</div>
<div class="bottom">
{#if app.view === 'settings' || app.view === 'stats'}
{#if app.view !== 'home'}
<button class="ghost icon big" onclick={() => (app.view = 'home')} aria-label="Home" title="Home"><Home size={20} /></button>
{/if}
<button class="ghost icon big" class:on={app.view === 'quests'} onclick={() => (app.view = 'quests')} aria-label="Quests & Objectives" title="Quests & Objectives">
<Target size={19} />
</button>
<button class="ghost icon big" class:on={app.view === 'guilds'} onclick={() => (app.view = 'guilds')} aria-label="Guilds & Claims" title="Guilds & Claims">
<Shield size={19} />
</button>
<button class="ghost icon big" class:on={app.view === 'social'} onclick={() => (app.view = 'social')} aria-label="Friends & Social" title="Friends & Social">
<Users size={19} />
</button>
<button class="ghost icon big" class:on={app.view === 'stats'} onclick={() => (app.view = 'stats')} aria-label="Stats & Leaderboards" title="Stats & Leaderboards">
<Trophy size={19} />
</button>
+117
View File
@@ -181,6 +181,123 @@ export async function mockInvoke(cmd: string, args: Record<string, any>): Promis
return null;
case 'detect_java':
return 21;
case 'get_my_level':
return { uuid: 'mock-user-1', level: 14, current_xp: 3450, next_level_xp: 5000, progress_pct: 69, title: 'Veteran Adventurer', badges: ['Beta Pioneer', 'Dragon Slayer'], rewards: [] };
case 'get_my_quests':
return [
{ quest: { id: 'q1', title: 'Deepslate Quarry', description: 'Mine 50 deepslate blocks in the caverns.', quest_type: 'daily', category: 'mining', xp_reward: 120, stat_type: 'mine_deepslate', target_count: 50, icon: 'pickaxe', active: true }, current_count: 35, completed: false, claimed: false, expires_at: '2026-10-01T00:00:00Z' },
{ quest: { id: 'q2', title: 'Night Patrol', description: 'Defeat 15 hostile monsters.', quest_type: 'daily', category: 'combat', xp_reward: 150, stat_type: 'kill_monsters', target_count: 15, icon: 'sword', active: true }, current_count: 15, completed: true, claimed: false, expires_at: '2026-10-01T00:00:00Z' },
{ quest: { id: 'q3', title: 'Territorial Fortress', description: 'Place 250 building blocks within claimed territory.', quest_type: 'weekly', category: 'building', xp_reward: 500, stat_type: 'place_blocks', target_count: 250, icon: 'hammer', active: true }, current_count: 180, completed: false, claimed: false, expires_at: '2026-10-05T00:00:00Z' },
];
case 'claim_quest':
return { uuid: 'mock-user-1', level: 14, current_xp: 3600, next_level_xp: 5000, progress_pct: 72, title: 'Veteran Adventurer', badges: ['Beta Pioneer'], rewards: [] };
case 'get_my_achievements':
return [
{ id: 'ach1', title: 'Stone Age', description: 'Mine your first piece of cobblestone.', category: 'mining', xp_reward: 50, frame_type: 'task', icon_item: '🪨', icon_bg: '#334155', icon_border: '#64748b', unlocked: true, unlocked_at: '2026-09-20T12:00:00Z' },
{ id: 'ach2', title: 'Acquire Hardware', description: 'Smelt an iron ingot in a furnace.', category: 'mining', xp_reward: 100, frame_type: 'task', icon_item: '⛓️', icon_bg: '#475569', icon_border: '#94a3b8', unlocked: true, unlocked_at: '2026-09-21T14:30:00Z' },
{ id: 'ach3', title: 'Diamonds!', description: 'Acquire diamonds from deep underground.', category: 'mining', xp_reward: 350, frame_type: 'goal', icon_item: '💎', icon_bg: '#0284c7', icon_border: '#38bdf8', unlocked: true, unlocked_at: '2026-09-24T18:15:00Z' },
{ id: 'ach4', title: 'Free the End', description: 'Defeat the Ender Dragon in the End dimension.', category: 'combat', xp_reward: 1000, frame_type: 'challenge', icon_item: '🐉', icon_bg: '#581c87', icon_border: '#f59e0b', unlocked: false, unlocked_at: null },
];
case 'get_guilds':
return [
{ id: 'g1', instance_id: args?.instanceId ?? 'survival-smp', name: 'Iron Fortress', tag: 'IRON', description: 'Defenders of the realm and builders of grand monuments.', icon_url: null, banner_url: null, leader_uuid: 'mock-user-1', member_count: 5, max_members: 20, claims_count: 8, created_at: '2026-09-01T00:00:00Z' },
{ id: 'g2', instance_id: args?.instanceId ?? 'survival-smp', name: 'Void Walkers', tag: 'VOID', description: 'Exploring the farthest reaches of every dimension.', icon_url: null, banner_url: null, leader_uuid: 'other-user', member_count: 8, max_members: 20, claims_count: 12, created_at: '2026-09-05T00:00:00Z' }
];
case 'get_my_guild':
return { id: 'g1', instance_id: args?.instanceId ?? 'survival-smp', name: 'Iron Fortress', tag: 'IRON', description: 'Defenders of the realm and builders of grand monuments.', icon_url: null, banner_url: null, leader_uuid: 'mock-user-1', member_count: 5, max_members: 20, claims_count: 8, created_at: '2026-09-01T00:00:00Z' };
case 'get_guild_claims':
return [
{ id: 101, guild_id: 'g1', instance_id: args?.instanceId ?? 'survival-smp', dimension: args?.dimension ?? 'minecraft:overworld', chunk_x: 0, chunk_z: 0, claimed_by_uuid: 'mock-user-1', claimed_at: '2026-09-10T00:00:00Z', guild_name: 'Iron Fortress', guild_tag: 'IRON' },
{ id: 102, guild_id: 'g1', instance_id: args?.instanceId ?? 'survival-smp', dimension: args?.dimension ?? 'minecraft:overworld', chunk_x: 1, chunk_z: 0, claimed_by_uuid: 'mock-user-1', claimed_at: '2026-09-10T00:00:00Z', guild_name: 'Iron Fortress', guild_tag: 'IRON' },
{ id: 103, guild_id: 'g2', instance_id: args?.instanceId ?? 'survival-smp', dimension: args?.dimension ?? 'minecraft:overworld', chunk_x: -2, chunk_z: 1, claimed_by_uuid: 'other-user', claimed_at: '2026-09-11T00:00:00Z', guild_name: 'Void Walkers', guild_tag: 'VOID' }
];
case 'claim_guild_chunk':
return { id: Math.floor(Math.random() * 10000), guild_id: 'g1', instance_id: args?.instanceId, dimension: args?.dimension, chunk_x: args?.chunkX, chunk_z: args?.chunkZ, claimed_by_uuid: 'mock-user-1', claimed_at: new Date().toISOString(), guild_name: 'Iron Fortress', guild_tag: 'IRON' };
case 'unclaim_guild_chunk':
return null;
case 'get_guild_members':
return [
{ guild_id: 'g1', user_uuid: 'mock-user-1', username: 'Alyssa', role: 'leader', joined_at: '2026-09-01T00:00:00Z', is_online: true },
{ guild_id: 'g1', user_uuid: 'friend-1', username: 'Alex', role: 'officer', joined_at: '2026-09-02T00:00:00Z', is_online: true },
{ guild_id: 'g1', user_uuid: 'friend-2', username: 'Steve', role: 'member', joined_at: '2026-09-05T00:00:00Z', is_online: false }
];
case 'get_guild_posts':
return [
{ id: 'gp1', guild_id: 'g1', author_uuid: 'mock-user-1', author_name: 'Alyssa', title: 'Community Farm Completed!', content: 'The automatic wheat and pumpkin farm at chunk [2, 1] is open to all guild members. Please replenish seeds after harvesting.', pinned: true, created_at: '2026-09-25T16:00:00Z' }
];
case 'create_guild_post':
return { id: 'gp' + Date.now(), guild_id: args?.guildId, author_uuid: 'mock-user-1', author_name: 'Alyssa', title: args?.title, content: args?.content, pinned: args?.pinned ?? false, created_at: new Date().toISOString() };
case 'get_friends':
return [
{ uuid: 'friend-1', username: 'Alex', status: 'accepted', is_online: true, playing_on: 'Survival SMP', avatar_url: null },
{ uuid: 'friend-2', username: 'Steve', status: 'accepted', is_online: false, playing_on: null, avatar_url: null },
{ uuid: 'friend-3', username: 'Notch', status: 'pending_received', is_online: true, playing_on: null, avatar_url: null }
];
case 'send_friend_request':
case 'respond_friend_request':
case 'remove_friend':
return null;
case 'get_direct_messages':
return [
{ id: 1, from_uuid: args?.friendUuid, to_uuid: 'mock-user-1', content: 'Hey! Want to team up and explore the trial chambers?', created_at: '2026-09-28T18:20:00Z', is_read: true },
{ id: 2, from_uuid: 'mock-user-1', to_uuid: args?.friendUuid, content: 'Definitely! Let me grab some potions from base first.', created_at: '2026-09-28T18:22:00Z', is_read: true }
];
case 'send_direct_message':
return { id: Date.now(), from_uuid: 'mock-user-1', to_uuid: args?.toUuid, content: args?.content, created_at: new Date().toISOString(), is_read: true };
case 'get_game_invites':
return [
{ id: 'inv-1', from_uuid: 'friend-1', from_username: 'Alex', to_uuid: 'mock-user-1', instance_id: 'survival-smp', server_address: 'smp.scopenet.gg', server_name: 'Survival SMP', status: 'pending', created_at: '2026-09-28T19:00:00Z' }
];
case 'send_game_invite':
return { id: 'inv-' + Date.now(), from_uuid: 'mock-user-1', from_username: 'Alyssa', to_uuid: args?.toUuid, instance_id: args?.instanceId, server_address: args?.serverAddress, server_name: args?.serverName, status: 'pending', created_at: new Date().toISOString() };
case 'respond_game_invite':
return null;
case 'get_user_profile':
return {
uuid: args?.uuid,
username: args?.uuid === 'mock-user-1' ? 'Alyssa' : (args?.uuid === 'friend-1' ? 'Alex' : 'Steve'),
bio: 'Explorer, redstone engineer, and proud founder of the Iron Fortress guild!',
banner_url: null,
skin_url: null,
skin_model: 'classic',
cape_url: null,
global_level: { uuid: args?.uuid, level: 14, current_xp: 3450, next_level_xp: 5000, progress_pct: 69, title: 'Veteran Adventurer', badges: ['Beta Pioneer', 'Dragon Slayer'], rewards: [] },
server_levels: [
{ server_id: 1, server_name: 'Survival SMP', uuid: args?.uuid, level: 9, current_xp: 1200, next_level_xp: 2200, progress_pct: 54, rank_title: 'Knight' }
],
badges: ['Beta Pioneer', 'Dragon Slayer'],
achievements: [
{ id: 'ach1', title: 'Diamonds!', description: 'Acquire diamonds.', category: 'mining', xp_reward: 350, frame_type: 'goal', icon_item: '💎', icon_bg: '#0284c7', icon_border: '#38bdf8', unlocked: true, unlocked_at: '2026-09-24T18:15:00Z' }
],
posts: [
{ id: 'post-1', user_uuid: args?.uuid, username: 'Alyssa', content: 'Just finished our mountain watchtower! What do you guys think?', image_url: null, likes_count: 7, liked_by_me: true, created_at: '2026-09-27T20:10:00Z' }
],
friends_count: 12,
stats: { playtime_secs: 74200, joins: 48, deaths: 12, player_kills: 19, mob_kills: 480, blocks_broken: 15400, blocks_placed: 9800, messages: 320, first_seen: '2026-08-01T00:00:00Z', last_seen: '2026-09-28T22:00:00Z' },
created_at: '2026-08-01T00:00:00Z'
};
case 'update_my_profile':
return {
uuid: 'mock-user-1',
username: 'Alyssa',
bio: args?.bio ?? '',
banner_url: args?.bannerUrl ?? null,
skin_url: null,
skin_model: 'classic',
cape_url: null,
global_level: { uuid: 'mock-user-1', level: 14, current_xp: 3450, next_level_xp: 5000, progress_pct: 69, title: 'Veteran Adventurer', badges: ['Beta Pioneer'], rewards: [] },
server_levels: [],
badges: ['Beta Pioneer'],
achievements: [],
posts: [],
friends_count: 12,
stats: null,
created_at: '2026-08-01T00:00:00Z'
};
case 'create_user_post':
return { id: 'post-' + Date.now(), user_uuid: 'mock-user-1', username: 'Alyssa', content: args?.content, image_url: args?.imageUrl ?? null, likes_count: 0, liked_by_me: false, created_at: new Date().toISOString() };
case 'like_user_post':
return true;
default:
return null;
}
+2 -1
View File
@@ -27,8 +27,9 @@ export const app = $state({
active: null as string | null,
panelUrl: null as string | null,
offline: false,
view: 'home' as 'home' | 'settings' | 'stats',
view: 'home' as 'home' | 'settings' | 'stats' | 'quests' | 'guilds' | 'social',
settingsTab: 'account',
viewProfileUuid: null as string | null,
selected: null as string | null,
launch: idle(),
launchingId: null as string | null,
+194
View File
@@ -122,3 +122,197 @@ export interface SkinProfile {
created_at: string;
}
export interface UpdateInfo { version: string; notes: string; url: string; size: number }
export interface PlayerStats {
server_id: number;
uuid: string;
name: string;
playtime_secs: number;
joins: number;
deaths: number;
player_kills: number;
mob_kills: number;
blocks_broken: number;
blocks_placed: number;
messages: number;
first_seen: string;
last_seen: string;
server_name?: string;
}
export interface LevelReward {
id: number;
level: number;
reward_type: 'title' | 'item' | 'cosmetic' | 'profile_badge';
reward_value: string;
description: string;
icon?: string;
}
export interface UserLevelInfo {
uuid: string;
level: number;
current_xp: number;
next_level_xp: number;
progress_pct: number;
title: string | null;
badges: string[];
rewards: LevelReward[];
}
export interface ServerLevelInfo {
server_id: number;
server_name: string;
uuid: string;
level: number;
current_xp: number;
next_level_xp: number;
progress_pct: number;
rank_title: string | null;
}
export interface Quest {
id: string;
title: string;
description: string;
quest_type: 'daily' | 'weekly';
category: 'mining' | 'combat' | 'building' | 'farming' | 'social' | 'exploration';
xp_reward: number;
stat_type: string;
target_count: number;
icon: string;
active: boolean;
}
export interface UserQuest {
quest: Quest;
current_count: number;
completed: boolean;
claimed: boolean;
expires_at: string;
}
export interface Achievement {
id: string;
title: string;
description: string;
category: string;
xp_reward: number;
frame_type: 'task' | 'goal' | 'challenge';
icon_item: string;
icon_bg: string;
icon_border: string;
stat_type?: string | null;
target_count?: number | null;
event_type?: string | null;
unlocked?: boolean;
unlocked_at?: string | null;
}
export interface Guild {
id: string;
instance_id: string;
name: string;
tag: string;
description: string;
icon_url: string | null;
banner_url: string | null;
leader_uuid: string;
member_count: number;
max_members: number;
claims_count: number;
created_at: string;
}
export interface GuildMember {
guild_id: string;
user_uuid: string;
username: string;
role: 'leader' | 'officer' | 'member';
joined_at: string;
is_online?: boolean;
}
export interface GuildPost {
id: string;
guild_id: string;
author_uuid: string;
author_name: string;
title: string;
content: string;
pinned: boolean;
created_at: string;
}
export interface GuildClaim {
id: number;
guild_id: string;
instance_id: string;
dimension: string;
chunk_x: number;
chunk_z: number;
claimed_by_uuid: string;
claimed_at: string;
guild_name?: string;
guild_tag?: string;
}
export interface FriendInfo {
uuid: string;
username: string;
status: 'pending_sent' | 'pending_received' | 'accepted';
is_online: boolean;
playing_on: string | null;
avatar_url?: string | null;
}
export interface DirectMessage {
id: number;
from_uuid: string;
to_uuid: string;
content: string;
created_at: string;
is_read: boolean;
}
export interface GameInvite {
id: string;
from_uuid: string;
from_username: string;
to_uuid: string;
instance_id: string;
server_address: string;
server_name: string;
status: 'pending' | 'accepted' | 'declined' | 'expired';
created_at: string;
}
export interface UserPost {
id: string;
user_uuid: string;
username: string;
content: string;
image_url: string | null;
likes_count: number;
liked_by_me: boolean;
created_at: string;
}
export interface UserProfileView {
uuid: string;
username: string;
bio: string;
banner_url: string | null;
skin_url: string | null;
skin_model: string;
cape_url: string | null;
global_level: UserLevelInfo;
server_levels: ServerLevelInfo[];
badges: string[];
achievements: Achievement[];
posts: UserPost[];
friends_count: number;
stats: PlayerStats | null;
created_at: string;
}
+850
View File
@@ -0,0 +1,850 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
Shield, Map, Users, MessageSquare, Plus, Crown, UserCheck, Trash2,
Pin, LoaderCircle, RefreshCw, ChevronDown, Check, AlertCircle, Sparkles, Send
} from '@lucide/svelte';
import Avatar from '../components/Avatar.svelte';
import ChunkMap from '../components/ChunkMap.svelte';
import Modal from '../components/Modal.svelte';
import { app, instances, selectedInstance, toast } from '../lib/store.svelte';
import { invoke } from '../lib/tauri';
import type { Guild, GuildMember, GuildPost } from '../lib/types';
let selectedInstId = $state<string>(selectedInstance()?.id || (instances()[0]?.id ?? ''));
let myGuild = $state<Guild | null>(null);
let allGuilds = $state<Guild[]>([]);
let members = $state<GuildMember[]>([]);
let posts = $state<GuildPost[]>([]);
let activeTab = $state<'territory' | 'members' | 'feed'>('territory');
let loading = $state(false);
// Create Guild Modal
let createModalOpen = $state(false);
let newGuildName = $state('');
let newGuildTag = $state('');
let newGuildDesc = $state('');
let newGuildIcon = $state('');
let newGuildBanner = $state('');
let creating = $state(false);
// New Post Form
let postTitle = $state('');
let postContent = $state('');
let postPinned = $state(false);
let posting = $state(false);
const currentInstance = $derived(
instances().find((i) => i.id === selectedInstId) ?? instances()[0] ?? null
);
async function loadInstanceGuildData() {
if (!selectedInstId) return;
loading = true;
try {
const [mine, list] = await Promise.all([
invoke<Guild | null>('get_my_guild', { instanceId: selectedInstId }).catch(() => null),
invoke<Guild[]>('get_guilds', { instanceId: selectedInstId }).catch(() => [])
]);
myGuild = mine;
allGuilds = list;
if (mine) {
const [mList, pList] = await Promise.all([
invoke<GuildMember[]>('get_guild_members', { guildId: mine.id }).catch(() => []),
invoke<GuildPost[]>('get_guild_posts', { guildId: mine.id }).catch(() => [])
]);
members = mList;
posts = pList;
} else {
members = [];
posts = [];
}
} catch (e: any) {
toast(e?.message ?? 'Failed to load guild data', 'error');
} finally {
loading = false;
}
}
async function handleCreateGuild() {
if (!newGuildName.trim() || !newGuildTag.trim()) {
toast('Please enter both guild name and tag', 'error');
return;
}
creating = true;
try {
const guild = await invoke<Guild>('create_guild', {
instanceId: selectedInstId,
name: newGuildName.trim(),
tag: newGuildTag.trim().toUpperCase(),
description: newGuildDesc.trim(),
iconUrl: newGuildIcon.trim() || null,
bannerUrl: newGuildBanner.trim() || null
});
toast(`Guild [${guild.tag}] ${guild.name} founded!`, 'ok');
createModalOpen = false;
newGuildName = '';
newGuildTag = '';
newGuildDesc = '';
newGuildIcon = '';
newGuildBanner = '';
await loadInstanceGuildData();
} catch (e: any) {
toast(e?.message ?? 'Failed to create guild', 'error');
} finally {
creating = false;
}
}
async function handleCreatePost() {
if (!myGuild || !postTitle.trim() || !postContent.trim()) {
toast('Please provide a title and announcement content', 'error');
return;
}
posting = true;
try {
const post = await invoke<GuildPost>('create_guild_post', {
guildId: myGuild.id,
title: postTitle.trim(),
content: postContent.trim(),
pinned: postPinned
});
posts = [post, ...posts];
postTitle = '';
postContent = '';
postPinned = false;
toast('Announcement posted to guild wall', 'ok');
} catch (e: any) {
toast(e?.message ?? 'Failed to create post', 'error');
} finally {
posting = false;
}
}
$effect(() => {
if (selectedInstId) {
loadInstanceGuildData();
}
});
onMount(() => {
if (selectedInstance()?.id) {
selectedInstId = selectedInstance()!.id;
}
loadInstanceGuildData();
});
</script>
<div class="guilds-page">
<!-- Page Header & Instance Picker -->
<div class="page-header">
<div class="title-wrap">
<h1><Shield class="guild-icon" size={26} /> Guilds & Territories</h1>
<p class="lead">Form factions, claim land chunks protected from griefing, and collaborate with your party.</p>
</div>
<div class="header-actions">
<div class="instance-select-wrap">
<label for="instance-select" class="tiny muted uppercase">Instance</label>
<select
id="instance-select"
class="inst-select"
bind:value={selectedInstId}
>
{#each instances() as inst}
<option value={inst.id}>{inst.name}</option>
{/each}
</select>
</div>
<button class="ghost icon" onclick={loadInstanceGuildData} title="Refresh" aria-label="Refresh">
<RefreshCw size={17} class={loading ? 'spin' : ''} />
</button>
</div>
</div>
{#if loading && !myGuild && !allGuilds.length}
<div class="center-state"><LoaderCircle class="spin" size={32} /></div>
{:else if myGuild}
<!-- User is in a Guild -->
<div class="guild-hub">
<!-- Guild Banner & Meta Header -->
<div
class="guild-hero glass"
style:background-image={myGuild.banner_url ? `linear-gradient(rgba(0,0,0,0.7), rgba(0,0,0,0.85)), url(${myGuild.banner_url})` : undefined}
>
<div class="guild-hero-content">
<div class="guild-tag-badge">
<span class="tag-text">[{myGuild.tag}]</span>
</div>
<div class="guild-info">
<div class="guild-title-line">
<h2>{myGuild.name}</h2>
<span class="badge member-badge"><Users size={12} /> {myGuild.member_count} / {myGuild.max_members} Members</span>
<span class="badge claim-badge"><Shield size={12} /> {myGuild.claims_count} Claims</span>
</div>
{#if myGuild.description}
<p class="guild-desc">{myGuild.description}</p>
{/if}
</div>
</div>
</div>
<!-- Segmented Navigation Tabs -->
<div class="guild-tabs-row">
<div class="tabs-segmented">
<button class:active={activeTab === 'territory'} onclick={() => (activeTab = 'territory')}>
<Map size={15} /> Territory & Land Claims
</button>
<button class:active={activeTab === 'members'} onclick={() => (activeTab = 'members')}>
<Users size={15} /> Roster ({members.length})
</button>
<button class:active={activeTab === 'feed'} onclick={() => (activeTab = 'feed')}>
<MessageSquare size={15} /> Guild Feed ({posts.length})
</button>
</div>
</div>
<!-- Tab Content -->
{#if activeTab === 'territory'}
<div class="territory-tab">
<div class="protection-callout glass">
<Shield size={18} class="callout-icon" />
<div class="callout-text">
<strong>Grief Protection Relay Active</strong>
<p class="tiny muted">
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.
</p>
</div>
</div>
<ChunkMap
instanceId={selectedInstId}
myGuildId={myGuild.id}
myGuildTag={myGuild.tag}
onclaim={() => { if (myGuild) myGuild.claims_count += 1; }}
onunclaim={() => { if (myGuild) myGuild.claims_count = Math.max(0, myGuild.claims_count - 1); }}
/>
</div>
{:else if activeTab === 'members'}
<div class="members-tab">
<div class="members-grid">
{#each members as m (m.user_uuid)}
<div
class="member-card glass"
role="button"
tabindex="0"
onclick={() => (app.viewProfileUuid = m.user_uuid)}
onkeydown={(e) => e.key === 'Enter' && (app.viewProfileUuid = m.user_uuid)}
>
<div class="member-avatar-wrap">
<Avatar uuid={m.user_uuid} name={m.username} size={2.5} />
{#if m.is_online}
<span class="online-indicator" title="Online"></span>
{/if}
</div>
<div class="member-details">
<div class="member-name-row">
<span class="member-name">{m.username}</span>
{#if m.role === 'leader'}
<span class="badge leader-badge"><Crown size={11} /> Leader</span>
{:else if m.role === 'officer'}
<span class="badge officer-badge"><UserCheck size={11} /> Officer</span>
{:else}
<span class="badge member-role-badge">Member</span>
{/if}
</div>
<span class="tiny muted">Joined {new Date(m.joined_at).toLocaleDateString()}</span>
</div>
</div>
{/each}
</div>
</div>
{:else if activeTab === 'feed'}
<div class="feed-tab">
<!-- Post Announcement Form -->
<div class="post-creator glass">
<span class="creator-title">Post Guild Announcement</span>
<input
type="text"
placeholder="Announcement Title"
bind:value={postTitle}
class="post-title-input"
/>
<textarea
placeholder="Share news, raid plans, or updates with your guild..."
bind:value={postContent}
rows="3"
class="post-textarea"
></textarea>
<div class="post-creator-footer">
<label class="pin-checkbox tiny">
<input type="checkbox" bind:checked={postPinned} />
<span>Pin to top</span>
</label>
<button
class="primary sm"
onclick={handleCreatePost}
disabled={posting}
>
{#if posting}<LoaderCircle size={13} class="spin" />{:else}<Send size={13} />{/if}
Publish
</button>
</div>
</div>
<!-- Posts Feed -->
<div class="posts-list">
{#if posts.length === 0}
<div class="empty-state glass">
<MessageSquare size={32} class="muted" />
<p>No guild announcements posted yet.</p>
</div>
{:else}
{#each posts as p (p.id)}
<div class="post-card glass" class:pinned={p.pinned}>
<div class="post-header">
<div class="author-meta">
<Avatar uuid={p.author_uuid} name={p.author_name} size={1.8} />
<div class="author-info">
<span class="author-name">{p.author_name}</span>
<span class="post-date tiny muted">{new Date(p.created_at).toLocaleString()}</span>
</div>
</div>
{#if p.pinned}
<span class="badge pinned-badge"><Pin size={11} /> Pinned</span>
{/if}
</div>
<h4 class="post-headline">{p.title}</h4>
<p class="post-text">{p.content}</p>
</div>
{/each}
{/if}
</div>
</div>
{/if}
</div>
{:else}
<!-- User is NOT in a Guild on this instance -->
<div class="no-guild-container">
<div class="hero-prompt glass">
<div class="prompt-icon-box">
<Shield size={38} class="guild-icon" />
</div>
<div class="prompt-text">
<h2>No Guild on {currentInstance?.name || 'this Instance'}</h2>
<p class="lead">
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.
</p>
<button class="primary create-guild-btn" onclick={() => (createModalOpen = true)}>
<Plus size={16} /> Create a Guild
</button>
</div>
</div>
<!-- Browse Guilds Section -->
<div class="browse-section">
<div class="browse-header">
<h3>Active Guilds ({allGuilds.length})</h3>
</div>
{#if allGuilds.length === 0}
<div class="empty-state glass">
<p>No guilds have been created on this instance yet. Be the first to establish territory!</p>
</div>
{:else}
<div class="guilds-directory-grid">
{#each allGuilds as g (g.id)}
<div class="guild-dir-card glass">
<div class="dir-top">
<div class="dir-tag">[{g.tag}]</div>
<div class="dir-meta">
<h4>{g.name}</h4>
<span class="tiny muted">{g.member_count} / {g.max_members} Members • {g.claims_count} Chunks Claimed</span>
</div>
</div>
{#if g.description}
<p class="dir-desc">{g.description}</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>
<!-- Create Guild Modal -->
<Modal bind:open={createModalOpen} title="Found a New Guild" width={28}>
<div class="modal-form">
<label>
<span class="field-label">Guild Name</span>
<input type="text" placeholder="e.g. Iron Fortress" bind:value={newGuildName} maxlength="32" />
</label>
<label>
<span class="field-label">Guild Tag (3 - 5 characters)</span>
<input type="text" placeholder="e.g. IRON" bind:value={newGuildTag} maxlength="5" class="tag-input" />
<span class="tiny muted">Appears in brackets on claimed chunks and chat tags.</span>
</label>
<label>
<span class="field-label">Description / Moto</span>
<textarea placeholder="Describe your guild's playstyle, goals, or rules..." bind:value={newGuildDesc} rows="3"></textarea>
</label>
<label>
<span class="field-label">Banner Image URL (Optional)</span>
<input type="url" placeholder="https://example.com/banner.png" bind:value={newGuildBanner} />
</label>
</div>
{#snippet footer()}
<button class="ghost" onclick={() => (createModalOpen = false)}>Cancel</button>
<button class="primary" onclick={handleCreateGuild} disabled={creating}>
{#if creating}<LoaderCircle size={14} class="spin" />{:else}<Sparkles size={14} />{/if}
Found Guild
</button>
{/snippet}
</Modal>
<style>
.guilds-page {
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 1.6rem 2.2rem 2.5rem;
gap: 1.2rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
border-bottom: 1px solid var(--line);
padding-bottom: 1rem;
flex-wrap: wrap;
gap: 1rem;
}
.title-wrap h1 {
font-size: 1.5rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 0.6rem;
}
:global(.guild-icon) {
color: var(--accent);
}
.lead {
color: var(--muted);
font-size: 0.88rem;
margin-top: 0.2rem;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.8rem;
}
.instance-select-wrap {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.inst-select {
background: color-mix(in srgb, var(--bg) 70%, transparent);
border: 1px solid var(--line);
color: var(--text);
padding: 0.35rem 0.75rem;
border-radius: var(--radius-sm);
font-size: 0.85rem;
}
.guild-hub {
display: flex;
flex-direction: column;
gap: 1rem;
}
.guild-hero {
display: flex;
padding: 1.5rem 1.8rem;
border-radius: var(--radius);
background-size: cover;
background-position: center;
border: 1px solid var(--line);
}
.guild-hero-content {
display: flex;
align-items: center;
gap: 1.2rem;
}
.guild-tag-badge {
padding: 0.6rem 1rem;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--accent) 25%, transparent);
border: 2px solid var(--accent);
box-shadow: 0 0 12px color-mix(in srgb, var(--accent) 30%, transparent);
}
.tag-text {
font-size: 1.4rem;
font-weight: 800;
letter-spacing: 0.08em;
color: var(--accent);
}
.guild-info {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.guild-title-line {
display: flex;
align-items: center;
gap: 0.8rem;
flex-wrap: wrap;
}
.guild-title-line h2 {
font-size: 1.4rem;
font-weight: 750;
}
.guild-desc {
font-size: 0.85rem;
color: var(--muted);
max-width: 42rem;
}
.guild-tabs-row {
display: flex;
}
.tabs-segmented {
display: flex;
background: color-mix(in srgb, var(--surface) 80%, transparent);
padding: 0.25rem;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
gap: 0.2rem;
}
.tabs-segmented button {
display: flex;
align-items: center;
gap: 0.4rem;
background: transparent;
border: none;
padding: 0.45rem 1rem;
font-size: 0.85rem;
font-weight: 560;
color: var(--muted);
border-radius: calc(var(--radius-sm) - 2px);
transition: all 0.15s;
}
.tabs-segmented button.active {
background: color-mix(in srgb, var(--text) 10%, transparent);
color: var(--text);
}
.territory-tab {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.protection-callout {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 0.75rem 1rem;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--success) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--success) 35%, transparent);
}
:global(.callout-icon) {
color: var(--success);
flex-shrink: 0;
}
.callout-text p {
margin: 0;
}
.members-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
gap: 0.9rem;
}
.member-card {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 0.8rem 1rem;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
background: var(--surface);
cursor: pointer;
transition: transform 0.12s, border-color 0.15s;
}
.member-card:hover {
transform: translateY(-2px);
border-color: var(--line-strong);
}
.member-avatar-wrap {
position: relative;
}
.online-indicator {
position: absolute;
bottom: -1px;
right: -1px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--success);
border: 2px solid var(--surface);
}
.member-details {
display: flex;
flex-direction: column;
gap: 0.2rem;
flex: 1;
min-width: 0;
}
.member-name-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.3rem;
}
.member-name {
font-weight: 650;
font-size: 0.88rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.leader-badge {
background: color-mix(in srgb, #f59e0b 20%, transparent);
color: #f59e0b;
}
.officer-badge {
background: color-mix(in srgb, var(--accent) 20%, transparent);
color: var(--accent);
}
.member-role-badge {
background: color-mix(in srgb, var(--text) 8%, transparent);
color: var(--muted);
}
.feed-tab {
display: flex;
flex-direction: column;
gap: 1.2rem;
}
.post-creator {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 1rem 1.2rem;
border-radius: var(--radius);
background: var(--surface);
border: 1px solid var(--line);
}
.creator-title {
font-size: 0.88rem;
font-weight: 650;
}
.post-title-input {
background: color-mix(in srgb, var(--bg) 60%, transparent);
border: 1px solid var(--line);
color: var(--text);
padding: 0.4rem 0.75rem;
border-radius: var(--radius-sm);
font-size: 0.88rem;
}
.post-textarea {
background: color-mix(in srgb, var(--bg) 60%, transparent);
border: 1px solid var(--line);
color: var(--text);
padding: 0.5rem 0.75rem;
border-radius: var(--radius-sm);
font-size: 0.85rem;
resize: vertical;
}
.post-creator-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.pin-checkbox {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--muted);
cursor: pointer;
}
.posts-list {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.post-card {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 1.1rem 1.3rem;
border-radius: var(--radius);
background: var(--surface);
border: 1px solid var(--line);
}
.post-card.pinned {
border-color: color-mix(in srgb, #f59e0b 40%, transparent);
background: linear-gradient(135deg, color-mix(in srgb, #f59e0b 6%, var(--surface)) 0%, var(--surface) 100%);
}
.post-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.author-meta {
display: flex;
align-items: center;
gap: 0.6rem;
}
.author-info {
display: flex;
flex-direction: column;
}
.author-name {
font-size: 0.84rem;
font-weight: 650;
}
.pinned-badge {
background: color-mix(in srgb, #f59e0b 20%, transparent);
color: #f59e0b;
}
.post-headline {
font-size: 1rem;
font-weight: 700;
}
.post-text {
font-size: 0.85rem;
color: color-mix(in srgb, var(--text) 85%, transparent);
line-height: 1.4;
white-space: pre-wrap;
}
/* No Guild View */
.no-guild-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.hero-prompt {
display: flex;
align-items: center;
gap: 1.8rem;
padding: 2rem;
border-radius: var(--radius);
background: var(--surface);
border: 1px solid var(--line);
}
.prompt-icon-box {
width: 4.5rem;
height: 4.5rem;
border-radius: var(--radius);
background: color-mix(in srgb, var(--accent) 15%, transparent);
display: grid;
place-items: center;
flex-shrink: 0;
}
.prompt-text {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.prompt-text h2 {
font-size: 1.3rem;
font-weight: 700;
}
.create-guild-btn {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.5rem;
}
.browse-section {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.browse-header h3 {
font-size: 1.1rem;
font-weight: 700;
}
.guilds-directory-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
gap: 1rem;
}
.guild-dir-card {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 1.1rem;
border-radius: var(--radius);
background: var(--surface);
border: 1px solid var(--line);
}
.dir-top {
display: flex;
align-items: center;
gap: 0.75rem;
}
.dir-tag {
font-size: 1rem;
font-weight: 800;
color: var(--accent);
padding: 0.3rem 0.6rem;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--accent) 15%, transparent);
}
.dir-meta h4 {
font-size: 0.95rem;
font-weight: 700;
}
.dir-desc {
font-size: 0.82rem;
color: var(--muted);
line-height: 1.35;
}
.modal-form {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.modal-form label {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field-label {
font-size: 0.82rem;
font-weight: 600;
}
.tag-input {
text-transform: uppercase;
font-family: monospace;
font-weight: 700;
letter-spacing: 0.05em;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
font-weight: 600;
padding: 0.16rem 0.5rem;
border-radius: 99rem;
}
.member-badge {
background: color-mix(in srgb, var(--text) 8%, transparent);
}
.claim-badge {
background: color-mix(in srgb, var(--success) 16%, transparent);
color: var(--success);
}
.center-state, .empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.8rem;
padding: 3rem 1.5rem;
text-align: center;
border-radius: var(--radius);
}
</style>
+723
View File
@@ -0,0 +1,723 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
Target, Trophy, Clock, Sparkles, Check, Gift, Pickaxe, Swords, Hammer,
Wheat, Users, Compass, RefreshCw, LoaderCircle, Flame, Star, Award, Shield
} from '@lucide/svelte';
import { invoke } from '../lib/tauri';
import { toast } from '../lib/store.svelte';
import type { UserLevelInfo, ServerLevelInfo, UserQuest, Achievement } from '../lib/types';
let activeTab = $state<'daily' | 'weekly' | 'achievements'>('daily');
let selectedCategory = $state<string>('all');
let loading = $state(false);
let claimingQuestId = $state<string | null>(null);
let levelInfo = $state<UserLevelInfo | null>(null);
let userQuests = $state<UserQuest[]>([]);
let achievements = $state<Achievement[]>([]);
const categoryIcons: Record<string, any> = {
mining: Pickaxe,
combat: Swords,
building: Hammer,
farming: Wheat,
social: Users,
exploration: Compass
};
async function loadData() {
loading = true;
try {
const [lvl, quests, achs] = await Promise.all([
invoke<UserLevelInfo>('get_my_level').catch(() => null),
invoke<UserQuest[]>('get_my_quests').catch(() => []),
invoke<Achievement[]>('get_my_achievements').catch(() => [])
]);
if (lvl) levelInfo = lvl;
userQuests = quests;
achievements = achs;
} catch (e: any) {
toast(e?.message ?? 'Failed to load quests data', 'error');
} finally {
loading = false;
}
}
async function handleClaimQuest(questId: string) {
claimingQuestId = questId;
try {
const updatedLevel = await invoke<UserLevelInfo>('claim_quest', { questId });
levelInfo = updatedLevel;
// Mark as claimed locally
userQuests = userQuests.map((uq) => {
if (uq.quest.id === questId) {
return { ...uq, claimed: true };
}
return uq;
});
toast('Quest reward claimed! XP awarded to your Global Level.', 'ok');
} catch (e: any) {
toast(e?.message ?? 'Failed to claim reward', 'error');
} finally {
claimingQuestId = null;
}
}
const filteredQuests = $derived(
userQuests.filter((uq) => {
if (uq.quest.quest_type !== activeTab) return false;
if (selectedCategory !== 'all' && uq.quest.category !== selectedCategory) return false;
return true;
})
);
const completedCount = $derived(
userQuests.filter((uq) => uq.quest.quest_type === activeTab && uq.completed).length
);
const totalTabQuests = $derived(
userQuests.filter((uq) => uq.quest.quest_type === activeTab).length
);
const unlockedAchievementsCount = $derived(
achievements.filter((a) => a.unlocked).length
);
function getTimeUntilReset(type: 'daily' | 'weekly'): string {
const now = new Date();
if (type === 'daily') {
const tomorrow = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
const diffMs = tomorrow.getTime() - now.getTime();
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const mins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
return `${hours}h ${mins}m`;
} else {
const day = now.getUTCDay();
const daysUntilSunday = (7 - day) % 7 || 7;
const nextSunday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + daysUntilSunday));
const diffMs = nextSunday.getTime() - now.getTime();
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
return `${days}d ${hours}h`;
}
}
onMount(() => {
loadData();
});
</script>
<div class="quests-page">
<!-- Page Header -->
<div class="page-header">
<div class="title-wrap">
<h1><Target class="target-icon" size={26} /> Quests & Progression</h1>
<p class="lead">Complete daily and weekly quests across servers to level up your SCOPENET account.</p>
</div>
<button class="ghost icon" onclick={loadData} title="Refresh" aria-label="Refresh">
<RefreshCw size={17} class={loading ? 'spin' : ''} />
</button>
</div>
<!-- Global Level Summary Hero Card -->
{#if levelInfo}
<div class="hero-level glass">
<div class="level-badge-wrap">
<div class="level-circle">
<span class="lvl-label">LVL</span>
<span class="lvl-number">{levelInfo.level}</span>
</div>
</div>
<div class="level-details">
<div class="level-title-row">
<div class="title-meta">
<span class="level-name">SCOPENET Global Level</span>
{#if levelInfo.title}
<span class="badge title-badge"><Award size={12} /> {levelInfo.title}</span>
{/if}
</div>
<span class="xp-ratio">
<strong>{levelInfo.current_xp.toLocaleString()}</strong> / {levelInfo.next_level_xp.toLocaleString()} XP
</span>
</div>
<div class="progress-bar-bg">
<div class="progress-fill" style:width="{Math.min(100, Math.max(0, levelInfo.progress_pct))}%"></div>
</div>
<div class="level-footer">
<span class="muted tiny">
Playing on any SCOPENET server earns Global XP. Unlocks exclusive titles, badges, and cosmetic items.
</span>
{#if levelInfo.badges && levelInfo.badges.length > 0}
<div class="badges-row">
{#each levelInfo.badges as badge}
<span class="badge icon-badge" title={badge}><Star size={11} /> {badge}</span>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/if}
<!-- Segmented Tabs Navigation -->
<div class="nav-bar">
<div class="tabs-segmented">
<button class:active={activeTab === 'daily'} onclick={() => (activeTab = 'daily')}>
<Clock size={15} /> Daily Quests
</button>
<button class:active={activeTab === 'weekly'} onclick={() => (activeTab = 'weekly')}>
<Flame size={15} /> Weekly Quests
</button>
<button class:active={activeTab === 'achievements'} onclick={() => (activeTab = 'achievements')}>
<Trophy size={15} /> Achievements ({unlockedAchievementsCount}/{achievements.length})
</button>
</div>
{#if activeTab !== 'achievements'}
<div class="reset-countdown muted tiny">
<Clock size={13} />
<span>Resets in: <strong>{getTimeUntilReset(activeTab)}</strong></span>
<span class="divider">•</span>
<span>Completed: <strong>{completedCount} / {totalTabQuests}</strong></span>
</div>
{/if}
</div>
<!-- Content Section -->
{#if loading && !userQuests.length}
<div class="center-state"><LoaderCircle class="spin" size={32} /></div>
{:else if activeTab === 'achievements'}
<!-- Achievements List with Minecraft Frame Styling -->
<div class="achievements-section">
<div class="achievements-grid">
{#each achievements as ach (ach.id)}
<div class="achievement-card glass" class:unlocked={ach.unlocked} class:frame-task={ach.frame_type === 'task'} class:frame-goal={ach.frame_type === 'goal'} class:frame-challenge={ach.frame_type === 'challenge'}>
<div class="achievement-icon-frame" style:background={ach.icon_bg || 'rgba(0,0,0,0.5)'} style:border-color={ach.icon_border || 'var(--line)'}>
<span class="sprite-icon">{ach.icon_item || '💎'}</span>
</div>
<div class="achievement-body">
<div class="achievement-header">
<span class="ach-title">{ach.title}</span>
<span class="badge frame-badge {ach.frame_type}">{ach.frame_type.toUpperCase()}</span>
</div>
<p class="ach-desc">{ach.description}</p>
<div class="ach-footer">
<span class="xp-tag">+{ach.xp_reward} XP</span>
{#if ach.unlocked}
<span class="badge unlocked-tag"><Check size={11} /> Unlocked</span>
{:else}
<span class="badge locked-tag">Locked</span>
{/if}
</div>
</div>
</div>
{/each}
</div>
</div>
{:else}
<!-- Quests List -->
<div class="quests-content">
<!-- Category Filter Pills -->
<div class="category-filters">
{#each ['all', 'mining', 'combat', 'building', 'farming', 'social', 'exploration'] as cat}
{@const CatIcon = categoryIcons[cat]}
<button
class="pill-btn"
class:active={selectedCategory === cat}
onclick={() => (selectedCategory = cat)}
>
{#if cat !== 'all' && CatIcon}
<CatIcon size={13} />
{/if}
<span class="cap">{cat}</span>
</button>
{/each}
</div>
{#if filteredQuests.length === 0}
<div class="empty-state glass">
<Target size={36} class="muted" />
<p>No {activeTab} quests found in this category.</p>
</div>
{:else}
<div class="quests-grid">
{#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}
<div class="quest-card glass" class:completed={uq.completed} class:claimed={uq.claimed}>
<div class="quest-top">
<div class="quest-icon-wrapper">
<IconComponent size={20} class="cat-icon" />
</div>
<div class="quest-meta">
<div class="quest-title-row">
<span class="quest-title">{uq.quest.title}</span>
<span class="badge cat-badge">{uq.quest.category}</span>
</div>
<p class="quest-desc">{uq.quest.description}</p>
</div>
</div>
<div class="quest-progress-wrap">
<div class="progress-labels">
<span class="progress-ratio">
<strong>{uq.current_count}</strong> / {uq.quest.target_count} ({pct}%)
</span>
<span class="xp-badge">+{uq.quest.xp_reward} XP</span>
</div>
<div class="progress-bar-bg">
<div class="progress-fill" style:width="{pct}%"></div>
</div>
</div>
<div class="quest-action-row">
{#if uq.claimed}
<span class="badge claimed-badge"><Check size={13} /> Reward Claimed</span>
{:else if uq.completed}
<button
class="primary claim-btn"
onclick={() => handleClaimQuest(uq.quest.id)}
disabled={claimingQuestId === uq.quest.id}
>
{#if claimingQuestId === uq.quest.id}
<LoaderCircle size={14} class="spin" />
{:else}
<Gift size={14} />
{/if}
Claim +{uq.quest.xp_reward} XP
</button>
{:else}
<span class="badge in-progress-badge">In Progress</span>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
<style>
.quests-page {
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 1.6rem 2.2rem 2.5rem;
gap: 1.2rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
border-bottom: 1px solid var(--line);
padding-bottom: 1rem;
}
.title-wrap h1 {
font-size: 1.5rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 0.6rem;
}
:global(.target-icon) {
color: var(--accent);
}
.lead {
color: var(--muted);
font-size: 0.88rem;
margin-top: 0.2rem;
}
.hero-level {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 1.2rem 1.6rem;
border-radius: var(--radius);
background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 15%, var(--surface)) 0%, var(--surface) 100%);
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
}
.level-badge-wrap {
display: flex;
align-items: center;
justify-content: center;
}
.level-circle {
width: 4.5rem;
height: 4.5rem;
border-radius: 50%;
background: color-mix(in srgb, var(--accent) 25%, transparent);
border: 3px solid var(--accent);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow: 0 0 16px color-mix(in srgb, var(--accent) 40%, transparent);
}
.lvl-label {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--accent);
}
.lvl-number {
font-size: 1.6rem;
font-weight: 800;
line-height: 1;
color: var(--text);
}
.level-details {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.level-title-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.title-meta {
display: flex;
align-items: center;
gap: 0.6rem;
}
.level-name {
font-size: 1.15rem;
font-weight: 700;
}
.title-badge {
background: color-mix(in srgb, #f59e0b 20%, transparent);
color: #f59e0b;
border: 1px solid color-mix(in srgb, #f59e0b 40%, transparent);
}
.xp-ratio {
font-size: 0.88rem;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.xp-ratio strong {
color: var(--text);
}
.progress-bar-bg {
width: 100%;
height: 0.55rem;
border-radius: 99rem;
background: color-mix(in srgb, var(--text) 10%, transparent);
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 99rem;
background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 60%, white));
transition: width 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.level-footer {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.6rem;
}
.badges-row {
display: flex;
gap: 0.4rem;
}
.icon-badge {
background: color-mix(in srgb, var(--text) 8%, transparent);
font-size: 0.72rem;
}
.nav-bar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.8rem;
}
.tabs-segmented {
display: flex;
background: color-mix(in srgb, var(--surface) 80%, transparent);
padding: 0.25rem;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
gap: 0.2rem;
}
.tabs-segmented button {
display: flex;
align-items: center;
gap: 0.4rem;
background: transparent;
border: none;
padding: 0.45rem 1rem;
font-size: 0.85rem;
font-weight: 560;
color: var(--muted);
border-radius: calc(var(--radius-sm) - 2px);
transition: all 0.15s;
}
.tabs-segmented button.active {
background: color-mix(in srgb, var(--text) 10%, transparent);
color: var(--text);
}
.reset-countdown {
display: flex;
align-items: center;
gap: 0.4rem;
}
.divider {
color: var(--line-strong);
}
.quests-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.category-filters {
display: flex;
gap: 0.4rem;
overflow-x: auto;
padding-bottom: 0.2rem;
}
.pill-btn {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
font-weight: 560;
border-radius: 99rem;
background: color-mix(in srgb, var(--text) 6%, transparent);
border: 1px solid transparent;
color: var(--muted);
transition: all 0.15s;
}
.pill-btn.active {
background: color-mix(in srgb, var(--accent) 18%, transparent);
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
color: var(--text);
}
.cap {
text-transform: capitalize;
}
.quests-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
gap: 1rem;
}
.quest-card {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 1.1rem 1.25rem;
border-radius: var(--radius);
background: var(--surface);
border: 1px solid var(--line);
gap: 0.9rem;
transition: transform 0.15s, border-color 0.15s;
}
.quest-card:hover {
transform: translateY(-2px);
border-color: var(--line-strong);
}
.quest-card.completed {
border-color: color-mix(in srgb, var(--success) 45%, transparent);
}
.quest-card.claimed {
opacity: 0.75;
}
.quest-top {
display: flex;
gap: 0.8rem;
align-items: flex-start;
}
.quest-icon-wrapper {
width: 2.2rem;
height: 2.2rem;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--accent) 14%, transparent);
display: grid;
place-items: center;
flex-shrink: 0;
color: var(--accent);
}
.quest-meta {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.quest-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.4rem;
}
.quest-title {
font-size: 0.92rem;
font-weight: 650;
line-height: 1.2;
}
.cat-badge {
text-transform: uppercase;
font-size: 0.65rem;
letter-spacing: 0.05em;
background: color-mix(in srgb, var(--text) 8%, transparent);
color: var(--muted);
}
.quest-desc {
font-size: 0.82rem;
color: var(--muted);
line-height: 1.35;
}
.quest-progress-wrap {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.progress-labels {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
}
.xp-badge {
color: var(--accent);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.quest-action-row {
display: flex;
justify-content: flex-end;
align-items: center;
border-top: 1px solid color-mix(in srgb, var(--line) 40%, transparent);
padding-top: 0.6rem;
}
.claim-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.82rem;
padding: 0.4rem 0.9rem;
background: var(--success);
border-color: var(--success);
animation: pulse 2s infinite ease-in-out;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--success) 50%, transparent); }
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--success) 0%, transparent); }
}
.claimed-badge {
background: color-mix(in srgb, var(--success) 15%, transparent);
color: var(--success);
}
.in-progress-badge {
background: color-mix(in srgb, var(--text) 8%, transparent);
color: var(--muted);
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
font-weight: 600;
padding: 0.16rem 0.5rem;
border-radius: 99rem;
}
.center-state, .empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.8rem;
padding: 3rem 1.5rem;
text-align: center;
border-radius: var(--radius);
}
/* Achievements Section */
.achievements-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
gap: 1rem;
}
.achievement-card {
display: flex;
gap: 0.9rem;
padding: 1.1rem;
border-radius: var(--radius);
background: var(--surface);
border: 1px solid var(--line);
position: relative;
opacity: 0.65;
transition: opacity 0.15s, border-color 0.15s;
}
.achievement-card.unlocked {
opacity: 1;
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
}
.achievement-card.frame-challenge.unlocked {
border-color: #f59e0b88;
background: linear-gradient(135deg, color-mix(in srgb, #f59e0b 8%, var(--surface)) 0%, var(--surface) 100%);
}
.achievement-icon-frame {
width: 2.8rem;
height: 2.8rem;
border-radius: var(--radius-sm);
border: 2px solid var(--line);
display: grid;
place-items: center;
flex-shrink: 0;
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.4);
}
.sprite-icon {
font-size: 1.4rem;
}
.achievement-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.achievement-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.ach-title {
font-size: 0.92rem;
font-weight: 700;
}
.frame-badge {
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.05em;
}
.frame-badge.task { background: #64748b33; color: #94a3b8; }
.frame-badge.goal { background: #3b82f633; color: #60a5fa; }
.frame-badge.challenge { background: #f59e0b33; color: #fbbf24; }
.ach-desc {
font-size: 0.8rem;
color: var(--muted);
line-height: 1.35;
}
.ach-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.2rem;
}
.xp-tag {
font-size: 0.78rem;
font-weight: 700;
color: var(--accent);
}
.unlocked-tag {
background: color-mix(in srgb, var(--success) 20%, transparent);
color: var(--success);
}
.locked-tag {
background: color-mix(in srgb, var(--text) 8%, transparent);
color: var(--muted);
}
</style>
+913
View File
@@ -0,0 +1,913 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
Users, UserPlus, MessageSquare, Gamepad2, Send, Check, X, Trash2,
Clock, ExternalLink, LoaderCircle, RefreshCw, Circle, UserX, Sparkles
} from '@lucide/svelte';
import Avatar from '../components/Avatar.svelte';
import Modal from '../components/Modal.svelte';
import { activeAccount, app, instances, toast } from '../lib/store.svelte';
import { invoke } from '../lib/tauri';
import type { FriendInfo, DirectMessage, GameInvite } from '../lib/types';
let activeSection = $state<'friends' | 'messages' | 'invites'>('friends');
let friendsFilter = $state<'all' | 'online' | 'pending'>('all');
let loading = $state(false);
let friends = $state<FriendInfo[]>([]);
let invites = $state<GameInvite[]>([]);
let activeChatFriend = $state<FriendInfo | null>(null);
let messages = $state<DirectMessage[]>([]);
let messageInput = $state('');
let sendingMessage = $state(false);
// Add Friend Modal / Input
let addFriendUsername = $state('');
let sendingFriendReq = $state(false);
// Send Game Invite Modal
let inviteModalOpen = $state(false);
let inviteTargetFriend = $state<FriendInfo | null>(null);
let inviteInstanceId = $state(instances()[0]?.id ?? '');
let sendingInvite = $state(false);
async function loadData() {
loading = true;
try {
const [fList, iList] = await Promise.all([
invoke<FriendInfo[]>('get_friends').catch(() => []),
invoke<GameInvite[]>('get_game_invites').catch(() => [])
]);
friends = fList;
invites = iList;
} catch (e: any) {
toast(e?.message ?? 'Failed to load social data', 'error');
} finally {
loading = false;
}
}
async function handleSendFriendRequest() {
if (!addFriendUsername.trim()) return;
sendingFriendReq = true;
try {
await invoke('send_friend_request', { friendUsername: addFriendUsername.trim() });
toast(`Friend request sent to ${addFriendUsername.trim()}!`, 'ok');
addFriendUsername = '';
await loadData();
} catch (e: any) {
toast(e?.message ?? 'Failed to send friend request', 'error');
} finally {
sendingFriendReq = false;
}
}
async function handleRespondRequest(friendUuid: string, accept: boolean) {
try {
await invoke('respond_friend_request', { friendUuid, accept });
toast(accept ? 'Friend request accepted!' : 'Request declined.', 'ok');
await loadData();
} catch (e: any) {
toast(e?.message ?? 'Action failed', 'error');
}
}
async function handleRemoveFriend(friendUuid: string) {
try {
await invoke('remove_friend', { friendUuid });
friends = friends.filter((f) => f.uuid !== friendUuid);
if (activeChatFriend?.uuid === friendUuid) {
activeChatFriend = null;
messages = [];
}
toast('Friend removed', 'ok');
} catch (e: any) {
toast(e?.message ?? 'Failed to remove friend', 'error');
}
}
async function selectChatFriend(friend: FriendInfo) {
activeChatFriend = friend;
activeSection = 'messages';
try {
messages = await invoke<DirectMessage[]>('get_direct_messages', {
friendUuid: friend.uuid,
limit: 50
});
} catch (e: any) {
toast(e?.message ?? 'Failed to load messages', 'error');
}
}
async function handleSendMessage() {
if (!activeChatFriend || !messageInput.trim()) return;
sendingMessage = true;
const text = messageInput.trim();
try {
const msg = await invoke<DirectMessage>('send_direct_message', {
toUuid: activeChatFriend.uuid,
content: text
});
messages = [...messages, msg];
messageInput = '';
} catch (e: any) {
toast(e?.message ?? 'Failed to send message', 'error');
} finally {
sendingMessage = false;
}
}
function openInviteModal(friend: FriendInfo) {
inviteTargetFriend = friend;
inviteModalOpen = true;
}
async function handleSendInvite() {
if (!inviteTargetFriend) return;
sendingInvite = true;
try {
const inst = instances().find((i) => i.id === inviteInstanceId);
await invoke('send_game_invite', {
toUuid: inviteTargetFriend.uuid,
instanceId: inviteInstanceId,
serverAddress: inst?.server?.address ?? 'localhost',
serverName: inst?.name ?? 'Minecraft Server'
});
toast(`Game invite sent to ${inviteTargetFriend.username}!`, 'ok');
inviteModalOpen = false;
} catch (e: any) {
toast(e?.message ?? 'Failed to send game invite', 'error');
} finally {
sendingInvite = false;
}
}
async function handleRespondInvite(inviteId: string, accept: boolean) {
try {
await invoke('respond_game_invite', { inviteId, accept });
invites = invites.map((inv) =>
inv.id === inviteId ? { ...inv, status: accept ? 'accepted' : 'declined' } : inv
);
toast(accept ? 'Game invite accepted!' : 'Invite declined.', 'ok');
} catch (e: any) {
toast(e?.message ?? 'Failed to respond to invite', 'error');
}
}
const acceptedFriends = $derived(friends.filter((f) => f.status === 'accepted'));
const pendingReceived = $derived(friends.filter((f) => f.status === 'pending_received'));
const pendingSent = $derived(friends.filter((f) => f.status === 'pending_sent'));
const displayedFriends = $derived(
friends.filter((f) => {
if (friendsFilter === 'online') return f.status === 'accepted' && f.is_online;
if (friendsFilter === 'pending') return f.status !== 'accepted';
return f.status === 'accepted';
})
);
onMount(() => {
loadData();
});
</script>
<div class="social-page">
<!-- Page Header -->
<div class="page-header">
<div class="title-wrap">
<h1><Users class="users-icon" size={26} /> Friends & Social</h1>
<p class="lead">Connect with friends, send direct messages, and team up with game invites.</p>
</div>
<div class="header-actions">
<button class="ghost icon" onclick={loadData} title="Refresh" aria-label="Refresh">
<RefreshCw size={17} class={loading ? 'spin' : ''} />
</button>
</div>
</div>
<!-- Social Navigation Tabs -->
<div class="tabs-row">
<div class="tabs-segmented">
<button class:active={activeSection === 'friends'} onclick={() => (activeSection = 'friends')}>
<Users size={15} /> Friends ({acceptedFriends.length})
</button>
<button class:active={activeSection === 'messages'} onclick={() => (activeSection = 'messages')}>
<MessageSquare size={15} /> Direct Messages
</button>
<button class:active={activeSection === 'invites'} onclick={() => (activeSection = 'invites')}>
<Gamepad2 size={15} /> Invites ({invites.filter((i) => i.status === 'pending').length})
</button>
</div>
</div>
<!-- Content Sections -->
{#if activeSection === 'friends'}
<div class="friends-layout">
<!-- Add Friend Bar -->
<div class="add-friend-bar glass">
<UserPlus size={18} class="muted" />
<input
type="text"
placeholder="Enter a player's username to add them..."
bind:value={addFriendUsername}
onkeydown={(e) => e.key === 'Enter' && handleSendFriendRequest()}
class="add-friend-input"
/>
<button
class="primary sm"
onclick={handleSendFriendRequest}
disabled={sendingFriendReq || !addFriendUsername.trim()}
>
{#if sendingFriendReq}<LoaderCircle size={14} class="spin" />{:else}Send Request{/if}
</button>
</div>
<!-- Filter Sub-tabs -->
<div class="filter-pills">
<button class="pill" class:active={friendsFilter === 'all'} onclick={() => (friendsFilter = 'all')}>
All ({acceptedFriends.length})
</button>
<button class="pill" class:active={friendsFilter === 'online'} onclick={() => (friendsFilter = 'online')}>
Online ({acceptedFriends.filter((f) => f.is_online).length})
</button>
<button class="pill" class:active={friendsFilter === 'pending'} onclick={() => (friendsFilter = 'pending')}>
Pending ({pendingReceived.length + pendingSent.length})
</button>
</div>
<!-- Pending Incoming Requests Alert -->
{#if pendingReceived.length > 0 && friendsFilter !== 'pending'}
<div class="pending-alert glass">
<span class="tiny bold">You have {pendingReceived.length} pending friend request(s)</span>
<button class="ghost sm" onclick={() => (friendsFilter = 'pending')}>Review</button>
</div>
{/if}
<!-- Friends List -->
{#if friendsFilter === 'pending'}
<div class="pending-lists">
{#if pendingReceived.length > 0}
<h4 class="subheading">Incoming Requests ({pendingReceived.length})</h4>
<div class="friends-grid">
{#each pendingReceived as req (req.uuid)}
<div class="friend-card glass">
<div class="card-left" role="button" tabindex="0" onclick={() => (app.viewProfileUuid = req.uuid)} onkeydown={(e) => e.key === 'Enter' && (app.viewProfileUuid = req.uuid)}>
<Avatar uuid={req.uuid} name={req.username} size={2.5} />
<div class="name-block">
<span class="user-title">{req.username}</span>
<span class="tiny muted">Wants to be friends</span>
</div>
</div>
<div class="req-actions">
<button class="primary icon sm" onclick={() => handleRespondRequest(req.uuid, true)} title="Accept">
<Check size={14} />
</button>
<button class="ghost icon sm" onclick={() => handleRespondRequest(req.uuid, false)} title="Decline">
<X size={14} />
</button>
</div>
</div>
{/each}
</div>
{/if}
{#if pendingSent.length > 0}
<h4 class="subheading" style:margin-top="1rem">Outgoing Requests ({pendingSent.length})</h4>
<div class="friends-grid">
{#each pendingSent as req (req.uuid)}
<div class="friend-card glass">
<div class="card-left">
<Avatar uuid={req.uuid} name={req.username} size={2.5} />
<div class="name-block">
<span class="user-title">{req.username}</span>
<span class="tiny muted">Request pending...</span>
</div>
</div>
<button class="ghost icon sm" onclick={() => handleRemoveFriend(req.uuid)} title="Cancel Request">
<Trash2 size={14} />
</button>
</div>
{/each}
</div>
{/if}
{#if pendingReceived.length === 0 && pendingSent.length === 0}
<div class="empty-state glass">
<p class="muted">No pending friend requests.</p>
</div>
{/if}
</div>
{:else if displayedFriends.length === 0}
<div class="empty-state glass">
<Users size={32} class="muted" />
<p class="muted">No friends found in this view. Add friends by typing their username above!</p>
</div>
{:else}
<div class="friends-grid">
{#each displayedFriends as friend (friend.uuid)}
<div class="friend-card glass">
<div
class="card-left"
role="button"
tabindex="0"
onclick={() => (app.viewProfileUuid = friend.uuid)}
onkeydown={(e) => e.key === 'Enter' && (app.viewProfileUuid = friend.uuid)}
title="View Profile"
>
<div class="avatar-holder">
<Avatar uuid={friend.uuid} name={friend.username} size={2.6} />
<span class="status-dot" class:online={friend.is_online}></span>
</div>
<div class="name-block">
<span class="user-title">{friend.username}</span>
{#if friend.is_online && friend.playing_on}
<span class="tiny playing-on">Playing on {friend.playing_on}</span>
{:else if friend.is_online}
<span class="tiny online-label">Online in Launcher</span>
{:else}
<span class="tiny muted">Offline</span>
{/if}
</div>
</div>
<div class="friend-actions">
<button class="ghost icon sm" onclick={() => selectChatFriend(friend)} title="Message">
<MessageSquare size={15} />
</button>
<button class="ghost icon sm" onclick={() => openInviteModal(friend)} title="Invite to Game">
<Gamepad2 size={15} />
</button>
<button class="ghost icon sm danger-hover" onclick={() => handleRemoveFriend(friend.uuid)} title="Remove Friend">
<UserX size={15} />
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
{:else if activeSection === 'messages'}
<!-- Direct Messages Split View -->
<div class="messages-split glass">
<!-- Conversation Sidebar -->
<div class="conv-sidebar">
<span class="conv-head tiny uppercase muted">Conversations</span>
<div class="conv-list">
{#each acceptedFriends as friend (friend.uuid)}
<button
class="conv-item"
class:active={activeChatFriend?.uuid === friend.uuid}
onclick={() => selectChatFriend(friend)}
>
<div class="avatar-holder">
<Avatar uuid={friend.uuid} name={friend.username} size={2} />
<span class="status-dot" class:online={friend.is_online}></span>
</div>
<div class="conv-meta">
<span class="conv-name">{friend.username}</span>
{#if friend.playing_on}
<span class="tiny muted">{friend.playing_on}</span>
{/if}
</div>
</button>
{/each}
</div>
</div>
<!-- Chat Thread Main -->
<div class="chat-thread">
{#if activeChatFriend}
<div class="thread-header">
<div
class="thread-user"
role="button"
tabindex="0"
onclick={() => (app.viewProfileUuid = activeChatFriend!.uuid)}
onkeydown={(e) => e.key === 'Enter' && (app.viewProfileUuid = activeChatFriend!.uuid)}
>
<Avatar uuid={activeChatFriend.uuid} name={activeChatFriend.username} size={2.2} />
<div>
<strong>{activeChatFriend.username}</strong>
<span class="tiny muted block">{activeChatFriend.is_online ? 'Active now' : 'Offline'}</span>
</div>
</div>
<button class="primary sm" onclick={() => openInviteModal(activeChatFriend!)}>
<Gamepad2 size={13} /> Invite
</button>
</div>
<div class="messages-scroll">
{#if messages.length === 0}
<div class="empty-state">
<MessageSquare size={28} class="muted" />
<p class="tiny muted">No messages yet with {activeChatFriend.username}. Say hello!</p>
</div>
{:else}
{#each messages as msg (msg.id)}
{@const isMine = msg.from_uuid === activeAccount()?.uuid}
<div class="bubble-row" class:is-mine={isMine}>
<div class="bubble" class:is-mine={isMine}>
<p class="bubble-text">{msg.content}</p>
<span class="bubble-time tiny">{new Date(msg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
</div>
</div>
{/each}
{/if}
</div>
<div class="message-composer">
<input
type="text"
placeholder="Type a message..."
bind:value={messageInput}
onkeydown={(e) => e.key === 'Enter' && handleSendMessage()}
class="composer-input"
/>
<button
class="primary icon"
onclick={handleSendMessage}
disabled={sendingMessage || !messageInput.trim()}
aria-label="Send Message"
>
{#if sendingMessage}<LoaderCircle size={15} class="spin" />{:else}<Send size={15} />{/if}
</button>
</div>
{:else}
<div class="center-state">
<MessageSquare size={36} class="muted" />
<p class="muted">Select a friend to start chatting</p>
</div>
{/if}
</div>
</div>
{:else if activeSection === 'invites'}
<!-- Game Invites View -->
<div class="invites-view">
{#if invites.length === 0}
<div class="empty-state glass">
<Gamepad2 size={36} class="muted" />
<p class="muted">No game invites received. When friends invite you to join their server, they will appear here!</p>
</div>
{:else}
<div class="invites-grid">
{#each invites as inv (inv.id)}
<div class="invite-card glass">
<div class="invite-top">
<Avatar uuid={inv.from_uuid} name={inv.from_username} size={2.5} />
<div class="invite-meta">
<span class="inviter-name"><strong>{inv.from_username}</strong> invited you to play</span>
<span class="server-title">{inv.server_name}</span>
<span class="tiny muted server-addr">{inv.server_address}</span>
</div>
</div>
<div class="invite-footer">
<span class="tiny muted">{new Date(inv.created_at).toLocaleDateString()}</span>
{#if inv.status === 'pending'}
<div class="invite-btns">
<button class="primary sm" onclick={() => handleRespondInvite(inv.id, true)}>
<Check size={13} /> Accept
</button>
<button class="ghost sm" onclick={() => handleRespondInvite(inv.id, false)}>
Decline
</button>
</div>
{:else}
<span class="badge status-badge {inv.status}">{inv.status}</span>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
<!-- Send Game Invite Modal -->
<Modal bind:open={inviteModalOpen} title="Invite {inviteTargetFriend?.username} to Play" width={24}>
<div class="invite-modal-body">
<p class="tiny muted">
Choose which instance / server you want to invite {inviteTargetFriend?.username} to join:
</p>
<label>
<span class="field-label">Instance</span>
<select bind:value={inviteInstanceId} class="modal-select">
{#each instances() as inst}
<option value={inst.id}>{inst.name}</option>
{/each}
</select>
</label>
</div>
{#snippet footer()}
<button class="ghost" onclick={() => (inviteModalOpen = false)}>Cancel</button>
<button class="primary" onclick={handleSendInvite} disabled={sendingInvite}>
{#if sendingInvite}<LoaderCircle size={14} class="spin" />{:else}<Gamepad2 size={14} />{/if}
Send Game Invite
</button>
{/snippet}
</Modal>
<style>
.social-page {
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 1.6rem 2.2rem 2.5rem;
gap: 1.2rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
border-bottom: 1px solid var(--line);
padding-bottom: 1rem;
}
.title-wrap h1 {
font-size: 1.5rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 0.6rem;
}
:global(.users-icon) {
color: var(--accent);
}
.lead {
color: var(--muted);
font-size: 0.88rem;
margin-top: 0.2rem;
}
.tabs-row {
display: flex;
}
.tabs-segmented {
display: flex;
background: color-mix(in srgb, var(--surface) 80%, transparent);
padding: 0.25rem;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
gap: 0.2rem;
}
.tabs-segmented button {
display: flex;
align-items: center;
gap: 0.4rem;
background: transparent;
border: none;
padding: 0.45rem 1rem;
font-size: 0.85rem;
font-weight: 560;
color: var(--muted);
border-radius: calc(var(--radius-sm) - 2px);
transition: all 0.15s;
}
.tabs-segmented button.active {
background: color-mix(in srgb, var(--text) 10%, transparent);
color: var(--text);
}
.friends-layout {
display: flex;
flex-direction: column;
gap: 1rem;
}
.add-friend-bar {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 0.6rem 1rem;
border-radius: var(--radius);
background: var(--surface);
border: 1px solid var(--line);
}
.add-friend-input {
flex: 1;
background: transparent;
border: none;
font-size: 0.88rem;
color: var(--text);
outline: none;
}
.filter-pills {
display: flex;
gap: 0.4rem;
}
.pill {
padding: 0.3rem 0.75rem;
font-size: 0.8rem;
font-weight: 560;
border-radius: 99rem;
background: color-mix(in srgb, var(--text) 6%, transparent);
border: 1px solid transparent;
color: var(--muted);
transition: all 0.15s;
}
.pill.active {
background: color-mix(in srgb, var(--accent) 18%, transparent);
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
color: var(--text);
}
.pending-alert {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6rem 1rem;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--accent) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
}
.friends-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
gap: 0.9rem;
}
.friend-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.9rem 1.1rem;
border-radius: var(--radius);
background: var(--surface);
border: 1px solid var(--line);
transition: border-color 0.15s, transform 0.12s;
}
.friend-card:hover {
border-color: var(--line-strong);
transform: translateY(-1px);
}
.card-left {
display: flex;
align-items: center;
gap: 0.8rem;
cursor: pointer;
flex: 1;
min-width: 0;
}
.avatar-holder {
position: relative;
}
.status-dot {
position: absolute;
bottom: -1px;
right: -1px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--muted);
border: 2px solid var(--surface);
}
.status-dot.online {
background: var(--success);
}
.name-block {
display: flex;
flex-direction: column;
min-width: 0;
}
.user-title {
font-weight: 650;
font-size: 0.92rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playing-on {
color: var(--accent);
font-weight: 560;
}
.online-label {
color: var(--success);
}
.friend-actions, .req-actions {
display: flex;
align-items: center;
gap: 0.3rem;
}
.danger-hover:hover {
color: var(--danger);
}
.subheading {
font-size: 0.92rem;
font-weight: 700;
margin-bottom: 0.6rem;
}
/* Direct Messages Split */
.messages-split {
display: flex;
height: 32rem;
border-radius: var(--radius);
background: var(--surface);
border: 1px solid var(--line);
overflow: hidden;
}
.conv-sidebar {
width: 15rem;
border-right: 1px solid var(--line);
display: flex;
flex-direction: column;
background: color-mix(in srgb, var(--surface) 90%, black);
}
.conv-head {
padding: 0.8rem 1rem 0.5rem;
letter-spacing: 0.06em;
font-weight: 700;
}
.conv-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
padding: 0.3rem 0.5rem;
gap: 0.2rem;
}
.conv-item {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.55rem 0.7rem;
border-radius: var(--radius-sm);
background: transparent;
border: none;
text-align: left;
transition: background 0.12s;
}
.conv-item:hover {
background: color-mix(in srgb, var(--text) 6%, transparent);
}
.conv-item.active {
background: color-mix(in srgb, var(--accent) 15%, transparent);
}
.conv-meta {
display: flex;
flex-direction: column;
min-width: 0;
}
.conv-name {
font-size: 0.86rem;
font-weight: 650;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-thread {
flex: 1;
display: flex;
flex-direction: column;
}
.thread-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.8rem 1.2rem;
border-bottom: 1px solid var(--line);
}
.thread-user {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
}
.block {
display: block;
}
.messages-scroll {
flex: 1;
overflow-y: auto;
padding: 1.2rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.bubble-row {
display: flex;
justify-content: flex-start;
}
.bubble-row.is-mine {
justify-content: flex-end;
}
.bubble {
max-width: 22rem;
padding: 0.6rem 0.9rem;
border-radius: var(--radius);
background: color-mix(in srgb, var(--surface) 90%, var(--text));
border: 1px solid var(--line);
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.bubble.is-mine {
background: color-mix(in srgb, var(--accent) 25%, transparent);
border-color: color-mix(in srgb, var(--accent) 50%, transparent);
}
.bubble-text {
font-size: 0.88rem;
margin: 0;
line-height: 1.35;
word-break: break-word;
}
.bubble-time {
align-self: flex-end;
color: var(--muted);
font-size: 0.68rem;
}
.message-composer {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.75rem 1.2rem;
border-top: 1px solid var(--line);
}
.composer-input {
flex: 1;
background: color-mix(in srgb, var(--bg) 60%, transparent);
border: 1px solid var(--line);
color: var(--text);
padding: 0.5rem 0.85rem;
border-radius: var(--radius-sm);
font-size: 0.88rem;
}
/* Invites View */
.invites-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
gap: 1rem;
}
.invite-card {
display: flex;
flex-direction: column;
gap: 0.9rem;
padding: 1.2rem;
border-radius: var(--radius);
background: var(--surface);
border: 1px solid var(--line);
}
.invite-top {
display: flex;
align-items: center;
gap: 0.9rem;
}
.invite-meta {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.server-title {
font-size: 1rem;
font-weight: 700;
}
.invite-footer {
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid color-mix(in srgb, var(--line) 40%, transparent);
padding-top: 0.7rem;
}
.invite-btns {
display: flex;
gap: 0.4rem;
}
.status-badge {
text-transform: capitalize;
}
.status-badge.accepted {
background: color-mix(in srgb, var(--success) 20%, transparent);
color: var(--success);
}
.status-badge.declined {
background: color-mix(in srgb, var(--danger) 20%, transparent);
color: var(--danger);
}
.invite-modal-body {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.modal-select {
width: 100%;
background: color-mix(in srgb, var(--bg) 60%, transparent);
border: 1px solid var(--line);
color: var(--text);
padding: 0.45rem 0.75rem;
border-radius: var(--radius-sm);
font-size: 0.88rem;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
font-weight: 600;
padding: 0.16rem 0.5rem;
border-radius: 99rem;
}
.center-state, .empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.8rem;
padding: 3rem 1.5rem;
text-align: center;
border-radius: var(--radius);
}
</style>
+14
View File
@@ -186,6 +186,13 @@ impl FromRequestParts<AppState> 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<AppState> for AuthUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
@@ -195,6 +202,13 @@ impl FromRequestParts<AppState> 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<AppState> for AdminUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
+207
View File
@@ -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<SqlitePool> {
@@ -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(())
}
+1
View File
@@ -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;
+296
View File
@@ -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<AppState>,
) -> AppResult<Json<Vec<Achievement>>> {
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<String>,
State(state): State<AppState>,
) -> AppResult<Json<Vec<Achievement>>> {
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<Vec<Achievement>> {
let rows: Vec<(
String,
String,
String,
String,
String,
String,
String,
String,
i64,
bool,
Option<String>,
)> = 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<AppState>,
) -> AppResult<Json<Vec<AdminAchievementRow>>> {
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<String>,
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<bool>,
}
/// Admin create achievement.
pub async fn admin_create_achievement(
_admin: AdminUser,
State(state): State<AppState>,
Json(payload): Json<AchievementPayload>,
) -> AppResult<Json<Value>> {
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<String>,
State(state): State<AppState>,
Json(payload): Json<AchievementPayload>,
) -> AppResult<Json<Value>> {
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<String>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
sqlx::query("DELETE FROM achievements WHERE id = ?")
.bind(&id)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
+829
View File
@@ -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<String>,
}
/// List guilds for an instance.
pub async fn list_guilds(
Query(query): Query<InstanceQuery>,
State(state): State<AppState>,
) -> AppResult<Json<Vec<Guild>>> {
let instance_id = query.instance_id.unwrap_or_default();
let rows: Vec<(
String,
String,
String,
String,
String,
String,
String,
Option<String>,
Option<String>,
i64,
i64,
i64,
String,
i64,
i64,
)> = sqlx::query_as(
"SELECT g.id, g.instance_id, g.name, g.tag, g.description, g.motd, g.leader_uuid,
g.icon_url, g.banner_url, g.level, g.xp, g.max_claims, g.created_at,
(SELECT COUNT(*) FROM guild_members gm WHERE gm.guild_id = g.id) as member_count,
(SELECT COUNT(*) FROM guild_claims gc WHERE gc.guild_id = g.id) as claims_count
FROM guilds g
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<InstanceQuery>,
State(state): State<AppState>,
) -> AppResult<Json<Option<GuildDetail>>> {
let instance_id = query.instance_id.unwrap_or_default();
let guild_id: Option<String> = 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<GuildMember>,
pub posts: Vec<GuildPost>,
pub claims: Vec<GuildClaim>,
}
async fn fetch_guild_detail(state: &AppState, guild_id: &str) -> AppResult<GuildDetail> {
let row: Option<(
String,
String,
String,
String,
String,
String,
String,
Option<String>,
Option<String>,
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<GuildMember> = 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<GuildPost> = 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<GuildClaim> = 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<String>,
State(state): State<AppState>,
) -> AppResult<Json<GuildDetail>> {
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<String>,
pub motd: Option<String>,
pub icon_url: Option<String>,
pub banner_url: Option<String>,
}
/// Create a new Guild.
pub async fn create_guild(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<CreateGuildPayload>,
) -> AppResult<Json<GuildDetail>> {
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<String>,
pub motd: Option<String>,
pub icon_url: Option<String>,
pub banner_url: Option<String>,
}
/// Update guild info.
pub async fn update_guild(
auth: AuthUser,
Path(id): Path<String>,
State(state): State<AppState>,
Json(payload): Json<UpdateGuildPayload>,
) -> AppResult<Json<Value>> {
let role: Option<String> = 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<String>,
State(state): State<AppState>,
Json(payload): Json<AddMemberPayload>,
) -> AppResult<Json<Value>> {
let role: Option<String> = 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<AppState>,
) -> AppResult<Json<Value>> {
let caller_role: Option<String> = 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<String>,
State(state): State<AppState>,
Json(payload): Json<CreatePostPayload>,
) -> AppResult<Json<Value>> {
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<i64>,
pub instance_id: Option<String>,
pub dimension: Option<String>,
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<String>,
State(state): State<AppState>,
Json(payload): Json<ClaimChunkPayload>,
) -> AppResult<Json<Value>> {
let role: Option<String> = 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<String>,
State(state): State<AppState>,
Json(payload): Json<ClaimChunkPayload>,
) -> AppResult<Json<Value>> {
let role: Option<String> = 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<i64>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
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<String> = 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<String>,
pub center_x: i32,
pub center_z: i32,
pub radius: Option<i32>,
}
/// Returns chunk claims in a bounding box centered around (center_x, center_z) chunks.
pub async fn get_chunk_grid(
Query(query): Query<ChunkGridQuery>,
State(state): State<AppState>,
) -> AppResult<Json<Vec<GuildClaim>>> {
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<AppState>,
Json(payload): Json<ServerCheckChunkPayload>,
) -> AppResult<Json<Value>> {
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}")) }
})))
}
+235
View File
@@ -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<i64>,
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<AppState>,
) -> AppResult<Json<UserLevelInfo>> {
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<String>,
State(state): State<AppState>,
) -> AppResult<Json<UserLevelInfo>> {
get_user_levels_by_uuid(&state, &uuid).await.map(Json)
}
pub async fn get_user_levels_by_uuid(state: &AppState, uuid: &str) -> AppResult<UserLevelInfo> {
let row: Option<(i64, i64, Option<String>, 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<String> = serde_json::from_str(&badges_json).unwrap_or_default();
// Query server levels for this user
let server_rows: Vec<(i64, String, i64, i64, Option<String>)> = 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<AppState>,
) -> AppResult<Json<Vec<Value>>> {
let rows: Vec<(String, String, i64, i64, Option<String>)> = 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<AppState>,
) -> AppResult<Json<Vec<Value>>> {
let rows: Vec<(i64, String, Option<i64>, 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<i64>,
pub level_req: i64,
pub reward_type: String,
pub reward_name: String,
pub reward_data: Option<Value>,
}
/// Admin create reward.
pub async fn admin_create_reward(
_admin: AdminUser,
State(state): State<AppState>,
Json(payload): Json<CreateRewardPayload>,
) -> AppResult<Json<Value>> {
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<i64>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
sqlx::query("DELETE FROM level_rewards WHERE id = ?")
.bind(id)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
+62 -1
View File
@@ -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<AppState> {
@@ -34,6 +39,54 @@ pub fn api(state: &AppState) -> Router<AppState> {
.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<AppState> {
.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<AppState> {
.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()
+373
View File
@@ -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<AppState>,
) -> AppResult<Json<Vec<UserQuest>>> {
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<i64>,
Option<bool>,
Option<bool>,
Option<String>,
)> = 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<String>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
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<AppState>,
) -> AppResult<Json<Vec<AdminQuestRow>>> {
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<String>,
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<String>,
pub enabled: Option<bool>,
}
/// Admin create quest.
pub async fn admin_create_quest(
_admin: AdminUser,
State(state): State<AppState>,
Json(payload): Json<QuestPayload>,
) -> AppResult<Json<Value>> {
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<String>,
State(state): State<AppState>,
Json(payload): Json<QuestPayload>,
) -> AppResult<Json<Value>> {
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<String>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
sqlx::query("DELETE FROM quests WHERE id = ?")
.bind(&id)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
+277 -1
View File
@@ -395,6 +395,19 @@ pub struct GameEvent {
at: Option<i64>,
}
#[derive(Deserialize, Default, Clone, Debug)]
#[serde(default)]
pub struct FeatureToggles {
pub leveling_enabled: Option<bool>,
pub global_xp_multiplier: Option<f64>,
pub server_xp_multiplier: Option<f64>,
pub quests_enabled: Option<bool>,
pub achievements_enabled: Option<bool>,
pub guilds_enabled: Option<bool>,
pub land_claiming_enabled: Option<bool>,
pub social_enabled: Option<bool>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
pub struct Sync {
@@ -404,6 +417,7 @@ pub struct Sync {
online: Vec<OnlinePlayer>,
stats: Vec<StatDelta>,
events: Vec<GameEvent>,
features: Option<FeatureToggles>,
}
/// 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<AppState>,
.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<AppState>,
.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<String> = 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<AppState>,
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<i64>, Option<i64>, Option<i64>, Option<i64>, Option<i64>, Option<i64>)> = 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<String> = 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<i64> = 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<String> = 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
// ---------------------------------------------------------------------------
+623
View File
@@ -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<AppState>,
) -> AppResult<Json<Vec<FriendInfo>>> {
let rows: Vec<(String, String, String, String, bool, Option<String>)> = 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<String>,
pub friend_username: Option<String>,
}
pub async fn send_friend_request(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<FriendRequestPayload>,
) -> AppResult<Json<Value>> {
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<String>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
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<String>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
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<String>,
pub friend_uuid: Option<String>,
pub accept: bool,
}
pub async fn respond_friend_request(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<RespondFriendPayload>,
) -> AppResult<Json<Value>> {
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<String>,
State(state): State<AppState>,
) -> AppResult<Json<Vec<DirectMessage>>> {
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<String>,
State(state): State<AppState>,
Json(payload): Json<SendMessagePayload>,
) -> AppResult<Json<DirectMessage>> {
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<AppState>,
) -> AppResult<Json<Vec<GameInvite>>> {
let rows: Vec<(
String,
String,
String,
String,
String,
String,
Option<i64>,
Option<String>,
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<i64>,
}
pub async fn send_game_invite(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<SendInvitePayload>,
) -> AppResult<Json<Value>> {
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<String>,
State(state): State<AppState>,
Json(payload): Json<RespondInvitePayload>,
) -> AppResult<Json<Value>> {
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<String>,
State(state): State<AppState>,
) -> AppResult<Json<UserProfileView>> {
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<String>, Option<String>, Option<String>)> = 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<String>, 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<String>,
pub banner_url: Option<String>,
pub custom_badge: Option<String>,
pub featured_achievement_id: Option<String>,
}
pub async fn update_my_profile(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<UpdateProfilePayload>,
) -> AppResult<Json<Value>> {
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<String>,
}
pub async fn create_user_post(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<CreatePostInput>,
) -> AppResult<Json<UserPost>> {
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<i64>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
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<i64>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
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 })))
}
+255
View File
@@ -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::<i64>, 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(())
}
+341
View File
@@ -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);
}
+12
View File
@@ -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 @@
<ServerDetail id={route.params[0]} />
{:else if route.name === 'servers'}
<Servers />
{:else if route.name === 'quests'}
<QuestEditor />
{:else if route.name === 'achievements'}
<AchievementEditor />
{:else if route.name === 'leveling'}
<LevelingAdmin />
{:else if route.name === 'guilds'}
<GuildsAdmin />
{:else if route.name === 'capes'}
<Capes />
{:else if route.name === 'activity'}
+5 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { LayoutDashboard, Boxes, Users, Palette, Settings, LogOut, ExternalLink, Server, Flag, Globe } from '@lucide/svelte';
import { LayoutDashboard, Boxes, Users, Palette, Settings, LogOut, ExternalLink, Server, Flag, Globe, Target, Trophy, Sparkles, Shield } from '@lucide/svelte';
import { route } from '../lib/router.svelte';
import { logout, session } from '../lib/session.svelte';
import Avatar from './Avatar.svelte';
@@ -11,6 +11,10 @@
{ id: 'instances', label: 'Instances', icon: Boxes },
{ id: 'users', label: 'Players', icon: Users },
{ id: 'servers', label: 'Servers', icon: Server },
{ id: 'quests', label: 'Quests', icon: Target },
{ id: 'achievements', label: 'Achievements', icon: Trophy },
{ id: 'leveling', label: 'Leveling & XP', icon: Sparkles },
{ id: 'guilds', label: 'Guilds & Claims', icon: Shield },
{ id: 'activity', label: 'Activity', icon: LayoutDashboard },
{ id: 'capes', label: 'Capes', icon: Flag },
{ id: 'branding', label: 'Launcher design', icon: Palette },
+177
View File
@@ -183,3 +183,180 @@ export interface LandingConfig {
faqs: FaqItem[];
custom_css: string;
}
export interface LevelReward {
id: number;
level: number;
reward_type: 'title' | 'item' | 'cosmetic' | 'profile_badge';
reward_value: string;
description: string;
icon?: string;
}
export interface UserLevelInfo {
uuid: string;
level: number;
current_xp: number;
next_level_xp: number;
progress_pct: number;
title: string | null;
badges: string[];
rewards: LevelReward[];
}
export interface ServerLevelInfo {
server_id: number;
server_name: string;
uuid: string;
level: number;
current_xp: number;
next_level_xp: number;
progress_pct: number;
rank_title: string | null;
}
export interface Quest {
id: string;
title: string;
description: string;
quest_type: 'daily' | 'weekly';
category: 'mining' | 'combat' | 'building' | 'farming' | 'social' | 'exploration';
xp_reward: number;
stat_type: string;
target_count: number;
icon: string;
active: boolean;
}
export interface UserQuest {
quest: Quest;
current_count: number;
completed: boolean;
claimed: boolean;
expires_at: string;
}
export interface Achievement {
id: string;
title: string;
description: string;
category: string;
xp_reward: number;
frame_type: 'task' | 'goal' | 'challenge';
icon_item: string;
icon_bg: string;
icon_border: string;
stat_type?: string | null;
target_count?: number | null;
event_type?: string | null;
unlocked?: boolean;
unlocked_at?: string | null;
}
export interface Guild {
id: string;
instance_id: string;
name: string;
tag: string;
description: string;
icon_url: string | null;
banner_url: string | null;
leader_uuid: string;
member_count: number;
max_members: number;
claims_count: number;
created_at: string;
}
export interface GuildMember {
guild_id: string;
user_uuid: string;
username: string;
role: 'leader' | 'officer' | 'member';
joined_at: string;
is_online?: boolean;
}
export interface GuildPost {
id: string;
guild_id: string;
author_uuid: string;
author_name: string;
title: string;
content: string;
pinned: boolean;
created_at: string;
}
export interface GuildClaim {
id: number;
guild_id: string;
instance_id: string;
dimension: string;
chunk_x: number;
chunk_z: number;
claimed_by_uuid: string;
claimed_at: string;
guild_name?: string;
guild_tag?: string;
}
export interface FriendInfo {
uuid: string;
username: string;
status: 'pending_sent' | 'pending_received' | 'accepted';
is_online: boolean;
playing_on: string | null;
avatar_url?: string | null;
}
export interface DirectMessage {
id: number;
from_uuid: string;
to_uuid: string;
content: string;
created_at: string;
is_read: boolean;
}
export interface GameInvite {
id: string;
from_uuid: string;
from_username: string;
to_uuid: string;
instance_id: string;
server_address: string;
server_name: string;
status: 'pending' | 'accepted' | 'declined' | 'expired';
created_at: string;
}
export interface UserPost {
id: string;
user_uuid: string;
username: string;
content: string;
image_url: string | null;
likes_count: number;
liked_by_me: boolean;
created_at: string;
}
export interface UserProfileView {
uuid: string;
username: string;
bio: string;
banner_url: string | null;
skin_url: string | null;
skin_model: string;
cape_url: string | null;
global_level: UserLevelInfo;
server_levels: ServerLevelInfo[];
badges: string[];
achievements: Achievement[];
posts: UserPost[];
friends_count: number;
stats: PlayerStats | null;
created_at: string;
}
@@ -0,0 +1,544 @@
<script lang="ts">
import { Plus, Pencil, Trash2, Trophy, Sparkles, Shield, Swords, Pickaxe, Compass, Users } from '@lucide/svelte';
import Modal from '../components/Modal.svelte';
import { get, post, put, del } from '../lib/api';
import { toast, toastError } from '../lib/toast.svelte';
import type { Achievement } from '../lib/types';
let achievements = $state<Achievement[]>([]);
let loading = $state(true);
let filterCategory = $state<string>('all');
let filterFrame = $state<string>('all');
let draft = $state<Partial<Achievement> | null>(null);
let draftOpen = $state(false);
let saving = $state(false);
let deleteConfirm = $state<Achievement | null>(null);
let deleteOpen = $state(false);
const categories = ['progression', 'mining', 'combat', 'building', 'social', 'guilds', 'exploration'];
const frameTypes = [
{ id: 'task', label: 'Task (Normal)', bannerTitle: 'Advancement Made!', bannerColor: '#55ff55' },
{ id: 'goal', label: 'Goal (Rounded)', bannerTitle: 'Goal Reached!', bannerColor: '#55ffff' },
{ id: 'challenge', label: 'Challenge (Spiked & Glowing)', bannerTitle: 'Challenge Complete!', bannerColor: '#ff55ff' },
] as const;
const bgTextures = [
{ id: 'stone', label: 'Stone', color: '#52525b', pattern: 'radial-gradient(circle, #52525b 0%, #27272a 100%)' },
{ id: 'deepslate', label: 'Deepslate', color: '#18181b', pattern: 'radial-gradient(circle, #27272a 0%, #09090b 100%)' },
{ id: 'obsidian', label: 'Obsidian', color: '#1e1b4b', pattern: 'radial-gradient(circle, #2e1065 0%, #0f172a 100%)' },
{ id: 'dirt', label: 'Dirt', color: '#78350f', pattern: 'radial-gradient(circle, #92400e 0%, #451a03 100%)' },
{ id: 'bedrock', label: 'Bedrock', color: '#09090b', pattern: 'radial-gradient(circle, #3f3f46 0%, #09090b 100%)' },
] as const;
const borderGlows = [
{ id: 'bronze', label: 'Bronze', color: '#b45309', glow: '0 0 10px rgba(180, 83, 9, 0.6)' },
{ id: 'silver', label: 'Silver', color: '#94a3b8', glow: '0 0 10px rgba(148, 163, 184, 0.6)' },
{ id: 'gold', label: 'Gold', color: '#eab308', glow: '0 0 12px rgba(234, 179, 8, 0.7)' },
{ id: 'diamond', label: 'Diamond', color: '#06b6d4', glow: '0 0 14px rgba(6, 182, 212, 0.8)' },
{ id: 'purple', label: 'Amethyst / Purple', color: '#c084fc', glow: '0 0 14px rgba(192, 132, 252, 0.8)' },
{ id: 'netherite', label: 'Netherite', color: '#475569', glow: '0 0 10px rgba(71, 85, 105, 0.7)' },
] as const;
const popularItems = [
{ id: 'diamond_pickaxe', label: 'Diamond Pickaxe', emoji: '⛏️' },
{ id: 'netherite_sword', label: 'Netherite Sword', emoji: '⚔️' },
{ id: 'beacon', label: 'Beacon', emoji: '✨' },
{ id: 'golden_apple', label: 'Golden Apple', emoji: '🍏' },
{ id: 'elytra', label: 'Elytra', emoji: '🪽' },
{ id: 'ender_eye', label: 'Eye of Ender', emoji: '👁️' },
{ id: 'crafting_table', label: 'Crafting Table', emoji: '🪚' },
{ id: 'shield', label: 'Shield', emoji: '🛡️' },
{ id: 'heart_of_the_sea', label: 'Heart of the Sea', emoji: '💙' },
{ id: 'dragon_egg', label: 'Dragon Egg', emoji: '🥚' },
{ id: 'totem_of_undying', label: 'Totem of Undying', emoji: '🗿' },
{ id: 'nether_star', label: 'Nether Star', emoji: '⭐' },
{ id: 'chest', label: 'Treasure Chest', emoji: '📦' },
{ id: 'castle', label: 'Castle Flag', emoji: '🚩' },
];
async function loadAchievements() {
loading = true;
try {
achievements = await get<Achievement[]>('/api/admin/achievements');
} catch (e) {
toastError(e);
} finally {
loading = false;
}
}
$effect(() => {
loadAchievements();
});
let filtered = $derived(
achievements.filter((a) => {
if (filterCategory !== 'all' && a.category !== filterCategory) return false;
if (filterFrame !== 'all' && a.frame_type !== filterFrame) return false;
return true;
})
);
function openNew() {
draft = {
id: `ach_${Date.now()}`,
title: 'New Advancement',
description: 'Describe the achievement requirements...',
category: 'progression',
xp_reward: 200,
frame_type: 'task',
icon_item: 'diamond_pickaxe',
icon_bg: 'deepslate',
icon_border: 'gold',
stat_type: 'blocks_broken',
target_count: 100,
};
draftOpen = true;
}
function openEdit(a: Achievement) {
draft = { ...a };
draftOpen = true;
}
async function save() {
if (!draft || !draft.title || !draft.description) {
toast('Title and description are required', 'error');
return;
}
saving = true;
try {
const exists = achievements.some((a) => a.id === draft?.id);
if (exists) {
await put(`/api/admin/achievements/${draft.id}`, draft);
toast('Achievement updated');
} else {
await post('/api/admin/achievements', draft);
toast('Achievement created');
}
draftOpen = false;
draft = null;
await loadAchievements();
} catch (e) {
toastError(e);
} finally {
saving = false;
}
}
async function remove(a: Achievement) {
try {
await del(`/api/admin/achievements/${a.id}`);
toast(`Achievement "${a.title}" deleted`);
deleteConfirm = null;
await loadAchievements();
} catch (e) {
toastError(e);
}
}
function getItemEmoji(id?: string | null): string {
const item = popularItems.find((i) => i.id === id);
return item ? item.emoji : '🏆';
}
function getBorderColor(border?: string | null): string {
const b = borderGlows.find((bg) => bg.id === border);
return b ? b.color : '#eab308';
}
function getBorderGlow(border?: string | null): string {
const b = borderGlows.find((bg) => bg.id === border);
return b ? b.glow : '0 0 10px rgba(234, 179, 8, 0.6)';
}
function getBgPattern(bg?: string | null): string {
const b = bgTextures.find((t) => t.id === bg);
return b ? b.pattern : 'radial-gradient(circle, #27272a 0%, #09090b 100%)';
}
</script>
<div class="page">
<header>
<div>
<h1>Minecraft Achievements & Advancements</h1>
<p>Create and customize in-game achievements with the Minecraft-themed icon and advancement creator.</p>
</div>
<button class="primary" onclick={openNew}>
<Plus size={16} /> New Achievement
</button>
</header>
<div class="creator-spotlight">
<div class="spotlight-header">
<Trophy size={18} class="text-gold" />
<span>Minecraft Advancement Toast Preview</span>
</div>
<div class="toast-preview-row">
<!-- Task Preview -->
<div class="mc-toast task">
<div class="mc-frame-outer task border-gold">
<div class="mc-frame-inner bg-stone">
<span class="mc-icon">⛏️</span>
</div>
</div>
<div class="mc-toast-body">
<div class="mc-toast-title text-task">Advancement Made!</div>
<div class="mc-toast-desc">Stone Age</div>
</div>
</div>
<!-- Goal Preview -->
<div class="mc-toast goal">
<div class="mc-frame-outer goal border-diamond">
<div class="mc-frame-inner bg-deepslate">
<span class="mc-icon">✨</span>
</div>
</div>
<div class="mc-toast-body">
<div class="mc-toast-title text-goal">Goal Reached!</div>
<div class="mc-toast-desc">Beaconator</div>
</div>
</div>
<!-- Challenge Preview -->
<div class="mc-toast challenge">
<div class="mc-frame-outer challenge border-purple">
<div class="mc-frame-inner bg-obsidian">
<span class="mc-icon">🪽</span>
</div>
</div>
<div class="mc-toast-body">
<div class="mc-toast-title text-challenge">Challenge Complete!</div>
<div class="mc-toast-desc">Sky's the Limit</div>
</div>
</div>
</div>
</div>
<div class="toolbar">
<div class="filters">
<label for="filter-cat">Category:</label>
<select id="filter-cat" bind:value={filterCategory}>
<option value="all">All Categories</option>
{#each categories as cat}
<option value={cat}>{cat.toUpperCase()}</option>
{/each}
</select>
<label for="filter-frame">Frame Style:</label>
<select id="filter-frame" bind:value={filterFrame}>
<option value="all">All Frames</option>
<option value="task">Task (Normal)</option>
<option value="goal">Goal (Rounded)</option>
<option value="challenge">Challenge (Spiked)</option>
</select>
</div>
<span class="count-badge">{filtered.length} Achievements</span>
</div>
{#if loading}
<div class="empty">Loading achievements...</div>
{:else if filtered.length === 0}
<div class="empty">No achievements match your filters.</div>
{:else}
<div class="grid">
{#each filtered as a}
<div class="ach-card">
<div class="ach-icon-container">
<div
class="mc-frame-outer {a.frame_type}"
style="border-color: {getBorderColor(a.icon_border)}; box-shadow: {getBorderGlow(a.icon_border)};"
>
<div class="mc-frame-inner" style="background: {getBgPattern(a.icon_bg)};">
<span class="mc-icon">{getItemEmoji(a.icon_item)}</span>
</div>
</div>
</div>
<div class="ach-info">
<div class="ach-header">
<span class="ach-type {a.frame_type}">{a.frame_type.toUpperCase()}</span>
<span class="ach-xp"><Sparkles size={12} /> +{a.xp_reward} XP</span>
</div>
<h3>{a.title}</h3>
<p>{a.description}</p>
<div class="ach-meta">
<span class="meta-tag">{a.category}</span>
{#if a.stat_type}
<code class="meta-trigger">{a.stat_type} &ge; {a.target_count}</code>
{:else if a.event_type}
<code class="meta-trigger">event: {a.event_type}</code>
{/if}
</div>
</div>
<div class="card-actions">
<button class="ghost icon" title="Edit" onclick={() => openEdit(a)}>
<Pencil size={15} />
</button>
<button class="ghost icon danger" title="Delete" onclick={() => { deleteConfirm = a; deleteOpen = true; }}>
<Trash2 size={15} />
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
{#if draft}
<Modal bind:open={draftOpen} title={draft.id && achievements.some((a) => a.id === draft?.id) ? 'Edit Achievement' : 'Achievement Creator'}>
<form onsubmit={(e) => { e.preventDefault(); save(); }} class="form">
<!-- Live Achievement Preview Box -->
<div class="preview-panel">
<span class="preview-label">Live In-Game Toast Preview</span>
<div class="mc-toast {draft.frame_type}">
<div
class="mc-frame-outer {draft.frame_type}"
style="border-color: {getBorderColor(draft.icon_border)}; box-shadow: {getBorderGlow(draft.icon_border)};"
>
<div class="mc-frame-inner" style="background: {getBgPattern(draft.icon_bg)};">
<span class="mc-icon">{getItemEmoji(draft.icon_item)}</span>
</div>
</div>
<div class="mc-toast-body">
<div class="mc-toast-title text-{draft.frame_type}">
{frameTypes.find((f) => f.id === draft?.frame_type)?.bannerTitle}
</div>
<div class="mc-toast-desc">{draft.title || 'Untitled Achievement'}</div>
</div>
</div>
</div>
<div class="field">
<label for="a-id">Achievement ID</label>
<input id="a-id" type="text" bind:value={draft.id} required placeholder="ach_dragon_slayer" />
</div>
<div class="grid-2">
<div class="field">
<label for="a-title">Title</label>
<input id="a-title" type="text" bind:value={draft.title} required placeholder="Free the End" />
</div>
<div class="field">
<label for="a-xp">XP Reward</label>
<input id="a-xp" type="number" min="50" step="50" bind:value={draft.xp_reward} required />
</div>
</div>
<div class="field">
<label for="a-desc">Description</label>
<textarea id="a-desc" rows="2" bind:value={draft.description} required placeholder="Kill the Ender Dragon..."></textarea>
</div>
<div class="section-title">Minecraft Icon Styling</div>
<div class="grid-3">
<div class="field">
<label for="a-frame">Frame Type</label>
<select id="a-frame" bind:value={draft.frame_type}>
{#each frameTypes as ft}
<option value={ft.id}>{ft.label}</option>
{/each}
</select>
</div>
<div class="field">
<label for="a-item">Icon Item</label>
<select id="a-item" bind:value={draft.icon_item}>
{#each popularItems as it}
<option value={it.id}>{it.emoji} {it.label}</option>
{/each}
</select>
</div>
<div class="field">
<label for="a-bg">Background Texture</label>
<select id="a-bg" bind:value={draft.icon_bg}>
{#each bgTextures as bt}
<option value={bt.id}>{bt.label}</option>
{/each}
</select>
</div>
</div>
<div class="grid-2">
<div class="field">
<label for="a-border">Glowing Border Accent</label>
<select id="a-border" bind:value={draft.icon_border}>
{#each borderGlows as bg}
<option value={bg.id}>{bg.label}</option>
{/each}
</select>
</div>
<div class="field">
<label for="a-cat">Category</label>
<select id="a-cat" bind:value={draft.category}>
{#each categories as cat}
<option value={cat}>{cat.charAt(0).toUpperCase() + cat.slice(1)}</option>
{/each}
</select>
</div>
</div>
<div class="section-title">Trigger Conditions</div>
<div class="grid-2">
<div class="field">
<label for="a-stat">Tracked Stat (Optional)</label>
<input id="a-stat" type="text" bind:value={draft.stat_type} placeholder="e.g. mob_kills or blocks_broken" />
</div>
<div class="field">
<label for="a-target">Target Count (Optional)</label>
<input id="a-target" type="number" min="1" bind:value={draft.target_count} placeholder="100" />
</div>
</div>
<div class="field">
<label for="a-event">Game Event Trigger (Optional)</label>
<input id="a-event" type="text" bind:value={draft.event_type} placeholder="e.g. kill_dragon, enter_nether, craft_beacon" />
</div>
<div class="modal-actions">
<button type="button" class="ghost" onclick={() => (draftOpen = false)}>Cancel</button>
<button type="submit" class="primary" disabled={saving}>
{saving ? 'Saving...' : 'Save Achievement'}
</button>
</div>
</form>
</Modal>
{/if}
{#if deleteConfirm}
<Modal bind:open={deleteOpen} title="Delete Achievement">
<p>Are you sure you want to delete achievement <strong>{deleteConfirm.title}</strong> (<code>{deleteConfirm.id}</code>)?</p>
<div class="modal-actions">
<button class="ghost" onclick={() => { deleteOpen = false; deleteConfirm = null; }}>Cancel</button>
<button class="danger" onclick={() => { if (deleteConfirm) { remove(deleteConfirm); deleteOpen = false; } }}>Delete Achievement</button>
</div>
</Modal>
{/if}
<style>
.page { padding: 32px; max-width: 1400px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 6px; }
header p { color: var(--muted); margin: 0; font-size: 0.95rem; }
.creator-spotlight { background: linear-gradient(135deg, rgba(24,24,27,0.8), rgba(9,9,11,0.9)); border: 1px solid var(--line); border-radius: 14px; padding: 20px 24px; display: flex; flex-direction: column; gap: 16px; }
.spotlight-header { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 0.95rem; }
.text-gold { color: #eab308; }
.toast-preview-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
/* Authentic Minecraft Toast Styling */
.mc-toast {
display: flex;
align-items: center;
gap: 14px;
background: #212121;
border: 2px solid #3f3f46;
border-radius: 8px;
padding: 10px 14px;
box-shadow: 0 4px 14px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.1);
}
.mc-toast.goal { border-color: #0284c7; }
.mc-toast.challenge { border-color: #9333ea; }
.mc-toast-body { display: flex; flex-direction: column; gap: 2px; }
.mc-toast-title { font-size: 0.8rem; font-weight: 700; letter-spacing: 0.05em; font-family: monospace, sans-serif; text-transform: uppercase; }
.text-task { color: #ffff55; text-shadow: 1px 1px 0 #3f3f00; }
.text-goal { color: #55ffff; text-shadow: 1px 1px 0 #003f3f; }
.text-challenge { color: #ff55ff; text-shadow: 1px 1px 0 #3f003f; }
.mc-toast-desc { font-size: 0.9rem; font-weight: 600; color: #ffffff; text-shadow: 1px 1px 0 #181818; }
/* Minecraft Frame Creator Classes */
.mc-frame-outer {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #eab308;
background: #18181b;
transition: all 0.2s ease;
}
.mc-frame-outer.task { border-radius: 6px; }
.mc-frame-outer.goal { border-radius: 50%; }
.mc-frame-outer.challenge {
border-radius: 6px;
clip-path: polygon(15% 0%, 85% 0%, 100% 15%, 100% 85%, 85% 100%, 15% 100%, 0% 85%, 0% 15%);
}
.mc-frame-inner {
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
border-radius: inherit;
}
.mc-icon { font-size: 1.3rem; }
.border-gold { border-color: #eab308 !important; box-shadow: 0 0 10px rgba(234, 179, 8, 0.6) !important; }
.border-diamond { border-color: #06b6d4 !important; box-shadow: 0 0 12px rgba(6, 182, 212, 0.8) !important; }
.border-purple { border-color: #c084fc !important; box-shadow: 0 0 14px rgba(192, 132, 252, 0.8) !important; }
.bg-stone { background: radial-gradient(circle, #52525b 0%, #27272a 100%); }
.bg-deepslate { background: radial-gradient(circle, #27272a 0%, #09090b 100%); }
.bg-obsidian { background: radial-gradient(circle, #2e1065 0%, #0f172a 100%); }
.toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; }
.filters { display: flex; align-items: center; gap: 12px; }
.filters label { font-size: 0.82rem; color: var(--muted); }
select { background: var(--bg-2); border: 1px solid var(--line); border-radius: 10px; padding: 8px 14px; color: var(--text); font-size: 0.85rem; outline: none; }
.count-badge { font-size: 0.85rem; color: var(--muted); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 16px; }
.ach-card {
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: 14px;
padding: 18px;
display: flex;
gap: 16px;
align-items: flex-start;
position: relative;
transition: transform 0.15s, border-color 0.15s;
}
.ach-card:hover { transform: translateY(-2px); border-color: rgba(255,255,255,0.15); }
.ach-icon-container { flex-shrink: 0; padding-top: 2px; }
.ach-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.ach-header { display: flex; align-items: center; gap: 8px; }
.ach-type { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.05em; padding: 2px 6px; border-radius: 4px; }
.ach-type.task { background: rgba(234, 179, 8, 0.15); color: #eab308; }
.ach-type.goal { background: rgba(6, 182, 212, 0.15); color: #06b6d4; }
.ach-type.challenge { background: rgba(192, 132, 252, 0.15); color: #c084fc; }
.ach-xp { display: inline-flex; align-items: center; gap: 4px; font-size: 0.75rem; font-weight: 600; color: #eab308; }
.ach-info h3 { font-size: 1rem; font-weight: 600; margin: 2px 0 0; }
.ach-info p { margin: 0; color: var(--muted); font-size: 0.84rem; line-height: 1.35; }
.ach-meta { display: flex; align-items: center; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
.meta-tag { font-size: 0.72rem; padding: 2px 6px; border-radius: 4px; background: rgba(255,255,255,0.06); color: var(--text-2); text-transform: capitalize; }
.meta-trigger { font-size: 0.72rem; color: #a1a1aa; background: rgba(255,255,255,0.03); padding: 2px 6px; border-radius: 4px; }
.card-actions { display: flex; flex-direction: column; gap: 4px; }
.preview-panel { background: rgba(0,0,0,0.3); border: 1px dashed var(--line); border-radius: 10px; padding: 14px; display: flex; flex-direction: column; gap: 10px; }
.preview-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
.form { display: flex; flex-direction: column; gap: 14px; padding-top: 8px; }
.section-title { font-size: 0.85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); margin-top: 6px; border-top: 1px solid var(--line); padding-top: 12px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field label { font-size: 0.82rem; font-weight: 500; color: var(--muted); }
.field input, .field textarea, .field select { background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; color: var(--text); font-size: 0.9rem; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
.empty { text-align: center; padding: 60px 20px; color: var(--muted); background: var(--bg-2); border-radius: 12px; border: 1px solid var(--line); }
</style>
+222
View File
@@ -0,0 +1,222 @@
<script lang="ts">
import { Shield, Users, MapPin, Trash2, Search, ExternalLink, Flag } from '@lucide/svelte';
import Modal from '../components/Modal.svelte';
import { get, del } from '../lib/api';
import { toast, toastError } from '../lib/toast.svelte';
import type { Guild, Instance } from '../lib/types';
let guilds = $state<Guild[]>([]);
let instances = $state<Instance[]>([]);
let loading = $state(true);
let search = $state('');
let selectedInstance = $state<string>('all');
let deleteConfirm = $state<Guild | null>(null);
let deleteOpen = $state(false);
async function loadData() {
loading = true;
try {
const [g, inst] = await Promise.all([
get<Guild[]>('/api/admin/guilds'),
get<Instance[]>('/api/v1/instances'),
]);
guilds = g;
instances = inst;
} catch (e) {
toastError(e);
} finally {
loading = false;
}
}
$effect(() => {
loadData();
});
let filtered = $derived(
guilds.filter((g) => {
if (selectedInstance !== 'all' && g.instance_id !== selectedInstance) return false;
if (search.trim()) {
const s = search.toLowerCase();
return g.name.toLowerCase().includes(s) || g.tag.toLowerCase().includes(s) || g.id.toLowerCase().includes(s);
}
return true;
})
);
async function remove(g: Guild) {
try {
await del(`/api/admin/guilds/${g.id}`);
toast(`Guild [${g.tag}] ${g.name} deleted`);
deleteConfirm = null;
await loadData();
} catch (e) {
toastError(e);
}
}
</script>
<div class="page">
<header>
<div>
<h1>Guilds & Land Claims Management</h1>
<p>View instances' guilds, member counts, active chunk claims, and moderate guilds.</p>
</div>
</header>
<div class="stats-row">
<div class="stat-card">
<span class="label">Total Guilds</span>
<span class="value">{guilds.length}</span>
</div>
<div class="stat-card">
<span class="label">Total Members</span>
<span class="value text-accent">{guilds.reduce((acc, g) => acc + g.member_count, 0)}</span>
</div>
<div class="stat-card">
<span class="label">Claimed Chunks</span>
<span class="value text-success">{guilds.reduce((acc, g) => acc + g.claims_count, 0)}</span>
</div>
</div>
<div class="toolbar">
<div class="search-box">
<Search size={16} />
<input type="text" placeholder="Search guilds by name, tag, or id..." bind:value={search} />
</div>
<div class="filters">
<select bind:value={selectedInstance}>
<option value="all">All Instances</option>
{#each instances as inst}
<option value={inst.id}>{inst.name}</option>
{/each}
</select>
</div>
</div>
{#if loading}
<div class="empty">Loading guilds...</div>
{:else if filtered.length === 0}
<div class="empty">No guilds found.</div>
{:else}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Guild</th>
<th>Tag</th>
<th>Instance</th>
<th>Leader</th>
<th>Members</th>
<th>Claimed Chunks</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each filtered as g}
<tr>
<td>
<div class="guild-cell">
{#if g.icon_url}
<img src={g.icon_url} alt="" class="guild-avatar" />
{:else}
<div class="guild-avatar fallback">
<Shield size={18} />
</div>
{/if}
<div>
<strong>{g.name}</strong>
{#if g.description}
<p class="guild-desc">{g.description}</p>
{/if}
</div>
</div>
</td>
<td>
<span class="tag-badge">[{g.tag}]</span>
</td>
<td>
<code class="instance-code">{g.instance_id}</code>
</td>
<td>
<code class="uuid-code">{g.leader_uuid.slice(0, 8)}...</code>
</td>
<td>
<span class="count-pill">
<Users size={13} /> {g.member_count} / {g.max_members}
</span>
</td>
<td>
<span class="claims-pill">
<MapPin size={13} /> {g.claims_count} chunks
</span>
</td>
<td>
<span class="date-text">{new Date(g.created_at).toLocaleDateString()}</span>
</td>
<td>
<button class="ghost icon danger" title="Disband guild" onclick={() => { deleteConfirm = g; deleteOpen = true; }}>
<Trash2 size={15} />
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{#if deleteConfirm}
<Modal bind:open={deleteOpen} title="Disband Guild">
<p>Are you sure you want to forcibly disband <strong>[{deleteConfirm.tag}] {deleteConfirm.name}</strong>?</p>
<p class="warn-note">This will kick all {deleteConfirm.member_count} members and unclaim all {deleteConfirm.claims_count} chunks of land!</p>
<div class="modal-actions">
<button class="ghost" onclick={() => { deleteOpen = false; deleteConfirm = null; }}>Cancel</button>
<button class="danger" onclick={() => { if (deleteConfirm) { remove(deleteConfirm); deleteOpen = false; } }}>Disband Guild</button>
</div>
</Modal>
{/if}
<style>
.page { padding: 32px; max-width: 1400px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
header h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 6px; }
header p { color: var(--muted); margin: 0; font-size: 0.95rem; }
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }
.stat-card { background: var(--bg-2); border: 1px solid var(--line); border-radius: 12px; padding: 18px 20px; display: flex; flex-direction: column; gap: 4px; }
.stat-card .label { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
.stat-card .value { font-size: 1.8rem; font-weight: 700; }
.text-success { color: var(--success, #22c55e); }
.text-accent { color: var(--accent, #6366f1); }
.toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; }
.search-box { display: flex; align-items: center; gap: 10px; background: var(--bg-2); border: 1px solid var(--line); border-radius: 10px; padding: 8px 14px; flex: 1; min-width: 260px; max-width: 500px; }
.search-box input { background: transparent; border: none; outline: none; color: var(--text); width: 100%; font-size: 0.9rem; }
select { background: var(--bg-2); border: 1px solid var(--line); border-radius: 10px; padding: 8px 14px; color: var(--text); font-size: 0.85rem; outline: none; }
.table-wrap { background: var(--bg-2); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; }
table { width: 100%; border-collapse: collapse; text-align: left; font-size: 0.9rem; }
th { padding: 12px 16px; background: rgba(255,255,255,0.02); border-bottom: 1px solid var(--line); color: var(--muted); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; }
td { padding: 14px 16px; border-bottom: 1px solid rgba(255,255,255,0.04); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255,255,255,0.015); }
.guild-cell { display: flex; align-items: center; gap: 12px; }
.guild-avatar { width: 36px; height: 36px; border-radius: 8px; object-fit: cover; }
.guild-avatar.fallback { background: rgba(255,255,255,0.06); display: flex; align-items: center; justify-content: center; color: var(--muted); }
.guild-desc { margin: 2px 0 0; font-size: 0.78rem; color: var(--muted); }
.tag-badge { font-family: monospace; font-weight: 700; color: #a5b4fc; background: rgba(99,102,241,0.12); padding: 2px 8px; border-radius: 4px; border: 1px solid rgba(99,102,241,0.25); }
.instance-code, .uuid-code { font-size: 0.8rem; color: var(--muted); background: rgba(255,255,255,0.04); padding: 2px 6px; border-radius: 4px; }
.count-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 0.85rem; color: var(--text-2); }
.claims-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 0.85rem; color: #4ade80; }
.date-text { font-size: 0.8rem; color: var(--muted); }
.warn-note { color: #f87171; font-size: 0.88rem; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
.empty { text-align: center; padding: 60px 20px; color: var(--muted); background: var(--bg-2); border-radius: 12px; border: 1px solid var(--line); }
</style>
+336
View File
@@ -0,0 +1,336 @@
<script lang="ts">
import { Sparkles, Trophy, Plus, Pencil, Trash2, Award, Shield, Crown } from '@lucide/svelte';
import Modal from '../components/Modal.svelte';
import { get, post, put, del } from '../lib/api';
import { toast, toastError } from '../lib/toast.svelte';
import type { LevelReward, UserLevelInfo } from '../lib/types';
let rewards = $state<LevelReward[]>([]);
let leaderboard = $state<UserLevelInfo[]>([]);
let loading = $state(true);
let draft = $state<Partial<LevelReward> | null>(null);
let draftOpen = $state(false);
let saving = $state(false);
let deleteConfirm = $state<LevelReward | null>(null);
let deleteOpen = $state(false);
const rewardTypes = [
{ id: 'title', label: 'Player Title / Prefix' },
{ id: 'profile_badge', label: 'Profile Badge' },
{ id: 'cosmetic', label: 'Cosmetic / Particle Effect' },
{ id: 'item', label: 'In-Game Kit / Item' },
] as const;
async function loadData() {
loading = true;
try {
const [r, lb] = await Promise.all([
get<LevelReward[]>('/api/v1/levels/rewards'),
get<UserLevelInfo[]>('/api/v1/levels/leaderboard?limit=10'),
]);
rewards = r.sort((a, b) => a.level - b.level);
leaderboard = lb;
} catch (e) {
toastError(e);
} finally {
loading = false;
}
}
$effect(() => {
loadData();
});
function openNew() {
draft = {
level: 10,
reward_type: 'title',
reward_value: 'Explorer',
description: 'Grants the exclusive [Explorer] title across all servers and in launcher profile.',
icon: '🧭',
};
draftOpen = true;
}
function openEdit(r: LevelReward) {
draft = { ...r };
draftOpen = true;
}
async function save() {
if (!draft || !draft.level || !draft.reward_value) {
toast('Level and reward value are required', 'error');
return;
}
saving = true;
try {
if (draft.id) {
await put(`/api/admin/levels/rewards/${draft.id}`, draft);
toast('Reward updated');
} else {
await post('/api/admin/levels/rewards', draft);
toast('Reward created');
}
draftOpen = false;
draft = null;
await loadData();
} catch (e) {
toastError(e);
} finally {
saving = false;
}
}
async function remove(r: LevelReward) {
try {
await del(`/api/admin/levels/rewards/${r.id}`);
toast('Reward deleted');
deleteConfirm = null;
await loadData();
} catch (e) {
toastError(e);
}
}
function calcXp(lvl: number): number {
if (lvl <= 1) return 0;
return Math.round(Math.pow(lvl, 1.5) * 100);
}
</script>
<div class="page">
<header>
<div>
<h1>Global Leveling & Rewards</h1>
<p>Configure account-wide global levels, unlockable titles, cosmetics, badges, and view global leaderboards.</p>
</div>
<button class="primary" onclick={openNew}>
<Plus size={16} /> New Level Reward
</button>
</header>
<div class="grid-layout">
<!-- Rewards Section -->
<div class="main-col">
<div class="card-header">
<h2>Milestone Rewards ({rewards.length})</h2>
<span class="sub">Granted automatically as players earn XP across all connected servers</span>
</div>
{#if loading}
<div class="empty">Loading rewards...</div>
{:else}
<div class="rewards-list">
{#each rewards as r}
<div class="reward-row">
<div class="level-bubble">
<span class="lvl-label">LVL</span>
<span class="lvl-num">{r.level}</span>
</div>
<div class="reward-content">
<div class="reward-top">
<span class="reward-icon">{r.icon || '🎁'}</span>
<strong>{r.reward_value}</strong>
<span class="type-pill {r.reward_type}">{r.reward_type.replace('_', ' ').toUpperCase()}</span>
</div>
<p>{r.description}</p>
<div class="reward-xp-needed">
Requires ~{calcXp(r.level).toLocaleString()} Total XP
</div>
</div>
<div class="reward-actions">
<button class="ghost icon" onclick={() => openEdit(r)} title="Edit"><Pencil size={15} /></button>
<button class="ghost icon danger" onclick={() => { deleteConfirm = r; deleteOpen = true; }} title="Delete"><Trash2 size={15} /></button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Sidebar Info / Leaderboard Preview -->
<div class="side-col">
<div class="side-card">
<h3><Sparkles size={16} class="text-accent" /> Leveling Formula</h3>
<p class="formula-desc">
Total XP required for level <em>L</em> is given by:
</p>
<code class="formula-box">XP = round(L ^ 1.5 &times; 100)</code>
<ul class="curve-points">
<li><span>Level 5:</span> <strong>{calcXp(5).toLocaleString()} XP</strong></li>
<li><span>Level 10:</span> <strong>{calcXp(10).toLocaleString()} XP</strong></li>
<li><span>Level 25:</span> <strong>{calcXp(25).toLocaleString()} XP</strong></li>
<li><span>Level 50:</span> <strong>{calcXp(50).toLocaleString()} XP</strong></li>
<li><span>Level 100:</span> <strong>{calcXp(100).toLocaleString()} XP</strong></li>
</ul>
</div>
<div class="side-card">
<h3><Crown size={16} class="text-gold" /> Top Global Players</h3>
{#if leaderboard.length === 0}
<p class="muted-note">No player progression recorded yet.</p>
{:else}
<div class="lb-list">
{#each leaderboard as p, i}
<div class="lb-item">
<span class="lb-rank">#{i + 1}</span>
<div class="lb-info">
<strong>UUID: {p.uuid.slice(0, 8)}...</strong>
<span>{p.current_xp.toLocaleString()} XP</span>
</div>
<span class="lb-lvl">Lvl {p.level}</span>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
{#if draft}
<Modal bind:open={draftOpen} title={draft.id ? 'Edit Level Reward' : 'New Level Reward'}>
<form onsubmit={(e) => { e.preventDefault(); save(); }} class="form">
<div class="grid-2">
<div class="field">
<label for="r-lvl">Target Global Level</label>
<input id="r-lvl" type="number" min="2" max="500" bind:value={draft.level} required />
</div>
<div class="field">
<label for="r-type">Reward Type</label>
<select id="r-type" bind:value={draft.reward_type}>
{#each rewardTypes as rt}
<option value={rt.id}>{rt.label}</option>
{/each}
</select>
</div>
</div>
<div class="grid-2">
<div class="field">
<label for="r-val">Reward Value / Name</label>
<input id="r-val" type="text" bind:value={draft.reward_value} required placeholder="e.g. Master Builder or Diamond Cape" />
</div>
<div class="field">
<label for="r-icon">Icon (Emoji / Symbol)</label>
<input id="r-icon" type="text" bind:value={draft.icon} placeholder="👑" />
</div>
</div>
<div class="field">
<label for="r-desc">Description</label>
<textarea id="r-desc" rows="3" bind:value={draft.description} required placeholder="Describe what the player unlocks..."></textarea>
</div>
<div class="modal-actions">
<button type="button" class="ghost" onclick={() => (draftOpen = false)}>Cancel</button>
<button type="submit" class="primary" disabled={saving}>
{saving ? 'Saving...' : 'Save Reward'}
</button>
</div>
</form>
</Modal>
{/if}
{#if deleteConfirm}
<Modal bind:open={deleteOpen} title="Delete Reward">
<p>Are you sure you want to delete the Level {deleteConfirm.level} reward (<strong>{deleteConfirm.reward_value}</strong>)?</p>
<div class="modal-actions">
<button class="ghost" onclick={() => { deleteOpen = false; deleteConfirm = null; }}>Cancel</button>
<button class="danger" onclick={() => { if (deleteConfirm) { remove(deleteConfirm); deleteOpen = false; } }}>Delete Reward</button>
</div>
</Modal>
{/if}
<style>
.page { padding: 32px; max-width: 1400px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 6px; }
header p { color: var(--muted); margin: 0; font-size: 0.95rem; }
.grid-layout { display: grid; grid-template-columns: 2fr 1fr; gap: 24px; }
@media (max-width: 1000px) {
.grid-layout { grid-template-columns: 1fr; }
}
.main-col { background: var(--bg-2); border: 1px solid var(--line); border-radius: 14px; padding: 24px; display: flex; flex-direction: column; gap: 18px; }
.card-header h2 { font-size: 1.2rem; font-weight: 600; margin: 0 0 4px; }
.card-header .sub { font-size: 0.85rem; color: var(--muted); }
.rewards-list { display: flex; flex-direction: column; gap: 12px; }
.reward-row {
background: var(--bg);
border: 1px solid var(--line);
border-radius: 12px;
padding: 16px 20px;
display: flex;
align-items: center;
gap: 18px;
transition: border-color 0.15s;
}
.reward-row:hover { border-color: rgba(255,255,255,0.15); }
.level-bubble {
width: 52px;
height: 52px;
border-radius: 12px;
background: linear-gradient(135deg, rgba(99,102,241,0.2), rgba(168,85,247,0.2));
border: 1px solid rgba(99,102,241,0.4);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.lvl-label { font-size: 0.65rem; font-weight: 700; letter-spacing: 0.05em; color: #a5b4fc; }
.lvl-num { font-size: 1.25rem; font-weight: 800; color: #ffffff; line-height: 1; }
.reward-content { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.reward-top { display: flex; align-items: center; gap: 10px; }
.reward-icon { font-size: 1.2rem; }
.reward-top strong { font-size: 1.05rem; }
.type-pill { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.05em; padding: 2px 7px; border-radius: 5px; }
.type-pill.title { background: rgba(234,179,8,0.15); color: #eab308; }
.type-pill.profile_badge { background: rgba(168,85,247,0.15); color: #c084fc; }
.type-pill.cosmetic { background: rgba(236,72,153,0.15); color: #f472b6; }
.type-pill.item { background: rgba(34,197,94,0.15); color: #4ade80; }
.reward-content p { margin: 0; color: var(--muted); font-size: 0.86rem; }
.reward-xp-needed { font-size: 0.75rem; color: #94a3b8; font-family: monospace; }
.reward-actions { display: flex; align-items: center; gap: 6px; }
.side-col { display: flex; flex-direction: column; gap: 20px; }
.side-card { background: var(--bg-2); border: 1px solid var(--line); border-radius: 14px; padding: 20px; display: flex; flex-direction: column; gap: 12px; }
.side-card h3 { display: flex; align-items: center; gap: 8px; font-size: 1rem; font-weight: 600; margin: 0; }
.text-accent { color: var(--accent, #6366f1); }
.text-gold { color: #eab308; }
.formula-desc { font-size: 0.85rem; color: var(--muted); margin: 0; }
.formula-box { background: rgba(0,0,0,0.3); border: 1px solid var(--line); padding: 8px 12px; border-radius: 8px; font-family: monospace; font-size: 0.85rem; color: #a5b4fc; }
.curve-points { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
.curve-points li { display: flex; justify-content: space-between; font-size: 0.85rem; border-bottom: 1px solid rgba(255,255,255,0.04); padding-bottom: 4px; }
.curve-points li span { color: var(--muted); }
.muted-note { color: var(--muted); font-size: 0.85rem; margin: 0; }
.lb-list { display: flex; flex-direction: column; gap: 8px; }
.lb-item { display: flex; align-items: center; gap: 10px; padding: 8px; background: var(--bg); border: 1px solid var(--line); border-radius: 8px; font-size: 0.85rem; }
.lb-rank { font-weight: 700; width: 24px; color: var(--muted); }
.lb-info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.lb-info strong { font-size: 0.85rem; }
.lb-info span { font-size: 0.72rem; color: var(--muted); }
.lb-lvl { font-weight: 700; color: #eab308; }
.form { display: flex; flex-direction: column; gap: 14px; padding-top: 8px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field label { font-size: 0.82rem; font-weight: 500; color: var(--muted); }
.field input, .field textarea, .field select { background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; color: var(--text); font-size: 0.9rem; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
.empty { text-align: center; padding: 40px 20px; color: var(--muted); }
</style>
+65 -2
View File
@@ -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<LandingConfig | null>(null);
let publicServers = $state<Array<{ id: number; name: string; online: boolean; players_online: number; players_max: number; software?: string; mc_version?: string }>>([]);
let leaderboard = $state<Array<{ uuid: string; name: string; playtime_secs: number; player_kills: number; mob_kills: number; blocks_broken: number; blocks_placed: number }>>([]);
let globalLevels = $state<Array<{ rank: number; uuid: string; username: string; global_level: number; global_xp: number; title: string | null }>>([]);
let publicGuilds = $state<Array<{ id: string; name: string; tag: string; description: string; member_count: number; claims_count: number; icon_url: string | null }>>([]);
let instances = $state<Array<{ id: string; name: string; description: string; mc_version: string; loader: string; file_count: number; logo_url: string | null; icon_url: string | null }>>([]);
let news = $state<Array<{ id: string; title: string; body: string; date: string | null; tag: string | null }>>([]);
let totalPlaytime = $state(0);
@@ -81,6 +83,15 @@
news = res.branding?.news || [];
})
.catch(() => {});
// 5. Load Global Levels & Guilds
get<any[]>('/api/v1/levels/leaderboard')
.then((res) => (globalLevels = res || []))
.catch(() => {});
get<any[]>('/api/v1/guilds')
.then((res) => (publicGuilds = (res || []).slice(0, 6)))
.catch(() => {});
});
$effect(() => {
@@ -294,6 +305,7 @@
<div class="sort-chips-bar">
{#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}
</div>
{#if leaderboard.length}
{#if lbSort === 'global_level' && globalLevels.length}
<div class="lb-table-wrap">
<div class="lb-table-head">
<span>Rank</span>
<span>Player</span>
<span class="text-right">Global Level</span>
<span class="text-right">Total XP</span>
<span class="text-right">Title</span>
</div>
{#each globalLevels.slice(0, 10) as row (row.uuid)}
<div class="lb-table-row">
<span class="rank-num">#{row.rank}</span>
<strong class="player-name">{row.username}</strong>
<span class="score-highlight text-right">Lvl {row.global_level}</span>
<span class="muted text-right">{row.global_xp.toLocaleString()} XP</span>
<span class="muted text-right">{row.title || '—'}</span>
</div>
{/each}
</div>
{:else if leaderboard.length}
<!-- Podium -->
{#if top3.length > 0}
<div class="podium-row">
@@ -366,6 +397,38 @@
<p class="muted">No leaderboard data recorded yet.</p>
</div>
{/if}
{#if publicGuilds.length > 0}
<div class="guilds-showcase" style="margin-top: 48px;">
<div class="section-head" style="margin-bottom: 20px;">
<h3>Top Guilds & Claimed Territories</h3>
<p class="muted">Form parties, recruit allies, and protect your faction's chunks.</p>
</div>
<div class="guilds-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px;">
{#each publicGuilds as g}
<div class="guild-card" style="background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 16px; display: flex; align-items: center; gap: 14px;">
{#if g.icon_url}
<img src={g.icon_url} alt="" style="width: 44px; height: 44px; border-radius: 8px; object-fit: cover;" />
{:else}
<div style="width: 44px; height: 44px; border-radius: 8px; background: rgba(99,102,241,0.15); display: flex; align-items: center; justify-content: center; color: var(--accent);">
<Shield size={22} />
</div>
{/if}
<div style="flex: 1; min-width: 0;">
<div style="display: flex; align-items: center; gap: 6px;">
<strong style="font-size: 0.95rem;">{g.name}</strong>
<span style="font-size: 0.72rem; font-family: monospace; font-weight: 700; color: #a5b4fc; background: rgba(99,102,241,0.1); padding: 1px 5px; border-radius: 4px;">[{g.tag}]</span>
</div>
<div style="display: flex; align-items: center; gap: 12px; font-size: 0.78rem; color: var(--muted); margin-top: 4px;">
<span><Users size={12} style="display: inline; vertical-align: -1px;" /> {g.member_count} members</span>
<span style="color: #4ade80;"><Box size={12} style="display: inline; vertical-align: -1px;" /> {g.claims_count} chunks</span>
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
</section>
+400
View File
@@ -0,0 +1,400 @@
<script lang="ts">
import { Search, Plus, Pencil, Trash2, CheckCircle2, XCircle, Award, Sparkles, Filter } from '@lucide/svelte';
import Modal from '../components/Modal.svelte';
import { get, post, put, del } from '../lib/api';
import { toast, toastError } from '../lib/toast.svelte';
import type { Quest } from '../lib/types';
let quests = $state<Quest[]>([]);
let loading = $state(true);
let filterType = $state<'all' | 'daily' | 'weekly'>('all');
let filterCategory = $state<string>('all');
let search = $state('');
let draft = $state<Partial<Quest> | null>(null);
let draftOpen = $state(false);
let saving = $state(false);
let deleteConfirm = $state<Quest | null>(null);
let deleteOpen = $state(false);
const categories = ['mining', 'combat', 'building', 'farming', 'social', 'exploration'];
const statTypes = [
{ id: 'blocks_broken', label: 'Blocks Broken' },
{ id: 'mob_kills', label: 'Mob Kills' },
{ id: 'player_kills', label: 'Player Kills' },
{ id: 'blocks_placed', label: 'Blocks Placed' },
{ id: 'playtime_secs', label: 'Playtime (Seconds)' },
{ id: 'messages', label: 'Chat Messages Sent' },
{ id: 'joins', label: 'Server Joins' },
{ id: 'friend_add', label: 'Add a Friend' },
{ id: 'guild_create', label: 'Create a Guild' },
];
async function loadQuests() {
loading = true;
try {
quests = await get<Quest[]>('/api/admin/quests');
} catch (e) {
toastError(e);
} finally {
loading = false;
}
}
$effect(() => {
loadQuests();
});
let filtered = $derived(
quests.filter((q) => {
if (filterType !== 'all' && q.quest_type !== filterType) return false;
if (filterCategory !== 'all' && q.category !== filterCategory) return false;
if (search.trim()) {
const s = search.toLowerCase();
return q.title.toLowerCase().includes(s) || q.description.toLowerCase().includes(s) || q.id.toLowerCase().includes(s);
}
return true;
})
);
let dailyCount = $derived(quests.filter((q) => q.quest_type === 'daily').length);
let weeklyCount = $derived(quests.filter((q) => q.quest_type === 'weekly').length);
let activeCount = $derived(quests.filter((q) => q.active).length);
function openNew() {
draft = {
id: `quest_${Date.now()}`,
title: '',
description: '',
quest_type: 'daily',
category: 'mining',
xp_reward: 150,
stat_type: 'blocks_broken',
target_count: 50,
icon: '⛏️',
active: true,
};
draftOpen = true;
}
function openEdit(q: Quest) {
draft = { ...q };
draftOpen = true;
}
async function save() {
if (!draft || !draft.title || !draft.description) {
toast('Title and description are required', 'error');
return;
}
saving = true;
try {
const exists = quests.some((q) => q.id === draft?.id);
if (exists) {
await put(`/api/admin/quests/${draft.id}`, draft);
toast('Quest updated');
} else {
await post('/api/admin/quests', draft);
toast('Quest created');
}
draftOpen = false;
draft = null;
await loadQuests();
} catch (e) {
toastError(e);
} finally {
saving = false;
}
}
async function remove(q: Quest) {
try {
await del(`/api/admin/quests/${q.id}`);
toast(`Quest "${q.title}" removed`);
deleteConfirm = null;
await loadQuests();
} catch (e) {
toastError(e);
}
}
</script>
<div class="page">
<header>
<div>
<h1>Quests Manager</h1>
<p>Configure daily and weekly player objectives, XP rewards, and stat progression tracking.</p>
</div>
<button class="primary" onclick={openNew}>
<Plus size={16} /> New Quest
</button>
</header>
<div class="stats-row">
<div class="stat-card">
<span class="label">Total Quests</span>
<span class="value">{quests.length}</span>
</div>
<div class="stat-card">
<span class="label">Active Quests</span>
<span class="value text-success">{activeCount}</span>
</div>
<div class="stat-card">
<span class="label">Daily Quests</span>
<span class="value text-accent">{dailyCount}</span>
</div>
<div class="stat-card">
<span class="label">Weekly Quests</span>
<span class="value text-accent-2">{weeklyCount}</span>
</div>
</div>
<div class="toolbar">
<div class="search-box">
<Search size={16} />
<input type="text" placeholder="Search quests by title, description, or id..." bind:value={search} />
</div>
<div class="filters">
<div class="segmented">
<button class:active={filterType === 'all'} onclick={() => (filterType = 'all')}>All ({quests.length})</button>
<button class:active={filterType === 'daily'} onclick={() => (filterType = 'daily')}>Daily ({dailyCount})</button>
<button class:active={filterType === 'weekly'} onclick={() => (filterType = 'weekly')}>Weekly ({weeklyCount})</button>
</div>
<select bind:value={filterCategory}>
<option value="all">All Categories</option>
{#each categories as cat}
<option value={cat}>{cat.toUpperCase()}</option>
{/each}
</select>
</div>
</div>
{#if loading}
<div class="empty">Loading quests...</div>
{:else if filtered.length === 0}
<div class="empty">No quests match your filters.</div>
{:else}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Icon</th>
<th>Title & Description</th>
<th>Type</th>
<th>Category</th>
<th>Target Objective</th>
<th>XP Reward</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each filtered as q}
<tr>
<td class="icon-cell">
<span class="quest-icon">{q.icon || '🎯'}</span>
</td>
<td class="info-cell">
<strong>{q.title}</strong>
<p>{q.description}</p>
<code class="id-tag">{q.id}</code>
</td>
<td>
<span class="badge {q.quest_type}">
{q.quest_type.toUpperCase()}
</span>
</td>
<td>
<span class="cat-tag">{q.category}</span>
</td>
<td>
<span class="stat-target">
<strong>{q.target_count.toLocaleString()}</strong>
<code>{q.stat_type}</code>
</span>
</td>
<td>
<span class="xp-badge">
<Sparkles size={13} />
+{q.xp_reward} XP
</span>
</td>
<td>
{#if q.active}
<span class="status-active"><CheckCircle2 size={14} /> Active</span>
{:else}
<span class="status-inactive"><XCircle size={14} /> Disabled</span>
{/if}
</td>
<td>
<div class="actions">
<button class="ghost icon" title="Edit quest" onclick={() => openEdit(q)}>
<Pencil size={15} />
</button>
<button class="ghost icon danger" title="Delete quest" onclick={() => { deleteConfirm = q; deleteOpen = true; }}>
<Trash2 size={15} />
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{#if draft}
<Modal bind:open={draftOpen} title={draft.id && quests.some((q) => q.id === draft?.id) ? 'Edit Quest' : 'New Quest'}>
<form onsubmit={(e) => { e.preventDefault(); save(); }} class="form">
<div class="field">
<label for="q-id">Quest Identifier</label>
<input id="q-id" type="text" bind:value={draft.id} required placeholder="e.g. daily_mine_iron" />
</div>
<div class="grid-2">
<div class="field">
<label for="q-title">Title</label>
<input id="q-title" type="text" bind:value={draft.title} required placeholder="e.g. Iron Seeker" />
</div>
<div class="field">
<label for="q-icon">Icon (Emoji / Symbol)</label>
<input id="q-icon" type="text" bind:value={draft.icon} placeholder="⛏️" />
</div>
</div>
<div class="field">
<label for="q-desc">Description</label>
<textarea id="q-desc" rows="2" bind:value={draft.description} required placeholder="Describe the objective..."></textarea>
</div>
<div class="grid-2">
<div class="field">
<label for="q-type">Cadence Type</label>
<select id="q-type" bind:value={draft.quest_type}>
<option value="daily">Daily Quest</option>
<option value="weekly">Weekly Quest</option>
</select>
</div>
<div class="field">
<label for="q-cat">Category</label>
<select id="q-cat" bind:value={draft.category}>
{#each categories as cat}
<option value={cat}>{cat.charAt(0).toUpperCase() + cat.slice(1)}</option>
{/each}
</select>
</div>
</div>
<div class="grid-3">
<div class="field">
<label for="q-stat">Tracked Stat</label>
<select id="q-stat" bind:value={draft.stat_type}>
{#each statTypes as st}
<option value={st.id}>{st.label}</option>
{/each}
</select>
</div>
<div class="field">
<label for="q-target">Target Count</label>
<input id="q-target" type="number" min="1" bind:value={draft.target_count} required />
</div>
<div class="field">
<label for="q-xp">XP Reward</label>
<input id="q-xp" type="number" min="10" step="10" bind:value={draft.xp_reward} required />
</div>
</div>
<div class="field checkbox">
<label>
<input type="checkbox" bind:checked={draft.active} />
Active (players can receive and complete this quest)
</label>
</div>
<div class="modal-actions">
<button type="button" class="ghost" onclick={() => (draftOpen = false)}>Cancel</button>
<button type="submit" class="primary" disabled={saving}>
{saving ? 'Saving...' : 'Save Quest'}
</button>
</div>
</form>
</Modal>
{/if}
{#if deleteConfirm}
<Modal bind:open={deleteOpen} title="Delete Quest">
<p>Are you sure you want to delete quest <strong>{deleteConfirm.title}</strong> (<code>{deleteConfirm.id}</code>)?</p>
<div class="modal-actions">
<button class="ghost" onclick={() => { deleteOpen = false; deleteConfirm = null; }}>Cancel</button>
<button class="danger" onclick={() => { if (deleteConfirm) { remove(deleteConfirm); deleteOpen = false; } }}>Delete Quest</button>
</div>
</Modal>
{/if}
<style>
.page { padding: 32px; max-width: 1400px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 6px; }
header p { color: var(--muted); margin: 0; font-size: 0.95rem; }
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }
.stat-card { background: var(--bg-2); border: 1px solid var(--line); border-radius: 12px; padding: 18px 20px; display: flex; flex-direction: column; gap: 4px; }
.stat-card .label { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
.stat-card .value { font-size: 1.8rem; font-weight: 700; }
.text-success { color: var(--success, #22c55e); }
.text-accent { color: var(--accent, #6366f1); }
.text-accent-2 { color: #a855f7; }
.toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; }
.search-box { display: flex; align-items: center; gap: 10px; background: var(--bg-2); border: 1px solid var(--line); border-radius: 10px; padding: 8px 14px; flex: 1; min-width: 260px; max-width: 500px; }
.search-box input { background: transparent; border: none; outline: none; color: var(--text); width: 100%; font-size: 0.9rem; }
.filters { display: flex; align-items: center; gap: 12px; }
.segmented { display: flex; background: var(--bg-2); border: 1px solid var(--line); border-radius: 10px; padding: 3px; gap: 2px; }
.segmented button { padding: 6px 14px; border-radius: 7px; border: none; background: transparent; color: var(--muted); font-size: 0.85rem; font-weight: 500; cursor: pointer; transition: all 0.15s; }
.segmented button.active { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
select { background: var(--bg-2); border: 1px solid var(--line); border-radius: 10px; padding: 8px 14px; color: var(--text); font-size: 0.85rem; outline: none; }
.table-wrap { background: var(--bg-2); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; }
table { width: 100%; border-collapse: collapse; text-align: left; font-size: 0.9rem; }
th { padding: 12px 16px; background: rgba(255,255,255,0.02); border-bottom: 1px solid var(--line); color: var(--muted); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; }
td { padding: 14px 16px; border-bottom: 1px solid rgba(255,255,255,0.04); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255,255,255,0.015); }
.icon-cell { width: 48px; text-align: center; }
.quest-icon { font-size: 1.6rem; display: inline-flex; align-items: center; justify-content: center; }
.info-cell strong { font-size: 0.95rem; display: block; margin-bottom: 2px; }
.info-cell p { margin: 0 0 4px; color: var(--muted); font-size: 0.82rem; }
.id-tag { font-size: 0.72rem; color: var(--muted); background: rgba(255,255,255,0.04); padding: 2px 6px; border-radius: 4px; }
.badge { display: inline-block; padding: 4px 10px; border-radius: 6px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.05em; }
.badge.daily { background: rgba(99, 102, 241, 0.15); color: #818cf8; border: 1px solid rgba(99, 102, 241, 0.3); }
.badge.weekly { background: rgba(168, 85, 247, 0.15); color: #c084fc; border: 1px solid rgba(168, 85, 247, 0.3); }
.cat-tag { display: inline-block; padding: 3px 8px; border-radius: 6px; background: rgba(255,255,255,0.05); font-size: 0.75rem; text-transform: capitalize; color: var(--text-2); }
.stat-target { display: flex; flex-direction: column; gap: 2px; }
.stat-target strong { font-size: 0.95rem; }
.stat-target code { font-size: 0.72rem; color: var(--muted); }
.xp-badge { display: inline-flex; align-items: center; gap: 5px; padding: 4px 9px; border-radius: 6px; background: rgba(234, 179, 8, 0.12); color: #eab308; font-weight: 600; font-size: 0.82rem; border: 1px solid rgba(234, 179, 8, 0.25); }
.status-active { display: inline-flex; align-items: center; gap: 5px; color: var(--success, #22c55e); font-size: 0.82rem; font-weight: 500; }
.status-inactive { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); font-size: 0.82rem; }
.actions { display: flex; align-items: center; gap: 6px; }
.form { display: flex; flex-direction: column; gap: 16px; padding-top: 12px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field label { font-size: 0.82rem; font-weight: 500; color: var(--muted); }
.field input, .field textarea, .field select { background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; color: var(--text); font-size: 0.9rem; }
.field.checkbox label { display: flex; align-items: center; gap: 8px; cursor: pointer; color: var(--text); font-size: 0.88rem; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
.empty { text-align: center; padding: 60px 20px; color: var(--muted); background: var(--bg-2); border-radius: 12px; border: 1px solid var(--line); }
</style>