Richer player profiles, launcher command guide, integration docs

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
Claude committed 2026-09-30 18:29:07 +00:00
1 parent 745c2178ee
commit 9dc7f12fb0
13 files changed
+655 -13

No files matched your search

+56
View File
@@ -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_json::Value>,
#[serde(default)]
pub online: bool,
#[serde(default)]
pub last_seen: Option<String>,
#[serde(default)]
pub guild: Option<ProfileGuild>,
#[serde(default)]
pub rank: Option<ProfileRank>,
/// The server they spend the most time on.
#[serde(default)]
pub favorite_server: Option<String>,
/// Total balance across the economies they hold money in.
#[serde(default)]
pub wealth: f64,
#[serde(default)]
pub recent_activity: Vec<ProfileActivity>,
#[serde(default)]
pub mutual_friends: Vec<MutualFriend>,
/// 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<String>,
#[serde(default)]
pub discord_linked: bool,
}
// ---------------------------------------------------------------------------
+47
View File
@@ -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.<command>[.<subcommand>]` (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.
+2
View File
@@ -1129,6 +1129,7 @@ pub async fn update_my_profile(
banner_url: Option<String>,
custom_badge: Option<String>,
featured_achievement_id: Option<String>,
accent_color: Option<String>,
) -> Res<UserProfileView> {
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
+3
View File
@@ -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 @@
<Guilds />
{:else if app.view === 'social'}
<Social />
{:else if app.view === 'commands'}
<Commands />
{:else}
<Home />
{/if}
+169 -11
View File
@@ -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<UserProfileView | null>(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<UserProfileView>('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<UserProfileView>('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<string, any> = { 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 @@
</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">
<div class="profile-modal glass" role="dialog" aria-modal="true" aria-label="Player Profile" style:--pa={profile?.accent_color || 'var(--accent)'}>
<button class="ghost icon close-btn" onclick={onclose} aria-label="Close Profile">
<X size={18} />
</button>
@@ -138,12 +187,19 @@
{/if}
</div>
<div class="meta-row tiny muted">
<span>Joined {new Date(profile.created_at).toLocaleDateString()}</span>
<span class="presence" class:on={profile.online}></span>
<span>{profile.online ? 'Online now' : `Seen ${ago(profile.last_seen)}`}</span>
<span>•</span>
<span>{profile.friends_count} Friends</span>
<span>•</span>
<span class="global-lvl-tag">Level {profile.level_info?.level ?? 1}</span>
</div>
{#if profile.guild || profile.rank}
<div class="chips-row">
{#if profile.guild}<button class="chip guild" onclick={() => { app.view = 'guilds'; onclose(); }} title="{profile.guild.role} of {profile.guild.name}"><Shield size={11} /> [{profile.guild.tag}] {profile.guild.name}</button>{/if}
{#if profile.rank}<span class="chip rank" title="Reported by {profile.rank.server_name}"><Sparkles size={11} /> {profile.rank.display}</span>{/if}
</div>
{/if}
</div>
</div>
@@ -155,6 +211,24 @@
</div>
</div>
{#if !isMe && profile.relationship}
<div class="relation-bar">
{#if profile.relationship === 'accepted'}
<button class="sm primary" onclick={message}><MessageSquare size={14} /> Message</button>
<button class="sm ghost" disabled={relationBusy} onclick={() => relate('remove')}><UserMinus size={14} /> Remove friend</button>
{:else if profile.relationship === 'pending_incoming'}
<span class="tiny muted">{profile.username} wants to be friends</span>
<button class="sm primary" disabled={relationBusy} onclick={() => relate('accept')}><UserCheck size={14} /> Accept</button>
<button class="sm ghost" disabled={relationBusy} onclick={() => relate('decline')}>Decline</button>
{:else if profile.relationship === 'pending_outgoing'}
<span class="tiny muted">Friend request sent</span>
<button class="sm ghost" disabled={relationBusy} onclick={() => relate('remove')}>Cancel request</button>
{:else}
<button class="sm primary" disabled={relationBusy} onclick={() => relate('add')}><UserPlus size={14} /> Add friend</button>
{/if}
</div>
{/if}
<!-- Bio / Edit Form -->
<div class="bio-section">
{#if editing}
@@ -167,6 +241,16 @@
<span class="tiny bold">Banner Image URL</span>
<input type="url" bind:value={editBanner} placeholder="https://example.com/banner.jpg" />
</label>
<div class="accent-edit">
<span class="tiny bold"><Palette size={12} /> Accent colour</span>
<div class="swatches">
{#each swatches as c}
<button type="button" class="swatch" class:on={editAccent === c} style:background={c} aria-label="Use {c}" onclick={() => (editAccent = c)}></button>
{/each}
<input type="color" value={editAccent || '#8b6cff'} oninput={(e) => (editAccent = e.currentTarget.value)} aria-label="Custom colour" />
<button type="button" class="ghost sm" onclick={() => (editAccent = '')}>Default</button>
</div>
</div>
<div class="edit-actions">
<button class="ghost sm" onclick={() => (editing = false)}>Cancel</button>
<button class="primary sm" onclick={handleSaveProfile} disabled={savingProfile}>
@@ -190,6 +274,9 @@
<!-- Segmented Profile Tabs -->
<div class="profile-tabs">
<button class:active={activeTab === 'overview'} onclick={() => (activeTab = 'overview')}>
<LayoutGrid size={14} /> Overview
</button>
<button class:active={activeTab === 'feed'} onclick={() => (activeTab = 'feed')}>
<MessageSquare size={14} /> Posts & Feed
</button>
@@ -206,7 +293,47 @@
<!-- Tab Content Area -->
<div class="modal-tab-content">
{#if activeTab === 'feed'}
{#if activeTab === 'overview'}
<div class="overview">
<div class="facts">
<div class="fact glass"><Star size={15} class="accent" /><span class="tiny muted">Level</span><strong>{profile.level_info?.level ?? 1}</strong></div>
<div class="fact glass"><Clock size={15} class="muted" /><span class="tiny muted">Playtime</span><strong>{formatPlaytime(profile.stats?.playtime_secs ?? 0)}</strong></div>
<div class="fact glass"><Swords size={15} class="accent" /><span class="tiny muted">K/D</span><strong>{kd}</strong></div>
<div class="fact glass"><Wallet size={15} class="success" /><span class="tiny muted">Wealth</span><strong>{money(profile.wealth ?? 0)}</strong></div>
<div class="fact glass"><MapPin size={15} class="warn" /><span class="tiny muted">Favourite server</span><strong class="small-val">{profile.favorite_server ?? '–'}</strong></div>
<div class="fact glass"><Calendar size={15} class="muted" /><span class="tiny muted">Joined</span><strong class="small-val">{new Date(profile.created_at).toLocaleDateString()}</strong></div>
</div>
{#if profile.featured_achievement}
<div class="featured glass"><Trophy size={15} class="warn" /><span class="tiny muted">Featured</span><strong>{profile.featured_achievement.title}</strong></div>
{/if}
{#if profile.mutual_friends && profile.mutual_friends.length}
<div>
<h4 class="subhead"><Users size={14} /> {profile.mutual_friends.length} mutual friend{profile.mutual_friends.length === 1 ? '' : 's'}</h4>
<div class="mutuals">
{#each profile.mutual_friends as m (m.uuid)}
<button class="mutual" onclick={() => (app.viewProfileUuid = m.uuid)}><Avatar uuid={m.uuid} name={m.username} size={1.6} /> {m.username}</button>
{/each}
</div>
</div>
{/if}
<div>
<h4 class="subhead">Recent activity</h4>
{#if profile.recent_activity && profile.recent_activity.length}
<div class="activity">
{#each profile.recent_activity as a}
{@const Icon = activityIcon[a.kind] ?? Sparkles}
<div class="act"><span class="act-ic {a.kind}"><Icon size={12} /></span><span>{a.text}</span><span class="tiny muted">{ago(a.at)}</span></div>
{/each}
</div>
{:else}
<p class="muted tiny">Nothing yet. Activity shows up as they play.</p>
{/if}
</div>
</div>
{:else if activeTab === 'feed'}
<div class="feed-container">
<!-- Create Post (if viewing self) -->
{#if isMe}
@@ -451,7 +578,7 @@
gap: 1rem;
}
.avatar-ring {
border: 3px solid var(--surface);
border: 3px solid var(--pa, var(--surface));
border-radius: 28%;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
@@ -482,7 +609,7 @@
gap: 0.4rem;
}
.global-lvl-tag {
color: var(--accent);
color: var(--pa, var(--accent));
font-weight: 650;
}
.edit-btn {
@@ -556,8 +683,39 @@
}
.profile-tabs button.active {
color: var(--text);
border-bottom-color: var(--accent);
border-bottom-color: var(--pa, var(--accent));
}
.profile-tabs { overflow-x: auto; }
.profile-tabs button { white-space: nowrap; }
.presence { width: 0.5rem; height: 0.5rem; border-radius: 50%; background: color-mix(in srgb, var(--muted) 60%, transparent); }
.presence.on { background: #34d399; box-shadow: 0 0 0 3px color-mix(in srgb, #34d399 25%, transparent); }
.chips-row { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-top: 0.25rem; }
.chip { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.7rem; font-weight: 650; padding: 0.15rem 0.55rem; border-radius: 99rem; background: rgba(0, 0, 0, 0.45); border: 1px solid color-mix(in srgb, var(--text) 18%, transparent); color: var(--text); }
.chip.guild { cursor: pointer; }
.chip.guild:hover { border-color: var(--pa, var(--accent)); }
.chip.rank { color: #fbbf24; }
.relation-bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.7rem 1.4rem 0; flex-wrap: wrap; }
.relation-bar .sm { display: inline-flex; align-items: center; gap: 0.35rem; }
.accent-edit { display: flex; flex-direction: column; gap: 0.4rem; }
.swatches { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
.swatch { width: 1.4rem; height: 1.4rem; padding: 0; border-radius: 50%; border: 2px solid transparent; }
.swatch.on { border-color: var(--text); }
.swatches input[type='color'] { width: 1.8rem; height: 1.6rem; padding: 0; border: none; background: none; }
.overview { display: flex; flex-direction: column; gap: 1rem; }
.facts { display: grid; grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr)); gap: 0.7rem; }
.fact { display: flex; flex-direction: column; gap: 0.2rem; padding: 0.75rem; border-radius: var(--radius-sm); border: 1px solid var(--line); }
.fact strong { font-size: 1.1rem; }
.fact strong.small-val { font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.featured { display: flex; align-items: center; gap: 0.5rem; padding: 0.6rem 0.8rem; border-radius: var(--radius-sm); border: 1px solid var(--line); }
.mutuals { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.4rem; }
.mutual { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.2rem 0.6rem 0.2rem 0.25rem; border-radius: 99rem; background: color-mix(in srgb, var(--text) 7%, transparent); font-size: 0.78rem; }
.activity { display: flex; flex-direction: column; gap: 0.45rem; margin-top: 0.4rem; }
.act { display: grid; grid-template-columns: 1.6rem 1fr auto; gap: 0.6rem; align-items: center; font-size: 0.82rem; }
.act-ic { width: 1.6rem; height: 1.6rem; display: grid; place-items: center; border-radius: 0.45rem; background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--muted); }
.act-ic.achievement, .act-ic.advancement { color: #fbbf24; }
.act-ic.death { color: #fb7185; }
.act-ic.join { color: #34d399; }
.subhead { display: flex; align-items: center; gap: 0.4rem; }
.modal-tab-content {
padding: 1.2rem 1.4rem;
}
+4 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Settings, Plus, Check, LogOut, Home, Trophy, Target, Shield, Users } from '@lucide/svelte';
import { Settings, Plus, Check, LogOut, Home, Trophy, Target, Shield, Users, Terminal } from '@lucide/svelte';
import Avatar from './Avatar.svelte';
import { abs, activeAccount, app, instances, removeAccount, selectInstance, switchAccount } from '../lib/store.svelte';
@@ -39,6 +39,9 @@
<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 === 'commands'} onclick={() => (app.view = 'commands')} aria-label="Command guide" title="Command guide">
<Terminal 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>
+3 -1
View File
@@ -27,9 +27,11 @@ export const app = $state({
active: null as string | null,
panelUrl: null as string | null,
offline: false,
view: 'home' as 'home' | 'settings' | 'stats' | 'quests' | 'guilds' | 'social',
view: 'home' as 'home' | 'settings' | 'stats' | 'quests' | 'guilds' | 'social' | 'commands',
settingsTab: 'account',
viewProfileUuid: null as string | null,
/** Open this friend's conversation when the Social page loads. */
chatWith: null as string | null,
selected: null as string | null,
launch: idle(),
launchingId: null as string | null,
+13
View File
@@ -324,6 +324,19 @@ export interface UserProfileView {
friends_count: number;
stats: PlayerStats | null;
created_at: string;
online?: boolean;
last_seen?: string | null;
guild?: { id: string; name: string; tag: string; role: string } | null;
rank?: { display: string; prefix: string; server_name: string } | null;
favorite_server?: string | null;
wealth?: number;
recent_activity?: { kind: string; text: string; at: string }[];
mutual_friends?: { uuid: string; username: string }[];
/** self | none | accepted | pending_outgoing | pending_incoming */
relationship?: string;
accent_color?: string | null;
discord_linked?: boolean;
featured_achievement?: Achievement | null;
}
export interface MemberProfile {
+142
View File
@@ -0,0 +1,142 @@
<script lang="ts">
import { Terminal, Search, Copy, Check } from '@lucide/svelte';
type Cmd = { usage: string; does: string; perm: string; aliases?: string[] };
type Group = { title: string; blurb: string; cmds: Cmd[] };
const groups: Group[] = [
{ title: 'Getting around', blurb: 'Teleports, homes and warps.', cmds: [
{ usage: '/spawn', does: 'Teleport to the server spawn.', perm: 'scopenet.command.spawn', aliases: ['hub', 'lobby'] },
{ usage: '/home [name]', does: 'Teleport to a home, or open the homes menu.', perm: 'scopenet.command.home', aliases: ['homes'] },
{ usage: '/sethome [name]', does: 'Save your current spot as a home.', perm: 'scopenet.command.sethome' },
{ usage: '/delhome <name>', does: 'Delete one of your homes.', perm: 'scopenet.command.delhome', aliases: ['rmhome'] },
{ usage: '/back', does: 'Return to where you were, or where you died.', perm: 'scopenet.command.back', aliases: ['return'] },
{ usage: '/warp [name]', does: 'Go to a server warp, or open the warps menu.', perm: 'scopenet.command.warp', aliases: ['warps'] },
{ usage: '/rtp', does: 'Jump to a random safe spot in the wild.', perm: 'scopenet.command.rtp', aliases: ['wild', 'randomtp'] },
{ usage: '/tpa <player>', does: 'Ask to teleport to another player.', perm: 'scopenet.command.tpa' },
{ usage: '/tpaccept', does: 'Accept a teleport request.', perm: 'scopenet.command.tpaccept', aliases: ['tpyes'] },
{ usage: '/tpdeny', does: 'Decline a teleport request.', perm: 'scopenet.command.tpdeny', aliases: ['tpno'] },
] },
{ title: 'Money & trading', blurb: 'Your balance, the shop, the market and player trades.', cmds: [
{ usage: '/balance', does: 'Check your balance.', perm: 'scopenet.command.balance', aliases: ['bal', 'money'] },
{ usage: '/pay <player> <amount>', does: 'Send money to a player.', perm: 'scopenet.command.pay' },
{ usage: '/baltop', does: 'See the richest players.', perm: 'scopenet.command.baltop', aliases: ['richest'] },
{ usage: '/shop', does: 'Open the server shop.', perm: 'scopenet.command.shop' },
{ usage: '/sell hand', does: 'Sell the item in your hand to the shop.', perm: 'scopenet.command.sell.hand' },
{ usage: '/sell', does: 'Open the selling menu.', perm: 'scopenet.command.sell' },
{ usage: '/market', does: 'Browse and buy from the player market.', perm: 'scopenet.command.market', aliases: ['ah', 'auction'] },
{ usage: '/market sell <price>', does: 'List the item in your hand on the market.', perm: 'scopenet.command.market.sell' },
{ usage: '/orders', does: 'View your active market orders.', perm: 'scopenet.command.orders' },
{ usage: '/trade <player>', does: 'Start a secure trade with another player.', perm: 'scopenet.command.trade' },
{ usage: '/transactions', does: 'See your recent money movements.', perm: 'scopenet.command.transactions' },
] },
{ title: 'Guilds & land', blurb: 'Team up, claim land and protect it.', cmds: [
{ usage: '/guild', does: 'Open your guild dashboard.', perm: 'scopenet.command.guild', aliases: ['g', 'clan'] },
{ usage: '/guild create <name> <tag>', does: 'Found a new guild.', perm: 'scopenet.command.guild.create' },
{ usage: '/guild leave', does: 'Leave your guild.', perm: 'scopenet.command.guild.leave' },
{ usage: '/guild members', does: 'List your guild members.', perm: 'scopenet.command.guild.members' },
{ usage: '/guild chat <message>', does: 'Talk to your guild only.', perm: 'scopenet.command.guild.chat', aliases: ['/guild c'] },
{ usage: '/guild sethome · /guild home', does: 'Set or visit the guild home.', perm: 'scopenet.command.guild.sethome' },
{ usage: '/guild map', does: 'Show nearby claims on a map.', perm: 'scopenet.command.guild.map' },
{ usage: '/claim', does: 'Claim the chunk you stand in for your guild.', perm: 'scopenet.command.claim' },
{ usage: '/unclaim', does: 'Give up the chunk you stand in.', perm: 'scopenet.command.unclaim' },
] },
{ title: 'Guild bank', blurb: 'Shared guild money. Officers and leaders can spend it.', cmds: [
{ usage: '/guild bank', does: 'Show the bank balance and recent activity.', perm: 'scopenet.command.guild.bank' },
{ usage: '/guild bank deposit <amount>', does: 'Put your own money into the bank.', perm: 'scopenet.command.guild.bank.deposit' },
{ usage: '/guild bank withdraw <amount>', does: 'Take money out (officers and leaders).', perm: 'scopenet.command.guild.bank.withdraw' },
{ usage: '/guild sell hand', does: 'Sell your held item and pay the bank.', perm: 'scopenet.command.guild.sell' },
{ usage: '/guild market', does: 'Buy and sell on the market as your guild.', perm: 'scopenet.command.guild.market' },
{ usage: '/guild pay <tag> <amount>', does: 'Pay another guild from your bank.', perm: 'scopenet.command.guild.pay' },
] },
{ title: 'Info & admin', blurb: 'Status and help.', cmds: [
{ usage: '/playtime', does: 'Check your total and session playtime.', perm: 'scopenet.command.playtime', aliases: ['ontime'] },
{ usage: '/scopenet help', does: 'List SCOPENET commands.', perm: 'scopenet.command.scopenet.help', aliases: ['sn'] },
{ usage: '/scopenet status', does: 'Panel connection, features and plugin integrations (admins).', perm: 'scopenet.command.scopenet.status' },
{ usage: '/scopenet reload', does: 'Reload the plugin config (admins).', perm: 'scopenet.command.scopenet.reload' },
] },
];
const tips = [
'Placeholders: use %scopenet_level%, %scopenet_guild%, %scopenet_balance% and more in scoreboards, tab and chat.',
'Some commands may be switched off or limited by the server. If one says you lack permission, ask an admin.',
'Everything here also works through your rank: servers using LuckPerms can grant each permission node to any group.',
];
let query = $state('');
let copied = $state('');
const q = $derived(query.trim().toLowerCase());
const shown = $derived(
groups
.map((g) => ({ ...g, cmds: g.cmds.filter((c) => !q || `${c.usage} ${c.does} ${c.perm} ${(c.aliases ?? []).join(' ')}`.toLowerCase().includes(q)) }))
.filter((g) => g.cmds.length)
);
async function copy(c: Cmd) {
const text = c.usage.split(' · ')[0];
try { await navigator.clipboard.writeText(text); } catch { /* clipboard may be unavailable */ }
copied = c.usage;
setTimeout(() => (copied = ''), 1200);
}
</script>
<div class="page">
<div class="head">
<div>
<h1><Terminal size={26} class="ic" /> Command guide</h1>
<p class="lead">Everything you can type in-game on SCOPENET servers. Click a command to copy it.</p>
</div>
<label class="search"><Search size={15} /><input bind:value={query} placeholder="Search commands" aria-label="Search commands" /></label>
</div>
{#each shown as g (g.title)}
<section class="group">
<div class="gh"><h2>{g.title}</h2><span class="muted tiny">{g.blurb}</span></div>
<div class="list">
{#each g.cmds as c (c.usage)}
<button class="cmd glass" onclick={() => copy(c)} title="Copy command">
<code class="usage">{c.usage}</code>
<span class="does">{c.does}</span>
<span class="meta">
{#each c.aliases ?? [] as a}<span class="alias">/{a.replace(/^\//, '')}</span>{/each}
<span class="perm" title="LuckPerms permission node">{c.perm}</span>
</span>
<span class="copy">{#if copied === c.usage}<Check size={14} />{:else}<Copy size={14} />{/if}</span>
</button>
{/each}
</div>
</section>
{:else}
<p class="muted">No commands match “{query}”.</p>
{/each}
<section class="tips glass">
<h2>Good to know</h2>
<ul>{#each tips as t}<li>{t}</li>{/each}</ul>
</section>
</div>
<style>
.page { flex: 1; display: flex; flex-direction: column; overflow-y: auto; padding: 1.6rem 2.2rem 2.5rem; gap: 1.2rem; }
.head { display: flex; justify-content: space-between; align-items: flex-end; gap: 1rem; flex-wrap: wrap; border-bottom: 1px solid var(--line); padding-bottom: 1rem; }
h1 { font-size: 1.5rem; font-weight: 700; display: flex; align-items: center; gap: 0.6rem; margin: 0; }
:global(.ic) { color: var(--accent); }
.lead { color: var(--muted); font-size: 0.88rem; margin: 0.2rem 0 0; }
.search { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.75rem; border-radius: var(--radius-sm); border: 1px solid var(--line); background: color-mix(in srgb, var(--surface) 80%, transparent); min-width: 14rem; }
.search input { background: transparent; border: none; outline: none; color: var(--text); flex: 1; font-size: 0.85rem; }
.group { display: flex; flex-direction: column; gap: 0.6rem; }
.gh { display: flex; align-items: baseline; gap: 0.7rem; flex-wrap: wrap; }
.gh h2 { font-size: 1.05rem; margin: 0; }
.list { display: grid; grid-template-columns: repeat(auto-fill, minmax(21rem, 1fr)); gap: 0.6rem; }
.cmd { position: relative; display: flex; flex-direction: column; gap: 0.3rem; text-align: left; padding: 0.75rem 2.2rem 0.75rem 0.9rem; border-radius: var(--radius-sm); border: 1px solid var(--line); transition: border-color 0.15s, transform 0.15s; }
.cmd:hover { border-color: var(--accent); transform: translateY(-1px); }
.usage { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.88rem; font-weight: 650; color: var(--accent); }
.does { font-size: 0.83rem; color: var(--text); line-height: 1.35; }
.meta { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
.alias { font-size: 0.68rem; padding: 0.05rem 0.45rem; border-radius: 99rem; background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--muted); }
.perm { font-family: ui-monospace, monospace; font-size: 0.66rem; color: var(--muted); opacity: 0.85; }
.copy { position: absolute; top: 0.7rem; right: 0.75rem; color: var(--muted); }
.tips { padding: 1rem 1.2rem; border-radius: var(--radius-sm); border: 1px solid var(--line); }
.tips h2 { font-size: 0.95rem; margin: 0 0 0.4rem; }
.tips ul { margin: 0; padding-left: 1.1rem; display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.83rem; color: var(--muted); }
</style>
+5
View File
@@ -47,6 +47,11 @@
]);
friends = fList;
invites = iList;
if (app.chatWith) {
const target = fList.find((f) => f.uuid === app.chatWith);
app.chatWith = null;
if (target) void selectChatFriend(target);
}
} catch (e: any) {
toast(e?.message ?? 'Failed to load social data', 'error');
} finally {
+4
View File
@@ -672,6 +672,10 @@ const MIGRATIONS: &[&str] = &[
r#"
ALTER TABLE groups ADD COLUMN discord_role TEXT NOT NULL DEFAULT '';
"#,
// Profile cosmetics.
r#"
ALTER TABLE user_profiles ADD COLUMN accent_color TEXT;
"#,
// Servers can share one economy (balances and guild banks) by sharing a group name.
r#"
ALTER TABLE game_servers ADD COLUMN economy_group TEXT NOT NULL DEFAULT '';
+159
View File
@@ -566,7 +566,19 @@ pub async fn get_player_profile(
SUM(blocks_placed) blocks_placed, SUM(messages) messages, MIN(first_seen) first_seen, MAX(last_seen) last_seen
FROM player_stats WHERE uuid = ? GROUP BY uuid")
.bind(&puuid).fetch_optional(&state.db).await?;
let extras = profile_extras(&state, &puuid, viewer.as_ref().map(|v| v.uuid.as_str())).await?;
Ok(Json(UserProfileView {
online: extras.online,
last_seen: extras.last_seen,
guild: extras.guild,
rank: extras.rank,
favorite_server: extras.favorite_server,
wealth: extras.wealth,
recent_activity: extras.recent_activity,
mutual_friends: extras.mutual_friends,
relationship: extras.relationship,
accent_color: extras.accent_color,
discord_linked: extras.discord_linked,
level_info: levels.clone(),
badges: levels.badges.clone(),
achievements,
@@ -588,8 +600,145 @@ pub async fn get_player_profile(
}))
}
#[derive(Default)]
struct ProfileExtras {
online: bool,
last_seen: Option<String>,
guild: Option<scopenet_shared::ProfileGuild>,
rank: Option<scopenet_shared::ProfileRank>,
favorite_server: Option<String>,
wealth: f64,
recent_activity: Vec<scopenet_shared::ProfileActivity>,
mutual_friends: Vec<scopenet_shared::MutualFriend>,
relationship: String,
accent_color: Option<String>,
discord_linked: bool,
}
/// Everything on a profile beyond the basics: presence, guild, rank, wealth,
/// recent activity and how the viewer is connected to the player.
async fn profile_extras(state: &AppState, uuid: &str, viewer: Option<&str>) -> AppResult<ProfileExtras> {
let db = &state.db;
let online: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM server_online WHERE uuid = ?)").bind(uuid).fetch_one(db).await?;
let last_seen: Option<String> = sqlx::query_scalar("SELECT MAX(last_seen) FROM player_stats WHERE uuid = ?").bind(uuid).fetch_one(db).await?;
let guild: Option<(String, String, String, String)> = sqlx::query_as(
"SELECT g.id, g.name, g.tag, gm.role FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? ORDER BY gm.joined_at LIMIT 1",
)
.bind(uuid)
.fetch_optional(db)
.await?;
// LuckPerms stays authoritative: this is only what a server last reported.
let rank: Option<(String, String, String, String)> = sqlx::query_as(
"SELECT r.primary_group, r.display, r.prefix, gs.name FROM player_ranks r JOIN game_servers gs ON gs.id = r.server_id
WHERE r.uuid = ? AND r.primary_group <> '' ORDER BY r.weight DESC, r.updated_at DESC LIMIT 1",
)
.bind(uuid)
.fetch_optional(db)
.await?;
let favorite_server: Option<String> = sqlx::query_scalar(
"SELECT gs.name FROM player_stats ps JOIN game_servers gs ON gs.id = ps.server_id WHERE ps.uuid = ? AND ps.playtime_secs > 0 ORDER BY ps.playtime_secs DESC LIMIT 1",
)
.bind(uuid)
.fetch_optional(db)
.await?;
// Servers that share an economy group hold one balance between them, so count each pool once.
let wealth: f64 = sqlx::query_scalar(
"SELECT COALESCE(SUM(se.balance), 0.0) FROM server_economy se WHERE se.uuid = ? AND se.server_id IN (
SELECT COALESCE((SELECT MIN(b.id) FROM game_servers b WHERE b.economy_group <> '' AND b.economy_group = a.economy_group COLLATE NOCASE), a.id) FROM game_servers a)",
)
.bind(uuid)
.fetch_one(db)
.await?;
// Only moments worth showing; chat and commands stay private.
let events: Vec<(String, Option<String>, String, String)> = sqlx::query_as(
"SELECT e.kind, e.detail, gs.name, e.created_at FROM server_events e JOIN game_servers gs ON gs.id = e.server_id
WHERE e.uuid = ? AND e.kind IN ('join', 'death', 'advancement', 'kill') ORDER BY e.id DESC LIMIT 8",
)
.bind(uuid)
.fetch_all(db)
.await?;
let unlocked: Vec<(String, String)> = sqlx::query_as(
"SELECT a.title, ua.unlocked_at FROM user_achievements ua JOIN achievements a ON a.id = ua.achievement_id WHERE ua.user_uuid = ? ORDER BY ua.unlocked_at DESC LIMIT 8",
)
.bind(uuid)
.fetch_all(db)
.await?;
let mut recent_activity: Vec<scopenet_shared::ProfileActivity> = events
.into_iter()
.map(|(kind, detail, server, at)| {
let detail = detail.unwrap_or_default();
let text = match kind.as_str() {
"join" => format!("Joined {server}"),
"death" => format!("Died on {server}"),
"kill" => format!("Defeated {}", if detail.is_empty() { "a foe".into() } else { detail.chars().take(40).collect::<String>() }),
_ => format!("Earned {}", if detail.is_empty() { "an advancement".into() } else { detail.chars().take(60).collect::<String>() }),
};
scopenet_shared::ProfileActivity { kind, text, at }
})
.chain(unlocked.into_iter().map(|(title, at)| scopenet_shared::ProfileActivity { kind: "achievement".into(), text: format!("Unlocked {title}"), at }))
.collect();
recent_activity.sort_by(|a, b| b.at.cmp(&a.at));
recent_activity.truncate(8);
let (relationship, mutual_friends) = match viewer {
Some(v) if v == uuid => ("self".to_string(), vec![]),
Some(v) => {
let row: Option<(String, String)> = sqlx::query_as(
"SELECT status, action_uuid FROM friendships WHERE (user_uuid = ? AND friend_uuid = ?) OR (user_uuid = ? AND friend_uuid = ?)",
)
.bind(v).bind(uuid).bind(uuid).bind(v)
.fetch_optional(db)
.await?;
let relationship = match row {
Some((s, _)) if s == "accepted" => "accepted",
Some((_, action)) if action == v => "pending_outgoing",
Some(_) => "pending_incoming",
None => "none",
}
.to_string();
let mutual: Vec<(String, String)> = sqlx::query_as(
"WITH mine AS (SELECT CASE WHEN user_uuid = ?1 THEN friend_uuid ELSE user_uuid END AS f FROM friendships WHERE status = 'accepted' AND (user_uuid = ?1 OR friend_uuid = ?1)),
theirs AS (SELECT CASE WHEN user_uuid = ?2 THEN friend_uuid ELSE user_uuid END AS f FROM friendships WHERE status = 'accepted' AND (user_uuid = ?2 OR friend_uuid = ?2))
SELECT u.uuid, u.username FROM users u WHERE u.uuid IN (SELECT f FROM mine INTERSECT SELECT f FROM theirs) ORDER BY u.username COLLATE NOCASE LIMIT 12",
)
.bind(v)
.bind(uuid)
.fetch_all(db)
.await?;
(relationship, mutual.into_iter().map(|(uuid, username)| scopenet_shared::MutualFriend { uuid, username }).collect())
}
None => ("none".to_string(), vec![]),
};
let accent_color: Option<String> = sqlx::query_scalar("SELECT accent_color FROM user_profiles WHERE uuid = ?").bind(uuid).fetch_optional(db).await?.flatten();
let discord_linked: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM account_connections ac JOIN users u ON u.id = ac.user_id WHERE u.uuid = ? AND ac.provider = 'discord')")
.bind(uuid)
.fetch_one(db)
.await?;
Ok(ProfileExtras {
online,
last_seen,
guild: guild.map(|(id, name, tag, role)| scopenet_shared::ProfileGuild { id, name, tag, role }),
rank: rank.map(|(primary, display, prefix, server_name)| scopenet_shared::ProfileRank { display: if display.is_empty() { primary } else { display }, prefix, server_name }),
favorite_server,
wealth,
recent_activity,
mutual_friends,
relationship,
accent_color,
discord_linked,
})
}
#[derive(Deserialize)]
pub struct UpdateProfilePayload {
pub accent_color: Option<String>,
pub bio: Option<String>,
pub banner_url: Option<String>,
pub custom_badge: Option<String>,
@@ -602,6 +751,7 @@ pub async fn update_my_profile(
Json(payload): Json<UpdateProfilePayload>,
) -> AppResult<Json<Value>> {
let now = chrono::Utc::now().to_rfc3339();
let payload_accent = payload.accent_color.clone();
sqlx::query(
"INSERT INTO user_profiles (uuid, bio, banner_url, custom_badge, featured_achievement_id, updated_at)
@@ -626,6 +776,15 @@ pub async fn update_my_profile(
.execute(&state.db)
.await?;
if let Some(color) = payload_accent {
let color = color.trim().to_lowercase();
let valid = color.is_empty() || (color.len() == 7 && color.starts_with('#') && color[1..].chars().all(|c| c.is_ascii_hexdigit()));
if !valid {
return Err(AppError::bad_request("accent colour must look like #8b6cff"));
}
sqlx::query("UPDATE user_profiles SET accent_color = ? WHERE uuid = ?").bind(if color.is_empty() { None } else { Some(color) }).bind(&auth.uuid).execute(&state.db).await?;
}
Ok(Json(serde_json::json!({ "ok": true })))
}
+48
View File
@@ -0,0 +1,48 @@
//! Player profiles: presence, guild, rank, wealth, activity, mutual friends and cosmetics.
mod common;
use common::*;
#[tokio::test]
async fn profile_shows_the_whole_player() {
let t = setup().await;
let admin = t.login("admin", "supersecret").await;
for n in ["Alex", "Steve", "Mia"] {
t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": n, "password": "password123"}))).await;
}
let (alex, steve) = (t.login("Alex", "password123").await, t.login("Steve", "password123").await);
let (au, su, mu) = (t.uuid("Alex").await, t.uuid("Steve").await, t.uuid("Mia").await);
let (_, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "SMP", "instance_id": "smp"}))).await;
let sid = srv["server"]["id"].as_i64().unwrap();
// Alex and Steve both know Mia, but not each other.
for (a, b) in [(&au, &mu), (&su, &mu)] {
sqlx::query("INSERT INTO friendships (user_uuid, friend_uuid, status, action_uuid, created_at, updated_at) VALUES (?, ?, 'accepted', ?, 'x', 'x')").bind(a).bind(b).bind(a).execute(&t.db).await.unwrap();
}
t.call("POST", "/api/v1/guilds", Some(&steve), Some(json!({"instance_id": "smp", "name": "Iron", "tag": "IRON"}))).await;
sqlx::query("INSERT INTO server_economy (server_id, uuid, username, balance, updated_at) VALUES (?, ?, 'Steve', 4321.5, 'x')").bind(sid).bind(&su).execute(&t.db).await.unwrap();
sqlx::query("INSERT INTO player_ranks (server_id, uuid, primary_group, display, prefix, groups, permissions, weight, updated_at) VALUES (?, ?, 'vip', 'VIP', '[VIP]', '[\"vip\"]', '[]', 10, 'x')").bind(sid).bind(&su).execute(&t.db).await.unwrap();
sqlx::query("INSERT INTO player_stats (server_id, uuid, name, playtime_secs, first_seen, last_seen) VALUES (?, ?, 'Steve', 7200, 'x', '2026-01-01T00:00:00Z')").bind(sid).bind(&su).execute(&t.db).await.unwrap();
sqlx::query("INSERT INTO server_events (server_id, uuid, name, kind, detail, created_at) VALUES (?, ?, 'Steve', 'join', NULL, '2026-01-01T00:00:00Z'), (?, ?, 'Steve', 'chat', 'secret', '2026-01-01T00:00:01Z')").bind(sid).bind(&su).bind(sid).bind(&su).execute(&t.db).await.unwrap();
let (s, p) = t.call("GET", &format!("/api/v1/profiles/{su}"), Some(&alex), None).await;
assert_eq!(s, StatusCode::OK, "{p}");
assert_eq!(p["relationship"], "none");
assert_eq!(p["mutual_friends"][0]["username"], "Mia");
assert_eq!((p["guild"]["tag"].as_str(), p["guild"]["role"].as_str()), (Some("IRON"), Some("leader")));
assert_eq!((p["rank"]["display"].as_str(), p["rank"]["server_name"].as_str()), (Some("VIP"), Some("SMP")));
assert_eq!(p["favorite_server"], "SMP");
assert_eq!(p["wealth"], 4321.5);
let activity = p["recent_activity"].as_array().unwrap();
assert!(activity.iter().any(|a| a["text"] == "Joined SMP"));
assert!(activity.iter().all(|a| !a["text"].as_str().unwrap().contains("secret")), "chat must not leak");
// Viewing yourself, and cosmetics.
let (_, me) = t.call("GET", &format!("/api/v1/profiles/{au}"), Some(&alex), None).await;
assert_eq!(me["relationship"], "self");
let (s, _) = t.call("PUT", "/api/v1/profiles/me", Some(&alex), Some(json!({"bio": "hi", "accent_color": "#22D3EE"}))).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(t.call("GET", &format!("/api/v1/profiles/{au}"), Some(&alex), None).await.1["accent_color"], "#22d3ee");
let (s, _) = t.call("PUT", "/api/v1/profiles/me", Some(&alex), Some(json!({"bio": "hi", "accent_color": "red; background:url(x)"}))).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}