Added some features :D, Codex limits are burned out by GPT 6 Astra D:
This commit is contained in:
@@ -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).
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
96
app/workshop.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
setName(d.name);
|
if (!dirty.current) {
|
||||||
setDescription(d.description);
|
setName(d.name);
|
||||||
setSelected(d.manifest.viewpoints.map((p) => p.id));
|
setDescription(d.description);
|
||||||
if (["uploading", "processing"].includes(d.state))
|
setSelected(d.manifest.viewpoints.map((p) => p.id));
|
||||||
|
}
|
||||||
|
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,28 +199,38 @@ 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>
|
||||||
)}
|
)}
|
||||||
<label className="field">
|
<section className="editor-panel">
|
||||||
Map name
|
<h2>Map details</h2>
|
||||||
<input
|
<label className="field">
|
||||||
value={name}
|
Map name
|
||||||
onChange={(e) => setName(e.target.value)}
|
<input
|
||||||
disabled={!editable}
|
value={name}
|
||||||
/>
|
onChange={(e) => {
|
||||||
</label>
|
dirty.current = true;
|
||||||
<label className="field">
|
setName(e.target.value);
|
||||||
Description
|
}}
|
||||||
<textarea
|
disabled={!editable}
|
||||||
value={description}
|
/>
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
</label>
|
||||||
disabled={!editable}
|
<label className="field">
|
||||||
/>
|
Description
|
||||||
</label>
|
<textarea
|
||||||
<h2>Choose your locations.</h2>
|
value={description}
|
||||||
|
onChange={(e) => {
|
||||||
|
dirty.current = true;
|
||||||
|
setDescription(e.target.value);
|
||||||
|
}}
|
||||||
|
disabled={!editable}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
87
components/MapRefresh.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
58
lib/api.ts
58
lib/api.ts
@@ -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),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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()));}});}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
82
supabase/migrations/20261005141142_map_refresh.sql
Normal file
82
supabase/migrations/20261005141142_map_refresh.sql
Normal 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; $$;
|
||||||
@@ -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
57
tests/sql/map-refresh.sql
Normal 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;
|
||||||
@@ -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 })
|
||||||
|
|||||||
Reference in New Issue
Block a user