Integrate Vantage live map for guild claims [skip ci]
This commit is contained in:
1 parent
52de8c3031
commit
602e7fd0f0
17 files changed
+470
-21
No files matched your search
@@ -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
|
||||
|
||||
@@ -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).
|
||||
Generated
+94
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -538,7 +538,58 @@ pub async fn get_public_servers(state: State<'_, AppState>) -> Res<serde_json::V
|
||||
if !resp.status().is_success() {
|
||||
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 })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AccountsFile>,
|
||||
pub secrets: Secrets,
|
||||
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).
|
||||
pub task: Mutex<Option<tokio::task::AbortHandle>>,
|
||||
pub game: Mutex<Vec<RunningGame>>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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 dimension = $state<'minecraft:overworld' | 'minecraft:the_nether' | 'minecraft:the_end'>('minecraft:overworld');
|
||||
let centerX = $state(0);
|
||||
@@ -31,8 +32,13 @@
|
||||
let claims = $state<GuildClaim[]>([]);
|
||||
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<string[]>([]);
|
||||
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<GuildClaim[]>('get_guild_claims', {
|
||||
const next = await invoke<GuildClaim[]>('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 @@
|
||||
<button
|
||||
class="dim-btn"
|
||||
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} />
|
||||
<span>{dim.label}</span>
|
||||
@@ -175,6 +185,22 @@
|
||||
</div>
|
||||
|
||||
<!-- 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="chunk-grid"
|
||||
@@ -214,6 +240,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Legend & Action Bar -->
|
||||
<div class="map-footer">
|
||||
|
||||
@@ -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>
|
||||
@@ -124,6 +124,18 @@ async function fakeLaunch(id: string) {
|
||||
export async function mockInvoke(cmd: string, args: Record<string, any>): Promise<unknown> {
|
||||
await sleep(40);
|
||||
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': {
|
||||
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' },
|
||||
|
||||
@@ -345,17 +345,17 @@ export interface ServerEconomyBalance {
|
||||
server_name: string;
|
||||
balance: number;
|
||||
currency_symbol: string;
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
export interface EconomyTransaction {
|
||||
id: number;
|
||||
server_id: number;
|
||||
server_name?: string;
|
||||
sender_uuid: string;
|
||||
recipient_uuid: string;
|
||||
from_uuid: string;
|
||||
from_name: string;
|
||||
to_uuid: string;
|
||||
to_name: string;
|
||||
amount: number;
|
||||
tx_type: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -427,9 +427,7 @@
|
||||
<span class="currency-sym">{bal.currency_symbol || '$'}</span>
|
||||
<span>{bal.balance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
<div class="econ-card-footer tiny muted">
|
||||
Updated {formatDate(bal.last_updated)}
|
||||
</div>
|
||||
<div class="econ-card-footer tiny muted">Live server balance</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -508,12 +506,12 @@
|
||||
<span class="text-right">Amount</span>
|
||||
</div>
|
||||
{#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">
|
||||
<span class="tx-type-pill badge" class:send={isSender} class:receive={!isSender}>
|
||||
{#if isSender}<ArrowUpRight size={12} /> Sent{:else}<ArrowDownLeft size={12} /> Received{/if}
|
||||
</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-amount text-right" class:negative={isSender} class:positive={!isSender}>
|
||||
{isSender ? '-' : '+'}${Math.abs(tx.amount).toFixed(2)}
|
||||
|
||||
@@ -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<SqlitePool> {
|
||||
|
||||
@@ -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<String>,
|
||||
#[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<AppState>, 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<AppState>, 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<Json<ServerView>> {
|
||||
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<AppState>) -> AppResult<Json<Val
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"instance_id": s.instance_id,
|
||||
"map_url": s.map_url,
|
||||
"online": is_online,
|
||||
"players_online": if is_online { s.online_count } else { 0 },
|
||||
"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">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">
|
||||
<span class="lbl">Who can join</span>
|
||||
<div class="opts">
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user