diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 060e67d..c855873 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -712,6 +712,36 @@ pub struct UserPost { pub created_at: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct ProfileGuild { + pub id: String, + pub name: String, + pub tag: String, + pub role: String, +} + +/// A rank reported by a server's permissions plugin (LuckPerms), shown read-only. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct ProfileRank { + pub display: String, + pub prefix: String, + pub server_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct ProfileActivity { + /// `join`, `death`, `advancement`, `kill` or `achievement`. + pub kind: String, + pub text: String, + pub at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct MutualFriend { + pub uuid: String, + pub username: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] pub struct UserProfileView { pub uuid: String, @@ -732,6 +762,32 @@ pub struct UserProfileView { pub friends_count: i64, pub created_at: String, pub stats: Option, + #[serde(default)] + pub online: bool, + #[serde(default)] + pub last_seen: Option, + #[serde(default)] + pub guild: Option, + #[serde(default)] + pub rank: Option, + /// The server they spend the most time on. + #[serde(default)] + pub favorite_server: Option, + /// Total balance across the economies they hold money in. + #[serde(default)] + pub wealth: f64, + #[serde(default)] + pub recent_activity: Vec, + #[serde(default)] + pub mutual_friends: Vec, + /// Relationship to the person looking: `self`, `none`, `accepted`, `pending_outgoing` or `pending_incoming`. + #[serde(default)] + pub relationship: String, + /// Profile cosmetic: a `#rrggbb` accent colour. + #[serde(default)] + pub accent_color: Option, + #[serde(default)] + pub discord_linked: bool, } // --------------------------------------------------------------------------- diff --git a/docs/integrations.md b/docs/integrations.md new file mode 100644 index 0000000..36516b3 --- /dev/null +++ b/docs/integrations.md @@ -0,0 +1,47 @@ +# Plugin integrations, developer API and network features + +## Optional plugins (Paper) + +SCOPENET finds these plugins by itself at startup. Nothing is hard-wired into `ScopenetPlugin`; each integration is a module in `net.scopenet.paper.compat` that is only loaded when its plugin is present. Switch any off in `config.yml` under `integrations:`. `/scopenet status` lists what is active. + +| Plugin | What SCOPENET does | +|---|---| +| LuckPerms | Reports each online player's rank to the panel (shown read-only on profiles and the server page). Panel groups can be mapped to LuckPerms groups (Players → Groups). LuckPerms always decides real permissions. Sync modes: off, game→panel, panel→game, both (adds only, never removes; panel→game also needs `apply-panel-groups: true`). | +| PlaceholderAPI | `%scopenet_level%`, `%scopenet_guild%`, `%scopenet_balance%`, `%scopenet_playtime%`, `%scopenet_quest_progress%` and more (see the expansion class for the full list). | +| Vault | SCOPENET's economy becomes Vault's economy, so other plugins pay into the same balances. | +| CoreProtect | Logged-activity rate, top actors and backup folder status appear on the server page. | +| WorldGuard | Regions (area, owners, flags) appear on the server page, including server-owned territory. | +| Spark | TPS, MSPT, CPU and memory appear on the server page. | + +## Permissions + +Every command has a LuckPerms-friendly node, `scopenet.command.[.]` (for example `scopenet.command.guild.bank.withdraw`). Wildcards `scopenet.command.*` and `scopenet.command.guild.*` work. `scopenet.claims.bypass` lets staff build in other guilds' claims. All nodes are declared in `plugin.yml`. + +## Developer API + +Depend on the `integrations/api` module (`net.scopenet.api`). Get the API from Bukkit's services manager or `ScopenetApiProvider.get()`: + +```java +ScopenetApi api = ScopenetApiProvider.get(); +api.getPlayer(uuid).thenAccept(p -> ...); +api.getGuild(uuid); api.getBalance(uuid); api.getFriends(uuid); +api.addXP(uuid, 50, "my-plugin"); +api.completeQuestObjective(uuid, "my_action", 1); +``` + +Bukkit events: `ScopenetLevelUpEvent`, `GuildJoinEvent`, `AchievementUnlockEvent`. They are raised on the main thread when the panel reports them. + +## Discord + +Players link Discord in the launcher (Settings → Connections). In panel Settings → Discord community, admins can: +- map panel groups to Discord role IDs (Players → Groups) and pick a sync direction (adds only; also runs every 15 minutes), +- set an invite link shown in the launcher, +- post announcements (achievements, new guilds, new players) to a webhook. + +## Networks + +Servers already track a global level plus a level per server; friends, guilds and achievements are account-wide. To share money, give servers the same **Shared economy group** name in the server's settings: balances and guild banks are then one pool. Existing per-server balances are not merged when a server joins a group. + +## Deleting accounts + +Deleting a player in the panel removes their levels, quests, achievements, stats, friends, messages, posts, profile, economy, guild membership (leadership passes on, empty guilds are dissolved), sessions and skin file. Other players' records that mention them (transactions, bank logs) are anonymised. The name stays reserved so nobody can impersonate them. Plugin-local data such as homes lives on the game server and is not reachable from the panel. diff --git a/launcher/src-tauri/src/commands.rs b/launcher/src-tauri/src/commands.rs index d136f6b..da107e2 100644 --- a/launcher/src-tauri/src/commands.rs +++ b/launcher/src-tauri/src/commands.rs @@ -1129,6 +1129,7 @@ pub async fn update_my_profile( banner_url: Option, custom_badge: Option, featured_achievement_id: Option, + accent_color: Option, ) -> Res { let req = account_api(&state, reqwest::Method::PUT, "/profiles/me").await?; let resp = req @@ -1137,6 +1138,7 @@ pub async fn update_my_profile( "banner_url": banner_url, "custom_badge": custom_badge, "featured_achievement_id": featured_achievement_id, + "accent_color": accent_color, })) .send() .await diff --git a/launcher/src/App.svelte b/launcher/src/App.svelte index f6daf82..7f39782 100644 --- a/launcher/src/App.svelte +++ b/launcher/src/App.svelte @@ -16,6 +16,7 @@ import Stats from './pages/Stats.svelte'; import Quests from './pages/Quests.svelte'; import Guilds from './pages/Guilds.svelte'; + import Commands from './pages/Commands.svelte'; import Social from './pages/Social.svelte'; import PlayerProfileModal from './components/PlayerProfileModal.svelte'; import { app, init } from './lib/store.svelte'; @@ -55,6 +56,8 @@ {:else if app.view === 'social'} + {:else if app.view === 'commands'} + {:else} {/if} diff --git a/launcher/src/components/PlayerProfileModal.svelte b/launcher/src/components/PlayerProfileModal.svelte index 455d0b6..e28803d 100644 --- a/launcher/src/components/PlayerProfileModal.svelte +++ b/launcher/src/components/PlayerProfileModal.svelte @@ -3,7 +3,8 @@ import { onMount } from 'svelte'; import { X, Trophy, Shield, Heart, Image as ImageIcon, Send, Edit3, Check, - Clock, Swords, Flame, Pickaxe, Box, LoaderCircle, Award, Star, MessageSquare + Clock, Swords, Flame, Pickaxe, Box, LoaderCircle, Award, Star, MessageSquare, + UserPlus, UserMinus, UserCheck, Wallet, MapPin, Users, Calendar, Palette, LayoutGrid, Skull, LogIn, Sparkles } from '@lucide/svelte'; import Avatar from './Avatar.svelte'; import Modal from './Modal.svelte'; @@ -21,12 +22,15 @@ let profile = $state(null); let loading = $state(true); - let activeTab = $state<'feed' | 'progression' | 'achievements' | 'stats'>('feed'); + let activeTab = $state<'overview' | 'feed' | 'progression' | 'achievements' | 'stats'>('overview'); // Edit Profile State let editing = $state(false); let editBio = $state(''); let editBanner = $state(''); + let editAccent = $state(''); + let relationBusy = $state(false); + const swatches = ['#8b6cff', '#22d3ee', '#34d399', '#fbbf24', '#fb7185', '#f472b6', '#60a5fa']; let savingProfile = $state(false); // New Post State @@ -36,13 +40,14 @@ const isMe = $derived(activeAccount()?.uuid === uuid); - async function loadProfile() { - loading = true; + async function loadProfile(showSpinner = true) { + loading = showSpinner; try { profile = await invoke('get_user_profile', { uuid }); if (profile) { editBio = profile.bio || ''; editBanner = profile.banner_url || ''; + editAccent = profile.accent_color || ''; } } catch (e: any) { toast(e?.message ?? 'Failed to load player profile', 'error'); @@ -56,7 +61,8 @@ try { profile = await invoke('update_my_profile', { bio: editBio.trim(), - bannerUrl: editBanner.trim() || null + bannerUrl: editBanner.trim() || null, + accentColor: editAccent }); editing = false; toast('Profile updated successfully!', 'ok'); @@ -99,6 +105,49 @@ } } + /** Friend actions right from the profile. */ + async function relate(action: 'add' | 'accept' | 'decline' | 'remove') { + if (!profile) return; + relationBusy = true; + try { + if (action === 'add') await invoke('send_friend_request', { username: profile.username, friendUsername: profile.username }); + else if (action === 'accept' || action === 'decline') await invoke('respond_friend_request', { targetUuid: profile.uuid, friendUuid: profile.uuid, accept: action === 'accept' }); + else await invoke('remove_friend', { targetUuid: profile.uuid, friendUuid: profile.uuid }); + toast( + action === 'add' ? 'Friend request sent' : action === 'accept' ? `You and ${profile.username} are friends` : action === 'decline' ? 'Request declined' : `${profile.username} removed from friends`, + 'ok' + ); + await loadProfile(false); + } catch (e: any) { + toast(e?.message ?? String(e), 'error'); + } finally { + relationBusy = false; + } + } + + function message() { + if (!profile) return; + app.chatWith = profile.uuid; + app.view = 'social'; + onclose(); + } + + const kd = $derived.by(() => { + const k = Number(profile?.stats?.player_kills ?? 0); + const d = Number(profile?.stats?.deaths ?? 0); + return d === 0 ? (k ? `${k}.0` : '–') : (k / d).toFixed(2); + }); + const money = (n: number) => `$${Math.round(n).toLocaleString()}`; + const activityIcon: Record = { join: LogIn, death: Skull, kill: Swords, advancement: Award, achievement: Trophy }; + const ago = (iso?: string | null) => { + if (!iso) return 'never'; + const secs = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000); + if (secs < 90) return 'just now'; + if (secs < 5400) return `${Math.round(secs / 60)}m ago`; + if (secs < 129600) return `${Math.round(secs / 3600)}h ago`; + return `${Math.round(secs / 86400)}d ago`; + }; + function formatPlaytime(secs: number): string { if (!secs || secs < 60) return `${secs || 0}s`; const hours = Math.floor(secs / 3600); @@ -112,7 +161,7 @@ @@ -155,6 +211,24 @@ + {#if !isMe && profile.relationship} +
+ {#if profile.relationship === 'accepted'} + + + {:else if profile.relationship === 'pending_incoming'} + {profile.username} wants to be friends + + + {:else if profile.relationship === 'pending_outgoing'} + Friend request sent + + {:else} + + {/if} +
+ {/if} +
{#if editing} @@ -167,6 +241,16 @@ Banner Image URL +
+ Accent colour +
+ {#each swatches as c} + + {/each} + (editAccent = e.currentTarget.value)} aria-label="Custom colour" /> + +
+
@@ -206,7 +293,47 @@