diff --git a/README.md b/README.md index c9193f9..8db3306 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ docker-compose.yml Panel deployment - [Admin guide](docs/admin-guide.md) — deploying, HTTPS, modpacks, players, backups - [Official server setup](docs/server-integration.md) — private authentication, supported versions, activity and UUID protection - [The SCOPENET Map](docs/map.md) — the built-in world map: terrain, guild land, shops and players +- [Discord, live embeds and email](docs/discord-and-email.md) — customisable announcements, self-updating status boards and emails to players - [Rewards, limits and permission nodes](docs/rewards-and-limits.md) — what quests can grant, and every setting you can change - [Architecture](docs/architecture.md) — how the pieces fit, API reference - [Development](docs/development.md) — running everything locally, tests diff --git a/docs/discord-and-email.md b/docs/discord-and-email.md new file mode 100644 index 0000000..847a6ed --- /dev/null +++ b/docs/discord-and-email.md @@ -0,0 +1,39 @@ +# Discord messages, live embeds and emails + +## Announcements (Admin panel → Discord → Announcements) + +Three messages are posted by the panel's webhook (Settings → Discord → webhook): **Achievement unlocked**, **New player** and **New guild**. Each one is fully editable, with a live preview built the way Discord will show it: + +- Message text above the box (mentions such as `<@&roleid>` work there), title and link, description, colour, a small picture on the right, a big picture at the bottom, author and footer (with icons), the time, and any number of side-by-side or full-width fields. +- The sender's name and picture. +- **Placeholders** such as `{player}`, `{avatar}`, `{achievement}`, `{achievement_description}`, `{xp}`, `{level}`, `{rank_title}`, `{guild_line}` (achievements), `{members_total}` (new players), `{guild}`, `{leader}`, `{members}`, `{claims}` (guilds) and `{server_name}`, `{time}` everywhere. Click a placeholder to insert it where you are typing. Unknown ones stay as typed so mistakes are easy to spot. +- Player names are cleaned so they can't ping anyone or break the formatting. + +Switch each message on or off, and press **Save & send a sample** to see it in your channel. + +## Live embeds (Discord → Live embeds) + +Four boards that the panel posts once and then **edits in place**, so the channel never fills up: + +| Embed | Shows | Rows are set by | +|---|---|---| +| **Server status** | Every server: online, players, TPS, version | the line template | +| **Active guilds** | Top guilds with level, members, land and leader | rows shown | +| **Player leaderboard** | Top players by playtime, level, kills or blocks | ranked by, rows shown | +| **Richest players** | Top balances of one server economy | economy of, rows shown | + +Every embed has the same editor as the announcements, plus a **line for each row** (for example `{rank}. **{player}** — {value}`) that fills `{rows}` in the description, an update interval (1 minute to 1 hour), and an optional **own webhook** so each board can live in its own channel. If someone deletes the message, the next update posts a fresh one; **Post as new message** forces it. + +## Emails (Admin panel → Emails) + +Set up the sender and Resend key under **Settings → Connections** first. Then: + +1. Write a **template**: subject and message with placeholders (`{player}`, `{level}`, `{rank_title}`, `{guild}`, `{brand}`, `{panel_url}`…). Simple formatting turns into a branded HTML email in your launcher colours: `# Heading`, `**bold**`, `- list`, `[link](https://…)`, `[button: Label](https://…)`. +2. Check the live **preview**, and **send a test** to yourself. +3. **Send** to everyone with an email, a group, or chosen players. The panel shows how many will receive it first, sends them one by one in the background, and keeps a log with failures and their reasons. + +Every email has an unsubscribe link; players who use it are skipped by later emails. Mark a message as an **account notice** to reach everyone regardless, without the unsubscribe link: use that only for things about their account. + +## Live map on the landing page + +In **Landing page**, add a **Live map** section. Choose the server (or leave it at the first server with a map), the height, and what visitors may see: guild land and places are on by default, **live player positions are off** (they let strangers find players). Visitors can pan and zoom the same map your players see in the launcher; removing the section closes the public map again. diff --git a/docs/map.md b/docs/map.md index 27bc0c4..224d446 100644 --- a/docs/map.md +++ b/docs/map.md @@ -27,6 +27,10 @@ Players with a linked server get a **Live Map** button on the Home screen. Pan b **Servers → your server** shows a map card with the number of tiles, how much space they use, when the server last sent something and the live map itself. **Redraw world** deletes the stored tiles; the server notices and draws everything again (useful after a world reset or a renderer update). +## On your public landing page + +Add a **Live map** section in the landing page builder to show the map to visitors, with only the layers you allow. See [discord-and-email.md](discord-and-email.md#live-map-on-the-landing-page). + ## How it works - The game server reads its region files (`region/*.mca`), works out the top block of every column, and writes 256 × 256 pixel tiles at one pixel per block. Zoomed-out levels are built from them, keeping real map colours so coastlines and roads stay sharp. diff --git a/panel/server/src/routes/mod.rs b/panel/server/src/routes/mod.rs index 5d2eadf..7972340 100644 --- a/panel/server/src/routes/mod.rs +++ b/panel/server/src/routes/mod.rs @@ -65,6 +65,8 @@ pub fn api(state: &AppState) -> Router { .route("/guilds/{id}/invites", post(map::send_invite)) .route("/leaderboard", get(servers::global_leaderboard)) .route("/landing", get(landing::public_landing)) + .route("/public/map/{id}", get(worldmap::public_info)) + .route("/public/map/{id}/overlay", get(worldmap::public_overlay)) .route("/email/unsubscribe", get(emails::unsubscribe)) .route("/launcher/authlib-injector.json", get(account::authlib_index)) .route("/launcher/authlib-injector.jar", get(account::authlib_jar)) diff --git a/panel/server/src/routes/worldmap.rs b/panel/server/src/routes/worldmap.rs index 99d064c..f4b2a23 100644 --- a/panel/server/src/routes/worldmap.rs +++ b/panel/server/src/routes/worldmap.rs @@ -171,3 +171,49 @@ pub async fn tile(State(state): State, Path((id, dim, z, x, file)): Pa } resp } + +// --------------------------------------------------------------------------- +// Public map (landing page) +// --------------------------------------------------------------------------- + +/// The landing page's map section decides what the public may see. Without an enabled map section, nothing is public. +async fn landing_map(state: &AppState, requested: i64) -> AppResult<(i64, Value)> { + let cfg = crate::routes::landing::get_config(state).await?; + let block = cfg.blocks.iter().find(|b| b.block_type == "map" && b.enabled).ok_or_else(|| AppError::not_found("no public map"))?; + let wanted = block.options.get("server_id").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))).unwrap_or(0); + let id: i64 = if wanted > 0 { + wanted + } else { + sqlx::query_scalar("SELECT COALESCE(MIN(id), 0) FROM game_servers WHERE live_map_enabled = 1").fetch_one(&state.db).await? + }; + if id == 0 || (requested != 0 && requested != id) { + return Err(AppError::not_found("no public map")); + } + let server = get_server(state, id).await?; + if !server.map_enabled { + return Err(AppError::not_found("no public map")); + } + let flag = |key: &str, default: bool| block.options.get(key).and_then(|v| v.as_bool().or_else(|| v.as_str().map(|s| s == "true"))).unwrap_or(default); + Ok((id, json!({ "claims": flag("show_claims", true), "pins": flag("show_pins", true), "players": flag("show_players", false) }))) +} + +/// `GET /api/v1/public/map/{server}`: tile key and dimensions for the landing page map (0 = the server the page chose). +pub async fn public_info(State(state): State, Path(requested): Path) -> AppResult> { + let (id, layers) = landing_map(&state, requested).await?; + let mut v = info(&state, id, true).await; + v["server_id"] = json!(id); + v["layers"] = layers; + Ok(Json(v)) +} + +pub async fn public_overlay(State(state): State, Path(requested): Path) -> AppResult> { + let (id, layers) = landing_map(&state, requested).await?; + let overlay = state.worldmap.overlay(id); + let on = |k: &str| layers[k].as_bool().unwrap_or(false); + Ok(Json(json!({ + "players": if on("players") { json!(state.worldmap.live_players(id)) } else { json!([]) }, + "claims": if on("claims") { overlay.get("claims").cloned().unwrap_or(json!([])) } else { json!([]) }, + "pins": if on("pins") { overlay.get("pins").cloned().unwrap_or(json!([])) } else { json!([]) }, + "updated": state.worldmap.stats(id).last_overlay_at, + }))) +} diff --git a/panel/server/tests/public_map.rs b/panel/server/tests/public_map.rs new file mode 100644 index 0000000..4fdd9f8 --- /dev/null +++ b/panel/server/tests/public_map.rs @@ -0,0 +1,52 @@ +mod common; +use common::*; + +fn tile(zoom: u8, x: i32, y: i32) -> Vec { + let png = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]; + let mut v = vec![zoom]; + v.extend(x.to_be_bytes()); + v.extend(y.to_be_bytes()); + v.extend((png.len() as u32).to_be_bytes()); + v.extend(png); + v +} + +#[tokio::test] +async fn the_landing_page_decides_what_the_public_sees_of_the_map() { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + let (_, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "SMP", "instance_id": "smp"}))).await; + let key = srv["token"].as_str().unwrap().to_string(); + assert_eq!(t.call_raw("POST", "/api/server/v1/map/tiles?dim=minecraft:overworld", Some(&key), tile(0, 0, 0)).await.0, StatusCode::OK); + t.call("POST", "/api/server/v1/map/players", Some(&key), Some(json!({"players": [{"uuid": "11111111-1111-1111-1111-111111111111", "name": "Steve", "dimension": "minecraft:overworld", "x": 1.0, "y": 64.0, "z": 2.0, "yaw": 0.0}]}))).await; + t.call("POST", "/api/server/v1/map/overlay", Some(&key), Some(json!({"claims": [{"id": "c"}], "pins": [{"id": "p"}]}))).await; + + // No map section on the landing page: nothing is public. + assert_eq!(t.call("GET", "/api/v1/public/map/0", None, None).await.0, StatusCode::NOT_FOUND); + + let landing = |opts: Value| json!({"blocks": [{"id": "m1", "type": "map", "enabled": true, "options": opts}]}); + assert_eq!(t.call("PUT", "/api/admin/landing", Some(&admin), Some(landing(json!({"show_players": false})))).await.0, StatusCode::OK); + let (s, info) = t.call("GET", "/api/v1/public/map/0", None, None).await; + assert_eq!(s, StatusCode::OK, "{info}"); + assert_eq!(info["ready"], true); + assert_eq!(info["layers"]["players"], false); + assert_eq!(info["layers"]["claims"], true); + // The tile key from the public info loads tiles without signing in. + let token = info["token"].as_str().unwrap(); + let (s, png) = t.fetch(&format!("/api/map/{}/overworld/0/0/0.png?t={token}", info["server_id"])).await; + assert_eq!((s, png.len()), (StatusCode::OK, 12)); + + // Players stay hidden unless the admin turns them on. + let (_, o) = t.call("GET", "/api/v1/public/map/0/overlay", None, None).await; + assert_eq!(o["players"].as_array().unwrap().len(), 0); + assert_eq!(o["claims"].as_array().unwrap().len(), 1); + t.call("PUT", "/api/admin/landing", Some(&admin), Some(landing(json!({"show_players": true, "show_pins": false})))).await; + let (_, o) = t.call("GET", "/api/v1/public/map/0/overlay", None, None).await; + assert_eq!(o["players"][0]["name"], "Steve"); + assert_eq!(o["pins"].as_array().unwrap().len(), 0); + + // Another server's map isn't exposed, and turning the section off closes it. + assert_eq!(t.call("GET", "/api/v1/public/map/999", None, None).await.0, StatusCode::NOT_FOUND); + t.call("PUT", "/api/admin/landing", Some(&admin), Some(json!({"blocks": [{"id": "m1", "type": "map", "enabled": false, "options": {}}]}))).await; + assert_eq!(t.call("GET", "/api/v1/public/map/0", None, None).await.0, StatusCode::NOT_FOUND); +} diff --git a/panel/web/src/components/LandingMap.svelte b/panel/web/src/components/LandingMap.svelte new file mode 100644 index 0000000..7534f1a --- /dev/null +++ b/panel/web/src/components/LandingMap.svelte @@ -0,0 +1,32 @@ + + +
+ +
+ + diff --git a/panel/web/src/lib/landingSchema.ts b/panel/web/src/lib/landingSchema.ts index 26dd9f1..b691ad5 100644 --- a/panel/web/src/lib/landingSchema.ts +++ b/panel/web/src/lib/landingSchema.ts @@ -44,6 +44,7 @@ export const BLOCK_SCHEMAS: Record = { divider: { type: 'divider', label: 'Divider', description: 'Space or rule between sections', group: 'Layout', defaults: { style: 'line', height: 48 }, fields: [choice('style', 'Style', ['line', 'space', 'glow']), field('height', 'Height', 'number')] }, testimonials: { type: 'testimonials', label: 'Testimonials', description: 'Player quotes and reviews', group: 'Content', defaults: { columns: '3', items: [] }, fields: [choice('columns', 'Columns', ['2', '3']), list('items', 'Quotes', [field('quote', 'Quote', 'textarea'), field('name', 'Player name'), field('role', 'Role')], { quote: '', name: '', role: '' })] }, video: { type: 'video', label: 'Video', description: 'Hosted video or YouTube embed', group: 'Media', defaults: { url: '', caption: '', poster: '' }, fields: [field('url', 'Video or YouTube URL', 'media'), field('poster', 'Poster image', 'media'), field('caption', 'Caption')] }, + map: { type: 'map', label: 'Live map', description: 'The SCOPENET Map of a server: terrain, guild land and shops', group: 'Network', defaults: { server_id: 0, height: 560, show_claims: true, show_pins: true, show_players: false }, fields: [field('server_id', 'Server id (0 = the first server with a map)', 'number'), field('height', 'Height (px)', 'number'), field('show_claims', 'Show guild land', 'toggle'), field('show_pins', 'Show spawn, warps, markets and shops', 'toggle'), field('show_players', 'Show players online (live positions!)', 'toggle')] }, buttons: { type: 'buttons', label: 'Button group', description: 'Custom links and action buttons', group: 'Conversion', defaults: { align: 'center', items: [] }, fields: [choice('align', 'Alignment', ['left', 'center']), list('items', 'Buttons', [field('label', 'Label'), field('url', 'URL', 'url'), choice('style', 'Style', ['primary', 'secondary'])], { label: 'Visit', url: '', style: 'primary' })] }, }; diff --git a/panel/web/src/lib/types.ts b/panel/web/src/lib/types.ts index 5a4541a..2dfe7c3 100644 --- a/panel/web/src/lib/types.ts +++ b/panel/web/src/lib/types.ts @@ -172,7 +172,7 @@ export interface FaqItem { answer: string; } -export type BlockType = 'hero' | 'download' | 'servers' | 'leaderboard' | 'stats' | 'instances' | 'news' | 'faq' | 'socials' | 'text' | 'image' | 'split' | 'features' | 'gallery' | 'cta' | 'divider' | 'testimonials' | 'video' | 'buttons'; +export type BlockType = 'hero' | 'download' | 'servers' | 'leaderboard' | 'stats' | 'instances' | 'news' | 'faq' | 'socials' | 'text' | 'image' | 'split' | 'features' | 'gallery' | 'cta' | 'divider' | 'testimonials' | 'video' | 'buttons' | 'map'; export interface LandingBlock { id: string; diff --git a/panel/web/src/pages/PublicLanding.svelte b/panel/web/src/pages/PublicLanding.svelte index 540a37d..5236309 100644 --- a/panel/web/src/pages/PublicLanding.svelte +++ b/panel/web/src/pages/PublicLanding.svelte @@ -6,6 +6,7 @@ } from '@lucide/svelte'; import { get, formatBytes } from '../lib/api'; import { session } from '../lib/session.svelte'; + import LandingMap from '../components/LandingMap.svelte'; import type { LandingConfig, HostedDownload, BlockType, FaqItem } from '../lib/types'; let config = $state(null); @@ -724,6 +725,11 @@ {#if block.options?.caption}

{block.options.caption}

{/if} + {:else if block.type === 'map'} +

{block.title}

{block.subtitle}

+ +
+ {:else if block.type === 'buttons'}

{block.title}

{block.subtitle}

{#each block.options?.items ?? [] as item}{#if item.label && item.url}{item.label}{/if}{/each} diff --git a/shared/map/MapViewer.svelte b/shared/map/MapViewer.svelte index 90d3e29..0a41bd3 100644 --- a/shared/map/MapViewer.svelte +++ b/shared/map/MapViewer.svelte @@ -9,7 +9,7 @@ // click on a player does through the `player` snippet. let { info = null, overlay = null, tileUrl, avatarUrl, selfUuid = null, error = '', initialDimension = '', - player, toolbar, onplayer + player, toolbar, onplayer, available = { claims: true, pins: true, players: true } }: { info: MapInfo | null; overlay: MapOverlay | null; @@ -23,6 +23,8 @@ /** Extra buttons for the top bar. */ toolbar?: Snippet; onplayer?: (p: LivePlayer | null) => void; + /** Which layers exist for this viewer (the public map may hide some). */ + available?: MapLayers; } = $props(); let canvas: HTMLCanvasElement; @@ -97,23 +99,23 @@ {dims[0].label} {/if}
- - {/if} + {#if available.pins} - {/if} + {#if available.players} + {/if}
{#if toolbar}{@render toolbar()}{/if}
- + {/if} {#if listOpen && everyone.length}
    {#each everyone as p (p.uuid)}