Integrate Vantage live map for guild claims [skip ci]

This commit is contained in:
SCOPEDD committed 2026-09-30 11:02:51 -04:00
1 parent 52de8c3031
commit 602e7fd0f0
17 files changed
+470 -21

No files matched your search

+6
View File
@@ -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 `docker compose ps` before directing players to it. If `ADMIN_PASSWORD` is
blank on first start, the generated password appears in the panel logs. 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 The named `panel-data` volume holds the database, uploads, hosted launcher
downloads, and the generated JWT signing key. Preserve that volume when 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 upgrading. To back it up, stop the panel, archive the volume with a temporary
+44
View File
@@ -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).
+94 -1
View File
@@ -15,19 +15,29 @@
"@fontsource-variable/space-grotesk": "^5.3.0", "@fontsource-variable/space-grotesk": "^5.3.0",
"@tauri-apps/api": "^2.12.0", "@tauri-apps/api": "^2.12.0",
"@tauri-apps/plugin-dialog": "^2.8.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": { "devDependencies": {
"@lucide/svelte": "^1.48.0", "@lucide/svelte": "^1.48.0",
"@sveltejs/vite-plugin-svelte": "^7.3.1", "@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tauri-apps/cli": "^2.12.0", "@tauri-apps/cli": "^2.12.0",
"@tsconfig/svelte": "^5.0.8", "@tsconfig/svelte": "^5.0.8",
"@types/three": "^0.180.0",
"svelte": "^5.57.1", "svelte": "^5.57.1",
"svelte-check": "^4.7.6", "svelte-check": "^4.7.6",
"typescript": "~5.9.0", "typescript": "~5.9.0",
"vite": "^8.3.1" "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": { "node_modules/@fontsource-variable/inter": {
"version": "5.3.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
@@ -690,6 +700,25 @@
"@tauri-apps/api": "^2.12.0" "@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": { "node_modules/@tsconfig/svelte": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz",
@@ -697,6 +726,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -704,6 +740,43 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/acorn": {
"version": "8.18.0", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", "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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1136,6 +1216,13 @@
"@jridgewell/sourcemap-codec": "^1.6.0" "@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": { "node_modules/mri": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -1361,6 +1448,12 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+4 -1
View File
@@ -18,13 +18,16 @@
"@fontsource-variable/space-grotesk": "^5.3.0", "@fontsource-variable/space-grotesk": "^5.3.0",
"@tauri-apps/api": "^2.12.0", "@tauri-apps/api": "^2.12.0",
"@tauri-apps/plugin-dialog": "^2.8.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": { "devDependencies": {
"@lucide/svelte": "^1.48.0", "@lucide/svelte": "^1.48.0",
"@sveltejs/vite-plugin-svelte": "^7.3.1", "@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tauri-apps/cli": "^2.12.0", "@tauri-apps/cli": "^2.12.0",
"@tsconfig/svelte": "^5.0.8", "@tsconfig/svelte": "^5.0.8",
"@types/three": "^0.180.0",
"svelte": "^5.57.1", "svelte": "^5.57.1",
"svelte-check": "^4.7.6", "svelte-check": "^4.7.6",
"typescript": "~5.9.0", "typescript": "~5.9.0",
+52 -1
View File
@@ -538,7 +538,58 @@ pub async fn get_public_servers(state: State<'_, AppState>) -> Res<serde_json::V
if !resp.status().is_success() { if !resp.status().is_success() {
return Err("unable to load servers".into()); return Err("unable to load servers".into());
} }
resp.json().await.map_err(err) let data: serde_json::Value = resp.json().await.map_err(err)?;
let urls = data["servers"].as_array().into_iter().flatten().filter_map(|s| {
Some((s["id"].as_i64()?, s["map_url"].as_str()?.to_owned()))
}).collect();
*state.map_urls.lock().unwrap() = urls;
Ok(data)
}
#[derive(Serialize)]
pub struct MapResource {
status: u16,
data: Option<String>,
etag: Option<String>,
}
/// 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<String>,
) -> Res<MapResource> {
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 })
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+1
View File
@@ -85,6 +85,7 @@ pub fn run() {
commands::get_player_stats, commands::get_player_stats,
commands::get_leaderboard, commands::get_leaderboard,
commands::get_public_servers, commands::get_public_servers,
commands::fetch_vantage_resource,
commands::get_my_level, commands::get_my_level,
commands::get_my_quests, commands::get_my_quests,
commands::claim_quest, commands::claim_quest,
+4
View File
@@ -4,6 +4,7 @@ use crate::settings::Settings;
use scopenet_core::Layout; use scopenet_core::Layout;
use scopenet_shared::LauncherManifest; use scopenet_shared::LauncherManifest;
use std::path::PathBuf; use std::path::PathBuf;
use std::collections::HashMap;
use std::sync::{Mutex, RwLock}; use std::sync::{Mutex, RwLock};
/// Values baked in at build time by CI (see `.github/workflows/release.yml`). /// Values baked in at build time by CI (see `.github/workflows/release.yml`).
@@ -34,6 +35,8 @@ pub struct AppState {
pub accounts: RwLock<AccountsFile>, pub accounts: RwLock<AccountsFile>,
pub secrets: Secrets, pub secrets: Secrets,
pub manifest: RwLock<Option<LauncherManifest>>, pub manifest: RwLock<Option<LauncherManifest>>,
/// Vantage URLs received from the panel, keyed by linked game server.
pub map_urls: Mutex<HashMap<i64, String>>,
/// Install/launch pipeline in progress (abortable). /// Install/launch pipeline in progress (abortable).
pub task: Mutex<Option<tokio::task::AbortHandle>>, pub task: Mutex<Option<tokio::task::AbortHandle>>,
pub game: Mutex<Vec<RunningGame>>, pub game: Mutex<Vec<RunningGame>>,
@@ -52,6 +55,7 @@ impl AppState {
settings: RwLock::new(settings), settings: RwLock::new(settings),
accounts: RwLock::new(accounts), accounts: RwLock::new(accounts),
manifest: RwLock::new(manifest), manifest: RwLock::new(manifest),
map_urls: Mutex::new(HashMap::new()),
task: Mutex::new(None), task: Mutex::new(None),
game: Mutex::new(Vec::new()), game: Mutex::new(Vec::new()),
data_dir, data_dir,
+32 -5
View File
@@ -7,6 +7,7 @@
import { invoke } from '../lib/tauri'; import { invoke } from '../lib/tauri';
import { toast } from '../lib/store.svelte'; import { toast } from '../lib/store.svelte';
import type { GuildClaim } from '../lib/types'; import type { GuildClaim } from '../lib/types';
import VantageMap from './VantageMap.svelte';
let { let {
instanceId, instanceId,
@@ -22,7 +23,7 @@
onunclaim?: (claimId: number) => void; onunclaim?: (claimId: number) => void;
} = $props(); } = $props();
let servers = $state<Array<{ id: number; name: string; instance_id: string }>>([]); let servers = $state<Array<{ id: number; name: string; instance_id: string; map_url: string }>>([]);
let serverId = $state<number | null>(null); let serverId = $state<number | null>(null);
let dimension = $state<'minecraft:overworld' | 'minecraft:the_nether' | 'minecraft:the_end'>('minecraft:overworld'); let dimension = $state<'minecraft:overworld' | 'minecraft:the_nether' | 'minecraft:the_end'>('minecraft:overworld');
let centerX = $state(0); let centerX = $state(0);
@@ -31,8 +32,13 @@
let claims = $state<GuildClaim[]>([]); let claims = $state<GuildClaim[]>([]);
let loading = $state(false); let loading = $state(false);
let claiming = $state(false); let claiming = $state(false);
let claimLoad = 0;
let hoveredChunk = $state<{ x: number; z: number } | null>(null); let hoveredChunk = $state<{ x: number; z: number } | null>(null);
let selectedChunk = $state<{ x: number; z: number } | null>(null); let selectedChunk = $state<{ x: number; z: number } | null>(null);
let mapDimensions = $state<string[]>([]);
let mapFailed = $state(false);
const mapUrl = $derived(servers.find((s) => s.id === serverId)?.map_url || '');
$effect(() => { mapUrl; mapFailed = false; mapDimensions = []; });
const dimensions = [ const dimensions = [
{ id: 'minecraft:overworld', label: 'Overworld', icon: Globe }, { id: 'minecraft:overworld', label: 'Overworld', icon: Globe },
@@ -41,20 +47,23 @@
] as const; ] as const;
async function loadClaims() { async function loadClaims() {
const request = ++claimLoad;
if (!instanceId || !serverId) { claims = []; return; } if (!instanceId || !serverId) { claims = []; return; }
loading = true; loading = true;
claims = [];
try { try {
claims = await invoke<GuildClaim[]>('get_guild_claims', { const next = await invoke<GuildClaim[]>('get_guild_claims', {
instanceId, instanceId,
serverId, serverId,
centerX, centerX,
centerZ, centerZ,
dimension dimension
}); });
if (request === claimLoad) claims = next;
} catch (e: any) { } 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 { } finally {
loading = false; if (request === claimLoad) loading = false;
} }
} }
@@ -149,7 +158,8 @@
<button <button
class="dim-btn" class="dim-btn"
class:active={dimension === dim.id} class:active={dimension === dim.id}
onclick={() => { dimension = dim.id; selectedChunk = null; }} disabled={!!mapUrl && !mapFailed && mapDimensions.length > 0 && !mapDimensions.includes(dim.id)}
onclick={() => { dimension = dim.id; claims = []; selectedChunk = null; }}
> >
<DimIcon size={14} /> <DimIcon size={14} />
<span>{dim.label}</span> <span>{dim.label}</span>
@@ -175,6 +185,22 @@
</div> </div>
<!-- Main Chunk Map Canvas / Grid --> <!-- Main Chunk Map Canvas / Grid -->
{#if mapUrl && !mapFailed}
{#key `${serverId}:${mapUrl}`}
<VantageMap
world={mapUrl}
serverId={serverId!}
{claims}
{dimension}
{myGuildId}
onselect={(x, z) => { selectedChunk = { x, z }; }}
ondimension={(value) => { if (dimension !== value) { dimension = value; claims = []; selectedChunk = null; } }}
ondimensions={(value) => { mapDimensions = value; }}
oncenterchange={(x, z) => { centerX = x; centerZ = z; }}
onerror={(message) => { toast(`Live map unavailable: ${message}. Showing chunk grid.`, 'error'); mapFailed = true; }}
/>
{/key}
{:else}
<div class="grid-wrapper"> <div class="grid-wrapper">
<div <div
class="chunk-grid" class="chunk-grid"
@@ -214,6 +240,7 @@
{/each} {/each}
</div> </div>
</div> </div>
{/if}
<!-- Legend & Action Bar --> <!-- Legend & Action Bar -->
<div class="map-footer"> <div class="map-footer">
+187
View File
@@ -0,0 +1,187 @@
<script lang="ts">
import { onMount } from 'svelte';
import type * as Three from 'three';
import type { VantageViewer } from '@thoughts-on-things/vantage-mc/three';
import type { GuildClaim } from '../lib/types';
import { invoke, inTauri } from '../lib/tauri';
let { world, serverId, claims, dimension, myGuildId, onselect, ondimension, ondimensions, oncenterchange, onerror }: {
world: string;
serverId: number;
claims: GuildClaim[];
dimension: string;
myGuildId: string | null;
onselect: (x: number, z: number) => void;
ondimension: (dimension: 'minecraft:overworld' | 'minecraft:the_nether' | 'minecraft:the_end') => void;
ondimensions: (dimensions: string[]) => void;
oncenterchange: (x: number, z: number) => void;
onerror: (message: string) => void;
} = $props();
let host: HTMLDivElement;
let THREE: typeof import('three');
let viewer: VantageViewer | null = null;
let overlay: Three.Group | null = null;
let error = $state('');
let ready = $state(false);
function surface(x: number, z: number): number {
return (viewer?.controls.heightAt?.(x, z) ?? viewer?.controls.position.y ?? 64) + 2;
}
function label(text: string, mine: boolean): Three.Sprite {
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 96;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = mine ? 'rgba(9, 65, 46, .94)' : 'rgba(70, 27, 37, .94)';
ctx.roundRect(0, 4, 512, 88, 15);
ctx.fill();
ctx.font = 'bold 35px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#ffffff';
ctx.fillText(text.slice(0, 28), 256, 48, 484);
const texture = new THREE.CanvasTexture(canvas);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, depthTest: false, transparent: true }));
sprite.scale.set(24, 4.5, 1);
sprite.renderOrder = 1001;
return sprite;
}
function clearOverlay() {
if (!overlay || !viewer) return;
viewer.scene.remove(overlay);
overlay.traverse((node) => {
if (node instanceof THREE.Line) { node.geometry.dispose(); (node.material as Three.Material).dispose(); }
if (node instanceof THREE.Sprite) {
node.material.map?.dispose();
node.material.dispose();
}
});
overlay = null;
}
function drawClaims() {
if (!viewer || !ready) return;
clearOverlay();
const group = new THREE.Group();
for (const claim of claims.slice(0, 1000)) {
const mine = claim.guild_id === myGuildId;
const x = claim.chunk_x * 16;
const z = claim.chunk_z * 16;
const points: Three.Vector3[] = [];
for (let t = 0; t <= 16; t += 4) points.push(new THREE.Vector3(x + t, surface(x + t, z), z));
for (let t = 4; t <= 16; t += 4) points.push(new THREE.Vector3(x + 16, surface(x + 16, z + t), z + t));
for (let t = 12; t >= 0; t -= 4) points.push(new THREE.Vector3(x + t, surface(x + t, z + 16), z + 16));
for (let t = 12; t >= 0; t -= 4) points.push(new THREE.Vector3(x, surface(x, z + t), z + t));
const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), new THREE.LineBasicMaterial({ color: mine ? 0x4ade80 : 0xfb7185, depthTest: false, linewidth: 2 }));
line.renderOrder = 1000;
group.add(line);
const name = label(claim.guild_name || claim.guild_tag || 'Guild', mine);
name.position.set(x + 8, surface(x + 8, z + 8) + 4, z + 8);
group.add(name);
}
viewer.scene.add(group);
overlay = group;
viewer.invalidate();
}
$effect(() => { claims; myGuildId; drawClaims(); });
onMount(() => {
let cancelled = false;
let timer: ReturnType<typeof setInterval> | undefined;
let redraw: ReturnType<typeof setTimeout> | undefined;
let down: { x: number; y: number } | null = null;
let lastX = Number.NaN;
let lastZ = Number.NaN;
const downHandler = (e: PointerEvent) => { down = { x: e.clientX, y: e.clientY }; };
const upHandler = (e: PointerEvent) => {
if (!viewer || !down || Math.hypot(e.clientX - down.x, e.clientY - down.y) > 5) return;
const rect = viewer.renderer.domElement.getBoundingClientRect();
const ndc = new THREE.Vector2((e.clientX - rect.left) / rect.width * 2 - 1, -(e.clientY - rect.top) / rect.height * 2 + 1);
const ray = new THREE.Raycaster();
ray.setFromCamera(ndc, viewer.camera);
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -viewer.controls.position.y);
const hit = new THREE.Vector3();
if (!ray.ray.intersectPlane(plane, hit)) return;
for (let i = 0; i < 4; i++) {
plane.constant = -surface(hit.x, hit.z);
if (!ray.ray.intersectPlane(plane, hit)) break;
}
onselect(Math.floor(hit.x / 16), Math.floor(hit.z / 16));
};
Promise.all([import('three'), import('@thoughts-on-things/vantage-mc/three'), import('@thoughts-on-things/vantage-mc/core')]).then(async ([three, vantage, core]) => {
THREE = three;
const source = inTauri ? core.worldFromHttp(world, { fetch: async (url, init) => {
if (init?.signal?.aborted) throw new DOMException('Aborted', 'AbortError');
const headers = new Headers(init?.headers);
const result = await invoke<{ status: number; data: string | null; etag: string | null }>('fetch_vantage_resource', {
serverId, url, etag: headers.get('if-none-match')
});
if (init?.signal?.aborted) throw new DOMException('Aborted', 'AbortError');
const body = result.data ? Uint8Array.from(atob(result.data), (c) => c.charCodeAt(0)) : null;
return new Response(body, { status: result.status, headers: result.etag ? { etag: result.etag } : {} });
} }) : Promise.resolve(world);
return vantage.VantageViewer.mount(host, { world: await source, view: 'top', players: { offline: false } });
}).then((v) => {
if (cancelled) { v.dispose(); return; }
viewer = v;
ready = true;
ondimensions(v.dimensions.map((d) => d.id).length ? v.dimensions.map((d) => d.id) : [v.dimension?.id || 'minecraft:overworld']);
const initialId = v.dimension?.id;
if (initialId === 'minecraft:overworld' || initialId === 'minecraft:the_nether' || initialId === 'minecraft:the_end') ondimension(initialId);
const canvas = v.renderer.domElement;
canvas.addEventListener('pointerdown', downHandler);
canvas.addEventListener('pointerup', upHandler);
v.on('dimension', ({ current }) => {
ondimensions(v.dimensions.map((d) => d.id).length ? v.dimensions.map((d) => d.id) : [current?.id || 'minecraft:overworld']);
const id = current?.id;
if (id === 'minecraft:overworld' || id === 'minecraft:the_nether' || id === 'minecraft:the_end') ondimension(id);
drawClaims();
});
v.on('stats', () => {
if (redraw) clearTimeout(redraw);
redraw = setTimeout(drawClaims, 250);
});
timer = setInterval(() => {
const cx = Math.floor(v.controls.position.x / 16);
const cz = Math.floor(v.controls.position.z / 16);
if (!Number.isFinite(lastX) || Math.abs(cx - lastX) >= 8 || Math.abs(cz - lastZ) >= 8) {
lastX = cx; lastZ = cz; oncenterchange(cx, cz);
drawClaims();
}
}, 1000);
drawClaims();
}).catch((e) => { if (!cancelled) { error = e instanceof Error ? e.message : String(e); onerror(error); } });
return () => {
cancelled = true;
if (timer) clearInterval(timer);
if (redraw) clearTimeout(redraw);
viewer?.renderer.domElement.removeEventListener('pointerdown', downHandler);
viewer?.renderer.domElement.removeEventListener('pointerup', upHandler);
clearOverlay();
viewer?.dispose();
viewer = null;
};
});
$effect(() => {
if (viewer && ready && viewer.dimension && viewer.dimension.id !== dimension && viewer.dimensions.some((d) => d.id === dimension)) {
void viewer.setDimension(dimension).catch((e) => { error = String(e); });
}
});
</script>
<div class="vantage-map" bind:this={host}>
{#if !ready && !error}<div class="map-message">Loading live terrain map…</div>{/if}
{#if error}<div class="map-message error">Map unavailable: {error}</div>{/if}
</div>
<style>
.vantage-map { height: min(60vh, 680px); min-height: 430px; position: relative; overflow: hidden; border-radius: 14px; background: #102026; }
.vantage-map :global(canvas) { display: block; }
.map-message { position: absolute; z-index: 1; inset: 0; display: grid; place-items: center; padding: 24px; color: #e5e7eb; text-align: center; pointer-events: none; }
.error { background: #102026; color: #fda4af; }
</style>
+12
View File
@@ -124,6 +124,18 @@ async function fakeLaunch(id: string) {
export async function mockInvoke(cmd: string, args: Record<string, any>): Promise<unknown> { export async function mockInvoke(cmd: string, args: Record<string, any>): Promise<unknown> {
await sleep(40); await sleep(40);
switch (cmd) { switch (cmd) {
case 'get_public_servers':
return { servers: [{ id: 1, name: 'Survival SMP', instance_id: 'survival-smp', map_url: '', online: true, players_online: 42, players_max: 100 }] };
case 'get_player_stats':
return { username: 'Notch', uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', total: null, servers: [], events: [] };
case 'get_leaderboard':
return { leaderboard: [], totals: { players: 0, playtime_secs: 0 }, sort: args?.sort ?? 'playtime_secs' };
case 'get_economy_balances':
return [{ server_id: 1, server_name: 'Survival SMP', balance: 2450.75, currency_symbol: '$' }];
case 'get_server_baltop':
return [{ rank: 1, uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', username: 'Notch', balance: 2450.75 }];
case 'get_transactions':
return [{ id: 1, server_id: 1, server_name: 'Survival SMP', from_uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', from_name: 'Notch', to_uuid: 'friend-1', to_name: 'Alex', amount: 150, description: 'Payment to Alex', created_at: '2026-09-29T15:30:00Z' }];
case 'bootstrap': { case 'bootstrap': {
const b: Bootstrap = { const b: Bootstrap = {
app: { version: '0.1.0', default_panel_url: null, panel_locked: false, repo: 'scopeddlol/SCOPENET-MC', os: 'windows', arch: 'x86_64', total_ram_mb: 16384, data_dir: 'C:\\Users\\you\\AppData\\Roaming\\net.scopenet.launcher' }, app: { version: '0.1.0', default_panel_url: null, panel_locked: false, repo: 'scopeddlol/SCOPENET-MC', os: 'windows', arch: 'x86_64', total_ram_mb: 16384, data_dir: 'C:\\Users\\you\\AppData\\Roaming\\net.scopenet.launcher' },
+4 -4
View File
@@ -345,17 +345,17 @@ export interface ServerEconomyBalance {
server_name: string; server_name: string;
balance: number; balance: number;
currency_symbol: string; currency_symbol: string;
last_updated: string;
} }
export interface EconomyTransaction { export interface EconomyTransaction {
id: number; id: number;
server_id: number; server_id: number;
server_name?: string; server_name?: string;
sender_uuid: string; from_uuid: string;
recipient_uuid: string; from_name: string;
to_uuid: string;
to_name: string;
amount: number; amount: number;
tx_type: string;
description: string; description: string;
created_at: string; created_at: string;
} }
+3 -5
View File
@@ -427,9 +427,7 @@
<span class="currency-sym">{bal.currency_symbol || '$'}</span> <span class="currency-sym">{bal.currency_symbol || '$'}</span>
<span>{bal.balance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span> <span>{bal.balance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
</div> </div>
<div class="econ-card-footer tiny muted"> <div class="econ-card-footer tiny muted">Live server balance</div>
Updated {formatDate(bal.last_updated)}
</div>
</button> </button>
{/each} {/each}
</div> </div>
@@ -508,12 +506,12 @@
<span class="text-right">Amount</span> <span class="text-right">Amount</span>
</div> </div>
{#each transactions.slice(0, 15) as tx (tx.id)} {#each transactions.slice(0, 15) as tx (tx.id)}
{@const isSender = tx.sender_uuid === me?.uuid} {@const isSender = tx.from_uuid === me?.uuid}
<div class="tx-row"> <div class="tx-row">
<span class="tx-type-pill badge" class:send={isSender} class:receive={!isSender}> <span class="tx-type-pill badge" class:send={isSender} class:receive={!isSender}>
{#if isSender}<ArrowUpRight size={12} /> Sent{:else}<ArrowDownLeft size={12} /> Received{/if} {#if isSender}<ArrowUpRight size={12} /> Sent{:else}<ArrowDownLeft size={12} /> Received{/if}
</span> </span>
<span class="tx-desc" title={tx.description}>{tx.description || tx.tx_type}</span> <span class="tx-desc" title={tx.description}>{tx.description || `${tx.from_name} → ${tx.to_name}`}</span>
<span class="tx-date tiny muted">{new Date(tx.created_at).toLocaleString()}</span> <span class="tx-date tiny muted">{new Date(tx.created_at).toLocaleString()}</span>
<span class="tx-amount text-right" class:negative={isSender} class:positive={!isSender}> <span class="tx-amount text-right" class:negative={isSender} class:positive={!isSender}>
{isSender ? '-' : '+'}${Math.abs(tx.amount).toFixed(2)} {isSender ? '-' : '+'}${Math.abs(tx.amount).toFixed(2)}
+3
View File
@@ -506,6 +506,9 @@ const MIGRATIONS: &[&str] = &[
PRIMARY KEY (post_id, user_uuid) 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<SqlitePool> { pub async fn connect(data_dir: &Path) -> Result<SqlitePool> {
+20 -2
View File
@@ -61,6 +61,7 @@ fn clip(s: &str, max: usize) -> String {
#[derive(Debug, Clone, sqlx::FromRow, Serialize)] #[derive(Debug, Clone, sqlx::FromRow, Serialize)]
pub struct ServerRow { pub struct ServerRow {
pub instance_id: String, pub instance_id: String,
pub map_url: String,
pub id: i64, pub id: i64,
pub name: String, pub name: String,
#[serde(skip)] #[serde(skip)]
@@ -832,6 +833,8 @@ pub struct ServerInput {
allowed_groups: Vec<String>, allowed_groups: Vec<String>,
#[serde(default)] #[serde(default)]
require_launcher: bool, require_launcher: bool,
#[serde(default)]
map_url: String,
} }
fn default_access() -> String { fn default_access() -> String {
@@ -849,6 +852,18 @@ impl ServerInput {
if self.access == "groups" && self.allowed_groups.is_empty() { if self.access == "groups" && self.allowed_groups.is_empty() {
return Err(AppError::bad_request("pick at least one group")); 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(()) Ok(())
} }
} }
@@ -865,7 +880,7 @@ pub async fn create(_: AdminUser, State(state): State<AppState>, Json(input): Js
input.validate()?; input.validate()?;
let token = new_token(); let token = new_token();
let id: i64 = sqlx::query_scalar( 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(input.name.trim())
.bind(hash_token(&token)) .bind(hash_token(&token))
@@ -875,6 +890,7 @@ pub async fn create(_: AdminUser, State(state): State<AppState>, Json(input): Js
.bind(input.require_launcher) .bind(input.require_launcher)
.bind(now()) .bind(now())
.bind(&input.instance_id) .bind(&input.instance_id)
.bind(input.map_url.trim())
.fetch_one(&state.db) .fetch_one(&state.db)
.await?; .await?;
let server = get_server(&state, id).await?; let server = get_server(&state, id).await?;
@@ -889,12 +905,13 @@ pub async fn update(
) -> AppResult<Json<ServerView>> { ) -> AppResult<Json<ServerView>> {
input.validate()?; input.validate()?;
get_server(&state, id).await?; 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.name.trim())
.bind(&input.access) .bind(&input.access)
.bind(serde_json::to_string(&input.allowed_groups).unwrap_or_else(|_| "[]".into())) .bind(serde_json::to_string(&input.allowed_groups).unwrap_or_else(|_| "[]".into()))
.bind(input.require_launcher) .bind(input.require_launcher)
.bind(&input.instance_id) .bind(&input.instance_id)
.bind(input.map_url.trim())
.bind(id) .bind(id)
.execute(&state.db) .execute(&state.db)
.await?; .await?;
@@ -1078,6 +1095,7 @@ pub async fn public_servers(State(state): State<AppState>) -> AppResult<Json<Val
"id": s.id, "id": s.id,
"name": s.name, "name": s.name,
"instance_id": s.instance_id, "instance_id": s.instance_id,
"map_url": s.map_url,
"online": is_online, "online": is_online,
"players_online": if is_online { s.online_count } else { 0 }, "players_online": if is_online { s.online_count } else { 0 },
"players_max": s.max_players, "players_max": s.max_players,
@@ -18,6 +18,7 @@
<label class="field">Name<input bind:value={draft.name} maxlength="48" placeholder="Survival SMP" /></label> <label class="field">Name<input bind:value={draft.name} maxlength="48" placeholder="Survival SMP" /></label>
<label class="field">Launcher instance<select bind:value={draft.instance_id}><option value="">Unlinked</option>{#each instances as instance}<option value={instance.id}>{instance.name}</option>{/each}</select></label> <label class="field">Launcher instance<select bind:value={draft.instance_id}><option value="">Unlinked</option>{#each instances as instance}<option value={instance.id}>{instance.name}</option>{/each}</select></label>
<label class="field">Vantage live map manifest URL<input type="url" bind:value={draft.map_url} placeholder="https://maps.example.com/world.json" /><span class="tiny muted">Optional. Use a public Vantage render or a session-gated same-origin endpoint. The URL is visible to launcher users.</span></label>
<div class="field"> <div class="field">
<span class="lbl">Who can join</span> <span class="lbl">Who can join</span>
<div class="opts"> <div class="opts">
+2 -1
View File
@@ -92,6 +92,7 @@ export interface AuthServerInfo { public_url: string; yggdrasil_url: string; pub
export interface GameServer { export interface GameServer {
instance_id: string; instance_id: string;
map_url: string;
id: number; id: number;
name: string; name: string;
token_hint: 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 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 { export interface HostedDownload {
platform: 'windows' | 'mac' | 'linux'; platform: 'windows' | 'mac' | 'linux';
+1 -1
View File
@@ -78,7 +78,7 @@
function openEdit() { function openEdit() {
if (!s) return; 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; editOpen = true;
} }
async function saveEdit() { async function saveEdit() {