Added some features :D, Codex limits are burned out by GPT 6 Astra D:

This commit is contained in:
2026-10-05 19:33:00 +05:00
parent b33db48b63
commit 923abdc472
23 changed files with 538 additions and 53 deletions

View File

@@ -39,6 +39,10 @@ Maps support up to 24 locations and games choose five at random. Owners can opt
Contributions check the server address recorded during capture. Owners can correct or clear the map's expected address. Fabric records the connected server automatically; plugin admins must set `server-address` or run `/minegeo server <hostname[:port]>` before exporting. Contributions check the server address recorded during capture. Owners can correct or clear the map's expected address. Fabric records the connected server automatically; plugin admins must set `server-address` or run `/minegeo server <hostname[:port]>` before exporting.
In **Dashboard → Open map → Map tools**, owners can add their own panoramas with `/minegeo contribute <map ID>` even when guest contributions are disabled. Owners can fill all 24 slots; other players retain their cumulative 12-location limit. Processed captures must be submitted and accepted in the map’s inbox, then the map update submitted for moderator review.
**Refresh guessing map** rerenders only the overhead map through the worker, without consuming panorama slots or changing panorama images. Choose the original capture, another processed capture you own, or an accepted contribution with exactly matching boundaries and the expected server address. To reflect new builds, export/upload fresh terrain first (a separate map upload also works when the original map is full). Fabric refreshes use the uploaded surface capture. The approved map and ongoing games keep their previous image until moderation approves the update. Apply the `map_refresh` migration and restart both the website and worker when updating an existing installation.
Ready-to-install JARs are committed under `public/tools/plugin` and `public/tools/mod` and served by `/downloads`. After pulling an update, replace the Minecraft server's installed JAR and restart that server; pulling the website alone does not update an already installed plugin. Ready-to-install JARs are committed under `public/tools/plugin` and `public/tools/mod` and served by `/downloads`. After pulling an update, replace the Minecraft server's installed JAR and restart that server; pulling the website alone does not update an already installed plugin.
- **Server:** Java 21 and Bukkit/Paper 1.21.11. Follow [the plugin guide](docs/server-plugin.md). - **Server:** Java 21 and Bukkit/Paper 1.21.11. Follow [the plugin guide](docs/server-plugin.md).

View File

@@ -14,8 +14,8 @@ export default async function Draft({
<> <>
<Header /> <Header />
<main className="section page-content"> <main className="section page-content">
<span className="eyebrow">YOUR WORLD, BEFORE THE WORLD SEES IT</span> <span className="eyebrow">YOUR WORLD · YOUR LOCATIONS</span>
<h1>At the drawing board.</h1> <h1>Map workshop</h1>
<DraftEditor id={id} /> <DraftEditor id={id} />
</main> </main>
<Footer /> <Footer />

View File

@@ -3,6 +3,7 @@ import "./globals.css";
import "./block-theme.css"; import "./block-theme.css";
import "./account.css"; import "./account.css";
import "./map-theme.css"; import "./map-theme.css";
import "./workshop.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
default: "MineGeo — Get lost in a familiar world", default: "MineGeo — Get lost in a familiar world",

View File

@@ -34,6 +34,7 @@ export default async function ServerPage({
alt={`${map.name} surface map`} alt={`${map.name} surface map`}
width={1024} width={1024}
height={1024} height={1024}
loading="eager"
unoptimized unoptimized
/> />
</div> </div>

96
app/workshop.css Normal file
View File

@@ -0,0 +1,96 @@
.editor-navigation {
display: flex;
flex-wrap: wrap;
gap: 12px 24px;
margin: 16px 0 28px;
color: var(--green);
}
.editor-panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 24px;
margin: 28px 0;
max-width: 850px;
}
.editor-panel h2 {
margin: 0 0 16px;
}
.editor-panel p {
line-height: 1.7;
}
.editor-panel select {
width: 100%;
background: var(--bg);
color: var(--text);
border: 1px solid var(--line);
padding: 12px;
font: inherit;
border-radius: 4px;
}
.command-line {
display: block;
padding: 14px;
background: var(--bg);
overflow-wrap: anywhere;
user-select: all;
border: 1px solid var(--line);
}
.token-box code,
.page-content code {
overflow-wrap: anywhere;
}
.token-box .token-box {
border: 0;
border-top: 1px solid var(--line);
padding: 24px 0 0;
background: transparent;
}
.draft-row {
gap: 20px;
}
.draft-row > div {
min-width: 0;
}
.draft-row h3 {
overflow-wrap: anywhere;
}
.preview-tile label {
position: relative;
display: block;
}
.preview-tile input[type="checkbox"] {
position: absolute;
top: 12px;
left: 12px;
width: 22px;
height: 22px;
accent-color: var(--green);
z-index: 1;
}
.preview-tile:has(input:checked) {
outline: 2px solid var(--green);
outline-offset: 2px;
}
.preview-tile label > span {
display: block;
padding: 12px;
}
.detail-grid > * {
min-width: 0;
}
@media (max-width: 600px) {
.editor-panel {
padding: 18px;
}
.draft-row {
flex-direction: column;
align-items: stretch;
}
.inline-actions .button {
flex: 1 1 180px;
}
.page-content h1 {
font-size: 44px;
}
}

View File

@@ -3,10 +3,12 @@ export function ContributionInstructions({
id, id,
bounds, bounds,
serverAddress, serverAddress,
isOwner = false,
}: { }: {
id: string; id: string;
bounds: { minX: number; minZ: number; maxX: number; maxZ: number }; bounds: { minX: number; minZ: number; maxX: number; maxZ: number };
serverAddress: string | null; serverAddress: string | null;
isOwner?: boolean;
}) { }) {
return ( return (
<div className="token-box"> <div className="token-box">
@@ -29,15 +31,26 @@ export function ContributionInstructions({
)} )}
<p> <p>
Capture in the same Minecraft world, within X {bounds.minX} to{" "} Capture in the same Minecraft world, within X {bounds.minX} to{" "}
{bounds.maxX} and Z {bounds.minZ} to {bounds.maxZ}. Each player may add {bounds.maxX} and Z {bounds.minZ} to {bounds.maxZ}.{" "}
up to 12 panoramas in total; the map holds at most 24, including pending {isOwner
uploads. ? "As the owner, you can fill the map’s 24 panorama slots."
: "Each guest contributor may add up to 12 panoramas in total."}{" "}
The map holds at most 24, including pending uploads and unselected
captures.
</p> </p>
<p> <p>
In the Fabric mod, paste this map ID into “Add to map”. For the server In the Fabric mod, paste this map ID into “Add to map”. For the server
plugin, export the area and run: plugin, export the area and run:
</p> </p>
<code>/minegeo contribute {id}</code> <code className="command-line">/minegeo contribute {id}</code>
{isOwner && (
<p>
This command also adds your own panoramas. Open the resulting
contribution in your dashboard, submit it, then accept it in this
map’s submissions below. Finally, submit the map update for moderator
review.
</p>
)}
<p> <p>
Map ID: <code>{id}</code> Map ID: <code>{id}</code>
</p> </p>

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { api } from "./StartGame"; import { api } from "./StartGame";
import type { DatasetManifest } from "@/shared/protocol"; import type { DatasetManifest } from "@/shared/protocol";
@@ -7,6 +7,8 @@ import type { ProcessingJob } from "@/shared/processing-progress";
import { ProcessingProgress } from "./ProcessingProgress"; import { ProcessingProgress } from "./ProcessingProgress";
import { PanoramaPreview } from "./PanoramaPreview"; import { PanoramaPreview } from "./PanoramaPreview";
import { OwnerContributions } from "./OwnerContributions"; import { OwnerContributions } from "./OwnerContributions";
import Link from "next/link";
import { MapRefresh } from "./MapRefresh";
type Draft = { type Draft = {
id: string; id: string;
name: string; name: string;
@@ -25,6 +27,7 @@ type Draft = {
update_state: string | null; update_state: string | null;
}; };
export function DraftEditor({ id }: { id: string }) { export function DraftEditor({ id }: { id: string }) {
const dirty = useRef(false);
const [draft, setDraft] = useState<Draft | null>(null), const [draft, setDraft] = useState<Draft | null>(null),
[name, setName] = useState(""), [name, setName] = useState(""),
[description, setDescription] = useState(""), [description, setDescription] = useState(""),
@@ -43,10 +46,15 @@ export function DraftEditor({ id }: { id: string }) {
if (disposed) return; if (disposed) return;
setError(""); setError("");
setDraft(d); setDraft(d);
if (!dirty.current) {
setName(d.name); setName(d.name);
setDescription(d.description); setDescription(d.description);
setSelected(d.manifest.viewpoints.map((p) => p.id)); setSelected(d.manifest.viewpoints.map((p) => p.id));
if (["uploading", "processing"].includes(d.state)) }
if (
["uploading", "processing"].includes(d.state) ||
["queued", "running"].includes(d.upload_jobs?.state ?? "")
)
timer = setTimeout(load, 5000); timer = setTimeout(load, 5000);
}) })
.catch((e) => { .catch((e) => {
@@ -71,6 +79,7 @@ export function DraftEditor({ id }: { id: string }) {
{ name, description, selectedIds: selected }, { name, description, selectedIds: selected },
"PATCH", "PATCH",
); );
dirty.current = false;
if (submit) { if (submit) {
await api("datasets/" + id + "/submit", {}); await api("datasets/" + id + "/submit", {});
setVersion((v) => v + 1); setVersion((v) => v + 1);
@@ -118,9 +127,17 @@ export function DraftEditor({ id }: { id: string }) {
!["submitted", "accepted", "rejected"].includes( !["submitted", "accepted", "rejected"].includes(
draft.contribution_status ?? "", draft.contribution_status ?? "",
); );
const refreshing =
draft.upload_jobs?.kind === "map" &&
["queued", "running"].includes(draft.upload_jobs.state);
const minimum = draft.parent_dataset_id ? 1 : 5; const minimum = draft.parent_dataset_id ? 1 : 5;
return ( return (
<> <>
<div className="editor-navigation">
<Link href="/dashboard">← Your maps</Link>
<a href="#locations">Choose locations</a>
<a href="#map-tools">Map tools</a>
</div>
<span className="badge">{draft.state}</span> <span className="badge">{draft.state}</span>
{draft.update_state && ( {draft.update_state && (
<span className="badge">Update: {draft.update_state}</span> <span className="badge">Update: {draft.update_state}</span>
@@ -146,6 +163,19 @@ export function DraftEditor({ id }: { id: string }) {
</p> </p>
)} )}
<ProcessingProgress job={draft.upload_jobs} /> <ProcessingProgress job={draft.upload_jobs} />
{draft.upload_jobs?.kind === "map" &&
draft.upload_jobs.state === "failed" && (
<p role="alert" className="error-message">
Map refresh failed: {draft.upload_jobs.error}. Your previous map is
still available. Try refreshing again below.
</p>
)}
{refreshing && (
<p role="status" className="success-message">
Refreshing your guessing map. This page updates automatically; the
published version stays playable.
</p>
)}
{["uploading", "processing"].includes(draft.state) && ( {["uploading", "processing"].includes(draft.state) && (
<p role="status" className="success-message"> <p role="status" className="success-message">
{draft.state === "uploading" {draft.state === "uploading"
@@ -169,16 +199,22 @@ export function DraftEditor({ id }: { id: string }) {
src={draft.map} src={draft.map}
width={1024} width={1024}
height={1024} height={1024}
loading="eager"
alt="Draft surface map" alt="Draft surface map"
unoptimized unoptimized
/> />
</div> </div>
)} )}
<section className="editor-panel">
<h2>Map details</h2>
<label className="field"> <label className="field">
Map name Map name
<input <input
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => {
dirty.current = true;
setName(e.target.value);
}}
disabled={!editable} disabled={!editable}
/> />
</label> </label>
@@ -186,11 +222,15 @@ export function DraftEditor({ id }: { id: string }) {
Description Description
<textarea <textarea
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => {
dirty.current = true;
setDescription(e.target.value);
}}
disabled={!editable} disabled={!editable}
/> />
</label> </label>
<h2>Choose your locations.</h2> </section>
<h2 id="locations">Choose your locations.</h2>
<p className="muted"> <p className="muted">
Keep at least {minimum} clear, useful panorama{minimum === 1 ? "" : "s"} Keep at least {minimum} clear, useful panorama{minimum === 1 ? "" : "s"}
. Selected: {selected.length} / 24 . Selected: {selected.length} / 24
@@ -204,13 +244,14 @@ export function DraftEditor({ id }: { id: string }) {
checked={selected.includes(p.id)} checked={selected.includes(p.id)}
disabled={!editable || busy} disabled={!editable || busy}
aria-label={`Keep ${p.label}`} aria-label={`Keep ${p.label}`}
onChange={(e) => onChange={(e) => {
dirty.current = true;
setSelected((s) => setSelected((s) =>
e.target.checked e.target.checked
? [...s, p.id] ? [...s, p.id]
: s.filter((id) => id !== p.id), : s.filter((id) => id !== p.id),
) );
} }}
/> />
{p.image && ( {p.image && (
<Image <Image
@@ -242,14 +283,14 @@ export function DraftEditor({ id }: { id: string }) {
<div className="inline-actions"> <div className="inline-actions">
<button <button
className="button" className="button"
disabled={busy || selected.length < minimum} disabled={busy || refreshing || selected.length < minimum}
onClick={() => save()} onClick={() => save()}
> >
Save draft Save draft
</button> </button>
<button <button
className="button primary" className="button primary"
disabled={busy || selected.length < minimum} disabled={busy || refreshing || selected.length < minimum}
onClick={() => save(true)} onClick={() => save(true)}
> >
{draft.parent_dataset_id {draft.parent_dataset_id
@@ -258,12 +299,25 @@ export function DraftEditor({ id }: { id: string }) {
</button> </button>
</div> </div>
)} )}
<div id="map-tools">
<MapRefresh
id={id}
disabled={busy || !editable || refreshing}
onQueued={() => {
setMessage(
"Map refresh queued. Keep this page open to follow progress.",
);
setVersion((v) => v + 1);
}}
/>
</div>
{!draft.parent_dataset_id && ( {!draft.parent_dataset_id && (
<OwnerContributions <OwnerContributions
id={id} id={id}
allowed={draft.allow_contributions} allowed={draft.allow_contributions}
serverAddress={draft.contribution_server_address} serverAddress={draft.contribution_server_address}
bounds={draft.manifest.bounds} bounds={draft.manifest.bounds}
count={draft.previews.length}
locked={draft.state === "review" || draft.update_state === "review"} locked={draft.state === "review" || draft.update_state === "review"}
onChange={() => setVersion((v) => v + 1)} onChange={() => setVersion((v) => v + 1)}
/> />

View File

@@ -20,6 +20,7 @@ export function MapCard({ map, index = 0 }: { map: CardMap; index?: number }) {
src={map.image || "/images/example-map.webp"} src={map.image || "/images/example-map.webp"}
alt={`${map.name} terrain`} alt={`${map.name} terrain`}
fill fill
loading={index < 3 ? "eager" : "lazy"}
unoptimized unoptimized
sizes="(max-width: 640px) 100vw, 33vw" sizes="(max-width: 640px) 100vw, 33vw"
/> />

87
components/MapRefresh.tsx Normal file
View File

@@ -0,0 +1,87 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "./StartGame";
export function MapRefresh({
id,
disabled,
onQueued,
}: {
id: string;
disabled: boolean;
onQueued: () => void;
}) {
const [sources, setSources] = useState<
{ id: string; name: string; created_at: string }[]
>([]);
const [source, setSource] = useState(id);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let active = true;
api<{ sources: typeof sources }>(`datasets/${id}/refresh-map`)
.then((r) => {
if (active) setSources(r.sources);
})
.catch((e) => {
if (active) setError(e.message);
});
return () => {
active = false;
};
}, [id]);
async function refresh() {
setBusy(true);
setError("");
try {
await api(`datasets/${id}/refresh-map`, { sourceId: source });
onQueued();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<section className="editor-panel">
<span className="eyebrow">THE MAP PLAYERS GUESS ON</span>
<h2>Refresh guessing map</h2>
<p className="muted">
Rebuild the overhead map from a processed capture. To show new
buildings, export the same area again and upload it first. Select that
capture here; its boundaries must match this map exactly.
</p>
<label className="field">
Terrain capture
<select
value={source}
onChange={(e) => setSource(e.target.value)}
disabled={disabled || busy}
>
{sources.map((s) => (
<option key={s.id} value={s.id}>
{s.id === id ? "Original capture" : s.name} ·{" "}
{new Date(s.created_at).toLocaleDateString()} · {s.id.slice(0, 8)}
</option>
))}
</select>
</label>
<button
className="button"
disabled={disabled || busy || !sources.some((s) => s.id === source)}
onClick={refresh}
>
{busy ? "Queueing…" : "Refresh guessing map"}
</button>
<p className="muted">
Panoramas stay unchanged. Refreshing uses no panorama slots, even at
24/24. Submit the finished map update for review to make it playable.
</p>
{error && (
<p role="alert" className="error-message">
{error}
</p>
)}
</section>
);
}

View File

@@ -21,6 +21,7 @@ export function OwnerContributions({
bounds, bounds,
locked, locked,
onChange, onChange,
count,
}: { }: {
id: string; id: string;
allowed: boolean; allowed: boolean;
@@ -28,6 +29,7 @@ export function OwnerContributions({
bounds: { minX: number; minZ: number; maxX: number; maxZ: number }; bounds: { minX: number; minZ: number; maxX: number; maxZ: number };
locked: boolean; locked: boolean;
onChange: () => void; onChange: () => void;
count: number;
}) { }) {
const [items, setItems] = useState<Contribution[]>([]), const [items, setItems] = useState<Contribution[]>([]),
[address, setAddress] = useState(serverAddress ?? ""), [address, setAddress] = useState(serverAddress ?? ""),
@@ -111,7 +113,12 @@ export function OwnerContributions({
} }
return ( return (
<section className="token-box"> <section className="token-box">
<h2>Panorama settings</h2> <h2>Add panoramas · {count}/24</h2>
<p className="muted">
Owners can fill all 24 slots, even with player contributions turned off.
Export a fresh capture, then use the command below to add it to this
map.
</p>
<label> <label>
<input <input
type="checkbox" type="checkbox"
@@ -166,6 +173,7 @@ export function OwnerContributions({
id={id} id={id}
bounds={bounds} bounds={bounds}
serverAddress={serverAddress} serverAddress={serverAddress}
isOwner
/> />
<h3>Player submissions</h3> <h3>Player submissions</h3>
{locked && ( {locked && (

View File

@@ -23,7 +23,7 @@ export function ProcessingProgress({ job }: { job: ProcessingJob | null }) {
<p> <p>
{job.state === "queued" {job.state === "queued"
? "Waiting for a processing worker" ? "Waiting for a processing worker"
: `${progress}% · ${processingStage(progress)}`} : `${progress}% · ${job.kind === "map" ? "Refreshing guessing map" : processingStage(progress)}`}
</p> </p>
<progress <progress
max={100} max={100}

View File

@@ -180,7 +180,7 @@ export async function handle(request: NextRequest, path: string[]) {
.order("round_number"), .order("round_number"),
); );
const signed = await mediaUrls([ const signed = await mediaUrls([
`${revision.asset_root}/map.webp`, manifest.mapPath ?? `${revision.asset_root}/map.webp`,
...rounds.flatMap((r) => ...rounds.flatMap((r) =>
faces.map((f) => faces.map((f) =>
panoramaPath( panoramaPath(
@@ -332,7 +332,7 @@ export async function handle(request: NextRequest, path: string[]) {
await client await client
.from("datasets") .from("datasets")
.select( .select(
"*, upload_jobs(state,progress,error,updated_at,lease_until)", "*, upload_jobs(state,progress,error,updated_at,lease_until,kind)",
) )
.eq("owner_id", owner) .eq("owner_id", owner)
.order("created_at", { ascending: false }), .order("created_at", { ascending: false }),
@@ -348,6 +348,56 @@ export async function handle(request: NextRequest, path: string[]) {
}); });
} }
const d = await ownedDataset(request, path[1]); const d = await ownedDataset(request, path[1]);
if (path[2] === "refresh-map") {
if (method === "POST") {
const body = z
.object({ sourceId: idSchema })
.parse(await jsonBody(request));
await limit("map-refresh:" + d.owner_id, 6, 3600);
checkServiceError(
(
await client.rpc("refresh_map", {
p_dataset: d.id,
p_owner: d.owner_id,
p_source: body.sourceId,
})
).error,
);
return NextResponse.json({ ok: true });
}
if (method === "GET") {
const sources = required(
await client
.from("datasets")
.select(
"id,name,bounds,source,created_at,owner_id,parent_dataset_id,contribution_status",
)
.or("owner_id.eq." + d.owner_id + ",parent_dataset_id.eq." + d.id)
.not("map_path", "is", null)
.in("state", ["draft", "rejected", "review", "published"])
.order("created_at", { ascending: false }),
);
return NextResponse.json({
sources: sources
.filter(
(s) =>
(s.owner_id === d.owner_id ||
s.contribution_status === "accepted") &&
["minX", "minZ", "maxX", "maxZ"].every(
(k) =>
(s.bounds as Record<string, number>)[k] ===
(d.bounds as Record<string, number>)[k],
),
)
.map((s) => ({
id: s.id,
name: s.name,
source: s.source,
created_at: s.created_at,
})),
});
}
}
if (method === "GET" && path[2] === "panoramas") { if (method === "GET" && path[2] === "panoramas") {
const input = required( const input = required(
await client await client
@@ -385,7 +435,7 @@ export async function handle(request: NextRequest, path: string[]) {
description: manifest.description, description: manifest.description,
panorama_count: manifest.viewpoints.length, panorama_count: manifest.viewpoints.length,
manifest, manifest,
map: await mediaUrl(d.map_path), map: await mediaUrl(manifest.mapPath ?? d.map_path),
previews: await Promise.all( previews: await Promise.all(
(manifest.availableViewpoints ?? manifest.viewpoints).map( (manifest.availableViewpoints ?? manifest.viewpoints).map(
async (p, index) => ({ async (p, index) => ({
@@ -764,7 +814,7 @@ export async function handle(request: NextRequest, path: string[]) {
description: pending?.description ?? m.description, description: pending?.description ?? m.description,
panorama_count: pending?.viewpoints.length ?? m.panorama_count, panorama_count: pending?.viewpoints.length ?? m.panorama_count,
image: await mediaUrl(m.cover_path), image: await mediaUrl(m.cover_path),
map: await mediaUrl(m.map_path), map: await mediaUrl(pending?.mapPath ?? m.map_path),
}; };
}), }),
), ),

View File

@@ -28,7 +28,7 @@ public final class MineGeoClient implements ClientModInitializer {
public String server(){var c=MinecraftClient.getInstance();return c.getCurrentServerEntry()!=null?c.getCurrentServerEntry().address:"Singleplayer";} public String server(){var c=MinecraftClient.getInstance();return c.getCurrentServerEntry()!=null?c.getCurrentServerEntry().address:"Singleplayer";}
public void saveConfig()throws Exception{Files.createDirectories(config.getParent());var j=new JsonObject();j.addProperty("website",website);j.addProperty("token",token);j.addProperty("targetMap",targetMap);Files.writeString(config,gson.toJson(j));} public void saveConfig()throws Exception{Files.createDirectories(config.getParent());var j=new JsonObject();j.addProperty("website",website);j.addProperty("token",token);j.addProperty("targetMap",targetMap);Files.writeString(config,gson.toJson(j));}
public void message(String text){status=text;var c=MinecraftClient.getInstance();if(c.player!=null)c.player.sendMessage(Text.literal("[MineGeo] "+text),false);} public void message(String text){status=text;var c=MinecraftClient.getInstance();if(c.player!=null)c.player.sendMessage(Text.literal("[MineGeo] "+text),false);}
public void capture(){try{if(dataset==null)throw new IllegalStateException("Join an Overworld and explore first");var c=MinecraftClient.getInstance();if(!dataset.contains(c.player.getX(),c.player.getZ()))throw new IllegalStateException("Outside this map's 512-block boundary. Start a new map here.");int limit=targetMap.isBlank()?24:12;if(dataset.points.size()>=limit)throw new IllegalStateException("This capture already contains "+limit+" locations");pending=new SurfaceDataset.Point("p"+java.util.UUID.randomUUID().toString().replace("-","").substring(0,12),c.player.getX(),c.player.getEyeY(),c.player.getZ());c.setScreen(null);Capture.start(dataset.directory.resolve(pending.id()));message("Capturing six views. Stand still; press G to cancel.");}catch(Exception e){message(e.getMessage());}} public void capture(){try{if(dataset==null)throw new IllegalStateException("Join an Overworld and explore first");var c=MinecraftClient.getInstance();if(!dataset.contains(c.player.getX(),c.player.getZ()))throw new IllegalStateException("Outside this map's 512-block boundary. Start a new map here.");int limit=24;if(dataset.points.size()>=limit)throw new IllegalStateException("This capture already contains "+limit+" locations");pending=new SurfaceDataset.Point("p"+java.util.UUID.randomUUID().toString().replace("-","").substring(0,12),c.player.getX(),c.player.getEyeY(),c.player.getZ());c.setScreen(null);Capture.start(dataset.directory.resolve(pending.id()));message("Capturing six views. Stand still; press G to cancel.");}catch(Exception e){message(e.getMessage());}}
public void savedCapture(Path path){try{if(pending==null||!path.getFileName().toString().equals(pending.id()))return;dataset.points.add(pending);dataset.save();message("Panorama saved locally ("+dataset.points.size()+" total). Press G for preview or upload.");}catch(Exception e){message("Saving capture failed: "+e.getMessage());}finally{pending=null;}} public void savedCapture(Path path){try{if(pending==null||!path.getFileName().toString().equals(pending.id()))return;dataset.points.add(pending);dataset.save();message("Panorama saved locally ("+dataset.points.size()+" total). Press G for preview or upload.");}catch(Exception e){message("Saving capture failed: "+e.getMessage());}finally{pending=null;}}
public void newMap(){try{if(uploading||Capture.active())throw new IllegalStateException("Finish the current operation first");dataset=SurfaceDataset.create(server());message("New 512 × 512 map started. Older maps remain in .minecraft/minegeo.");}catch(Exception e){message(e.getMessage());}} public void newMap(){try{if(uploading||Capture.active())throw new IllegalStateException("Finish the current operation first");dataset=SurfaceDataset.create(server());message("New 512 × 512 map started. Older maps remain in .minecraft/minegeo.");}catch(Exception e){message(e.getMessage());}}
public void pair(){background.execute(()->{try{saveConfig();var uploader=new UploadClient(website,"");var pair=uploader.beginPairing();String code=pair.get("deviceCode").getAsString();String link=pair.get("verificationUri").getAsString();MinecraftClient.getInstance().execute(()->{message("Approve code "+pair.get("userCode").getAsString()+" at "+link);net.minecraft.util.Util.getOperatingSystem().open(link);});for(int i=0;i<120;i++){Thread.sleep(5000);var response=uploader.pollPairing(code);if(response.get("status").getAsString().equals("approved")){token=response.get("token").getAsString();saveConfig();MinecraftClient.getInstance().execute(()->message("Account linked. You can upload maps now."));return;}}throw new IllegalStateException("Pairing expired. Try linking again.");}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Pairing: "+e.getMessage()));}});} public void pair(){background.execute(()->{try{saveConfig();var uploader=new UploadClient(website,"");var pair=uploader.beginPairing();String code=pair.get("deviceCode").getAsString();String link=pair.get("verificationUri").getAsString();MinecraftClient.getInstance().execute(()->{message("Approve code "+pair.get("userCode").getAsString()+" at "+link);net.minecraft.util.Util.getOperatingSystem().open(link);});for(int i=0;i<120;i++){Thread.sleep(5000);var response=uploader.pollPairing(code);if(response.get("status").getAsString().equals("approved")){token=response.get("token").getAsString();saveConfig();MinecraftClient.getInstance().execute(()->message("Account linked. You can upload maps now."));return;}}throw new IllegalStateException("Pairing expired. Try linking again.");}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Pairing: "+e.getMessage()));}});}

View File

@@ -53,7 +53,7 @@ public final class UploadClient {
if(cached.has("hash")&&cached.get("hash").getAsString().equals(originalHash))manifest=cached.getAsJsonObject("manifest"); if(cached.has("hash")&&cached.get("hash").getAsString().equals(originalHash))manifest=cached.getAsJsonObject("manifest");
else { else {
JsonObject capacity=api("/contributions/"+targetMap,null),bounds=capacity.getAsJsonObject("bounds"); JsonObject capacity=api("/contributions/"+targetMap,null),bounds=capacity.getAsJsonObject("bounds");
int room=Math.min(12,Math.min(capacity.get("remaining").getAsInt(),capacity.get("playerRemaining").getAsInt())); int room=Math.min(capacity.get("remaining").getAsInt(),capacity.get("playerRemaining").getAsInt());
if(room<1)throw new IOException("This map or your player contribution limit is full. Wait for pending uploads to be reviewed or withdrawn."); if(room<1)throw new IOException("This map or your player contribution limit is full. Wait for pending uploads to be reviewed or withdrawn.");
manifest.addProperty("contributionTo",targetMap); manifest.addProperty("contributionTo",targetMap);
// Export positions are already random. Keep a stable subset for resumable retries. // Export positions are already random. Keep a stable subset for resumable retries.

View File

@@ -425,6 +425,8 @@ export type Database = {
}; };
upload_jobs: { upload_jobs: {
Row: { Row: {
kind: string;
source_dataset_id: string | null;
attempts: number; attempts: number;
created_at: string; created_at: string;
dataset_id: string; dataset_id: string;
@@ -437,6 +439,8 @@ export type Database = {
updated_at: string; updated_at: string;
}; };
Insert: { Insert: {
kind?: string;
source_dataset_id?: string | null;
attempts?: number; attempts?: number;
created_at?: string; created_at?: string;
dataset_id: string; dataset_id: string;
@@ -449,6 +453,8 @@ export type Database = {
updated_at?: string; updated_at?: string;
}; };
Update: { Update: {
kind?: string;
source_dataset_id?: string | null;
attempts?: number; attempts?: number;
created_at?: string; created_at?: string;
dataset_id?: string; dataset_id?: string;
@@ -475,6 +481,10 @@ export type Database = {
[_ in never]: never; [_ in never]: never;
}; };
Functions: { Functions: {
refresh_map: {
Args: { p_dataset: string; p_owner: string; p_source: string };
Returns: undefined;
};
activate_round: { activate_round: {
Args: { p_access_hash: string; p_round: number; p_session: string }; Args: { p_access_hash: string; p_round: number; p_session: string };
Returns: string; Returns: string;
@@ -503,6 +513,8 @@ export type Database = {
claim_job: { claim_job: {
Args: { p_worker: string }; Args: { p_worker: string };
Returns: { Returns: {
kind: string;
source_dataset_id: string | null;
attempts: number; attempts: number;
created_at: string; created_at: string;
dataset_id: string; dataset_id: string;

View File

@@ -1,4 +1,5 @@
export type ProcessingJob = { export type ProcessingJob = {
kind?: string;
state: string; state: string;
progress: number; progress: number;
error: string | null; error: string | null;

View File

@@ -51,6 +51,10 @@ export const datasetSchema = z
bounds: boundsSchema, bounds: boundsSchema,
north: z.literal("-z"), north: z.literal("-z"),
contributionTo: z.string().uuid().optional(), contributionTo: z.string().uuid().optional(),
mapPath: z
.string()
.regex(/^[a-f0-9-]{36}\/maps\/[a-f0-9-]{36}\.webp$/)
.optional(),
viewpoints: z.array(viewpointSchema).min(1).max(24), viewpoints: z.array(viewpointSchema).min(1).max(24),
// Retain the capture's locations while viewpoints holds the saved selection. // Retain the capture's locations while viewpoints holds the saved selection.
availableViewpoints: z.array(viewpointSchema).min(1).max(24).optional(), availableViewpoints: z.array(viewpointSchema).min(1).max(24).optional(),
@@ -138,6 +142,8 @@ export function parseUploadManifest(value: unknown) {
throw new Error( throw new Error(
"Upload all captured viewpoints; selection happens in the dashboard", "Upload all captured viewpoints; selection happens in the dashboard",
); );
if (manifest.mapPath)
throw new Error("Uploads cannot specify server map paths");
return manifest; return manifest;
} }
export type DatasetManifest = z.infer<typeof datasetSchema>; export type DatasetManifest = z.infer<typeof datasetSchema>;

View File

@@ -0,0 +1,82 @@
alter table public.upload_jobs
add column kind text not null default 'upload' check (kind in ('upload','map')),
add column source_dataset_id uuid references public.datasets(id);
create function public.refresh_map(p_dataset uuid,p_owner uuid,p_source uuid) returns void
language plpgsql security invoker set search_path='' as $$
declare d public.datasets; s public.datasets;
begin
select * into d from public.datasets where id=p_dataset and owner_id=p_owner for update;
if not found then raise exception 'Map owner permission required'; end if;
if d.state not in ('draft','rejected','published') or d.update_state='review' or d.contribution_status in ('submitted','accepted','rejected') then raise exception 'Wait until the current review finishes'; end if;
if exists(select 1 from public.upload_jobs where dataset_id=d.id and state in ('queued','running')) then raise exception 'A render is already in progress'; end if;
select * into s from public.datasets where id=p_source;
if not found or not (s.owner_id=p_owner or (s.parent_dataset_id=d.id and s.contribution_status='accepted')) then raise exception 'Capture permission required'; end if;
if s.bounds<>d.bounds then raise exception 'Capture boundaries must match the map exactly'; end if;
if s.map_path is null or s.state not in ('draft','rejected','review','published') or not exists(select 1 from public.dataset_inputs where dataset_id=s.id and validated) then raise exception 'Wait for the capture to finish processing'; end if;
perform public.assert_capture_server(d.contribution_server_address, (select manifest->>'serverAddress' from public.dataset_inputs where dataset_id=s.id));
insert into public.upload_jobs(dataset_id,kind,source_dataset_id) values(d.id,'map',s.id)
on conflict(dataset_id) do update set id=gen_random_uuid(),kind='map',source_dataset_id=s.id,state='queued',attempts=0,progress=0,error=null,lease_owner=null,lease_until=null,created_at=now(),updated_at=now();
end; $$;
revoke execute on function public.refresh_map(uuid,uuid,uuid) from public,anon,authenticated;
grant execute on function public.refresh_map(uuid,uuid,uuid) to service_role;
create or replace function public.complete_job(p_job uuid,p_worker text,p_count integer,p_cover text) returns void
language plpgsql security invoker set search_path='' as $$
declare j public.upload_jobs; d public.datasets;
begin
select * into j from public.upload_jobs where id=p_job and lease_owner=p_worker and state='running' and lease_until>now() for update;
if not found then raise exception 'Worker lease lost'; end if;
select * into d from public.datasets where id=j.dataset_id for update;
if j.kind='map' then
if d.state not in ('draft','rejected','published') or d.update_state='review' or d.contribution_status in ('submitted','accepted','rejected') then raise exception 'Map cannot change during review'; end if;
update public.dataset_inputs set manifest=manifest||jsonb_build_object('mapPath',d.id::text||'/maps/'||j.id::text||'.webp') where dataset_id=d.id and validated;
if not found then raise exception 'Dataset not validated'; end if;
update public.datasets set update_state=case when state='published' then 'draft' else null end,state=case when state='published' then state else 'draft' end,rejection_reason=null,updated_at=now() where id=d.id;
update public.upload_jobs set state='complete',progress=100,lease_until=null,error=null,updated_at=now() where id=j.id;
return;
end if;
if p_count<(case when d.parent_dataset_id is null then 5 else 1 end) or p_count>24 then raise exception 'Invalid panorama count'; end if;
update public.datasets set state='draft',panorama_count=p_count,map_path=j.dataset_id::text||'/map.webp',cover_path=j.dataset_id::text||'/'||p_cover||'/preview.webp',updated_at=now() where id=j.dataset_id and state='processing';
if not found then raise exception 'Dataset processing cancelled'; end if;
update public.dataset_inputs set validated=true where dataset_id=j.dataset_id;
update public.upload_jobs set state='complete',progress=100,lease_until=null,error=null,updated_at=now() where id=p_job;
end; $$;
create or replace function public.submit_draft(p_dataset uuid,p_owner uuid) returns void
language plpgsql security invoker set search_path='' as $$
declare d public.datasets; count integer;
begin
select * into d from public.datasets where id=p_dataset and owner_id=p_owner for update;
if not found then raise exception 'Dataset not found'; end if;
if d.state not in ('draft','rejected','published') or (d.state='published' and coalesce(d.update_state,'') not in ('draft','rejected')) or d.contribution_status in ('submitted','accepted','rejected') then raise exception 'Draft is not ready for submission'; end if;
if exists(select 1 from public.upload_jobs where dataset_id=d.id and state in ('queued','running')) then raise exception 'Wait for map rendering to finish before submitting'; end if;
select jsonb_array_length(manifest->'viewpoints') into count from public.dataset_inputs where dataset_id=p_dataset and validated;
if count is null or count<(case when d.parent_dataset_id is null then 5 else 1 end) or count>24 then raise exception 'Invalid panorama count'; end if;
update public.datasets set state=case when state='published' then state else 'review' end,
update_state=case when state='published' then 'review' end,
contribution_status=case when parent_dataset_id is not null then 'submitted' end,updated_at=now() where id=p_dataset;
end; $$;
create or replace function public.publish_dataset(p_dataset uuid,p_reviewer uuid,p_decision text,p_reason text default null) returns void
language plpgsql security invoker set search_path='' as $$
declare d public.datasets; m jsonb; rev uuid; first jsonb; count integer;
begin
if not exists(select 1 from public.profiles where id=p_reviewer and role='moderator') then raise exception 'Moderator permission required'; end if;
select * into d from public.datasets where id=p_dataset and parent_dataset_id is null for update;
if not found then raise exception 'Map not found'; end if;
if p_decision='approve' then
if not(d.state='review' or (d.state='published' and coalesce(d.update_state,'')='review')) then raise exception 'Map is not ready for publication'; end if;
select manifest into m from public.dataset_inputs where dataset_id=p_dataset and validated;
count:=jsonb_array_length(m->'viewpoints'); first:=m->'viewpoints'->0;
if m is null or count<5 or count>24 then raise exception 'Map requires 5–24 validated panoramas'; end if;
insert into public.revisions(dataset_id,manifest,asset_root) values(p_dataset,m,p_dataset::text) returning id into rev;
update public.datasets set state='published',revision_id=rev,update_state=null,name=m->>'name',description=m->>'description',panorama_count=count,
map_path=coalesce(m->>'mapPath',d.map_path),
cover_path=coalesce(first->>'assetRoot',id::text)||'/'||coalesce(first->>'assetId',first->>'id')||'/preview.webp',rejection_reason=null,updated_at=now() where id=p_dataset;
elsif p_decision='reject' and (d.state='review' or (d.state='published' and d.update_state='review')) then
if coalesce(length(trim(p_reason)),0)=0 then raise exception 'Provide a rejection reason'; end if;
update public.datasets set state=case when state='published' then state else 'rejected' end,update_state=case when state='published' then 'rejected' end,rejection_reason=p_reason,updated_at=now() where id=p_dataset;
elsif p_decision='unpublish' and d.state='published' then
update public.datasets set state='unpublished',update_state=null,updated_at=now() where id=p_dataset;
else raise exception 'Invalid review transition'; end if;
insert into public.review_decisions(dataset_id,reviewer_id,decision,reason) values(p_dataset,p_reviewer,p_decision,p_reason);
end; $$;

View File

@@ -49,6 +49,9 @@ test("contributions can contain one panorama, while new maps still need five", (
); );
}); });
test("clients cannot choose other uploads' media or claim server-assigned provenance", () => { test("clients cannot choose other uploads' media or claim server-assigned provenance", () => {
const mapPath = `${root}/maps/${child}.webp`;
assert.equal(datasetSchema.parse({ ...manifest, mapPath }).mapPath, mapPath);
assert.throws(() => parseUploadManifest({ ...manifest, mapPath }));
for (const field of ["assetRoot", "assetId", "contributorId", "source"]) for (const field of ["assetRoot", "assetId", "contributorId", "source"])
assert.throws(() => assert.throws(() =>
parseUploadManifest({ parseUploadManifest({

57
tests/sql/map-refresh.sql Normal file
View File

@@ -0,0 +1,57 @@
-- Run against the configured project; all fixture changes roll back.
begin;
do $$
declare original public.datasets; fixture uuid; job uuid; owner uuid; moderator uuid; before_path text; before_revision uuid;
begin
select * into original from public.datasets where state='published' and parent_dataset_id is null limit 1;
if not found then raise exception 'A published fixture source is required'; end if;
owner:=original.owner_id;
select id into moderator from public.profiles where role='moderator' limit 1;
if moderator is null then raise exception 'A moderator fixture is required'; end if;
insert into public.datasets(owner_id,server_id,name,source,bounds,state,map_path,panorama_count,update_state)
values(owner,original.server_id,'Refresh regression',original.source,original.bounds,'published',original.map_path,original.panorama_count,'draft') returning id into fixture;
insert into public.dataset_inputs(dataset_id,manifest,validated) select fixture,manifest,true from public.dataset_inputs where dataset_id=original.id;
begin
perform public.refresh_map(fixture,gen_random_uuid(),original.id);
raise exception 'Unauthorized refresh succeeded';
exception when others then
if sqlerrm<>'Map owner permission required' then raise; end if;
end;
perform public.refresh_map(fixture,owner,original.id);
select id into job from public.upload_jobs where dataset_id=fixture;
if not exists(select 1 from public.upload_jobs where id=job and kind='map' and state='queued' and source_dataset_id=original.id) then raise exception 'Queue missing'; end if;
begin
perform public.refresh_map(fixture,owner,original.id);
raise exception 'Duplicate refresh succeeded';
exception when others then
if sqlerrm<>'A render is already in progress' then raise; end if;
end;
begin
perform public.submit_draft(fixture,owner);
raise exception 'Submission raced with rendering';
exception when others then
if sqlerrm<>'Wait for map rendering to finish before submitting' then raise; end if;
end;
update public.upload_jobs set state='running',lease_owner='regression',lease_until=now()+interval '2 minutes' where id=job;
perform public.complete_job(job,'regression',0,'');
if (select map_path from public.datasets where id=fixture) is distinct from original.map_path then raise exception 'Live map changed before approval'; end if;
if (select manifest->>'mapPath' from public.dataset_inputs where dataset_id=fixture) <> fixture::text||'/maps/'||job::text||'.webp' then raise exception 'Draft map missing'; end if;
perform public.submit_draft(fixture,owner);
perform public.publish_dataset(fixture,moderator,'approve');
select revision_id,map_path into before_revision,before_path from public.datasets where id=fixture;
if before_path<>fixture::text||'/maps/'||job::text||'.webp' then raise exception 'Approved map was not published'; end if;
perform public.refresh_map(fixture,owner,original.id);
select id into job from public.upload_jobs where dataset_id=fixture;
update public.upload_jobs set state='running',lease_owner='regression',lease_until=now()+interval '2 minutes' where id=job;
perform public.complete_job(job,'regression',0,'');
if (select manifest->>'mapPath' from public.revisions where id=before_revision)<>before_path then raise exception 'Existing game revision changed'; end if;
update public.upload_jobs set state='failed' where id=job;
update public.datasets set bounds=jsonb_set(bounds,'{maxX}',to_jsonb((bounds->>'maxX')::int+1)) where id=fixture;
begin
perform public.refresh_map(fixture,owner,original.id);
raise exception 'Mismatched bounds accepted';
exception when others then
if sqlerrm<>'Capture boundaries must match the map exactly' then raise; end if;
end;
end; $$;
rollback;

View File

@@ -33,7 +33,11 @@ async function processJob(job: {
id: string; id: string;
dataset_id: string; dataset_id: string;
attempts: number; attempts: number;
kind?: string;
source_dataset_id?: string | null;
}) { }) {
const mapOnly = job.kind === "map";
const sourceId = mapOnly ? job.source_dataset_id! : job.dataset_id;
const abort = new AbortController(), const abort = new AbortController(),
directory = `.cache/jobs/${job.id}`; directory = `.cache/jobs/${job.id}`;
activeAbort = abort; activeAbort = abort;
@@ -91,28 +95,29 @@ async function processJob(job: {
}, 20000); }, 20000);
try { try {
const dataset = check( const dataset = check(
await client await client.from("datasets").select("*").eq("id", sourceId).single(),
.from("datasets")
.select("*")
.eq("id", job.dataset_id)
.single(),
); );
const input = check( const input = check(
await client await client
.from("dataset_inputs") .from("dataset_inputs")
.select("manifest") .select("manifest")
.eq("dataset_id", job.dataset_id) .eq("dataset_id", sourceId)
.single(), .single(),
); );
const manifest = datasetSchema.parse(input.manifest); const manifest = datasetSchema.parse(input.manifest);
await progress(0, `Downloading ${manifest.assets.length} export files`); await progress(0, `Downloading ${manifest.assets.length} export files`);
let downloaded = 0; let downloaded = 0;
for (const asset of manifest.assets) { for (const asset of manifest.assets.filter(
(a) =>
!mapOnly ||
a.path ===
(manifest.source === "plugin" ? "terrain.json.gz" : "map.png"),
)) {
if (abort.signal.aborted) throw new Error("Lease lost or cancelled"); if (abort.signal.aborted) throw new Error("Lease lost or cancelled");
const blob = check( const blob = check(
await client.storage await client.storage
.from("minegeo-raw") .from("minegeo-raw")
.download(`${dataset.owner_id}/${job.dataset_id}/${asset.path}`), .download(`${dataset.owner_id}/${sourceId}/${asset.path}`),
); );
const bytes = Buffer.from(await blob.arrayBuffer()); const bytes = Buffer.from(await blob.arrayBuffer());
if ( if (
@@ -179,7 +184,7 @@ async function processJob(job: {
); );
const report = await renderSnapshot( const report = await renderSnapshot(
terrain, terrain,
manifest.availableViewpoints ?? manifest.viewpoints, mapOnly ? [] : (manifest.availableViewpoints ?? manifest.viewpoints),
output, output,
{ {
size: Number(process.env.MINEGEO_FACE_SIZE || 1024), size: Number(process.env.MINEGEO_FACE_SIZE || 1024),
@@ -196,7 +201,9 @@ async function processJob(job: {
.webp({ lossless: true }) .webp({ lossless: true })
.toFile(`${output}/map.webp`); .toFile(`${output}/map.webp`);
let processedFaces = 0; let processedFaces = 0;
for (const point of manifest.availableViewpoints ?? manifest.viewpoints) { for (const point of mapOnly
? []
: (manifest.availableViewpoints ?? manifest.viewpoints)) {
await mkdir(`${output}/${point.id}`, { recursive: true }); await mkdir(`${output}/${point.id}`, { recursive: true });
for (const face of faces) { for (const face of faces) {
const bytes = await readFile( const bytes = await readFile(
@@ -252,7 +259,9 @@ async function processJob(job: {
await client.storage await client.storage
.from("minegeo-media") .from("minegeo-media")
.upload( .upload(
`${job.dataset_id}/${normalized}`, mapOnly
? `${job.dataset_id}/maps/${job.id}.webp`
: `${job.dataset_id}/${normalized}`,
await readFile(`${output}/${entry}`), await readFile(`${output}/${entry}`),
{ {
contentType: "image/webp", contentType: "image/webp",
@@ -304,7 +313,7 @@ async function processJob(job: {
.eq("state", "running") .eq("state", "running")
.select("id"), .select("id"),
); );
if (state === "failed" && rows.length) if (!mapOnly && state === "failed" && rows.length)
await client await client
.from("datasets") .from("datasets")
.update({ state: "failed", rejection_reason: message }) .update({ state: "failed", rejection_reason: message })