From 602e7fd0f0a63eb9f5e0db003abcf28c565a0050 Mon Sep 17 00:00:00 2001 From: SCOPEDD Date: Wed, 30 Sep 2026 11:02:51 -0400 Subject: [PATCH] Integrate Vantage live map for guild claims [skip ci] --- docs/deployment.md | 6 + docs/live-map.md | 44 +++++ launcher/package-lock.json | 95 ++++++++++- launcher/package.json | 5 +- launcher/src-tauri/src/commands.rs | 53 +++++- launcher/src-tauri/src/lib.rs | 1 + launcher/src-tauri/src/state.rs | 4 + launcher/src/components/ChunkMap.svelte | 37 +++- launcher/src/components/VantageMap.svelte | 187 +++++++++++++++++++++ launcher/src/lib/mock.ts | 12 ++ launcher/src/lib/types.ts | 8 +- launcher/src/pages/Stats.svelte | 8 +- panel/server/src/db.rs | 3 + panel/server/src/routes/servers.rs | 22 ++- panel/web/src/components/ServerForm.svelte | 1 + panel/web/src/lib/types.ts | 3 +- panel/web/src/pages/ServerDetail.svelte | 2 +- 17 files changed, 470 insertions(+), 21 deletions(-) create mode 100644 docs/live-map.md create mode 100644 launcher/src/components/VantageMap.svelte diff --git a/docs/deployment.md b/docs/deployment.md index 7a85e49..f9183b3 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -23,6 +23,12 @@ The panel listens on `PANEL_PORT` (default `8080`). Docker checks `docker compose ps` before directing players to it. If `ADMIN_PASSWORD` is blank on first start, the generated password appears in the panel logs. +To show a Vantage world map in the launcher, deploy Vantage next to each game +server and configure its manifest URL in the panel. See +[Live map setup](live-map.md). Vantage needs access to the game world's saved +files, so it runs alongside the game server rather than inside the panel +container. + The named `panel-data` volume holds the database, uploads, hosted launcher downloads, and the generated JWT signing key. Preserve that volume when upgrading. To back it up, stop the panel, archive the volume with a temporary diff --git a/docs/live-map.md b/docs/live-map.md new file mode 100644 index 0000000..59198e8 --- /dev/null +++ b/docs/live-map.md @@ -0,0 +1,44 @@ +# Vantage live map + +SCOPENET can display a [Vantage](https://github.com/thoughts-on-things/vantage-mc) +world in the launcher's **Guilds & Claims → Territory** view. The map shows +live terrain, optional player positions, SCOPENET guild names, and the claim +outline for each nearby chunk. Clicking terrain selects the chunk; the Claim +and Unclaim buttons use the existing authenticated SCOPENET API. Claim +protection on the game server is unchanged. + +## Set up the world map + +1. Install a Vantage release on the machine that can read the Minecraft world + files. Give it read access to the save, and a separate writable cache. +2. Start a live render for that world. For example: + + ```sh + vantage live /srv/minecraft/world --out /var/cache/vantage/world --host 127.0.0.1 --port 8268 + ``` + +3. Put Vantage behind an HTTPS reverse proxy, or have your portal proxy its + map files. The launcher's **Vantage live map manifest URL** is the complete + `manifest.json` URL, such as `https://maps.example.com/manifest.json`. + A pre-rendered multi-dimension world can use `world.json` instead. In the + panel, open **Servers → your server → Edit** and save that URL. +4. Link the game server to the correct launcher instance. Open that instance's + Guilds & Claims page in the launcher. The selected game server controls both + the Vantage world and the SCOPENET claims shown on it. + +The native launcher streams map assets through its HTTP client so the map host +does not need browser CORS headers. Browser-only development previews require +CORS on the map host. The URL is included in the public game-server summary; +use a map you intend players to see. Keep Vantage `server` bearer credentials +out of this URL. For private worlds, put Vantage behind a session-gated proxy +that serves the manifest and tile files with the player's existing session. +Vantage's [server guide](https://github.com/thoughts-on-things/vantage-mc/blob/main/docs/server.md) +describes the sidecar and its authorization boundary. + +Live terrain comes from Vantage's read-only view of saved world files. Claims +and guild names come from SCOPENET's claim database. If the map cannot be +loaded, the launcher falls back to the chunk grid. The map does not run inside +Minecraft; `/guild map` is still the Paper plugin's nearby chat radar. + +Vantage's web package is MIT licensed. See its +[license](https://github.com/thoughts-on-things/vantage-mc/blob/main/LICENSE). diff --git a/launcher/package-lock.json b/launcher/package-lock.json index 0f0cfda..60789dc 100644 --- a/launcher/package-lock.json +++ b/launcher/package-lock.json @@ -15,19 +15,29 @@ "@fontsource-variable/space-grotesk": "^5.3.0", "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-dialog": "^2.8.0", - "@tauri-apps/plugin-opener": "^2.6.0" + "@tauri-apps/plugin-opener": "^2.6.0", + "@thoughts-on-things/vantage-mc": "^0.15.1", + "three": "^0.180.0" }, "devDependencies": { "@lucide/svelte": "^1.48.0", "@sveltejs/vite-plugin-svelte": "^7.3.1", "@tauri-apps/cli": "^2.12.0", "@tsconfig/svelte": "^5.0.8", + "@types/three": "^0.180.0", "svelte": "^5.57.1", "svelte-check": "^4.7.6", "typescript": "~5.9.0", "vite": "^8.3.1" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@fontsource-variable/inter": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", @@ -690,6 +700,25 @@ "@tauri-apps/api": "^2.12.0" } }, + "node_modules/@thoughts-on-things/vantage-mc": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@thoughts-on-things/vantage-mc/-/vantage-mc-0.15.1.tgz", + "integrity": "sha512-muhg9950CgOa8keoCUV+utDHUKhkckwWGVn5O3HFMrgr7OrPo3VpEsBYVSUVL/RjmQDiqEaC19FPLGMTH97jpg==", + "license": "MIT", + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18", + "three": ">=0.160" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/@tsconfig/svelte": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", @@ -697,6 +726,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -704,6 +740,43 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.180.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz", + "integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": "*", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.22.0" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webgpu/types": { + "version": "0.1.74", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz", + "integrity": "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -833,6 +906,13 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1136,6 +1216,13 @@ "@jridgewell/sourcemap-codec": "^1.6.0" } }, + "node_modules/meshoptimizer": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", + "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==", + "dev": true, + "license": "MIT" + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -1361,6 +1448,12 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/three": { + "version": "0.180.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", + "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", diff --git a/launcher/package.json b/launcher/package.json index b23e6c6..0d19fee 100644 --- a/launcher/package.json +++ b/launcher/package.json @@ -18,13 +18,16 @@ "@fontsource-variable/space-grotesk": "^5.3.0", "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-dialog": "^2.8.0", - "@tauri-apps/plugin-opener": "^2.6.0" + "@tauri-apps/plugin-opener": "^2.6.0", + "@thoughts-on-things/vantage-mc": "^0.15.1", + "three": "^0.180.0" }, "devDependencies": { "@lucide/svelte": "^1.48.0", "@sveltejs/vite-plugin-svelte": "^7.3.1", "@tauri-apps/cli": "^2.12.0", "@tsconfig/svelte": "^5.0.8", + "@types/three": "^0.180.0", "svelte": "^5.57.1", "svelte-check": "^4.7.6", "typescript": "~5.9.0", diff --git a/launcher/src-tauri/src/commands.rs b/launcher/src-tauri/src/commands.rs index 0b848c9..050aaef 100644 --- a/launcher/src-tauri/src/commands.rs +++ b/launcher/src-tauri/src/commands.rs @@ -538,7 +538,58 @@ pub async fn get_public_servers(state: State<'_, AppState>) -> Res, + etag: Option, +} + +/// Native transport for Vantage assets. Browser WebViews cannot fetch a +/// cross-origin Vantage live server, so the launcher uses its HTTP client. +/// Only paths under a map URL supplied by the panel can be fetched. +#[tauri::command] +pub async fn fetch_vantage_resource( + state: State<'_, AppState>, server_id: i64, url: String, etag: Option, +) -> Res { + use base64::Engine; + let configured = state.map_urls.lock().unwrap().get(&server_id).cloned().ok_or("map not configured for this server")?; + let base = reqwest::Url::parse(&configured).map_err(err)?; + let target = reqwest::Url::parse(&url).map_err(err)?; + let root = base.join(".").map_err(err)?; + if !matches!(target.scheme(), "http" | "https") || target.origin() != base.origin() + || !target.path().starts_with(root.path()) || target.username() != "" + || target.password().is_some() || target.fragment().is_some() { + return Err("map asset is outside the configured Vantage world".into()); + } + if target.as_str().len() > 2048 { return Err("map asset URL is too long".into()); } + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(30)) + .build().map_err(err)?; + let mut request = client.get(target); + if let Some(etag) = etag.filter(|e| e.len() < 256 && !e.contains('\n') && !e.contains('\r')) { + request = request.header(reqwest::header::IF_NONE_MATCH, etag); + } + let response = request.send().await.map_err(err)?; + let status = response.status().as_u16(); + let etag = response.headers().get(reqwest::header::ETAG).and_then(|v| v.to_str().ok()).map(str::to_owned); + if status == 304 { return Ok(MapResource { status, data: None, etag }); } + if !response.status().is_success() { return Ok(MapResource { status, data: None, etag }); } + if response.content_length().is_some_and(|n| n > 64 * 1024 * 1024) { + return Err("Vantage asset exceeds 64 MiB".into()); + } + let bytes = response.bytes().await.map_err(err)?; + if bytes.len() > 64 * 1024 * 1024 { return Err("Vantage asset exceeds 64 MiB".into()); } + Ok(MapResource { status, data: Some(base64::engine::general_purpose::STANDARD.encode(bytes)), etag }) } // --------------------------------------------------------------------------- diff --git a/launcher/src-tauri/src/lib.rs b/launcher/src-tauri/src/lib.rs index c2df5cc..fd52db5 100644 --- a/launcher/src-tauri/src/lib.rs +++ b/launcher/src-tauri/src/lib.rs @@ -85,6 +85,7 @@ pub fn run() { commands::get_player_stats, commands::get_leaderboard, commands::get_public_servers, + commands::fetch_vantage_resource, commands::get_my_level, commands::get_my_quests, commands::claim_quest, diff --git a/launcher/src-tauri/src/state.rs b/launcher/src-tauri/src/state.rs index 9e2729c..46bd7c0 100644 --- a/launcher/src-tauri/src/state.rs +++ b/launcher/src-tauri/src/state.rs @@ -4,6 +4,7 @@ use crate::settings::Settings; use scopenet_core::Layout; use scopenet_shared::LauncherManifest; use std::path::PathBuf; +use std::collections::HashMap; use std::sync::{Mutex, RwLock}; /// Values baked in at build time by CI (see `.github/workflows/release.yml`). @@ -34,6 +35,8 @@ pub struct AppState { pub accounts: RwLock, pub secrets: Secrets, pub manifest: RwLock>, + /// Vantage URLs received from the panel, keyed by linked game server. + pub map_urls: Mutex>, /// Install/launch pipeline in progress (abortable). pub task: Mutex>, pub game: Mutex>, @@ -52,6 +55,7 @@ impl AppState { settings: RwLock::new(settings), accounts: RwLock::new(accounts), manifest: RwLock::new(manifest), + map_urls: Mutex::new(HashMap::new()), task: Mutex::new(None), game: Mutex::new(Vec::new()), data_dir, diff --git a/launcher/src/components/ChunkMap.svelte b/launcher/src/components/ChunkMap.svelte index 00223f3..23942e6 100644 --- a/launcher/src/components/ChunkMap.svelte +++ b/launcher/src/components/ChunkMap.svelte @@ -7,6 +7,7 @@ import { invoke } from '../lib/tauri'; import { toast } from '../lib/store.svelte'; import type { GuildClaim } from '../lib/types'; + import VantageMap from './VantageMap.svelte'; let { instanceId, @@ -22,7 +23,7 @@ onunclaim?: (claimId: number) => void; } = $props(); - let servers = $state>([]); + let servers = $state>([]); let serverId = $state(null); let dimension = $state<'minecraft:overworld' | 'minecraft:the_nether' | 'minecraft:the_end'>('minecraft:overworld'); let centerX = $state(0); @@ -31,8 +32,13 @@ let claims = $state([]); let loading = $state(false); let claiming = $state(false); + let claimLoad = 0; let hoveredChunk = $state<{ x: number; z: number } | null>(null); let selectedChunk = $state<{ x: number; z: number } | null>(null); + let mapDimensions = $state([]); + let mapFailed = $state(false); + const mapUrl = $derived(servers.find((s) => s.id === serverId)?.map_url || ''); + $effect(() => { mapUrl; mapFailed = false; mapDimensions = []; }); const dimensions = [ { id: 'minecraft:overworld', label: 'Overworld', icon: Globe }, @@ -41,20 +47,23 @@ ] as const; async function loadClaims() { + const request = ++claimLoad; if (!instanceId || !serverId) { claims = []; return; } loading = true; + claims = []; try { - claims = await invoke('get_guild_claims', { + const next = await invoke('get_guild_claims', { instanceId, serverId, centerX, centerZ, dimension }); + if (request === claimLoad) claims = next; } catch (e: any) { - toast(e?.message ?? 'Failed to load territory claims', 'error'); + if (request === claimLoad) toast(e?.message ?? 'Failed to load territory claims', 'error'); } finally { - loading = false; + if (request === claimLoad) loading = false; } } @@ -149,7 +158,8 @@ {/each} @@ -508,12 +506,12 @@ Amount {#each transactions.slice(0, 15) as tx (tx.id)} - {@const isSender = tx.sender_uuid === me?.uuid} + {@const isSender = tx.from_uuid === me?.uuid}
{#if isSender} Sent{:else} Received{/if} - {tx.description || tx.tx_type} + {tx.description || `${tx.from_name} → ${tx.to_name}`} {new Date(tx.created_at).toLocaleString()} {isSender ? '-' : '+'}${Math.abs(tx.amount).toFixed(2)} diff --git a/panel/server/src/db.rs b/panel/server/src/db.rs index 7722132..b8f8905 100644 --- a/panel/server/src/db.rs +++ b/panel/server/src/db.rs @@ -506,6 +506,9 @@ const MIGRATIONS: &[&str] = &[ PRIMARY KEY (post_id, user_uuid) ); "#, + r#" + ALTER TABLE game_servers ADD COLUMN map_url TEXT NOT NULL DEFAULT ''; + "#, ]; pub async fn connect(data_dir: &Path) -> Result { diff --git a/panel/server/src/routes/servers.rs b/panel/server/src/routes/servers.rs index 5679cbb..37a0708 100644 --- a/panel/server/src/routes/servers.rs +++ b/panel/server/src/routes/servers.rs @@ -61,6 +61,7 @@ fn clip(s: &str, max: usize) -> String { #[derive(Debug, Clone, sqlx::FromRow, Serialize)] pub struct ServerRow { pub instance_id: String, + pub map_url: String, pub id: i64, pub name: String, #[serde(skip)] @@ -832,6 +833,8 @@ pub struct ServerInput { allowed_groups: Vec, #[serde(default)] require_launcher: bool, + #[serde(default)] + map_url: String, } fn default_access() -> String { @@ -849,6 +852,18 @@ impl ServerInput { if self.access == "groups" && self.allowed_groups.is_empty() { return Err(AppError::bad_request("pick at least one group")); } + if !self.map_url.trim().is_empty() { + let url = reqwest::Url::parse(self.map_url.trim()).map_err(|_| AppError::bad_request("map URL must be an absolute URL"))?; + if url.scheme() != "https" && !(url.scheme() == "http" && matches!(url.host_str(), Some("localhost" | "127.0.0.1"))) { + return Err(AppError::bad_request("map URL must use HTTPS (or localhost for development)")); + } + if url.username() != "" || url.password().is_some() || url.fragment().is_some() || url.query().is_some() { + return Err(AppError::bad_request("map URL cannot contain credentials, query parameters, or a fragment")); + } + if !matches!(url.path().rsplit('/').next(), Some("manifest.json" | "world.json")) { + return Err(AppError::bad_request("map URL must end with manifest.json or world.json")); + } + } Ok(()) } } @@ -865,7 +880,7 @@ pub async fn create(_: AdminUser, State(state): State, Json(input): Js input.validate()?; let token = new_token(); let id: i64 = sqlx::query_scalar( - "INSERT INTO game_servers (name, token_hash, token_hint, access, allowed_groups, require_launcher, created_at, instance_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", + "INSERT INTO game_servers (name, token_hash, token_hint, access, allowed_groups, require_launcher, created_at, instance_id, map_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", ) .bind(input.name.trim()) .bind(hash_token(&token)) @@ -875,6 +890,7 @@ pub async fn create(_: AdminUser, State(state): State, Json(input): Js .bind(input.require_launcher) .bind(now()) .bind(&input.instance_id) + .bind(input.map_url.trim()) .fetch_one(&state.db) .await?; let server = get_server(&state, id).await?; @@ -889,12 +905,13 @@ pub async fn update( ) -> AppResult> { input.validate()?; get_server(&state, id).await?; - sqlx::query("UPDATE game_servers SET name = ?, access = ?, allowed_groups = ?, require_launcher = ?, instance_id = ? WHERE id = ?") + sqlx::query("UPDATE game_servers SET name = ?, access = ?, allowed_groups = ?, require_launcher = ?, instance_id = ?, map_url = ? WHERE id = ?") .bind(input.name.trim()) .bind(&input.access) .bind(serde_json::to_string(&input.allowed_groups).unwrap_or_else(|_| "[]".into())) .bind(input.require_launcher) .bind(&input.instance_id) + .bind(input.map_url.trim()) .bind(id) .execute(&state.db) .await?; @@ -1078,6 +1095,7 @@ pub async fn public_servers(State(state): State) -> AppResultName +
Who can join
diff --git a/panel/web/src/lib/types.ts b/panel/web/src/lib/types.ts index 13b4755..8c0b033 100644 --- a/panel/web/src/lib/types.ts +++ b/panel/web/src/lib/types.ts @@ -92,6 +92,7 @@ export interface AuthServerInfo { public_url: string; yggdrasil_url: string; pub export interface GameServer { instance_id: string; + map_url: string; id: number; name: string; token_hint: string; @@ -140,7 +141,7 @@ export interface ServerDetail { export interface UserActivity { servers: PlayerStats[]; events: ServerEvent[]; online_on: { id: number; name: string }[] } -export type ServerDraft = { instance_id?: string; name: string; access: 'all' | 'members' | 'groups'; allowed_groups: string[]; require_launcher: boolean }; +export type ServerDraft = { instance_id?: string; name: string; map_url?: string; access: 'all' | 'members' | 'groups'; allowed_groups: string[]; require_launcher: boolean }; export interface HostedDownload { platform: 'windows' | 'mac' | 'linux'; diff --git a/panel/web/src/pages/ServerDetail.svelte b/panel/web/src/pages/ServerDetail.svelte index bb0be2d..d82d630 100644 --- a/panel/web/src/pages/ServerDetail.svelte +++ b/panel/web/src/pages/ServerDetail.svelte @@ -78,7 +78,7 @@ function openEdit() { if (!s) return; - draft = { instance_id: s.instance_id, name: s.name, access: s.access, allowed_groups: [...s.allowed_groups], require_launcher: s.require_launcher }; + draft = { instance_id: s.instance_id, name: s.name, map_url: s.map_url, access: s.access, allowed_groups: [...s.allowed_groups], require_launcher: s.require_launcher }; editOpen = true; } async function saveEdit() {