Live map: plugin/mod chunk+player sync, admin panel Live Map section, launcher transport

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
Claude committed 2026-09-30 17:15:24 +00:00
1 parent 8acb4a64e6
commit fdc54577fd
14 files changed
+769 -8

No files matched your search

@@ -0,0 +1,192 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { Map as MapIcon, RefreshCw, Trash2, CircleCheck, CircleAlert, Users, Boxes, Cpu, Package, LoaderCircle } from '@lucide/svelte';
import { del, formatBytes, get, timeAgo } from '../lib/api';
import { session } from '../lib/session.svelte';
import { toast, toastError } from '../lib/toast.svelte';
import Modal from './Modal.svelte';
import type { LiveMapStatus } from '../lib/types';
let { id, enabled }: { id: number; enabled: boolean } = $props();
let status = $state<LiveMapStatus | null>(null);
let dim = $state('overworld');
let host = $state<HTMLDivElement>();
let viewer: any = null;
let loading = $state(false);
let viewError = $state('');
let resetOpen = $state(false);
let loadedKey = '';
async function refresh() {
try {
status = await get<LiveMapStatus>(`/api/admin/servers/${id}/livemap`);
if (!status.dimensions.some((d) => d.slug === dim)) dim = status.dimensions.find((d) => d.available)?.slug ?? 'overworld';
} catch (e) {
toastError(e);
}
}
$effect(() => {
void id;
void enabled;
refresh();
const t = setInterval(refresh, 10000);
return () => clearInterval(t);
});
function closeViewer() {
viewer?.dispose();
viewer = null;
loadedKey = '';
}
async function openViewer() {
if (!host || !status?.ready) return;
const key = `${id}:${dim}`;
if (loadedKey === key && viewer) return;
closeViewer();
loading = true;
viewError = '';
try {
const [{ VantageViewer }, { worldFromHttp }] = await Promise.all([
import('@thoughts-on-things/vantage-mc/three'),
import('@thoughts-on-things/vantage-mc/core'),
]);
const url = `${location.origin}${status.base}/${dim}/v1/worlds/default/manifest.json`;
const world = await worldFromHttp(url, { accessToken: session.token ?? undefined });
const v = new VantageViewer(host, { view: 'top', players: { offline: false } });
try {
await v.load({ world, view: 'top' });
} catch (e) {
v.dispose();
throw e;
}
viewer = v;
loadedKey = key;
} catch (e) {
viewError = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
// Reload whenever the dimension changes or the map becomes ready.
$effect(() => {
void dim;
if (status?.ready && host) openViewer();
});
onDestroy(closeViewer);
async function reset() {
try {
await del(`/api/admin/servers/${id}/livemap`);
closeViewer();
resetOpen = false;
toast('Live map data cleared — the server will re-upload its world');
refresh();
} catch (e) {
toastError(e);
}
}
const totalRegions = $derived(status?.world.dimensions.reduce((n, d) => n + d.regions, 0) ?? 0);
const totalBytes = $derived(status?.world.dimensions.reduce((n, d) => n + d.bytes, 0) ?? 0);
</script>
<section class="card live">
<div class="section-title">
<h2><MapIcon size={17} /> Live Map</h2>
<span class="pill" class:ok={status?.ready} class:off={!enabled}>{!enabled ? 'Off' : status?.ready ? 'Live' : 'Setting up'}</span>
<button class="ghost icon" aria-label="Refresh" onclick={refresh}><RefreshCw size={15} /></button>
</div>
{#if !enabled}
<p class="muted small">Turn on <strong>Live Map</strong> in this server's settings. The SCOPENET plugin/mod will then send chunks and player positions here, and launchers get a Live Map button.</p>
{:else if status}
<div class="checks">
<div class="check" class:good={status.generator.installed}>
{#if status.generator.installed}<CircleCheck size={16} />{:else}<CircleAlert size={16} />{/if}
<span><Cpu size={13} /> Vantage generator</span>
<small>{status.generator.installed ? status.generator.path : 'Not installed on the panel'}</small>
</div>
<div class="check" class:good={status.assets.configured}>
{#if status.assets.configured}<CircleCheck size={16} />{:else}<CircleAlert size={16} />{/if}
<span><Package size={13} /> Minecraft assets</span>
<small>{status.assets.configured ? status.assets.path : 'Set SCOPENET_VANTAGE_ASSETS'}</small>
</div>
<div class="check" class:good={status.world.level_dat && totalRegions > 0}>
{#if status.world.level_dat && totalRegions > 0}<CircleCheck size={16} />{:else}<CircleAlert size={16} />{/if}
<span><Boxes size={13} /> World data</span>
<small>{totalRegions ? `${totalRegions} regions · ${formatBytes(totalBytes)} · ${status.stats.chunks_received.toLocaleString()} chunks this session` : 'Waiting for the server to upload'}</small>
</div>
<div class="check" class:good={status.players > 0}>
{#if status.players > 0}<CircleCheck size={16} />{:else}<CircleAlert size={16} />{/if}
<span><Users size={13} /> Player positions</span>
<small>{status.stats.last_players_at ? `${status.players} live · updated ${timeAgo(status.stats.last_players_at)}` : 'Nothing received yet'}</small>
</div>
</div>
{#if !status.generator.installed || !status.assets.configured}
<p class="hint-box small">
The panel stores what the server sends, but rendering needs the Vantage generator and Minecraft client assets.
Install them as described in <code>docs/live-map.md</code> — until then launchers show a “map is being set up” message.
</p>
{/if}
{#if status.last_error}<p class="err small">Generator: {status.last_error}</p>{/if}
{#if status.ready}
<div class="tabs">
{#each status.dimensions as d (d.slug)}
<button class="tab" class:on={dim === d.slug} disabled={!d.available} onclick={() => (dim = d.slug)}>{d.label}</button>
{/each}
<span class="grow"></span>
<button class="ghost" onclick={() => (resetOpen = true)}><Trash2 size={14} /> Reset map data</button>
</div>
<div class="stage">
<div class="viewer" bind:this={host}></div>
{#if loading}<div class="overlay"><LoaderCircle class="spin" size={22} /> Rendering terrain…</div>{/if}
{#if viewError}<div class="overlay err">Map unavailable: {viewError}</div>{/if}
</div>
{:else if status.message}
<p class="muted small">{status.message}</p>
{/if}
{:else}
<div class="skeleton"></div>
{/if}
</section>
<Modal bind:open={resetOpen} title="Reset map data?">
<p class="muted">Deletes the mirrored world and rendered tiles for this server. The game server uploads everything again, which can take a while for big worlds.</p>
{#snippet footer()}
<button class="ghost" onclick={() => (resetOpen = false)}>Cancel</button>
<button class="danger" onclick={reset}><Trash2 size={16} /> Reset</button>
{/snippet}
</Modal>
<style>
.live { display: flex; flex-direction: column; gap: 14px; margin-bottom: 16px; }
h2 { display: flex; align-items: center; gap: 8px; }
.pill { margin-left: auto; padding: 3px 11px; border-radius: 99px; font-size: 0.75rem; font-weight: 600; background: color-mix(in srgb, #fcd34d 16%, transparent); color: #fcd34d; }
.pill.ok { background: color-mix(in srgb, #6ee7b7 16%, transparent); color: #6ee7b7; }
.pill.off { background: var(--bg-2); color: var(--muted); }
.checks { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 10px; }
.check { display: grid; grid-template-columns: 18px 1fr; gap: 2px 10px; align-items: center; padding: 11px 13px; border-radius: 12px; background: var(--bg-2); color: #fcd34d; }
.check.good { color: #6ee7b7; }
.check span { color: var(--text); font-size: 0.85rem; font-weight: 560; display: inline-flex; align-items: center; gap: 6px; }
.check span :global(svg) { color: var(--muted); }
.check small { grid-column: 2; color: var(--muted); font-size: 0.75rem; overflow-wrap: anywhere; }
.hint-box { padding: 11px 14px; border-radius: 12px; background: color-mix(in srgb, var(--accent) 8%, var(--bg-2)); color: var(--text-2); line-height: 1.5; }
.err { color: #fda4af; }
.tabs { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.tab { padding: 7px 14px; border-radius: 99px; font-size: 0.85rem; background: var(--bg-2); }
.tab.on { border-color: color-mix(in srgb, var(--accent) 60%, transparent); background: var(--accent-soft); }
.tab:disabled { opacity: 0.4; }
.grow { flex: 1; }
.stage { position: relative; border-radius: 14px; overflow: hidden; background: #102026; height: min(62vh, 620px); min-height: 380px; }
.viewer { position: absolute; inset: 0; }
.viewer :global(canvas) { display: block; }
.overlay { position: absolute; inset: 0; display: grid; place-items: center; grid-auto-flow: column; gap: 10px; justify-content: center; color: #e5e7eb; background: rgba(16, 32, 38, 0.72); pointer-events: none; }
.overlay.err { color: #fda4af; text-align: center; padding: 24px; }
.skeleton { height: 120px; border-radius: 12px; background: var(--bg-2); }
</style>
+12 -1
View File
@@ -18,7 +18,15 @@
<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>
<Toggle
bind:checked={draft.live_map_enabled}
label="Live Map"
help="The SCOPENET plugin/mod sends this server's chunks and player positions to the panel, which hosts a Vantage 3D map. Launchers show a Live Map button — nothing to run next to the server."
/>
<details class="adv">
<summary>Use an external Vantage map instead</summary>
<label class="field">Vantage manifest URL<input type="url" bind:value={draft.map_url} placeholder="https://maps.example.com/world.json" /><span class="tiny muted">Optional and only used when Live Map above is off. The URL is visible to launcher users.</span></label>
</details>
<div class="field">
<span class="lbl">Who can join</span>
<div class="opts">
@@ -58,5 +66,8 @@
.opt.on :global(svg) { color: var(--accent-2); }
.gpick { padding: 6px 12px; border-radius: 99px; font-size: 0.85rem; background: var(--bg-2); }
.gpick.on { border-color: var(--c); background: color-mix(in srgb, var(--c) 18%, transparent); }
.adv { border: 1px dashed var(--line, rgba(255, 255, 255, 0.12)); border-radius: 10px; padding: 10px 12px; }
.adv summary { cursor: pointer; font-size: 0.85rem; color: var(--muted); }
.adv[open] summary { margin-bottom: 10px; }
.dot { width: 10px; height: 10px; border-radius: 50%; }
</style>