Files
SCOPENET-MC/crates/shared/src/lib.rs
T

840 lines
24 KiB
Rust

//! Wire types shared by the SCOPENET launcher and the admin panel.
//!
//! Everything the panel sends to the launcher is described here, so both
//! sides always agree on the JSON shape.
use serde::{Deserialize, Serialize};
pub const API_VERSION: u32 = 1;
// ---------------------------------------------------------------------------
// Branding / theming
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Branding {
pub name: String,
pub tagline: String,
pub logo_url: Option<String>,
pub icon_url: Option<String>,
pub background: Background,
pub colors: Palette,
/// One of the fonts bundled in the launcher: "Inter", "Outfit",
/// "Space Grotesk", "Plus Jakarta Sans".
pub font: String,
/// Corner radius in px used for cards and buttons.
pub radius: u8,
/// Frosted-glass surfaces (backdrop blur). Disable for very old GPUs.
pub glass: bool,
/// Shown on the Minecraft title screen / F3 (the `version_type` arg).
pub version_label: String,
pub news: Vec<NewsItem>,
pub links: Vec<SocialLink>,
pub about: AboutContent,
pub features: Features,
/// Raw CSS appended to the launcher stylesheet (power users).
pub custom_css: String,
}
impl Default for Branding {
fn default() -> Self {
Self {
name: "SCOPENET".into(),
tagline: "Your worlds, one click away.".into(),
logo_url: None,
icon_url: None,
background: Background::default(),
colors: Palette::default(),
font: "Inter".into(),
radius: 10,
glass: false,
version_label: "SCOPENET".into(),
news: vec![NewsItem {
id: "welcome".into(),
title: "Welcome to SCOPENET".into(),
body: "Your admin can customise this launcher from the panel: colours, news, instances and more.".into(),
image_url: None,
link: None,
date: None,
pinned: true,
tag: Some("News".into()),
}],
links: vec![],
about: AboutContent::default(),
features: Features::default(),
custom_css: String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct AboutContent {
pub title: String,
pub icon_url: Option<String>,
pub body: String,
pub links: Vec<SocialLink>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Background {
/// "gradient" | "image" | "video"
pub kind: String,
pub url: Option<String>,
/// Blur in px applied to image/video backgrounds.
pub blur: u8,
/// Darkening overlay 0-100.
pub dim: u8,
}
impl Default for Background {
fn default() -> Self {
Self { kind: "gradient".into(), url: None, blur: 0, dim: 55 }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Palette {
pub accent: String,
pub accent_2: String,
pub background: String,
pub surface: String,
pub text: String,
pub muted: String,
pub success: String,
pub danger: String,
}
impl Default for Palette {
fn default() -> Self {
Self {
accent: "#6d6af5".into(),
accent_2: "#8b88f8".into(),
background: "#0d0e12".into(),
surface: "#16181e".into(),
text: "#ecedf1".into(),
muted: "#8b8f9a".into(),
success: "#3fb97c".into(),
danger: "#e5484d".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct NewsItem {
pub id: String,
pub title: String,
pub body: String,
pub image_url: Option<String>,
pub link: Option<String>,
/// ISO date (YYYY-MM-DD)
pub date: Option<String>,
pub pinned: bool,
pub tag: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct SocialLink {
pub label: String,
pub url: String,
/// "discord" | "website" | "youtube" | "twitter" | "github" | "store" | "twitch"
pub icon: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Features {
pub news: bool,
pub server_status: bool,
/// Let players override colours/theme in their own settings.
pub allow_user_theme: bool,
/// Let players change JVM arguments / Java path.
pub allow_advanced_java: bool,
}
impl Default for Features {
fn default() -> Self {
Self { news: true, server_status: true, allow_user_theme: true, allow_advanced_java: true }
}
}
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum RegistrationMode {
/// Only admins can create accounts.
#[default]
Closed,
/// Anyone can sign up from the launcher.
Open,
/// Anyone can sign up, but an admin must approve the account.
Approval,
}
/// Public auth configuration the launcher uses to decide which sign-in
/// options to show.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct AuthConfig {
pub panel_accounts: bool,
pub registration: RegistrationMode,
pub offline_local: bool,
/// Absolute URL of the panel's Yggdrasil (authlib-injector) API root.
/// Filled in by the panel; the launcher falls back to `{panel}/api/yggdrasil`.
pub yggdrasil_url: Option<String>,
}
impl Default for AuthConfig {
fn default() -> Self {
Self { panel_accounts: true, registration: RegistrationMode::Closed, offline_local: true, yggdrasil_url: None }
}
}
/// Yggdrasil session tokens handed to the launcher at sign-in. The access
/// token is what the game (via authlib-injector) uses to join servers.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct YggdrasilTokens {
pub access_token: String,
pub client_token: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct CapeInfo {
pub id: i64,
pub name: String,
pub url: String,
}
/// A player's in-game identity: UUID, skin and cape.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct PlayerProfile {
pub uuid: String,
pub name: String,
pub skin_url: Option<String>,
/// "classic" (Steve arms) or "slim" (Alex arms).
pub skin_model: String,
pub cape: Option<CapeInfo>,
/// Capes this player is allowed to pick.
pub available_capes: Vec<CapeInfo>,
}
/// A saved skin and cape preset (wardrobe look).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SkinProfile {
pub id: String,
pub name: String,
pub skin_data: Option<String>,
#[serde(default = "default_skin_model")]
pub skin_model: String,
#[serde(default)]
pub cape_id: Option<i64>,
#[serde(default)]
pub cape_name: Option<String>,
#[serde(default)]
pub cape_url: Option<String>,
#[serde(default)]
pub account_id: Option<String>,
#[serde(default)]
pub created_at: String,
}
fn default_skin_model() -> String {
"classic".into()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegisterRequest {
pub username: String,
pub password: String,
#[serde(default)]
pub email: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PublicUser {
pub id: i64,
pub username: String,
/// The player's UUID (dashed). New accounts get the offline-mode UUID for
/// their name, so offline and authenticated servers agree.
pub uuid: String,
pub role: String,
pub groups: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthResponse {
/// Panel session token (manifest, skins, account API).
pub token: String,
pub user: PublicUser,
/// Set when the account exists but is waiting for admin approval.
#[serde(default)]
pub pending: bool,
/// Game session for authlib-injector; absent for pending accounts.
#[serde(default)]
pub yggdrasil: Option<YggdrasilTokens>,
}
// ---------------------------------------------------------------------------
// Instances
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, Hash)]
#[serde(rename_all = "lowercase")]
pub enum Loader {
#[default]
Vanilla,
Fabric,
Quilt,
Forge,
NeoForge,
}
impl Loader {
pub fn as_str(&self) -> &'static str {
match self {
Loader::Vanilla => "vanilla",
Loader::Fabric => "fabric",
Loader::Quilt => "quilt",
Loader::Forge => "forge",
Loader::NeoForge => "neoforge",
}
}
pub fn parse(s: &str) -> Option<Self> {
Some(match s.to_ascii_lowercase().as_str() {
"vanilla" | "" => Loader::Vanilla,
"fabric" | "fabric-loader" => Loader::Fabric,
"quilt" | "quilt-loader" => Loader::Quilt,
"forge" => Loader::Forge,
"neoforge" | "neo-forge" => Loader::NeoForge,
_ => return None,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct ServerEntry {
pub name: String,
pub address: String,
pub port: u16,
/// Connect straight to the server when the game starts.
pub auto_join: bool,
/// Add the server to the in-game multiplayer list (servers.dat).
pub inject: bool,
}
impl Default for ServerEntry {
fn default() -> Self {
Self { name: String::new(), address: String::new(), port: 25565, auto_join: false, inject: true }
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct MemoryDefaults {
pub min_mb: u32,
pub max_mb: u32,
}
impl Default for MemoryDefaults {
fn default() -> Self {
Self { min_mb: 1024, max_mb: 4096 }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct InstanceSummary {
pub id: String,
pub name: String,
pub description: String,
pub icon_url: Option<String>,
pub banner_url: Option<String>,
pub logo_url: Option<String>,
pub mc_version: String,
pub loader: Loader,
pub loader_version: Option<String>,
/// Bumped by the panel whenever the instance changes; the launcher
/// re-syncs files when this differs from what it last installed.
pub revision: i64,
pub server: Option<ServerEntry>,
pub memory: MemoryDefaults,
pub jvm_args: String,
pub featured: bool,
/// Human description of where the instance came from ("Modrinth: Fabulously Optimized 6.2").
pub source_label: String,
pub file_count: u32,
pub total_size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct FileEntry {
/// Path relative to the instance game directory, always using `/`.
pub path: String,
/// Absolute URL, or a panel-relative URL starting with `/`.
pub url: String,
pub sha1: String,
pub size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct InstanceManifest {
pub instance: InstanceSummary,
pub files: Vec<FileEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct LauncherManifest {
pub api_version: u32,
pub panel_version: String,
pub branding: Branding,
pub auth: AuthConfig,
pub instances: Vec<InstanceSummary>,
/// Present when the request carried a valid panel token.
pub user: Option<PublicUser>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LaunchEvent {
pub instance_id: String,
pub kind: String,
#[serde(default)]
pub username: Option<String>,
}
// ---------------------------------------------------------------------------
// Leveling & Quests
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct UserLevelInfo {
pub uuid: String,
pub global_xp: i64,
#[serde(default)]
pub current_xp: i64,
pub global_level: i64,
#[serde(default)]
pub 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>,
#[serde(default)]
pub rank: Option<usize>,
#[serde(default)]
pub rewards: Vec<LevelReward>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct LevelReward {
pub id: i64,
pub level: i64,
pub reward_type: String,
pub reward_value: String,
pub description: String,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub server_id: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
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,
#[serde(default)]
pub quest_type: Option<String>,
#[serde(default)]
pub stat_type: Option<String>,
#[serde(default)]
pub active: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct UserQuest {
pub quest: Quest,
#[serde(default)]
pub progress: i64,
#[serde(default)]
pub current_count: i64,
pub completed: bool,
pub claimed: bool,
pub period_key: String,
#[serde(default)]
pub expires_at: Option<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"
#[serde(default)]
pub frame_type: Option<String>,
pub icon_item: String,
pub icon_bg: String,
pub icon_border: String,
pub xp_reward: i64,
pub secret: bool,
#[serde(default)]
pub stat_type: Option<String>,
#[serde(default)]
pub target_count: Option<i64>,
#[serde(default)]
pub unlocked: bool,
#[serde(default)]
pub unlocked_at: Option<String>,
}
// ---------------------------------------------------------------------------
// Economy
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct ServerEconomyBalance {
pub server_id: i64,
pub server_name: String,
pub balance: f64,
pub currency_symbol: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct EconomyTransaction {
pub id: i64,
pub server_id: i64,
#[serde(default)]
pub server_name: Option<String>,
pub from_uuid: String,
pub from_name: String,
pub to_uuid: String,
pub to_name: String,
pub amount: f64,
pub description: String,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct BaltopEntry {
pub rank: usize,
pub uuid: String,
pub username: String,
pub balance: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct MarketListing {
pub id: i64,
pub server_id: i64,
pub seller_uuid: String,
pub seller_name: String,
pub item_id: String,
pub item_name: String,
pub amount: i32,
pub price: f64,
pub created_at: String,
/// Set when a guild is selling; the sale is paid into its bank.
#[serde(default)]
pub seller_guild_tag: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct MemberProfile {
pub uuid: String,
pub username: String,
pub role: String,
pub status: String,
pub skin_url: Option<String>,
pub global_level: i64,
pub title: Option<String>,
pub playtime_secs: i64,
pub last_seen: Option<String>,
pub online: bool,
pub is_friend: bool,
/// `none`, `accepted`, `pending_outgoing` or `pending_incoming`.
#[serde(default)]
pub friendship_status: 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,
#[serde(default)]
pub liked_by_me: bool,
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,
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>,
pub level_info: UserLevelInfo,
pub achievements: Vec<Achievement>,
pub badges: Vec<String>,
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,
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// The UUID an offline-mode server assigns to `name`
/// (Java's `UUID.nameUUIDFromBytes("OfflinePlayer:" + name)`).
pub fn offline_uuid(name: &str) -> String {
use md5::{Digest, Md5};
let mut hash: [u8; 16] = Md5::digest(format!("OfflinePlayer:{name}").as_bytes()).into();
hash[6] = (hash[6] & 0x0f) | 0x30; // version 3
hash[8] = (hash[8] & 0x3f) | 0x80; // IETF variant
let h: String = hash.iter().map(|b| format!("{b:02x}")).collect();
format!("{}-{}-{}-{}-{}", &h[0..8], &h[8..12], &h[12..16], &h[16..20], &h[20..32])
}
/// Minecraft usernames: 3-16 chars of `[A-Za-z0-9_]`.
pub fn valid_username(name: &str) -> bool {
(3..=16).contains(&name.len()) && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn offline_uuid_matches_java() {
// Reference values from java.util.UUID.nameUUIDFromBytes.
assert_eq!(offline_uuid("Notch"), "b50ad385-829d-3141-a216-7e7d7539ba7f");
assert_eq!(offline_uuid("jeb_"), "a762f560-4fce-3236-812a-b80efff0b62b");
}
#[test]
fn usernames() {
assert!(valid_username("Steve_01"));
assert!(!valid_username("ab"));
assert!(!valid_username("has space"));
assert!(!valid_username("waytoolongusername1"));
}
#[test]
fn manifest_roundtrip_with_defaults() {
let m: LauncherManifest = serde_json::from_str("{}").unwrap();
assert_eq!(m.branding.name, "SCOPENET");
let loader: Loader = serde_json::from_str("\"neoforge\"").unwrap();
assert_eq!(loader, Loader::NeoForge);
}
}